Skip to content

Commit cce503b

Browse files
committed
Move buttons on Workspace > Overview page into a More dropdown button
1 parent 2846678 commit cce503b

4 files changed

Lines changed: 134 additions & 86 deletions

File tree

src/components/ButtonWithDropdownMenu/index.tsx

Lines changed: 47 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type {RefObject} from 'react';
2-
import React, {useCallback, useEffect, useRef, useState} from 'react';
2+
import React, {forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState} from 'react';
33
import {View} from 'react-native';
44
import type {GestureResponderEvent} from 'react-native';
55
import Button from '@components/Button';
@@ -17,41 +17,43 @@ import CONST from '@src/CONST';
1717
import type {AnchorPosition} from '@src/styles';
1818
import type {ButtonWithDropdownMenuProps} from './types';
1919

20-
function ButtonWithDropdownMenu<IValueType>({
21-
success = true,
22-
isSplitButton = true,
23-
isLoading = false,
24-
isDisabled = false,
25-
pressOnEnter = false,
26-
shouldAlwaysShowDropdownMenu = false,
27-
menuHeaderText = '',
28-
customText,
29-
style,
30-
disabledStyle,
31-
buttonSize = CONST.DROPDOWN_BUTTON_SIZE.MEDIUM,
32-
anchorAlignment = {
33-
horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.RIGHT,
34-
vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.TOP, // we assume that popover menu opens below the button, anchor is at TOP
35-
},
36-
popoverHorizontalOffsetType,
37-
buttonRef,
38-
onPress,
39-
options,
40-
onOptionSelected,
41-
onSubItemSelected,
42-
onOptionsMenuShow,
43-
onOptionsMenuHide,
44-
enterKeyEventListenerPriority = 0,
45-
wrapperStyle,
46-
useKeyboardShortcuts = false,
47-
shouldUseStyleUtilityForAnchorPosition = false,
48-
defaultSelectedIndex = 0,
49-
shouldShowSelectedItemCheck = false,
50-
testID,
51-
secondLineText = '',
52-
icon,
53-
shouldUseModalPaddingStyle = true,
54-
}: ButtonWithDropdownMenuProps<IValueType>) {
20+
function ButtonWithDropdownMenuInner<IValueType>(props: ButtonWithDropdownMenuProps<IValueType>, ref: React.Ref<{setIsMenuVisible: (visible: boolean) => void}>) {
21+
const {
22+
success = true,
23+
isSplitButton = true,
24+
isLoading = false,
25+
isDisabled = false,
26+
pressOnEnter = false,
27+
shouldAlwaysShowDropdownMenu = false,
28+
menuHeaderText = '',
29+
customText,
30+
style,
31+
disabledStyle,
32+
buttonSize = CONST.DROPDOWN_BUTTON_SIZE.MEDIUM,
33+
anchorAlignment = {
34+
horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.RIGHT,
35+
vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.TOP, // we assume that popover menu opens below the button, anchor is at TOP
36+
},
37+
popoverHorizontalOffsetType,
38+
buttonRef,
39+
onPress,
40+
options,
41+
onOptionSelected,
42+
onSubItemSelected,
43+
onOptionsMenuShow,
44+
onOptionsMenuHide,
45+
enterKeyEventListenerPriority = 0,
46+
wrapperStyle,
47+
useKeyboardShortcuts = false,
48+
shouldUseStyleUtilityForAnchorPosition = false,
49+
defaultSelectedIndex = 0,
50+
shouldShowSelectedItemCheck = false,
51+
testID,
52+
secondLineText = '',
53+
icon,
54+
shouldUseModalPaddingStyle = true,
55+
} = props;
56+
5557
const theme = useTheme();
5658
const styles = useThemeStyles();
5759
const StyleUtils = useStyleUtils();
@@ -138,6 +140,10 @@ function ButtonWithDropdownMenu<IValueType>({
138140
[isMenuVisible, isSplitButton, onPress, selectedItem?.value],
139141
);
140142

143+
useImperativeHandle(ref, () => ({
144+
setIsMenuVisible,
145+
}));
146+
141147
return (
142148
<View style={wrapperStyle}>
143149
{shouldAlwaysShowDropdownMenu || options.length > 1 ? (
@@ -226,7 +232,9 @@ function ButtonWithDropdownMenu<IValueType>({
226232
onModalShow={onOptionsMenuShow}
227233
onItemSelected={(selectedSubitem, index, event) => {
228234
onSubItemSelected?.(selectedSubitem, index, event);
229-
setIsMenuVisible(false);
235+
if (selectedSubitem.shouldCloseModalOnSelect !== false) {
236+
setIsMenuVisible(false);
237+
}
230238
}}
231239
anchorPosition={shouldUseStyleUtilityForAnchorPosition ? styles.popoverButtonDropdownMenuOffset(windowWidth) : popoverAnchorPosition}
232240
shouldShowSelectedItemCheck={shouldShowSelectedItemCheck}
@@ -254,6 +262,6 @@ function ButtonWithDropdownMenu<IValueType>({
254262
);
255263
}
256264

257-
ButtonWithDropdownMenu.displayName = 'ButtonWithDropdownMenu';
258-
265+
ButtonWithDropdownMenuInner.displayName = 'ButtonWithDropdownMenu';
266+
const ButtonWithDropdownMenu = forwardRef(ButtonWithDropdownMenuInner);
259267
export default ButtonWithDropdownMenu;

src/components/ButtonWithDropdownMenu/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ type DropdownOption<TValueType> = {
4444
backButtonText?: string;
4545
avatarSize?: ValueOf<typeof CONST.AVATAR_SIZE>;
4646
shouldShow?: boolean;
47+
/** Whether to show a loading spinner for this option */
48+
shouldShowLoadingSpinnerIcon?: boolean;
4749
};
4850

4951
type ButtonWithDropdownMenuProps<TValueType> = {

src/components/PopoverMenu.tsx

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,12 @@ type PopoverMenuItem = MenuItemProps & {
6767

6868
/** Test identifier used to find elements in unit and e2e tests */
6969
testID?: string;
70+
71+
/** Whether to show a loading spinner icon for the menu item */
72+
shouldShowLoadingSpinnerIcon?: boolean;
73+
74+
/** Whether to close the modal on select */
75+
shouldCloseModalOnSelect?: boolean;
7076
};
7177

7278
type PopoverModalProps = Pick<ModalProps, 'animationIn' | 'animationOut' | 'animationInTiming' | 'animationOutTiming'> & Pick<ReanimatedModalProps, 'animationInDelay'>;
@@ -236,13 +242,17 @@ function PopoverMenu({
236242
setFocusedIndex(selectedSubMenuItemIndex);
237243
} else if (selectedItem.shouldCallAfterModalHide && (!isSafari() || shouldAvoidSafariException)) {
238244
onItemSelected?.(selectedItem, index, event);
239-
close(
240-
() => {
241-
selectedItem.onSelected?.();
242-
},
243-
undefined,
244-
selectedItem.shouldCloseAllModals,
245-
);
245+
if (selectedItem.shouldCloseModalOnSelect !== false) {
246+
close(
247+
() => {
248+
selectedItem.onSelected?.();
249+
},
250+
undefined,
251+
selectedItem.shouldCloseAllModals,
252+
);
253+
} else {
254+
selectedItem.onSelected?.();
255+
}
246256
} else {
247257
onItemSelected?.(selectedItem, index, event);
248258
selectedItem.onSelected?.();
@@ -287,7 +297,7 @@ function PopoverMenu({
287297
};
288298

289299
const renderedMenuItems = currentMenuItems.map((item, menuIndex) => {
290-
const {text, onSelected, subMenuItems, shouldCallAfterModalHide, key, testID: menuItemTestID, ...menuItemProps} = item;
300+
const {text, onSelected, subMenuItems, shouldCallAfterModalHide, key, testID: menuItemTestID, shouldShowLoadingSpinnerIcon, ...menuItemProps} = item;
291301

292302
return (
293303
<OfflineWithFeedback
@@ -323,6 +333,7 @@ function PopoverMenu({
323333
titleStyle={StyleSheet.flatten([styles.flex1, item.titleStyle])}
324334
// Spread other props dynamically
325335
{...menuItemProps}
336+
shouldShowLoadingSpinnerIcon={shouldShowLoadingSpinnerIcon}
326337
/>
327338
</OfflineWithFeedback>
328339
);

src/pages/workspace/WorkspaceOverviewPage.tsx

Lines changed: 66 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import {useFocusEffect} from '@react-navigation/native';
2-
import React, {useCallback, useContext, useState} from 'react';
2+
import React, {useCallback, useContext, useEffect, useRef, useState} from 'react';
33
import type {ImageStyle, StyleProp} from 'react-native';
44
import {Image, StyleSheet, View} from 'react-native';
55
import {useOnyx} from 'react-native-onyx';
66
import Avatar from '@components/Avatar';
77
import AvatarWithImagePicker from '@components/AvatarWithImagePicker';
8-
import Button from '@components/Button';
8+
import ButtonWithDropdownMenu from '@components/ButtonWithDropdownMenu';
9+
import type {DropdownOption} from '@components/ButtonWithDropdownMenu/types';
910
import ConfirmModal from '@components/ConfirmModal';
1011
import {FallbackWorkspaceAvatar, ImageCropSquareMask, QrCode, Trashcan, UserPlus} from '@components/Icon/Expensicons';
1112
import {Building} from '@components/Icon/Illustrations';
@@ -173,6 +174,8 @@ function WorkspaceOverviewPage({policyDraft, policy: policyProp, route}: Workspa
173174

174175
const {setIsDeletingPaidWorkspace, isLoadingBill}: {setIsDeletingPaidWorkspace: (value: boolean) => void; isLoadingBill: boolean | undefined} = usePayAndDowngrade(setIsDeleteModalOpen);
175176

177+
const dropdownMenuRef = useRef<any>(null);
178+
176179
const confirmDeleteAndHideModal = useCallback(() => {
177180
if (!policy?.id || !policyName) {
178181
return;
@@ -186,6 +189,12 @@ function WorkspaceOverviewPage({policyDraft, policy: policyProp, route}: Workspa
186189
}
187190
}, [policy?.id, policyName, shouldUseNarrowLayout]);
188191

192+
useEffect(() => {
193+
if (!isLoadingBill) {
194+
dropdownMenuRef.current.setIsMenuVisible(false);
195+
}
196+
}, [isLoadingBill]);
197+
189198
const onDeleteWorkspace = useCallback(() => {
190199
if (shouldCalculateBillNewDot()) {
191200
setIsDeletingPaidWorkspace(true);
@@ -210,6 +219,59 @@ function WorkspaceOverviewPage({policyDraft, policy: policyProp, route}: Workspa
210219
Navigation.popToSidebar();
211220
};
212221

222+
const getHeaderButtons = () => {
223+
if (readOnly) {
224+
return null;
225+
}
226+
const secondaryActions: DropdownOption<string>[] = [];
227+
if (isPolicyAdmin) {
228+
secondaryActions.push({
229+
value: 'invite',
230+
text: translate('common.invite'),
231+
icon: UserPlus,
232+
onSelected: () => {
233+
if (isAccountLocked) {
234+
showLockedAccountModal();
235+
return;
236+
}
237+
clearInviteDraft(route.params.policyID);
238+
Navigation.navigate(ROUTES.WORKSPACE_INVITE.getRoute(route.params.policyID, Navigation.getActiveRouteWithoutParams()));
239+
},
240+
});
241+
}
242+
secondaryActions.push({
243+
value: 'share',
244+
text: translate('common.share'),
245+
icon: QrCode,
246+
onSelected: isAccountLocked ? showLockedAccountModal : onPressShare,
247+
});
248+
if (isOwner) {
249+
secondaryActions.push({
250+
value: 'delete',
251+
text: translate('common.delete'),
252+
icon: Trashcan,
253+
onSelected: onDeleteWorkspace,
254+
disabled: isLoadingBill,
255+
shouldShowLoadingSpinnerIcon: isLoadingBill,
256+
shouldCloseModalOnSelect: false,
257+
});
258+
}
259+
return (
260+
<View style={[!shouldUseNarrowLayout && styles.flexRow, !shouldUseNarrowLayout && styles.gap2]}>
261+
<ButtonWithDropdownMenu
262+
ref={dropdownMenuRef}
263+
success={false}
264+
onPress={() => {}}
265+
shouldAlwaysShowDropdownMenu
266+
customText={translate('common.more')}
267+
options={secondaryActions}
268+
isSplitButton={false}
269+
wrapperStyle={styles.flexGrow1}
270+
/>
271+
</View>
272+
);
273+
};
274+
213275
return (
214276
<WorkspacePageWithSections
215277
headerText={translate('workspace.common.profile')}
@@ -223,9 +285,11 @@ function WorkspaceOverviewPage({policyDraft, policy: policyProp, route}: Workspa
223285
shouldShowNotFoundPage={policy === undefined}
224286
onBackButtonPress={handleBackButtonPress}
225287
addBottomSafeAreaPadding
288+
headerContent={!shouldUseNarrowLayout && getHeaderButtons()}
226289
>
227290
{(hasVBA?: boolean) => (
228291
<View style={[styles.flex1, styles.mt3, shouldUseNarrowLayout ? styles.workspaceSectionMobile : styles.workspaceSection]}>
292+
{shouldUseNarrowLayout && <View style={[styles.pl5, styles.pr5, styles.pb5]}>{getHeaderButtons()}</View>}
229293
<Section
230294
isCentralPane
231295
title=""
@@ -375,43 +439,6 @@ function WorkspaceOverviewPage({policyDraft, policy: policyProp, route}: Workspa
375439
</View>
376440
</OfflineWithFeedback>
377441
)}
378-
{!readOnly && (
379-
<View style={[styles.flexRow, styles.mt6, styles.mnw120]}>
380-
{isPolicyAdmin && (
381-
<Button
382-
accessibilityLabel={translate('common.invite')}
383-
text={translate('common.invite')}
384-
onPress={() => {
385-
if (isAccountLocked) {
386-
showLockedAccountModal();
387-
return;
388-
}
389-
clearInviteDraft(route.params.policyID);
390-
Navigation.navigate(ROUTES.WORKSPACE_INVITE.getRoute(route.params.policyID, Navigation.getActiveRouteWithoutParams()));
391-
}}
392-
icon={UserPlus}
393-
style={[styles.mr2]}
394-
/>
395-
)}
396-
<Button
397-
accessibilityLabel={translate('common.share')}
398-
text={translate('common.share')}
399-
onPress={isAccountLocked ? showLockedAccountModal : onPressShare}
400-
icon={QrCode}
401-
/>
402-
{isOwner && (
403-
<Button
404-
accessibilityLabel={translate('common.delete')}
405-
text={translate('common.delete')}
406-
style={[styles.ml2]}
407-
onPress={onDeleteWorkspace}
408-
icon={Trashcan}
409-
isLoading={isLoadingBill}
410-
iconStyles={isLoadingBill ? styles.opacity0 : undefined}
411-
/>
412-
)}
413-
</View>
414-
)}
415442
</Section>
416443
<ConfirmModal
417444
title={translate('workspace.common.delete')}

0 commit comments

Comments
 (0)