Skip to content

Commit

Permalink
Merge pull request #47338 from rushatgabhane/account-switcher
Browse files Browse the repository at this point in the history
  • Loading branch information
dangrous authored Aug 28, 2024
2 parents 392da13 + 1646d07 commit 1c0a923
Show file tree
Hide file tree
Showing 23 changed files with 591 additions and 117 deletions.
17 changes: 17 additions & 0 deletions assets/images/caret-up-down.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
5 changes: 5 additions & 0 deletions src/CONST.ts
Original file line number Diff line number Diff line change
Expand Up @@ -387,6 +387,7 @@ const CONST = {
REPORT_FIELDS_FEATURE: 'reportFieldsFeature',
WORKSPACE_FEEDS: 'workspaceFeeds',
NETSUITE_USA_TAX: 'netsuiteUsaTax',
NEW_DOT_COPILOT: 'newDotCopilot',
WORKSPACE_RULES: 'workspaceRules',
COMBINED_TRACK_SUBMIT: 'combinedTrackSubmit',
},
Expand Down Expand Up @@ -3877,6 +3878,10 @@ const CONST = {
ENABLED: 'ENABLED',
DISABLED: 'DISABLED',
},
DELEGATE_ROLE: {
SUBMITTER: 'submitter',
ALL: 'all',
},
STRIPE_GBP_AUTH_STATUSES: {
SUCCEEDED: 'succeeded',
CARD_AUTHENTICATION_REQUIRED: 'authentication_required',
Expand Down
202 changes: 202 additions & 0 deletions src/components/AccountSwitcher.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
import React, {useRef, useState} from 'react';
import {View} from 'react-native';
import {useOnyx} from 'react-native-onyx';
import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails';
import useLocalize from '@hooks/useLocalize';
import useNetwork from '@hooks/useNetwork';
import usePermissions from '@hooks/usePermissions';
import useResponsiveLayout from '@hooks/useResponsiveLayout';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
import {clearDelegatorErrors, connect, disconnect} from '@libs/actions/Delegate';
import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils';
import variables from '@styles/variables';
import * as Modal from '@userActions/Modal';
import CONST from '@src/CONST';
import type {TranslationPaths} from '@src/languages/types';
import ONYXKEYS from '@src/ONYXKEYS';
import type {PersonalDetails} from '@src/types/onyx';
import Avatar from './Avatar';
import ConfirmModal from './ConfirmModal';
import Icon from './Icon';
import * as Expensicons from './Icon/Expensicons';
import type {MenuItemProps} from './MenuItem';
import MenuItemList from './MenuItemList';
import type {MenuItemWithLink} from './MenuItemList';
import Popover from './Popover';
import {PressableWithFeedback} from './Pressable';
import Text from './Text';

function AccountSwitcher() {
const currentUserPersonalDetails = useCurrentUserPersonalDetails();
const styles = useThemeStyles();
const theme = useTheme();
const {translate} = useLocalize();
const {isOffline} = useNetwork();
const {canUseNewDotCopilot} = usePermissions();
const {shouldUseNarrowLayout} = useResponsiveLayout();
const [account] = useOnyx(ONYXKEYS.ACCOUNT);
const buttonRef = useRef<HTMLDivElement>(null);

const [shouldShowDelegatorMenu, setShouldShowDelegatorMenu] = useState(false);
const [shouldShowOfflineModal, setShouldShowOfflineModal] = useState(false);
const delegators = account?.delegatedAccess?.delegators ?? [];

const isActingAsDelegate = !!account?.delegatedAccess?.delegate ?? false;
const canSwitchAccounts = canUseNewDotCopilot && (delegators.length > 0 || isActingAsDelegate);

const createBaseMenuItem = (personalDetails: PersonalDetails | undefined, error?: TranslationPaths, additionalProps: MenuItemWithLink = {}): MenuItemWithLink => {
return {
title: personalDetails?.displayName ?? personalDetails?.login,
description: personalDetails?.login,
avatarID: personalDetails?.accountID ?? -1,
icon: personalDetails?.avatar ?? '',
iconType: CONST.ICON_TYPE_AVATAR,
outerWrapperStyle: shouldUseNarrowLayout ? {} : styles.accountSwitcherPopover,
numberOfLinesDescription: 1,
errorText: error ? translate(error) : '',
shouldShowRedDotIndicator: !!error,
errorTextStyle: styles.mt2,
...additionalProps,
};
};

const menuItems = (): MenuItemProps[] => {
const currentUserMenuItem = createBaseMenuItem(currentUserPersonalDetails, undefined, {
wrapperStyle: [styles.buttonDefaultBG],
focused: true,
shouldShowRightIcon: true,
iconRight: Expensicons.Checkmark,
success: true,
key: `${currentUserPersonalDetails?.login}-current`,
});

if (isActingAsDelegate) {
const delegateEmail = account?.delegatedAccess?.delegate ?? '';

// Avoid duplicating the current user in the list when switching accounts
if (delegateEmail === currentUserPersonalDetails.login) {
return [currentUserMenuItem];
}

const delegatePersonalDetails = PersonalDetailsUtils.getPersonalDetailByEmail(delegateEmail);
const error = account?.delegatedAccess?.error;

return [
createBaseMenuItem(delegatePersonalDetails, error, {
onPress: () => {
if (isOffline) {
Modal.close(() => setShouldShowOfflineModal(true));
return;
}
disconnect();
},
key: `${delegateEmail}-delegate`,
}),
currentUserMenuItem,
];
}

const delegatorMenuItems: MenuItemProps[] = delegators
.filter(({email}) => email !== currentUserPersonalDetails.login)
.map(({email, role, error}, index) => {
const personalDetails = PersonalDetailsUtils.getPersonalDetailByEmail(email);
return createBaseMenuItem(personalDetails, error, {
badgeText: translate('delegate.role', role),
onPress: () => {
if (isOffline) {
Modal.close(() => setShouldShowOfflineModal(true));
return;
}
connect(email);
},
key: `${email}-${index}`,
});
});

return [currentUserMenuItem, ...delegatorMenuItems];
};

return (
<>
<PressableWithFeedback
accessible
accessibilityLabel={translate('common.profile')}
onPress={() => {
setShouldShowDelegatorMenu(!shouldShowDelegatorMenu);
}}
ref={buttonRef}
interactive={canSwitchAccounts}
wrapperStyle={[styles.flexGrow1, styles.flex1, styles.mnw0, styles.justifyContentCenter]}
>
<View style={[styles.flexRow, styles.gap3]}>
<Avatar
type={CONST.ICON_TYPE_AVATAR}
size={CONST.AVATAR_SIZE.MEDIUM}
avatarID={currentUserPersonalDetails?.accountID}
source={currentUserPersonalDetails?.avatar}
fallbackIcon={currentUserPersonalDetails.fallbackIcon}
/>
<View style={[styles.flex1, styles.flexShrink1, styles.flexBasis0, styles.justifyContentCenter, styles.gap1]}>
<View style={[styles.flexRow, styles.gap1]}>
<Text
numberOfLines={1}
style={[styles.textBold, styles.textLarge]}
>
{currentUserPersonalDetails?.displayName}
</Text>
{canSwitchAccounts && (
<View style={styles.justifyContentCenter}>
<Icon
fill={theme.icon}
src={Expensicons.CaretUpDown}
height={variables.iconSizeSmall}
width={variables.iconSizeSmall}
/>
</View>
)}
</View>
<Text
numberOfLines={1}
style={[styles.colorMuted, styles.fontSizeLabel]}
>
{currentUserPersonalDetails?.login}
</Text>
</View>
</View>
</PressableWithFeedback>
{canSwitchAccounts && (
<Popover
isVisible={shouldShowDelegatorMenu}
onClose={() => {
setShouldShowDelegatorMenu(false);
clearDelegatorErrors();
}}
anchorRef={buttonRef}
anchorPosition={styles.accountSwitcherAnchorPosition}
>
<View style={styles.pb4}>
<Text style={[styles.createMenuHeaderText, styles.ph5, styles.pb2, styles.pt4]}>{translate('delegate.switchAccount')}</Text>
<MenuItemList
menuItems={menuItems()}
shouldUseSingleExecution
/>
</View>
</Popover>
)}
<ConfirmModal
title={translate('common.youAppearToBeOffline')}
isVisible={shouldShowOfflineModal}
onConfirm={() => setShouldShowOfflineModal(false)}
onCancel={() => setShouldShowOfflineModal(false)}
confirmText={translate('common.buttonConfirm')}
prompt={translate('common.offlinePrompt')}
shouldShowCancelButton={false}
/>
</>
);
}

AccountSwitcher.displayName = 'AccountSwitcher';

export default AccountSwitcher;
Original file line number Diff line number Diff line change
Expand Up @@ -6,56 +6,53 @@ import SkeletonViewContentLoader from '@components/SkeletonViewContentLoader';
import useStyleUtils from '@hooks/useStyleUtils';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
import variables from '@styles/variables';
import CONST from '@src/CONST';

type CurrentUserPersonalDetailsSkeletonViewProps = {
type AccountSwitcherSkeletonViewProps = {
/** Whether to animate the skeleton view */
shouldAnimate?: boolean;

/** The size of the avatar */
avatarSize?: ValueOf<typeof CONST.AVATAR_SIZE>;
};

function CurrentUserPersonalDetailsSkeletonView({shouldAnimate = true, avatarSize = CONST.AVATAR_SIZE.LARGE}: CurrentUserPersonalDetailsSkeletonViewProps) {
function AccountSwitcherSkeletonView({shouldAnimate = true, avatarSize = CONST.AVATAR_SIZE.LARGE}: AccountSwitcherSkeletonViewProps) {
const theme = useTheme();
const styles = useThemeStyles();
const StyleUtils = useStyleUtils();
const avatarPlaceholderSize = StyleUtils.getAvatarSize(avatarSize);
const avatarPlaceholderRadius = avatarPlaceholderSize / 2;
const spaceBetweenAvatarAndHeadline = styles.mb3.marginBottom + styles.mt1.marginTop + (variables.lineHeightXXLarge - variables.fontSizeXLarge) / 2;
const headlineSize = variables.fontSizeXLarge;
const spaceBetweenHeadlineAndLabel = styles.mt1.marginTop + (variables.lineHeightXXLarge - variables.fontSizeXLarge) / 2;
const labelSize = variables.fontSizeLabel;
const startPositionX = 30;

return (
<View style={styles.avatarSectionWrapperSkeleton}>
<SkeletonViewContentLoader
animate={shouldAnimate}
backgroundColor={theme.skeletonLHNIn}
foregroundColor={theme.skeletonLHNOut}
height={avatarPlaceholderSize + spaceBetweenAvatarAndHeadline + headlineSize + spaceBetweenHeadlineAndLabel + labelSize}
height={avatarPlaceholderSize + styles.pb3.paddingBottom}
>
<Circle
cx="50%"
cx={startPositionX}
cy={avatarPlaceholderRadius}
r={avatarPlaceholderRadius}
/>
<Rect
x="20%"
y={avatarPlaceholderSize + spaceBetweenAvatarAndHeadline}
width="60%"
height={headlineSize}
x={startPositionX + avatarPlaceholderRadius + styles.gap3.gap}
y="11"
width="45%"
height="8"
/>
<Rect
x="15%"
y={avatarPlaceholderSize + spaceBetweenAvatarAndHeadline + headlineSize + spaceBetweenHeadlineAndLabel}
width="70%"
height={labelSize}
x={startPositionX + avatarPlaceholderRadius + styles.gap3.gap}
y="31"
width="55%"
height="8"
/>
</SkeletonViewContentLoader>
</View>
);
}

CurrentUserPersonalDetailsSkeletonView.displayName = 'CurrentUserPersonalDetailsSkeletonView';
export default CurrentUserPersonalDetailsSkeletonView;
AccountSwitcherSkeletonView.displayName = 'AccountSwitcherSkeletonView';
export default AccountSwitcherSkeletonView;
2 changes: 2 additions & 0 deletions src/components/Icon/Expensicons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import Calendar from '@assets/images/calendar.svg';
import Camera from '@assets/images/camera.svg';
import CarWithKey from '@assets/images/car-with-key.svg';
import Car from '@assets/images/car.svg';
import CaretUpDown from '@assets/images/caret-up-down.svg';
import Cash from '@assets/images/cash.svg';
import Chair from '@assets/images/chair.svg';
import ChatBubbleAdd from '@assets/images/chatbubble-add.svg';
Expand Down Expand Up @@ -387,5 +388,6 @@ export {
Filters,
CalendarSolid,
Filter,
CaretUpDown,
Feed,
};
5 changes: 4 additions & 1 deletion src/components/MenuItemList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ type MenuItemLink = string | (() => Promise<string>);
type MenuItemWithLink = MenuItemProps & {
/** The link to open when the menu item is clicked */
link?: MenuItemLink;

/** A unique key for the menu item */
key?: string;
};

type MenuItemListProps = {
Expand Down Expand Up @@ -43,7 +46,7 @@ function MenuItemList({menuItems = [], shouldUseSingleExecution = false}: MenuIt
<>
{menuItems.map((menuItemProps) => (
<MenuItem
key={menuItemProps.title}
key={menuItemProps.key ?? menuItemProps.title}
onSecondaryInteraction={menuItemProps.link !== undefined ? (e) => secondaryInteraction(menuItemProps.link, e) : undefined}
ref={popoverAnchor}
shouldBlockSelection={!!menuItemProps.link}
Expand Down
1 change: 1 addition & 0 deletions src/components/PopoverMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,7 @@ function PopoverMenu({
renderTooltipContent={item.renderTooltipContent}
numberOfLinesTitle={item.numberOfLinesTitle}
interactive={item.interactive}
badgeText={item.badgeText}
/>
))}
</View>
Expand Down
2 changes: 1 addition & 1 deletion src/components/Search/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -389,7 +389,7 @@ function Search({queryJSON, isCustomQuery}: SearchProps) {
/>
<DecisionModal
title={translate('common.youAppearToBeOffline')}
prompt={translate('search.offlinePrompt')}
prompt={translate('common.offlinePrompt')}
isSmallScreenWidth={isSmallScreenWidth}
onSecondOptionSubmit={() => setOfflineModalVisible(false)}
secondOptionText={translate('common.buttonConfirm')}
Expand Down
Loading

0 comments on commit 1c0a923

Please sign in to comment.