Skip to content

Commit 38e0501

Browse files
authored
Merge pull request Expensify#65315 from nyomanjyotisa/issue-64902
Move buttons on Workspace > Overview page into a More dropdown button
2 parents 2e4c4f6 + a0f6cd4 commit 38e0501

4 files changed

Lines changed: 143 additions & 88 deletions

File tree

src/components/ButtonWithDropdownMenu/index.tsx

Lines changed: 55 additions & 41 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,42 +17,48 @@ 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-
shouldUseOptionIcon = false,
55-
}: ButtonWithDropdownMenuProps<IValueType>) {
20+
type ButtonWithDropdownMenuRef = {
21+
setIsMenuVisible: (visible: boolean) => void;
22+
};
23+
24+
function ButtonWithDropdownMenuInner<IValueType>(props: ButtonWithDropdownMenuProps<IValueType>, ref: React.Ref<ButtonWithDropdownMenuRef>) {
25+
const {
26+
success = true,
27+
isSplitButton = true,
28+
isLoading = false,
29+
isDisabled = false,
30+
pressOnEnter = false,
31+
shouldAlwaysShowDropdownMenu = false,
32+
menuHeaderText = '',
33+
customText,
34+
style,
35+
disabledStyle,
36+
buttonSize = CONST.DROPDOWN_BUTTON_SIZE.MEDIUM,
37+
anchorAlignment = {
38+
horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.RIGHT,
39+
vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.TOP, // we assume that popover menu opens below the button, anchor is at TOP
40+
},
41+
popoverHorizontalOffsetType,
42+
buttonRef,
43+
onPress,
44+
options,
45+
onOptionSelected,
46+
onSubItemSelected,
47+
onOptionsMenuShow,
48+
onOptionsMenuHide,
49+
enterKeyEventListenerPriority = 0,
50+
wrapperStyle,
51+
useKeyboardShortcuts = false,
52+
shouldUseStyleUtilityForAnchorPosition = false,
53+
defaultSelectedIndex = 0,
54+
shouldShowSelectedItemCheck = false,
55+
testID,
56+
secondLineText = '',
57+
icon,
58+
shouldUseModalPaddingStyle = true,
59+
shouldUseOptionIcon = false,
60+
} = props;
61+
5662
const theme = useTheme();
5763
const styles = useThemeStyles();
5864
const StyleUtils = useStyleUtils();
@@ -72,7 +78,7 @@ function ButtonWithDropdownMenu<IValueType>({
7278
const areAllOptionsDisabled = options.every((option) => option.disabled);
7379
const innerStyleDropButton = StyleUtils.getDropDownButtonHeight(buttonSize);
7480
const isButtonSizeLarge = buttonSize === CONST.DROPDOWN_BUTTON_SIZE.LARGE;
75-
const nullCheckRef = (ref: RefObject<View | null>) => ref ?? null;
81+
const nullCheckRef = (refParam: RefObject<View | null>) => refParam ?? null;
7682
const shouldShowButtonRightIcon = !!options.at(0)?.shouldShowButtonRightIcon;
7783

7884
useEffect(() => {
@@ -140,6 +146,10 @@ function ButtonWithDropdownMenu<IValueType>({
140146
[isMenuVisible, isSplitButton, onPress, selectedItem?.value],
141147
);
142148

149+
useImperativeHandle(ref, () => ({
150+
setIsMenuVisible,
151+
}));
152+
143153
return (
144154
<View style={wrapperStyle}>
145155
{shouldAlwaysShowDropdownMenu || options.length > 1 ? (
@@ -231,7 +241,9 @@ function ButtonWithDropdownMenu<IValueType>({
231241
onModalShow={onOptionsMenuShow}
232242
onItemSelected={(selectedSubitem, index, event) => {
233243
onSubItemSelected?.(selectedSubitem, index, event);
234-
setIsMenuVisible(false);
244+
if (selectedSubitem.shouldCloseModalOnSelect !== false) {
245+
setIsMenuVisible(false);
246+
}
235247
}}
236248
anchorPosition={shouldUseStyleUtilityForAnchorPosition ? styles.popoverButtonDropdownMenuOffset(windowWidth) : popoverAnchorPosition}
237249
shouldShowSelectedItemCheck={shouldShowSelectedItemCheck}
@@ -259,6 +271,8 @@ function ButtonWithDropdownMenu<IValueType>({
259271
);
260272
}
261273

262-
ButtonWithDropdownMenu.displayName = 'ButtonWithDropdownMenu';
263-
274+
ButtonWithDropdownMenuInner.displayName = 'ButtonWithDropdownMenu';
275+
const ButtonWithDropdownMenu = forwardRef(ButtonWithDropdownMenuInner) as <IValueType>(
276+
props: ButtonWithDropdownMenuProps<IValueType> & {ref?: React.Ref<ButtonWithDropdownMenuRef>},
277+
) => ReturnType<typeof ButtonWithDropdownMenuInner>;
264278
export default ButtonWithDropdownMenu;

src/components/ButtonWithDropdownMenu/types.ts

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

5052
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: 67 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
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 Avatar from '@components/Avatar';
66
import AvatarWithImagePicker from '@components/AvatarWithImagePicker';
7-
import Button from '@components/Button';
7+
import ButtonWithDropdownMenu from '@components/ButtonWithDropdownMenu';
8+
import type {DropdownOption} from '@components/ButtonWithDropdownMenu/types';
89
import ConfirmModal from '@components/ConfirmModal';
910
import {FallbackWorkspaceAvatar, ImageCropSquareMask, QrCode, Trashcan, UserPlus} from '@components/Icon/Expensicons';
1011
import {Building} from '@components/Icon/Illustrations';
@@ -174,6 +175,8 @@ function WorkspaceOverviewPage({policyDraft, policy: policyProp, route}: Workspa
174175

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

178+
const dropdownMenuRef = useRef<{setIsMenuVisible: (visible: boolean) => void} | null>(null);
179+
177180
const confirmDeleteAndHideModal = useCallback(() => {
178181
if (!policy?.id || !policyName) {
179182
return;
@@ -187,6 +190,13 @@ function WorkspaceOverviewPage({policyDraft, policy: policyProp, route}: Workspa
187190
}
188191
}, [policy?.id, policyName, shouldUseNarrowLayout]);
189192

193+
useEffect(() => {
194+
if (isLoadingBill) {
195+
return;
196+
}
197+
dropdownMenuRef.current?.setIsMenuVisible(false);
198+
}, [isLoadingBill]);
199+
190200
const onDeleteWorkspace = useCallback(() => {
191201
if (shouldCalculateBillNewDot()) {
192202
setIsDeletingPaidWorkspace(true);
@@ -211,6 +221,59 @@ function WorkspaceOverviewPage({policyDraft, policy: policyProp, route}: Workspa
211221
Navigation.popToSidebar();
212222
};
213223

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

0 commit comments

Comments
 (0)