diff --git a/assets/images/user-pencil.svg b/assets/images/user-pencil.svg
new file mode 100644
index 000000000000..7cc7518c5fe0
--- /dev/null
+++ b/assets/images/user-pencil.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/CONST/index.ts b/src/CONST/index.ts
index d39bbb758944..12734c61eb66 100644
--- a/src/CONST/index.ts
+++ b/src/CONST/index.ts
@@ -5060,11 +5060,7 @@ const CONST = {
REMOVE: 'remove',
MAKE_MEMBER: 'makeMember',
MAKE_ADMIN: 'makeAdmin',
- MAKE_AUDITOR: 'makeAuditor',
- MAKE_GUEST: 'makeGuest',
- MAKE_CARD_ADMIN: 'makeCardAdmin',
- MAKE_PEOPLE_ADMIN: 'makePeopleAdmin',
- MAKE_PAYMENTS_ADMIN: 'makePaymentsAdmin',
+ CHANGE_ROLE: 'changeRole',
},
BULK_ACTION_TYPES: {
DELETE: 'delete',
diff --git a/src/CONST/runtimeConfigured.ts b/src/CONST/runtimeConfigured.ts
index a460e31708d5..49861ed9bb75 100644
--- a/src/CONST/runtimeConfigured.ts
+++ b/src/CONST/runtimeConfigured.ts
@@ -75,6 +75,7 @@ const CONST_RUNTIME: ConstRuntime = {
SCREENS.SUBMIT_PLAN_WELCOME_MODAL.DYNAMIC_ROOT,
SCREENS.MONEY_REQUEST.DYNAMIC_STEP_SCAN,
SCREENS.DOMAIN.MEMBERS_MOVE_TO_GROUP,
+ SCREENS.WORKSPACE.MEMBERS_ROLE,
SCREENS.PRE_MOUNT_BUFFER,
...Object.values(SCREENS.MULTIFACTOR_AUTHENTICATION),
],
diff --git a/src/CONST/runtimeDefaults.ts b/src/CONST/runtimeDefaults.ts
index 474e56cc06b5..61707304937e 100644
--- a/src/CONST/runtimeDefaults.ts
+++ b/src/CONST/runtimeDefaults.ts
@@ -98,6 +98,7 @@ const CONST_RUNTIME_DEFAULTS: ConstRuntime = {
'Dynamic_SubmitPlanWelcomeModal_Root',
'Money_Request_Step_Scan',
'Members_Move_To_Group',
+ 'Workspace_Members_Role',
'PreMountBuffer',
'Multifactor_Authentication_Validate_Code',
'Multifactor_Authentication_Outcome_Success',
diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts
index 34796fec00e3..9313563cc256 100755
--- a/src/ONYXKEYS.ts
+++ b/src/ONYXKEYS.ts
@@ -562,6 +562,9 @@ const ONYXKEYS = {
/** Selected domain member account IDs for the move-to-group operation */
RAM_ONLY_DOMAIN_MEMBERS_SELECTED_FOR_MOVE: 'domainMembersSelectedForMove',
+ /** The workspace members selected for the bulk role change, with the workspace they were selected on */
+ RAM_ONLY_WORKSPACE_MEMBERS_SELECTED_FOR_ROLE_CHANGE: 'workspaceMembersSelectedForRoleChange',
+
// The theme setting set by the user in preferences.
// This can be either "light", "dark", "system", "light-contrast", "dark-contrast" or "system-contrast"
PREFERRED_THEME: 'nvp_preferredTheme',
@@ -1895,6 +1898,7 @@ type OnyxValuesMapping = {
[ONYXKEYS.VIEWING_PUBLIC_ROOM_REPORT_ID]: string;
[ONYXKEYS.MY_DOMAIN_SECURITY_GROUPS]: Record;
[ONYXKEYS.RAM_ONLY_DOMAIN_MEMBERS_SELECTED_FOR_MOVE]: string[];
+ [ONYXKEYS.RAM_ONLY_WORKSPACE_MEMBERS_SELECTED_FOR_ROLE_CHANGE]: OnyxTypes.WorkspaceMembersSelectedForRoleChange;
[ONYXKEYS.VERIFY_3DS_SUBSCRIPTION]: string;
[ONYXKEYS.VERIFY_3DS_SUBSCRIPTION_SOURCE]: string;
[ONYXKEYS.PREFERRED_THEME]: ValueOf;
diff --git a/src/ROUTES.ts b/src/ROUTES.ts
index c7d28b6daf40..1209c6dbdcd9 100644
--- a/src/ROUTES.ts
+++ b/src/ROUTES.ts
@@ -3873,6 +3873,10 @@ const ROUTES = {
route: 'workspaces/:policyID/taxes/settings/tax-name',
getRoute: (policyID: string) => `workspaces/${policyID}/taxes/settings/tax-name` as const,
},
+ WORKSPACE_MEMBERS_ROLE: {
+ route: 'workspaces/:policyID/change-member-roles',
+ getRoute: (policyID: string) => `workspaces/${policyID}/change-member-roles` as const,
+ },
WORKSPACE_MEMBER_DETAILS: {
route: 'workspaces/:policyID/members/:accountID',
getRoute: (policyID: string, accountID: number) => `workspaces/${policyID}/members/${accountID}` as const,
diff --git a/src/SCREENS.ts b/src/SCREENS.ts
index 2a938bc7e7b7..35daa632553f 100644
--- a/src/SCREENS.ts
+++ b/src/SCREENS.ts
@@ -963,6 +963,7 @@ const SCREENS = {
MORE_FEATURES: 'Workspace_More_Features',
MEMBER_DETAILS: 'Workspace_Member_Details',
MEMBER_DETAILS_ROLE: 'Workspace_Member_Details_Role',
+ MEMBERS_ROLE: 'Workspace_Members_Role',
MEMBER_WORK_ARRANGEMENT: 'Workspace_Member_Work_Arrangement',
INVITE_WORK_ARRANGEMENT: 'Workspace_Invite_Work_Arrangement',
MEMBER_CUSTOM_FIELD: 'Workspace_Member_Custom_Field',
diff --git a/src/components/BulkActionBar/BulkActionBarButton.tsx b/src/components/BulkActionBar/BulkActionBarButton.tsx
index 544aff053090..6e12a5638216 100644
--- a/src/components/BulkActionBar/BulkActionBarButton.tsx
+++ b/src/components/BulkActionBar/BulkActionBarButton.tsx
@@ -75,6 +75,8 @@ function BulkActionBarButton({option, onSubItemSelected}: BulkAction
size={CONST.BUTTON_SIZE.SMALL}
isDisabled={option.disabled}
accessibilityLabel={option.text}
+ accessibilityState={hasSubMenu ? {expanded: isMenuVisible} : undefined}
+ accessibilityHasPopup={hasSubMenu ? CONST.ROLE.MENU : undefined}
sentryLabel={option.sentryLabel}
>
{!!option.icon && (
diff --git a/src/components/BulkActionBar/index.tsx b/src/components/BulkActionBar/index.tsx
index a63631deb9dd..bafe5f2c7c23 100644
--- a/src/components/BulkActionBar/index.tsx
+++ b/src/components/BulkActionBar/index.tsx
@@ -7,6 +7,7 @@ import Text from '@components/Text';
import ThemeProvider from '@components/ThemeProvider';
import ThemeStylesProvider from '@components/ThemeStylesContextProvider';
+import useAccessibilityAnnouncement from '@hooks/useAccessibilityAnnouncement';
import useInvertedThemePreference from '@hooks/useInvertedThemePreference';
import useKeyboardShortcut from '@hooks/useKeyboardShortcut';
import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset';
@@ -19,6 +20,8 @@ import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
import Accessibility from '@libs/Accessibility';
+import mergeRefs from '@libs/mergeRefs';
+import moveMiniToolbarFocusWithArrowKey from '@libs/moveContextMenuFocusWithArrowKey';
import shouldPopoverUseScrollView from '@libs/shouldPopoverUseScrollView';
import {areAllModalsHidden, setDisableDismissOnEscape} from '@userActions/Modal';
@@ -39,6 +42,9 @@ import type {BulkActionBarProps} from './types';
import BulkActionBarButton from './BulkActionBarButton';
import BulkActionBarMenuTheme from './BulkActionBarMenuTheme';
import {defaultPopoverAnchorPosition, MORE_MENU_ANCHOR_ALIGNMENT} from './popoverPosition';
+import useBulkActionBarFocus from './useBulkActionBarFocus';
+
+const noop = () => {};
/**
* The bar's contents. Everything here takes its colors from the theme it is rendered under, which `BulkActionBar`
@@ -77,6 +83,17 @@ function BulkActionBarContent({
const icons = useMemoizedLazyExpensifyIcons(['Close', 'DownArrow', 'UpArrow']);
const {calculatePopoverPosition} = usePopoverPosition();
+ const isFocused = useIsFocused();
+
+ // Esc clears the selection, but not while something is open over the bar. An RHP the bar is rendered inside
+ // reports itself visible too, and is told apart by the screen still being focused. The type is not worth reading
+ // here, because a popover opened and closed over an RHP leaves it stale at `popover`.
+ const [modal] = useOnyx(ONYXKEYS.MODAL);
+ const isCoveredByModal = !!modal?.isVisible && !isFocused;
+
+ const barElementRef = useRef | null>(null);
+ const {suppressStrayFocusRing, isFocusInsideBar} = useBulkActionBarFocus(barElementRef, isCoveredByModal);
+
const moreAnchorRef = useRef | null>(null);
const [isMoreMenuVisible, setIsMoreMenuVisible] = useState(false);
const [moreMenuAnchorPosition, setMoreMenuAnchorPosition] = useState(defaultPopoverAnchorPosition);
@@ -87,13 +104,6 @@ function BulkActionBarContent({
const inlineOptions = hasMoreMenu ? options.slice(0, inlineActionCount) : options;
const moreOptions = hasMoreMenu ? options.slice(inlineActionCount) : [];
- // Esc clears the selection, but not while something is open over the bar. An RHP the bar is rendered inside
- // reports itself visible too, and is told apart by the screen still being focused. The type is not worth reading
- // here, because a popover opened and closed over an RHP leaves it stale at `popover`.
- const [modal] = useOnyx(ONYXKEYS.MODAL);
- const isFocused = useIsFocused();
- const isCoveredByModal = !!modal?.isVisible && !isFocused;
-
const canOwnEscape = !modal?.willAlertModalBecomeVisible && !isCoveredByModal;
// The Side Panel is not a `BaseModal`, so it is absent from both the modal state and the registry of open modals.
@@ -104,6 +114,7 @@ function BulkActionBarContent({
// `willAlertModalBecomeVisible` is a single flag that every modal writes, so closing the topmost of a stack reads
// as though nothing is open any more. The registry of open modals is the only answer that survives stacking, and
// it has to be read when the key arrives rather than at subscribe time.
+ // Esc inside a text field is how you leave the field, so leave the selection alone there.
useKeyboardShortcut(
CONST.KEYBOARD_SHORTCUTS.ESCAPE,
() => {
@@ -111,11 +122,17 @@ function BulkActionBarContent({
return;
}
+ suppressStrayFocusRing();
onClearSelection();
},
- {isActive: shouldClearSelectionOnEscape},
+ {isActive: shouldClearSelectionOnEscape, captureOnInputs: false},
);
+ // The lists this bar floats over run a global Enter shortcut off a keyboard cursor that outlives tabbing away from
+ // the rows. While one of the bar's own buttons is focused, claim Enter without bubbling so pressing it only works
+ // that button instead of also opening whichever row the cursor was left on. Same guard as SearchPageFooter's.
+ useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.ENTER, noop, {isActive: isFocusInsideBar, shouldBubble: false, shouldPreventDefault: false});
+
// Escape must not dismiss the screen the selection sits on while the bar is the one answering it. The Side Panel
// is deliberately left out of the condition, because each write to this flag resubscribes the app's own Escape
// handler to the top of the stack, over the Side Panel's, and that one Escape would then both dismiss the screen
@@ -152,16 +169,32 @@ function BulkActionBarContent({
};
}, [isMoreMenuVisible, calculatePopoverPosition]);
+ // The bar appears where nothing was before, without taking focus, so a screen reader only learns about it and about
+ // the size of the selection it describes from an announcement.
+ useAccessibilityAnnouncement(countLabel, !isSelectedCountLoading, {shouldAnnounceOnWeb: true, shouldAnnounceOnNative: true, politeness: 'polite'});
+
return (
onBarLayout(event.nativeEvent.layout.width)}
>
{/* Sized for a three-digit count so the bar keeps still as the selection grows, and so swapping the
spinner for the count does not resize it either. */}
- {isSelectedCountLoading ? : {countLabel}}
+ {isSelectedCountLoading ? (
+
+ ) : (
+ {countLabel}
+ )}
{!!noticeText && {noticeText}}
{inlineOptions.map((option) => (
@@ -178,6 +211,8 @@ function BulkActionBarContent({
size={CONST.BUTTON_SIZE.SMALL}
onPress={() => setIsMoreMenuVisible((isVisible) => !isVisible)}
accessibilityLabel={translate('common.more')}
+ accessibilityState={{expanded: isMoreMenuVisible}}
+ accessibilityHasPopup={CONST.ROLE.MENU}
sentryLabel={CONST.SENTRY_LABEL.BULK_ACTION_BAR.MORE}
>
{translate('common.more')}
@@ -212,7 +247,7 @@ function BulkActionBarContent({
)}
{};
+
+/** Native has no tab order and no focus ring, so the bar's controls are reached through the screen reader instead. */
+const useBulkActionBarFocus: UseBulkActionBarFocus = () => ({suppressStrayFocusRing: noop, isFocusInsideBar: false});
+
+export default useBulkActionBarFocus;
diff --git a/src/components/BulkActionBar/useBulkActionBarFocus/index.ts b/src/components/BulkActionBar/useBulkActionBarFocus/index.ts
new file mode 100644
index 000000000000..2a2bc210d3e7
--- /dev/null
+++ b/src/components/BulkActionBar/useBulkActionBarFocus/index.ts
@@ -0,0 +1,156 @@
+import isHTMLElement from '@libs/isHTMLElement';
+import {pickLauncher} from '@libs/LauncherStack';
+import {nominateTriggerFallback} from '@libs/NavigationFocusReturn';
+import markProgrammaticFocus from '@libs/programmaticFocus';
+import restoreFocusWithModality from '@libs/restoreFocusWithModality';
+
+import {useEffect, useLayoutEffect, useRef, useState} from 'react';
+
+import type UseBulkActionBarFocus from './types';
+
+/**
+ * Whether the browser is already drawing a focus ring on this element. Browsers that cannot be asked are assumed to be
+ * drawing one, so a ring is never taken away from someone who navigated there with the keyboard.
+ */
+function isShowingFocusRing(element: HTMLElement): boolean {
+ try {
+ return element.matches(':focus-visible');
+ } catch {
+ return true;
+ }
+}
+
+/** Tracks where focus is relative to the bar, and hands it back when the bar goes away holding it. */
+const useBulkActionBarFocus: UseBulkActionBarFocus = (barRef, isCoveredByScreen) => {
+ // Where focus was before the bar took it. The bar goes away with the selection, so focus has to be handed back.
+ const lastFocusedOutsideRef = useRef(null);
+
+ const [isFocusInsideBar, setIsFocusInsideBar] = useState(false);
+
+ useEffect(() => {
+ const remember = (target: EventTarget | null) => {
+ if (!isHTMLElement(target) || target === document.body) {
+ return;
+ }
+
+ const bar = barRef.current;
+ if (isHTMLElement(bar) && bar.contains(target)) {
+ return;
+ }
+
+ // The role screen and the bar's own "More" menu both take focus to controls that leave with them, and the
+ // menu renders in a popover outside the bar rather than within it. Remembering either would leave the bar
+ // holding a target that is already gone when it needs one. A popover is identified by the launcher it
+ // registered as it opened, which it does before it moves focus into itself.
+ if (isCoveredByScreen || pickLauncher()) {
+ return;
+ }
+
+ lastFocusedOutsideRef.current = target;
+ // An action that opens a screen is navigated back from, and the restore that follows aims at the bar
+ // button it was opened from, which the cleared selection has taken away by then. This is the survivor to
+ // aim at instead, and it is offered here rather than on unmount because the capture happens first.
+ nominateTriggerFallback(target);
+ };
+
+ // The bar appears because of a selection, so the control that made it already holds focus and fires no
+ // focusin for the listener below to see.
+ if (!lastFocusedOutsideRef.current) {
+ remember(document.activeElement);
+ }
+
+ const handleFocusIn = (event: FocusEvent) => {
+ const target = event.target;
+ if (!isHTMLElement(target)) {
+ return;
+ }
+
+ const bar = barRef.current;
+ if (isHTMLElement(bar) && bar.contains(target)) {
+ setIsFocusInsideBar(true);
+ return;
+ }
+
+ setIsFocusInsideBar(false);
+ remember(target);
+ };
+
+ // Focus can also leave for nothing at all, such as an element being removed, which fires no matching focusin.
+ const handleFocusOut = (event: FocusEvent) => {
+ if (event.relatedTarget) {
+ return;
+ }
+
+ setIsFocusInsideBar(false);
+ };
+
+ document.addEventListener('focusin', handleFocusIn);
+ document.addEventListener('focusout', handleFocusOut);
+
+ return () => {
+ document.removeEventListener('focusin', handleFocusIn);
+ document.removeEventListener('focusout', handleFocusOut);
+ };
+ }, [barRef, isCoveredByScreen]);
+
+ // Most of the bar's actions clear the selection themselves, so the bar is torn out from under the button that was
+ // just pressed and focus would fall to the document. Handing it back here covers every one of those actions,
+ // rather than only the two the bar runs itself. A layout cleanup rather than a passive one, because this has to
+ // read the focus before the bar leaves the DOM.
+ useLayoutEffect(
+ () => () => {
+ nominateTriggerFallback(null);
+
+ const bar = barRef.current;
+ const active = document.activeElement;
+
+ // An action run from a screen opened over the table clears the selection from there, so the bar is gone
+ // before that screen hands its own focus back and there is nothing left for it to hand back to. Focus has
+ // already fallen to the document by then, which is the same loss as the bar being torn out from under it.
+ const hasFocusFallenToDocument = !active || active === document.body;
+
+ // An action run from the bar's own "More" menu is pressed with focus inside a popover that renders outside
+ // the bar, so the bar does not contain the focus it nonetheless has to hand back. The popover that leaves
+ // with the bar is the one whose launcher the bar does contain.
+ const launcher = pickLauncher();
+ const isFocusInsideBarPopover = isHTMLElement(bar) && !!launcher && bar.contains(launcher);
+ if (!hasFocusFallenToDocument && !isFocusInsideBarPopover && !(isHTMLElement(bar) && bar.contains(active))) {
+ return;
+ }
+
+ const previous = lastFocusedOutsideRef.current;
+ if (!previous?.isConnected) {
+ return;
+ }
+
+ // The screen that cleared the selection may still be unwinding its focus trap, which would otherwise pull
+ // focus straight back into the container it is closing.
+ restoreFocusWithModality(previous);
+ },
+ [barRef],
+ );
+
+ const suppressStrayFocusRing = () => {
+ const active = document.activeElement;
+ if (!isHTMLElement(active)) {
+ return;
+ }
+
+ const bar = barRef.current;
+ if (isHTMLElement(bar) && bar.contains(active)) {
+ return;
+ }
+
+ // Esc makes whatever holds focus match `:focus-visible`, so a checkbox clicked with the mouse would light up
+ // with a focus ring just as the selection it belongs to disappears.
+ if (isShowingFocusRing(active)) {
+ return;
+ }
+
+ markProgrammaticFocus(active);
+ };
+
+ return {suppressStrayFocusRing, isFocusInsideBar};
+};
+
+export default useBulkActionBarFocus;
diff --git a/src/components/BulkActionBar/useBulkActionBarFocus/types.ts b/src/components/BulkActionBar/useBulkActionBarFocus/types.ts
new file mode 100644
index 000000000000..376b5b2b8d98
--- /dev/null
+++ b/src/components/BulkActionBar/useBulkActionBarFocus/types.ts
@@ -0,0 +1,22 @@
+import type {RefObject} from 'react';
+
+type BulkActionBarFocus = {
+ /**
+ * Keeps Esc from painting a focus ring on a control the pointer put focus on. Focus that is inside the bar is left
+ * alone, since the bar hands that back itself as it unmounts.
+ */
+ suppressStrayFocusRing: () => void;
+
+ /** Whether one of the bar's own controls currently holds focus. */
+ isFocusInsideBar: boolean;
+};
+
+/**
+ * `isCoveredByScreen` is true while another screen is open over the bar. Focus landing outside the bar then belongs to
+ * that screen rather than to the table, and leaves with it, so it is no use as a hand-back target. Popovers are left
+ * out of this flag, because an RHP the bar is rendered inside reports itself as a visible modal for as long as it is
+ * open. The bar tells those apart by the launcher they were opened from instead.
+ */
+type UseBulkActionBarFocus = (barRef: RefObject, isCoveredByScreen: boolean) => BulkActionBarFocus;
+
+export default UseBulkActionBarFocus;
diff --git a/src/components/Button/Button.tsx b/src/components/Button/Button.tsx
index 29e62ca71729..8563ba0635f3 100644
--- a/src/components/Button/Button.tsx
+++ b/src/components/Button/Button.tsx
@@ -51,6 +51,7 @@ function Button({
sentryLabel,
ref,
accessibilityState,
+ accessibilityHasPopup,
}: ButtonProps) {
const theme = useTheme();
const styles = useThemeStyles();
@@ -140,6 +141,7 @@ function Button({
testID={testID}
accessibilityLabel={accessibilityLabel}
accessibilityState={accessibilityState}
+ accessibilityHasPopup={accessibilityHasPopup}
sentryLabel={sentryLabel}
role={CONST.ROLE.BUTTON}
isNested={isNested}
diff --git a/src/components/Button/types.ts b/src/components/Button/types.ts
index 6fad32beca96..634b4687d876 100644
--- a/src/components/Button/types.ts
+++ b/src/components/Button/types.ts
@@ -1,3 +1,5 @@
+import type {AccessibilityHasPopup} from '@components/Pressable/PressResponder/PressResponderContext';
+
import type {ButtonVariant} from '@styles/utils/types';
import type CONST from '@src/CONST';
@@ -107,6 +109,9 @@ type BaseButtonProps = WithSentryLabel &
/** Accessibility state to pass to the pressable */
accessibilityState?: AccessibilityState;
+ /** What the button opens, for a button that opens a menu or a dialog rather than acting straight away */
+ accessibilityHasPopup?: AccessibilityHasPopup;
+
ref?: ForwardedRef>;
};
diff --git a/src/components/Icon/chunks/expensify-icons.chunk.ts b/src/components/Icon/chunks/expensify-icons.chunk.ts
index a07fa8396598..4fa39f38a242 100644
--- a/src/components/Icon/chunks/expensify-icons.chunk.ts
+++ b/src/components/Icon/chunks/expensify-icons.chunk.ts
@@ -283,6 +283,7 @@ import UserCheck from '@assets/images/user-check.svg';
import UserEye from '@assets/images/user-eye.svg';
import UserLock from '@assets/images/user-lock.svg';
import UserMinus from '@assets/images/user-minus.svg';
+import UserPencil from '@assets/images/user-pencil.svg';
import UserPlus from '@assets/images/user-plus.svg';
import UserSearch from '@assets/images/user-search.svg';
import UserShield from '@assets/images/user-shield.svg';
@@ -581,6 +582,7 @@ const Expensicons = {
UserEye,
UserSearch,
CaretUpDown,
+ UserPencil,
UserPlus,
Feed,
Table,
diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx
index 92ba844f03e3..6368c9b974dd 100644
--- a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx
+++ b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx
@@ -311,13 +311,20 @@ function MoneyRequestReportActionsListContent({reportIDFromRoute, onLayout}: Mon
rules,
});
+ // The narrow layout puts its selection controls in a header above the list, while the wide layout floats a bar over
+ // the end of it. The floating bar has to follow the list here rather than lead it, or Tab reaches it only after the
+ // rest of the app has been walked through and wrapped back around.
+ const selectionToolbar = (
+
+ );
+
return (
-
+ {shouldUseNarrowLayout && selectionToolbar}
)}
+ {!shouldUseNarrowLayout && selectionToolbar}
);
}
diff --git a/src/components/Table/Table.tsx b/src/components/Table/Table.tsx
index b8d0cc2244c0..42b5de198b71 100644
--- a/src/components/Table/Table.tsx
+++ b/src/components/Table/Table.tsx
@@ -44,6 +44,7 @@ import useSelection from './middlewares/selection';
import useSorting from './middlewares/sorting';
import {shouldUseTableSemantics} from './tableAccessibility';
import {doesBodyRenderWhenEmpty} from './TableBody';
+import TableBulkActionBar from './TableBulkActionBar';
import TableContext from './TableContext';
import TableEmptyState from './TableEmptyStates/TableEmptyState';
import TableNoResultsState from './TableEmptyStates/TableNoResultsState';
@@ -358,6 +359,12 @@ function TableContent React.isValidElement(child) && child.type === TableBulkActionBar);
+ const isBulkActionBarVisible = hasBulkActionBarChild && !shouldUseNarrowLayout && selectedKeys.length > 0;
+
const tableListMetadata = useMemo(
() =>
getTableListMetadata({
@@ -453,6 +460,8 @@ function TableContent = Omit, 'selectedCount' | 'onClearSelection'> & {
+ /** Replaces what the bar's close button does, which is otherwise to clear the table's own selection. */
+ onClearSelection?: () => void;
+};
+
+/**
+ * The floating bulk action bar for the table's selection. Render it as a child of `
` and it mounts itself
+ * whenever the wide layout has rows selected, with the list reserving the space it floats over.
+ */
+function TableBulkActionBar({onClearSelection, ...bulkActionBarProps}: TableBulkActionBarProps) {
+ const {selectedKeys, tableMethods, isBulkActionBarVisible} = useTableContext();
+
+ if (!isBulkActionBarVisible) {
+ return null;
+ }
+
+ return (
+
+ );
+}
+
+export default TableBulkActionBar;
diff --git a/src/components/Table/TableContext.tsx b/src/components/Table/TableContext.tsx
index 9b3afa8ee41b..8b7d42be7026 100644
--- a/src/components/Table/TableContext.tsx
+++ b/src/components/Table/TableContext.tsx
@@ -61,6 +61,15 @@ type TableContextValue = {
},
isEmptyResult: false,
isDefaultViewEmpty: false,
+ selectedKeys: [],
+ isBulkActionBarVisible: false,
shouldUseNarrowTableLayout: false,
isMobileSelectionEnabled: false,
};
diff --git a/src/components/Table/index.tsx b/src/components/Table/index.tsx
index ca994883f0c5..c68d07362daa 100644
--- a/src/components/Table/index.tsx
+++ b/src/components/Table/index.tsx
@@ -36,6 +36,7 @@
import composeTableListHeader from './composeTableListHeader';
import TableComponent from './Table';
import TableBody from './TableBody';
+import TableBulkActionBar from './TableBulkActionBar';
import TableContext from './TableContext';
import TableEmptyState from './TableEmptyStates/TableEmptyState';
import TableNoResultsState from './TableEmptyStates/TableNoResultsState';
@@ -58,6 +59,7 @@ import TableRow from './TableRow';
* - `Table.EmptyState` - Renders when the table has no rows
* - `Table.NoResultsState` - Renders when the table has rows, but the user has filtered all of them out
* - `Table.LoadingState` - Renders a loading indicator when the table is loading its data
+ * - `Table.BulkActionBar` - The floating bar of bulk actions for the current selection
*/
const Table = Object.assign(TableComponent, {
/** The React context for accessing table state directly. */
@@ -86,6 +88,9 @@ const Table = Object.assign(TableComponent, {
/** Renders a loading indicator when the table is loading its data */
LoadingState: TableLoadingState,
+
+ /** The floating bar of bulk actions, shown in the wide layout while rows are selected */
+ BulkActionBar: TableBulkActionBar,
});
export default Table;
diff --git a/src/components/Tables/WorkspaceMembersTable/index.tsx b/src/components/Tables/WorkspaceMembersTable/index.tsx
index 0d3e367732fe..b8edd74226d6 100644
--- a/src/components/Tables/WorkspaceMembersTable/index.tsx
+++ b/src/components/Tables/WorkspaceMembersTable/index.tsx
@@ -1,3 +1,4 @@
+import type {DropdownOption, WorkspaceMemberBulkActionType} from '@components/ButtonWithDropdownMenu/types';
import type {CompareItemsCallback, FilterConfig, IsItemInFilterCallback, IsItemInSearchCallback, TableColumn, TableData, TableHandle} from '@components/Table';
import Table, {composeTableListHeader} from '@components/Table';
import compareOptionalValues from '@components/Table/compareOptionalValues';
@@ -59,6 +60,9 @@ type WorkspaceMembersTableProps = {
shouldShowBankAccountColumn: boolean;
onRowSelectionChange: (selectedRowKeys: string[]) => void;
headerComponent?: React.ReactElement;
+
+ /** The actions the selected members support, offered in the floating bar the wide layout shows while rows are selected. */
+ bulkActionOptions?: Array>;
};
const WORKSPACE_MEMBER_FILTER_VALUES = {
@@ -84,6 +88,7 @@ export default function WorkspaceMembersTable({
members,
onRowSelectionChange,
headerComponent,
+ bulkActionOptions,
}: WorkspaceMembersTableProps) {
const styles = useThemeStyles();
const {translate, localeCompare} = useLocalize();
@@ -349,6 +354,7 @@ export default function WorkspaceMembersTable({
+ {!!bulkActionOptions && }
);
}
diff --git a/src/components/WorkspaceMemberRoleList.tsx b/src/components/WorkspaceMemberRoleList.tsx
index 3a11f6c1af61..91d3d8e86772 100644
--- a/src/components/WorkspaceMemberRoleList.tsx
+++ b/src/components/WorkspaceMemberRoleList.tsx
@@ -3,7 +3,7 @@ import useLocalize from '@hooks/useLocalize';
import useThemeStyles from '@hooks/useThemeStyles';
import Navigation from '@libs/Navigation/Navigation';
-import {canMemberAssignRole} from '@libs/PolicyUtils';
+import {getSelectableRoles} from '@libs/PolicyUtils';
import CONST from '@src/CONST';
import type {Route} from '@src/ROUTES';
@@ -16,8 +16,9 @@ import React from 'react';
import {View} from 'react-native';
import type {LocalizedTranslate} from './LocaleContextProvider';
-import type {ListItem} from './SelectionList/types';
+import type {ConfirmButtonOptions, ListItem} from './SelectionList/types';
+import FormHelpMessage from './FormHelpMessage';
import HeaderWithBackButton from './HeaderWithBackButton';
import SelectionList from './SelectionList';
import SingleSelectListItem from './SelectionList/ListItem/SingleSelectListItem';
@@ -38,6 +39,15 @@ type WorkspaceMemberRoleListProps = {
/** When provided, restricts the selectable roles to this set (e.g. an Authorized Payer may only be an Admin or Payments Admin) */
allowedRoles?: Array>;
+
+ /** When provided, the list confirms the pick with a button instead of applying it as soon as a row is pressed */
+ confirmButtonOptions?: ConfirmButtonOptions;
+
+ /** Shown above the confirm button, for a confirmation pressed without a role picked */
+ errorMessage?: string;
+
+ /** Bump to have the error read out again, since a repeated confirmation leaves the message itself unchanged */
+ errorAnnouncementKey?: number;
};
/**
@@ -103,10 +113,22 @@ function getAssignableWorkspaceMemberRoleItems(
},
];
- return workspaceRoles.filter((item) => canMemberAssignRole(policy, currentUserLogin, item.value) && (!allowedRoles || allowedRoles.includes(item.value)));
+ const selectableRoles = getSelectableRoles(policy, currentUserLogin, allowedRoles);
+
+ return workspaceRoles.filter((item) => selectableRoles.includes(item.value));
}
-function WorkspaceMemberRoleList({role, policy, navigateBackTo = undefined, isLoading = false, onSelectRole = () => {}, allowedRoles = undefined}: WorkspaceMemberRoleListProps) {
+function WorkspaceMemberRoleList({
+ role,
+ policy,
+ navigateBackTo = undefined,
+ isLoading = false,
+ onSelectRole = () => {},
+ allowedRoles = undefined,
+ confirmButtonOptions = undefined,
+ errorMessage = '',
+ errorAnnouncementKey = 0,
+}: WorkspaceMemberRoleListProps) {
const {translate} = useLocalize();
const styles = useThemeStyles();
const {login: currentUserLogin = ''} = useCurrentUserPersonalDetails();
@@ -125,10 +147,23 @@ function WorkspaceMemberRoleList({role, policy, navigateBackTo = undefined, isLo
data={availableRoleItems}
ListItem={SingleSelectListItem}
onSelectRow={onSelectRole}
+ confirmButtonOptions={confirmButtonOptions}
shouldSingleExecuteRowSelect
initiallyFocusedItemKey={availableRoleItems.find((item) => item.isSelected)?.keyForList}
addBottomSafeAreaPadding
- />
+ >
+ {!!errorMessage && (
+
+ {/* Remounting is what makes the alert speak again. The list is not inside a `Form`,
+ so `FormHelpMessage` has no submit count of its own to re-announce from. */}
+
+
+ )}
+
)}
>
diff --git a/src/languages/de.ts b/src/languages/de.ts
index 7efbba1bb34c..e582714b010a 100644
--- a/src/languages/de.ts
+++ b/src/languages/de.ts
@@ -7373,6 +7373,7 @@ Der Control-Tarif beginnt bei 9 $ pro aktivem Mitglied und Monat.`,
'Das Mitglied arbeitet von zu Hause aus oder reist zwischen verschiedenen Standorten ohne festes Büro, daher gelten die Pendelregelungen nicht.',
futureOnlyNote: 'Änderungen gelten nur für zukünftige Kilometerberechnungen. Bestehende Kilometerabrechnungen werden nicht neu berechnet.',
},
+ changeRole: 'Rolle ändern',
},
card: {
getStartedIssuing: 'Beginne, indem du deine erste virtuelle oder physische Karte ausstellst.',
@@ -12212,5 +12213,6 @@ Hier ist ein *Testbeleg*, um dir zu zeigen, wie es funktioniert:`,
mobileRenewPrompt: 'Besuchen Sie Expensify in Ihrem Webbrowser, um vorzeitig zu verlängern.',
mobileClaimPrompt: 'Besuchen Sie Expensify in Ihrem Webbrowser, um Ihren Verlängerungsrabatt zu erhalten.',
},
+ bulkActionBar: {label: 'Sammelaktionen', clearSelection: 'Auswahl aufheben', loadingSelection: 'Auswahl wird geladen'},
};
export default translations;
diff --git a/src/languages/el.ts b/src/languages/el.ts
index 71b4323eaf3e..40d07982fb28 100644
--- a/src/languages/el.ts
+++ b/src/languages/el.ts
@@ -7506,6 +7506,7 @@ _Για πιο αναλυτικές οδηγίες, [επισκεφθείτε τ
optionNoRegularWorkspaceHelp: 'Το μέλος εργάζεται από το σπίτι ή ταξιδεύει μεταξύ τοποθεσιών χωρίς τακτικό γραφείο, επομένως οι κανόνες μετακίνησης δεν ισχύουν.',
futureOnlyNote: 'Οι αλλαγές ισχύουν μόνο για μελλοντικούς υπολογισμούς χιλιομέτρων. Οι υπάρχουσες δαπάνες χιλιομέτρων δεν επανυπολογίζονται.',
},
+ changeRole: 'Αλλαγή ρόλου',
},
card: {
getStartedIssuing: 'Ξεκινήστε εκδίδοντας την πρώτη σας εικονική ή φυσική κάρτα.',
@@ -12451,5 +12452,6 @@ ${reportName}`,
mobileRenewPrompt: 'Επισκεφθείτε το Expensify στον περιηγητή ιστού σας για να ανανεώσετε νωρίτερα.',
mobileClaimPrompt: 'Επισκεφθείτε το Expensify στον φυλλομετρητή σας για να διεκδικήσετε την έκπτωση ανανέωσης.',
},
+ bulkActionBar: {label: 'Μαζικές ενέργειες', clearSelection: 'Καθαρισμός επιλογής', loadingSelection: 'Φόρτωση επιλογής'},
};
export default translations;
diff --git a/src/languages/en.ts b/src/languages/en.ts
index 559d6331d419..49ffd1bccd27 100644
--- a/src/languages/en.ts
+++ b/src/languages/en.ts
@@ -43,6 +43,11 @@ type States = Record;
type AllCountries = Record;
const translations = {
+ bulkActionBar: {
+ label: 'Bulk actions',
+ clearSelection: 'Clear selection',
+ loadingSelection: 'Loading selection',
+ },
earlyRenewal: {
title: 'Renew your Expensify subscription',
subtitle: 'One less thing to do before the new year.',
@@ -7698,6 +7703,7 @@ const translations = {
removeMemberPrompt: (memberName: string) => `Are you sure you want to remove ${memberName}?`,
removeMemberTitle: 'Remove member',
transferOwner: 'Transfer owner',
+ changeRole: 'Change role',
makeMember: () => ({
one: 'Make member',
other: 'Make members',
diff --git a/src/languages/es.ts b/src/languages/es.ts
index f16b8c0a3c16..0ffe73596673 100644
--- a/src/languages/es.ts
+++ b/src/languages/es.ts
@@ -7151,6 +7151,7 @@ El plan Controlar empieza en 9 $ por miembro activo al mes.`,
removeMemberPrompt: (memberName) => `¿Estás seguro de que deseas eliminar a ${memberName}?`,
removeMemberTitle: 'Eliminar miembro',
transferOwner: 'Transferir la propiedad',
+ changeRole: 'Cambiar rol',
makeMember: () => ({
one: 'Convertir en miembro',
other: 'Convertir en miembros',
@@ -12467,5 +12468,6 @@ ${reportName}`,
mobileRenewPrompt: 'Visita Expensify en tu navegador web para renovar antes.',
mobileClaimPrompt: 'Visita Expensify en tu navegador web para obtener tu descuento de renovación.',
},
+ bulkActionBar: {label: 'Acciones en bloque', clearSelection: 'Borrar selección', loadingSelection: 'Cargando selección'},
};
export default translations;
diff --git a/src/languages/fr.ts b/src/languages/fr.ts
index 7ec23c40035f..405eb14248c9 100644
--- a/src/languages/fr.ts
+++ b/src/languages/fr.ts
@@ -7392,6 +7392,7 @@ Le forfait Control commence à 9 $ par Membre actif et par mois.`,
'Le membre travaille à domicile ou se déplace entre plusieurs sites sans bureau fixe, donc les règles relatives aux trajets domicile-travail ne s’appliquent pas.',
futureOnlyNote: 'Les modifications s’appliquent uniquement aux calculs de kilométrage futurs. Les dépenses de kilométrage existantes ne sont pas recalculées.',
},
+ changeRole: 'Modifier le rôle',
},
card: {
getStartedIssuing: 'Commencez par émettre votre première carte virtuelle ou physique.',
@@ -12241,5 +12242,6 @@ Voici un *reçu test* pour vous montrer comment ça fonctionne :`,
mobileRenewPrompt: 'Visitez Expensify dans votre navigateur web pour renouveler en avance.',
mobileClaimPrompt: 'Visitez Expensify dans votre navigateur web pour bénéficier de votre réduction de renouvellement.',
},
+ bulkActionBar: {label: 'Actions groupées', clearSelection: 'Effacer la sélection', loadingSelection: 'Chargement de la sélection'},
};
export default translations;
diff --git a/src/languages/it.ts b/src/languages/it.ts
index 750d6937f8b1..e36817e1cc06 100644
--- a/src/languages/it.ts
+++ b/src/languages/it.ts
@@ -7330,6 +7330,7 @@ Il piano Control parte da 9 $ al mese per ogni membro attivo.`,
'Il membro lavora da casa o si sposta tra diverse sedi senza un ufficio fisso, quindi le regole relative al tragitto casa-lavoro non si applicano.',
futureOnlyNote: 'Le modifiche si applicano solo ai calcoli chilometrici futuri. Le spese chilometriche esistenti non vengono ricalcolate.',
},
+ changeRole: 'Cambia ruolo',
},
card: {
getStartedIssuing: 'Inizia emettendo la tua prima carta virtuale o fisica.',
@@ -12160,5 +12161,6 @@ Ecco una *ricevuta di prova* per mostrarti come funziona:`,
mobileRenewPrompt: 'Visita Expensify nel browser web per rinnovare in anticipo.',
mobileClaimPrompt: 'Visita Expensify nel tuo browser web per richiedere lo sconto di rinnovo.',
},
+ bulkActionBar: {label: 'Azioni collettive', clearSelection: 'Cancella selezione', loadingSelection: 'Caricamento selezione'},
};
export default translations;
diff --git a/src/languages/ja.ts b/src/languages/ja.ts
index 3aebb570cf10..7711c3d7a642 100644
--- a/src/languages/ja.ts
+++ b/src/languages/ja.ts
@@ -7239,6 +7239,7 @@ Control プランは、アクティブメンバー1人あたり月額 $9 から
optionNoRegularWorkspaceHelp: 'メンバーは在宅勤務、または決まったオフィスがなく拠点間を移動しているため、通勤規程は適用されません。',
futureOnlyNote: '変更は今後の走行距離計算にのみ適用されます。既存の走行距離経費は再計算されません。',
},
+ changeRole: 'ロールを変更',
},
card: {
getStartedIssuing: 'まずは最初のバーチャルカードまたは物理カードを発行しましょう。',
@@ -11986,5 +11987,6 @@ ${reportName}`,
mobileRenewPrompt: '早期に更新するには、ウェブブラウザで Expensify にアクセスしてください。',
mobileClaimPrompt: '更新割引を受け取るには、Web ブラウザで Expensify にアクセスしてください。',
},
+ bulkActionBar: {label: '一括操作', clearSelection: '選択をクリア', loadingSelection: '選択内容を読み込み中'},
};
export default translations;
diff --git a/src/languages/nl.ts b/src/languages/nl.ts
index f114c56ad81f..5c3dee057e4a 100644
--- a/src/languages/nl.ts
+++ b/src/languages/nl.ts
@@ -7313,6 +7313,7 @@ Het Control-abonnement begint bij $9 per actieve deelnemer per maand.`,
optionNoRegularWorkspaceHelp: 'Lid werkt thuis of reist tussen locaties zonder vaste kantoorlocatie, waardoor woon-werkregels niet van toepassing zijn.',
futureOnlyNote: 'Wijzigingen zijn alleen van toepassing op toekomstige kilometerberekeningen. Bestaande kilometerdeclaraties worden niet opnieuw berekend.',
},
+ changeRole: 'Rol wijzigen',
},
card: {
getStartedIssuing: 'Begin met het uitgeven van je eerste virtuele of fysieke kaart.',
@@ -12125,5 +12126,6 @@ Hier is een *proefbon* om je te laten zien hoe het werkt:`,
mobileRenewPrompt: 'Bezoek Expensify in je webbrowser om vroegtijdig te verlengen.',
mobileClaimPrompt: 'Bezoek Expensify in je webbrowser om je verlengingskorting te claimen.',
},
+ bulkActionBar: {label: 'Bulkacties', clearSelection: 'Selectie wissen', loadingSelection: 'Selectie laden'},
};
export default translations;
diff --git a/src/languages/pl.ts b/src/languages/pl.ts
index 093056efbc98..5ff84ae7361d 100644
--- a/src/languages/pl.ts
+++ b/src/languages/pl.ts
@@ -7339,6 +7339,7 @@ Plan Control zaczyna się od 9 USD za aktywnego członka miesięcznie.`,
optionNoRegularWorkspaceHelp: 'Członek pracuje z domu lub podróżuje między lokalizacjami bez stałego biura, więc zasady dotyczące dojazdów do pracy nie mają zastosowania.',
futureOnlyNote: 'Zmiany mają zastosowanie tylko do przyszłych obliczeń kilometrówki. Istniejące wydatki za przejazdy nie są przeliczane ponownie.',
},
+ changeRole: 'Zmień rolę',
},
card: {
getStartedIssuing: 'Zacznij od wydania swojej pierwszej wirtualnej lub fizycznej karty.',
@@ -12143,5 +12144,6 @@ Oto *paragon testowy*, żeby pokazać Ci, jak to działa:`,
mobileRenewPrompt: 'Odwiedź Expensify w przeglądarce internetowej, żeby odnowić wcześniej.',
mobileClaimPrompt: 'Odwiedź Expensify w swojej przeglądarce internetowej, aby odebrać zniżkę na odnowienie.',
},
+ bulkActionBar: {label: 'Akcje zbiorcze', clearSelection: 'Wyczyść wybór', loadingSelection: 'Wczytywanie zaznaczenia'},
};
export default translations;
diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts
index c5f4d7386e8d..8b7636d1ac0e 100644
--- a/src/languages/pt-BR.ts
+++ b/src/languages/pt-BR.ts
@@ -7315,6 +7315,7 @@ O plano Control começa em US$ 9 por membro ativo por mês.`,
optionNoRegularWorkspaceHelp: 'O membro trabalha em casa ou viaja entre locais sem um escritório fixo, portanto as regras de deslocamento não se aplicam.',
futureOnlyNote: 'As alterações se aplicam apenas aos cálculos de quilometragem futuros. As despesas de quilometragem existentes não são recalculadas.',
},
+ changeRole: 'Alterar função',
},
card: {
getStartedIssuing: 'Comece emitindo seu primeiro cartão virtual ou físico.',
@@ -12116,5 +12117,6 @@ Aqui está um *comprovante de teste* para mostrar como funciona:`,
mobileRenewPrompt: 'Visite o Expensify no seu navegador para renovar antecipadamente.',
mobileClaimPrompt: 'Visite o Expensify no seu navegador para resgatar seu desconto de renovação.',
},
+ bulkActionBar: {label: 'Ações em massa', clearSelection: 'Limpar seleção', loadingSelection: 'Carregando seleção'},
};
export default translations;
diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts
index 1c7fe18135aa..90fbe37b90a4 100644
--- a/src/languages/zh-hans.ts
+++ b/src/languages/zh-hans.ts
@@ -7054,6 +7054,7 @@ _如需更详细的说明,请[访问我们的帮助网站](${CONST.NETSUITE_IM
optionNoRegularWorkspaceHelp: '成员在家办公或在各地点之间出差,没有固定办公地点,因此通勤规则不适用。',
futureOnlyNote: '更改仅适用于未来的里程计算,现有的里程报销将不会重新计算。',
},
+ changeRole: '更改角色',
},
card: {
getStartedIssuing: '从发放您的第一张虚拟卡或实体卡开始使用。',
@@ -11673,5 +11674,6 @@ ${reportName}`,
mobileRenewPrompt: '请在网页浏览器中访问 Expensify 以提前续订。',
mobileClaimPrompt: '请在网页浏览器中访问 Expensify 以领取您的续订折扣。',
},
+ bulkActionBar: {label: '批量操作', clearSelection: '清除选择', loadingSelection: '正在加载所选内容'},
};
export default translations;
diff --git a/src/libs/ExportOnyxState/masking.ts b/src/libs/ExportOnyxState/masking.ts
index 892aebc3afa9..ca599c6cd7c1 100644
--- a/src/libs/ExportOnyxState/masking.ts
+++ b/src/libs/ExportOnyxState/masking.ts
@@ -529,6 +529,7 @@ const onyxKeysToMaskFragileData = new Set([
ONYXKEYS.RAM_ONLY_HAS_FRESH_WALLET_DATA,
ONYXKEYS.RAM_ONLY_IS_LOADING_SEARCH_FILTERS_CATEGORY_DATA,
ONYXKEYS.RAM_ONLY_WALLET_ONFIDO,
+ ONYXKEYS.RAM_ONLY_WORKSPACE_MEMBERS_SELECTED_FOR_ROLE_CHANGE,
ONYXKEYS.RECENTLY_USED_CURRENCIES,
ONYXKEYS.RECENTLY_USED_REPORT_FIELDS,
ONYXKEYS.RECENT_SEARCHES,
diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx
index 44e8b496b579..f2c970107ccd 100644
--- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx
+++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx
@@ -614,6 +614,7 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../../pages/workspace/members/WorkspaceMemberDetailsPage').default,
[SCREENS.WORKSPACE.ROOM_CREATE]: () => require('../../../../pages/workspace/rooms/WorkspaceRoomCreatePage').default,
[SCREENS.WORKSPACE.MEMBER_DETAILS_ROLE]: () => require('../../../../pages/workspace/members/WorkspaceMemberDetailsRolePage').default,
+ [SCREENS.WORKSPACE.MEMBERS_ROLE]: () => require('../../../../pages/workspace/members/WorkspaceMembersRolePage').default,
[SCREENS.WORKSPACE.MEMBER_WORK_ARRANGEMENT]: () => require('../../../../pages/workspace/members/WorkArrangementPage').default,
[SCREENS.WORKSPACE.INVITE_WORK_ARRANGEMENT]: () => require('../../../../pages/workspace/members/WorkArrangementPage').default,
[SCREENS.WORKSPACE.MEMBER_CUSTOM_FIELD]: () => require('../../../../pages/workspace/members/WorkspaceMemberCustomFieldPage').default,
diff --git a/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts b/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts
index 62623bcbfc5e..85112a38bbfd 100755
--- a/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts
+++ b/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts
@@ -21,6 +21,7 @@ const WORKSPACE_TO_RHP: Partial['config'] = {
[SCREENS.WORKSPACE.MEMBER_DETAILS_ROLE]: {
path: ROUTES.WORKSPACE_MEMBER_DETAILS_ROLE.route,
},
+ [SCREENS.WORKSPACE.MEMBERS_ROLE]: {
+ path: ROUTES.WORKSPACE_MEMBERS_ROLE.route,
+ },
[SCREENS.WORKSPACE.MEMBER_WORK_ARRANGEMENT]: {
path: ROUTES.WORKSPACE_MEMBER_WORK_ARRANGEMENT.route,
},
diff --git a/src/libs/Navigation/paramLists/WorkspaceSettingsParamList.ts b/src/libs/Navigation/paramLists/WorkspaceSettingsParamList.ts
index feb4bd3572ca..052e2b54a28b 100644
--- a/src/libs/Navigation/paramLists/WorkspaceSettingsParamList.ts
+++ b/src/libs/Navigation/paramLists/WorkspaceSettingsParamList.ts
@@ -435,6 +435,9 @@ type WorkspaceSettingsParamList = {
policyID: string;
accountID: string;
};
+ [SCREENS.WORKSPACE.MEMBERS_ROLE]: {
+ policyID: string;
+ };
[SCREENS.WORKSPACE.MEMBER_WORK_ARRANGEMENT]: {
policyID: string;
accountID: string;
diff --git a/src/libs/NavigationFocusReturn/index.native.ts b/src/libs/NavigationFocusReturn/index.native.ts
index 7db71f9dd4e5..28d41d2f79dd 100644
--- a/src/libs/NavigationFocusReturn/index.native.ts
+++ b/src/libs/NavigationFocusReturn/index.native.ts
@@ -329,6 +329,10 @@ function shouldSkipAutoFocusDueToExistingFocus(): boolean {
return false;
}
+/** Web only. Native restores to a registered pressable rather than to a DOM element, so there is nothing to nominate. */
+// eslint-disable-next-line @typescript-eslint/no-unused-vars
+function nominateTriggerFallback(_element: HTMLElement | null): void {}
+
function resetForTests(): void {
teardownNavigationFocusReturn();
}
@@ -352,6 +356,7 @@ export {
teardownNavigationFocusReturn,
handleStateChange,
notifyPressedTrigger,
+ nominateTriggerFallback,
registerPressable,
notifyPushParamsForward,
notifyPushParamsBackward,
diff --git a/src/libs/NavigationFocusReturn/index.ts b/src/libs/NavigationFocusReturn/index.ts
index ba7b9046d8dc..5ee9e6ba979e 100644
--- a/src/libs/NavigationFocusReturn/index.ts
+++ b/src/libs/NavigationFocusReturn/index.ts
@@ -38,6 +38,7 @@ let lastMouseTriggerAt = 0;
let lastKeyboardTriggerElement: HTMLElement | null = null;
let lastKeyboardTriggerTime = 0;
let pendingActivationKey: 'Enter' | 'Space' | null = null;
+let nominatedFallback: HTMLElement | null = null;
function setTriggerEntry(routeKey: string, entry: TriggerEntry): void {
setFifoEntry(triggerMap, routeKey, entry, TRIGGER_MAP_MAX);
@@ -121,9 +122,23 @@ function captureTriggerForRoute(routeKey: string): void {
if (!inner) {
return;
}
+ if (nominatedFallback && nominatedFallback !== inner) {
+ setTriggerEntry(routeKey, {primary: inner, fallback: nominatedFallback});
+ return;
+ }
setTriggerEntry(routeKey, {primary: inner});
}
+/**
+ * Nominates a second choice to return to for the next route opened from here, for callers whose controls are removed
+ * while that route is open. A bulk action bar is the case this exists for. Its buttons go away with the selection the
+ * route cleared, so the captured trigger is detached by the time the route pops and focus falls to the document.
+ * Passing null withdraws the nomination, which callers do when they unmount.
+ */
+function nominateTriggerFallback(element: HTMLElement | null): void {
+ nominatedFallback = element;
+}
+
/** Single-site latch reset so the three fields always clear together and future additions get one call site. */
function clearKeyboardLatch(): void {
lastKeyboardTriggerElement = null;
@@ -599,6 +614,7 @@ function resetForTests(): void {
clearTransientCaptures();
lastRestoreTarget = null;
skipNextRestore = false;
+ nominatedFallback = null;
}
function setLastInteractiveElementForTests(element: HTMLElement | null): void {
@@ -690,6 +706,7 @@ export {
teardownNavigationFocusReturn,
handleStateChange,
captureTriggerForRoute,
+ nominateTriggerFallback,
restoreTriggerForRoute,
notifyPushParamsForward,
notifyPushParamsBackward,
diff --git a/src/libs/PolicyUtils/permissions.ts b/src/libs/PolicyUtils/permissions.ts
index 9c8b213fbf75..4a559c966495 100644
--- a/src/libs/PolicyUtils/permissions.ts
+++ b/src/libs/PolicyUtils/permissions.ts
@@ -203,6 +203,28 @@ function canMemberAssignRole(policy: OnyxInputOrEntry, login: string, ro
return isCorporatePolicy && canMemberWrite(policy, login, CONST.POLICY.POLICY_FEATURE.MEMBERS) && isNonElevatedRole;
}
+/**
+ * The roles a member can be moved to, in the order the role selector lists them. Owner and Editor are left out
+ * because neither is assignable through the role selector.
+ */
+const SELECTABLE_ROLES = [
+ CONST.POLICY.ROLE.ADMIN,
+ CONST.POLICY.ROLE.AUDITOR,
+ CONST.POLICY.ROLE.GUEST,
+ CONST.POLICY.ROLE.CARD_ADMIN,
+ CONST.POLICY.ROLE.PEOPLE_ADMIN,
+ CONST.POLICY.ROLE.PAYMENTS_ADMIN,
+ CONST.POLICY.ROLE.USER,
+] as const;
+
+/**
+ * The roles the given member may assign on this policy, optionally narrowed to a permitted set (for example an
+ * Authorized Payer may only hold a role that can pay).
+ */
+function getSelectableRoles(policy: OnyxInputOrEntry, login: string, allowedRoles?: Array>): Array> {
+ return SELECTABLE_ROLES.filter((role) => canMemberAssignRole(policy, login, role) && (!allowedRoles || allowedRoles.includes(role)));
+}
+
// Whether the member can assign any elevated role: admins (via assignElevatedRoles) on any policy, or People Admins (up to auditor) on Control.
function canMemberAssignElevatedRole(policy: OnyxInputOrEntry, login: string): boolean {
return canMemberWrite(policy, login, CONST.POLICY.POLICY_FEATURE.ASSIGN_ELEVATED_ROLES) || canMemberAssignRole(policy, login, CONST.POLICY.ROLE.AUDITOR);
@@ -352,6 +374,7 @@ export {
canMemberWrite,
hasReadOnlyAllReportsAccess,
canMemberAssignRole,
+ getSelectableRoles,
canMemberAssignElevatedRole,
canMemberManageMemberWithRole,
getPolicyRole,
diff --git a/src/libs/actions/Policy/Member.ts b/src/libs/actions/Policy/Member.ts
index 25dd8e134aa7..5951a78e5e22 100644
--- a/src/libs/actions/Policy/Member.ts
+++ b/src/libs/actions/Policy/Member.ts
@@ -1315,6 +1315,26 @@ function setWorkspaceInviteMembersDraft(policyID: string, invitedEmailsToAccount
clearWorkspaceInviteWorkArrangementDraft(policyID);
}
+function setMembersSelectedForRoleChange(policyID: string, memberLogins: string[]) {
+ Onyx.set(ONYXKEYS.RAM_ONLY_WORKSPACE_MEMBERS_SELECTED_FOR_ROLE_CHANGE, {policyID, logins: memberLogins});
+}
+
+/**
+ * Empties the selection in place, which is how the members table learns the change was saved and drops the rows it
+ * still has selected.
+ */
+function clearMembersSelectedForRoleChange(policyID: string) {
+ Onyx.set(ONYXKEYS.RAM_ONLY_WORKSPACE_MEMBERS_SELECTED_FOR_ROLE_CHANGE, {policyID, logins: []});
+}
+
+/**
+ * Drops the selection entirely, for leaving the role screen without saving. The members table keeps its rows selected,
+ * since an empty selection is reserved for a saved change.
+ */
+function discardMembersSelectedForRoleChange() {
+ Onyx.set(ONYXKEYS.RAM_ONLY_WORKSPACE_MEMBERS_SELECTED_FOR_ROLE_CHANGE, null);
+}
+
function setWorkspaceInviteRoleDraft(policyID: string, role: ValueOf) {
Onyx.set(`${ONYXKEYS.COLLECTION.WORKSPACE_INVITE_ROLE_DRAFT}${policyID}`, role);
}
@@ -1572,6 +1592,9 @@ export {
buildRoomMembersOnyxData,
openPolicyMemberProfilePage,
setWorkspaceInviteRoleDraft,
+ setMembersSelectedForRoleChange,
+ clearMembersSelectedForRoleChange,
+ discardMembersSelectedForRoleChange,
clearWorkspaceInviteRoleDraft,
setWorkspaceInviteWorkArrangementDraft,
clearWorkspaceInviteWorkArrangementDraft,
diff --git a/src/pages/workspace/WorkspaceMembersPage.tsx b/src/pages/workspace/WorkspaceMembersPage.tsx
index 02a07b411ffa..a68a94180fc0 100644
--- a/src/pages/workspace/WorkspaceMembersPage.tsx
+++ b/src/pages/workspace/WorkspaceMembersPage.tsx
@@ -43,7 +43,7 @@ import {
clearWorkspaceOwnerChangeFlow,
downloadMembersCSV,
removeMembers,
- updateWorkspaceMembersRole,
+ setMembersSelectedForRoleChange,
} from '@libs/actions/Policy/Member';
import {removeApprovalWorkflow as removeApprovalWorkflowAction, updateApprovalWorkflow} from '@libs/actions/Workflow';
import {isRuleBotEnforcingRules} from '@libs/AgentRulesUtils';
@@ -67,6 +67,7 @@ import {
getConnectionExporters,
getMemberAccountIDsForWorkspace,
getReimburserEmail,
+ getSelectableRoles,
hasActiveExpensifyCard,
isControlPolicy,
isGroupPolicy,
@@ -115,7 +116,7 @@ function invertObject(object: Record): Record {
function WorkspaceMembersPage({personalDetails, route, policy}: WorkspaceMembersPageProps) {
useWorkspaceDocumentTitle(policy?.name, 'common.members');
const tableRef = useRef>(null);
- const icons = useMemoizedLazyExpensifyIcons(['Download', 'FallbackAvatar', 'MakeAdmin', 'Plus', 'RemoveMembers', 'Sync', 'Table', 'User', 'UserEye']);
+ const icons = useMemoizedLazyExpensifyIcons(['Download', 'FallbackAvatar', 'Plus', 'RemoveMembers', 'Sync', 'Table', 'UserPencil']);
const employeePersonalDetails = usePersonalDetailsByLogins(Object.keys(policy?.employeeList ?? {}));
const policyMemberEmailsToAccountIDs = useMemo(
() => getMemberAccountIDsForWorkspace(policy?.employeeList, employeePersonalDetails, true),
@@ -354,7 +355,6 @@ function WorkspaceMembersPage({personalDetails, route, policy}: WorkspaceMembers
updateMemberRoleInline(policy, login, accountID, currentRole, newRole);
};
- const canAssignElevatedRoles = canMemberWrite(policy, currentUserLogin ?? '', CONST.POLICY.POLICY_FEATURE.ASSIGN_ELEVATED_ROLES);
const invitedPrimaryToSecondaryLogins = useMemo(() => invertObject(policy?.primaryLoginsInvited ?? {}), [policy?.primaryLoginsInvited]);
const isControlPolicyWithWideLayout = !shouldUseNarrowLayout && isControlPolicy(policy);
@@ -534,22 +534,18 @@ function WorkspaceMembersPage({personalDetails, route, policy}: WorkspaceMembers
onNavigationCallBack: () => Navigation.goBack(),
});
- const hasSelectedRuleBot = selectedEmployees.some((email) => isRuleBotEnforcingRules(policyMemberEmailsToAccountIDs[email], policy));
+ // The role change happens on its own screen, which reports the change back by emptying the shared selection.
+ // Backing out of that screen drops the selection instead, so only a drop to zero clears the rows here.
+ const [membersSelectedForRoleChange] = useOnyx(ONYXKEYS.RAM_ONLY_WORKSPACE_MEMBERS_SELECTED_FOR_ROLE_CHANGE);
+ const isRoleChangeSaved = membersSelectedForRoleChange?.policyID === policyID && membersSelectedForRoleChange.logins.length === 0;
+ const [wasRoleChangeSaved, setWasRoleChangeSaved] = useState(isRoleChangeSaved);
+ if (wasRoleChangeSaved !== isRoleChangeSaved) {
+ setWasRoleChangeSaved(isRoleChangeSaved);
- const changeUserRole = (role: ValueOf) => {
- if (role !== CONST.POLICY.ROLE.ADMIN && hasSelectedRuleBot) {
- showRuleBotGuardModal('changeRole', policyID);
- return;
+ if (isRoleChangeSaved) {
+ setSelectedEmployees([]);
}
- const loginsToUpdate = selectedEmployees.filter((login) => {
- return policy?.employeeList?.[login]?.role !== role;
- });
-
- const accountIDsToUpdate = loginsToUpdate.map((login) => policyMemberEmailsToAccountIDs[login]).filter((id) => id !== undefined);
-
- setSelectedEmployees([]);
- updateWorkspaceMembersRole(policy, loginsToUpdate, accountIDsToUpdate, role);
- };
+ }
const getBulkActionsButtonOptions = () => {
const selectedEmployeesRoles = selectedEmployees.map((email) => {
@@ -558,6 +554,24 @@ function WorkspaceMembersPage({personalDetails, route, policy}: WorkspaceMembers
const canManageSelectedEmployees = selectedEmployeesRoles.every((role) => canMemberManageMemberWithRole(policy, currentUserLogin ?? '', role));
const options: Array> = [];
+ // The Authorized Payer (reimburser) must stay a valid payer, so a selection holding them may only be moved to a role that can pay.
+ const reimburserEmail = getReimburserEmail(policy);
+ const hasAtLeastOnePayer = !!reimburserEmail && selectedEmployees.includes(reimburserEmail);
+ const selectableRoles = getSelectableRoles(policy, currentUserLogin ?? '', hasAtLeastOnePayer ? [...PAYER_ROLES] : undefined);
+
+ if (isPaidGroupPolicy(policy) && canManageSelectedEmployees && selectableRoles.length > 0) {
+ options.push({
+ text: translate('workspace.people.changeRole'),
+ value: CONST.POLICY.MEMBERS_BULK_ACTION_TYPES.CHANGE_ROLE,
+ icon: icons.UserPencil,
+ shouldSkipFocusRestore: true,
+ onSelected: () => {
+ setMembersSelectedForRoleChange(policyID, selectedEmployees);
+ Navigation.navigate(ROUTES.WORKSPACE_MEMBERS_ROLE.getRoute(policyID));
+ },
+ });
+ }
+
if (canManageSelectedEmployees) {
options.push({
text: translate('workspace.people.removeMembersTitle', {count: selectedEmployees.length}),
@@ -568,112 +582,6 @@ function WorkspaceMembersPage({personalDetails, route, policy}: WorkspaceMembers
});
}
- if (!isPaidGroupPolicy(policy)) {
- return options;
- }
-
- const memberOption = {
- text: translate('workspace.people.makeMember', {count: selectedEmployees.length}),
- value: CONST.POLICY.MEMBERS_BULK_ACTION_TYPES.MAKE_MEMBER,
- icon: icons.User,
- shouldSkipFocusRestore: hasSelectedRuleBot,
- onSelected: () => changeUserRole(CONST.POLICY.ROLE.USER),
- };
- const adminOption = {
- text: translate('workspace.people.makeAdmin', {count: selectedEmployees.length}),
- value: CONST.POLICY.MEMBERS_BULK_ACTION_TYPES.MAKE_ADMIN,
- icon: icons.MakeAdmin,
- onSelected: () => changeUserRole(CONST.POLICY.ROLE.ADMIN),
- };
-
- const auditorOption = {
- text: translate('workspace.people.makeAuditor', {count: selectedEmployees.length}),
- value: CONST.POLICY.MEMBERS_BULK_ACTION_TYPES.MAKE_AUDITOR,
- icon: icons.UserEye,
- shouldSkipFocusRestore: hasSelectedRuleBot,
- onSelected: () => changeUserRole(CONST.POLICY.ROLE.AUDITOR),
- };
- const guestOption = {
- text: translate('workspace.people.makeGuest', {count: selectedEmployees.length}),
- value: CONST.POLICY.MEMBERS_BULK_ACTION_TYPES.MAKE_GUEST,
- icon: icons.User,
- shouldSkipFocusRestore: hasSelectedRuleBot,
- onSelected: () => changeUserRole(CONST.POLICY.ROLE.GUEST),
- };
- const cardAdminOption = {
- text: translate('workspace.people.makeCardAdmin', {count: selectedEmployees.length}),
- value: CONST.POLICY.MEMBERS_BULK_ACTION_TYPES.MAKE_CARD_ADMIN,
- icon: icons.MakeAdmin,
- shouldSkipFocusRestore: hasSelectedRuleBot,
- onSelected: () => changeUserRole(CONST.POLICY.ROLE.CARD_ADMIN),
- };
- const peopleAdminOption = {
- text: translate('workspace.people.makePeopleAdmin', {count: selectedEmployees.length}),
- value: CONST.POLICY.MEMBERS_BULK_ACTION_TYPES.MAKE_PEOPLE_ADMIN,
- icon: icons.MakeAdmin,
- shouldSkipFocusRestore: hasSelectedRuleBot,
- onSelected: () => changeUserRole(CONST.POLICY.ROLE.PEOPLE_ADMIN),
- };
- const paymentsAdminOption = {
- text: translate('workspace.people.makePaymentsAdmin', {count: selectedEmployees.length}),
- value: CONST.POLICY.MEMBERS_BULK_ACTION_TYPES.MAKE_PAYMENTS_ADMIN,
- icon: icons.MakeAdmin,
- shouldSkipFocusRestore: hasSelectedRuleBot,
- onSelected: () => changeUserRole(CONST.POLICY.ROLE.PAYMENTS_ADMIN),
- };
-
- const hasAtLeastOneNonAuditorRole = selectedEmployeesRoles.some((role) => role !== CONST.POLICY.ROLE.AUDITOR);
- const hasAtLeastOneNonGuestRole = selectedEmployeesRoles.some((role) => role !== CONST.POLICY.ROLE.GUEST);
- const hasAtLeastOneNonCardAdminRole = selectedEmployeesRoles.some((role) => role !== CONST.POLICY.ROLE.CARD_ADMIN);
- const hasAtLeastOneNonPeopleAdminRole = selectedEmployeesRoles.some((role) => role !== CONST.POLICY.ROLE.PEOPLE_ADMIN);
- const hasAtLeastOneNonPaymentsAdminRole = selectedEmployeesRoles.some((role) => role !== CONST.POLICY.ROLE.PAYMENTS_ADMIN);
- const hasAtLeastOneNonMemberRole = selectedEmployeesRoles.some((role) => role !== CONST.POLICY.ROLE.USER);
- const hasAtLeastOneNonAdminRole = selectedEmployeesRoles.some((role) => role !== CONST.POLICY.ROLE.ADMIN);
- const reimburserEmail = getReimburserEmail(policy);
- const hasAtLeastOnePayer = !!reimburserEmail && selectedEmployees.includes(reimburserEmail);
-
- if (hasAtLeastOneNonMemberRole && !hasAtLeastOnePayer && canManageSelectedEmployees && canMemberAssignRole(policy, currentUserLogin ?? '', CONST.POLICY.ROLE.USER)) {
- options.push(memberOption);
- }
-
- // Admin is a valid payer role, so the payer may be promoted to Admin (Admin and Payments Admin are the two roles that can pay).
- if (hasAtLeastOneNonAdminRole && canAssignElevatedRoles) {
- options.push(adminOption);
- }
-
- if (
- hasAtLeastOneNonAuditorRole &&
- isControlPolicy(policy) &&
- !hasAtLeastOnePayer &&
- canManageSelectedEmployees &&
- canMemberAssignRole(policy, currentUserLogin ?? '', CONST.POLICY.ROLE.AUDITOR)
- ) {
- options.push(auditorOption);
- }
-
- if (
- hasAtLeastOneNonGuestRole &&
- isControlPolicy(policy) &&
- !hasAtLeastOnePayer &&
- canManageSelectedEmployees &&
- canMemberAssignRole(policy, currentUserLogin ?? '', CONST.POLICY.ROLE.GUEST)
- ) {
- options.push(guestOption);
- }
-
- if (hasAtLeastOneNonCardAdminRole && isControlPolicy(policy) && !hasAtLeastOnePayer && canAssignElevatedRoles) {
- options.push(cardAdminOption);
- }
-
- if (hasAtLeastOneNonPeopleAdminRole && isControlPolicy(policy) && !hasAtLeastOnePayer && canAssignElevatedRoles) {
- options.push(peopleAdminOption);
- }
-
- // Payments Admin is a valid payer role, so the payer may be changed to Payments Admin (Admin and Payments Admin are the two roles that can pay).
- if (hasAtLeastOneNonPaymentsAdminRole && isControlPolicy(policy) && canAssignElevatedRoles) {
- options.push(paymentsAdminOption);
- }
-
return options;
};
@@ -773,7 +681,10 @@ function WorkspaceMembersPage({personalDetails, route, policy}: WorkspaceMembers
return null;
}
const bulkActionOptions = getBulkActionsButtonOptions();
- return (shouldUseNarrowLayout ? canSelectMultiple : selectedEmployees.length > 0) ? (
+
+ // The wide layout offers these actions in the floating bar over the table instead, so its header keeps the
+ // page's own buttons no matter what is selected.
+ return shouldUseNarrowLayout && canSelectMultiple ? (
variant={CONST.BUTTON_VARIANT.SUCCESS}
shouldAlwaysShowDropdownMenu
@@ -875,6 +786,7 @@ function WorkspaceMembersPage({personalDetails, route, policy}: WorkspaceMembers
shouldShowBankAccountColumn={shouldShowBankAccountColumn}
onRowSelectionChange={setSelectedEmployees}
headerComponent={tableHeaderComponent}
+ bulkActionOptions={canWriteMembers ? getBulkActionsButtonOptions() : undefined}
/>
>
)}
diff --git a/src/pages/workspace/members/WorkspaceMembersRolePage.tsx b/src/pages/workspace/members/WorkspaceMembersRolePage.tsx
new file mode 100644
index 000000000000..ecf82dc5a5b4
--- /dev/null
+++ b/src/pages/workspace/members/WorkspaceMembersRolePage.tsx
@@ -0,0 +1,147 @@
+import ScreenWrapper from '@components/ScreenWrapper';
+import WorkspaceMemberRoleList from '@components/WorkspaceMemberRoleList';
+import type {ListItemType} from '@components/WorkspaceMemberRoleList';
+
+import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails';
+import useLocalize from '@hooks/useLocalize';
+import useOnyx from '@hooks/useOnyx';
+import {usePersonalDetailsByLogins} from '@hooks/usePersonalDetailByLogin';
+import useRedirectSubmitWorkspaceFeatureUpgrade from '@hooks/useRedirectSubmitWorkspaceFeatureUpgrade';
+import useRuleBotGuardModal from '@hooks/useRuleBotGuardModal';
+
+import {clearMembersSelectedForRoleChange, discardMembersSelectedForRoleChange, updateWorkspaceMembersRole} from '@libs/actions/Policy/Member';
+import {isRuleBotEnforcingRules} from '@libs/AgentRulesUtils';
+import Navigation from '@libs/Navigation/Navigation';
+import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types';
+import type {SettingsNavigatorParamList} from '@libs/Navigation/types';
+import {canMemberManageMemberWithRole, getMemberAccountIDsForWorkspace, getReimburserEmail, PAYER_ROLES} from '@libs/PolicyUtils';
+
+import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper';
+import withPolicyAndFullscreenLoading from '@pages/workspace/withPolicyAndFullscreenLoading';
+import type {WithPolicyAndFullscreenLoadingProps} from '@pages/workspace/withPolicyAndFullscreenLoading';
+
+import CONST from '@src/CONST';
+import ONYXKEYS from '@src/ONYXKEYS';
+import ROUTES from '@src/ROUTES';
+import type SCREENS from '@src/SCREENS';
+import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue';
+
+import type {ValueOf} from 'type-fest';
+
+import React, {useEffect, useRef, useState} from 'react';
+
+type WorkspaceMembersRolePageProps = WithPolicyAndFullscreenLoadingProps & PlatformStackScreenProps;
+
+function WorkspaceMembersRolePage({policy, route}: WorkspaceMembersRolePageProps) {
+ const policyID = route.params.policyID;
+ const {translate} = useLocalize();
+ const showRuleBotGuardModal = useRuleBotGuardModal();
+ const {login: currentUserLogin = ''} = useCurrentUserPersonalDetails();
+ const [selectedLogins, selectedLoginsMetadata] = useOnyx(ONYXKEYS.RAM_ONLY_WORKSPACE_MEMBERS_SELECTED_FOR_ROLE_CHANGE);
+ const employeePersonalDetails = usePersonalDetailsByLogins(Object.keys(policy?.employeeList ?? {}));
+ const memberEmailsToAccountIDs = getMemberAccountIDsForWorkspace(policy?.employeeList, employeePersonalDetails, true);
+
+ // A selection left behind by an earlier visit belongs to the workspace it was made on, so it is ignored here.
+ const memberLogins = selectedLogins?.policyID === policyID ? selectedLogins.logins : [];
+ const memberRoles = memberLogins.map((login) => policy?.employeeList?.[login]?.role);
+ const canManageSelectedMemberRoles = memberRoles.every((role) => canMemberManageMemberWithRole(policy, currentUserLogin, role));
+
+ // Only offer a role as already picked when the whole selection shares it, so a mixed selection starts with nothing
+ // selected and the member has to make a deliberate choice.
+ const sharedRole = memberRoles.length > 0 ? Object.values(CONST.POLICY.ROLE).find((role) => memberRoles.every((memberRole) => memberRole === role)) : undefined;
+ const [draftRole, setDraftRole] = useState>();
+ // Counted rather than flagged, so a Save pressed again with still nothing picked reads the error out again.
+ const [rejectedSaveCount, setRejectedSaveCount] = useState(0);
+ const selectedRole = draftRole ?? sharedRole;
+
+ // The Authorized Payer (reimburser) must stay a valid payer, so restrict the whole selection to the roles that can pay once it includes them.
+ const reimburserEmail = getReimburserEmail(policy);
+ const hasReimburser = !!reimburserEmail && memberLogins.includes(reimburserEmail);
+ const allowedRoles = hasReimburser ? [...PAYER_ROLES] : undefined;
+
+ useRedirectSubmitWorkspaceFeatureUpgrade({
+ policy,
+ backTo: ROUTES.WORKSPACE_MEMBERS.getRoute(policyID),
+ upgradeFeatureAlias: CONST.UPGRADE_FEATURE_INTRO_MAPPING.roles.alias,
+ });
+
+ // The selection only lives in memory, so a reload or a deep link landing here directly has nothing to act on.
+ const isSelectionLoading = isLoadingOnyxValue(selectedLoginsMetadata);
+ const memberCount = memberLogins.length;
+ useEffect(() => {
+ if (isSelectionLoading || memberCount > 0) {
+ return;
+ }
+ Navigation.navigate(ROUTES.WORKSPACE_MEMBERS.getRoute(policyID));
+ }, [isSelectionLoading, memberCount, policyID]);
+
+ // Leaving without saving drops the selection, so coming back to this screen through history cannot apply a role to
+ // members the table no longer has selected.
+ const hasSaved = useRef(false);
+ useEffect(
+ () => () => {
+ if (hasSaved.current) {
+ return;
+ }
+ discardMembersSelectedForRoleChange();
+ },
+ [],
+ );
+
+ const saveAndGoBack = () => {
+ if (!selectedRole) {
+ setRejectedSaveCount((count) => count + 1);
+ return;
+ }
+
+ const loginsToUpdate = memberLogins.filter((login) => policy?.employeeList?.[login]?.role !== selectedRole);
+ const accountIDsToUpdate = loginsToUpdate.map((login) => memberEmailsToAccountIDs[login]).filter((accountID) => accountID !== undefined);
+
+ if (loginsToUpdate.length > 0) {
+ if (selectedRole !== CONST.POLICY.ROLE.ADMIN && loginsToUpdate.some((login) => isRuleBotEnforcingRules(memberEmailsToAccountIDs[login], policy))) {
+ showRuleBotGuardModal('changeRole', policyID);
+ return;
+ }
+
+ updateWorkspaceMembersRole(policy, loginsToUpdate, accountIDsToUpdate, selectedRole);
+ }
+
+ hasSaved.current = true;
+ clearMembersSelectedForRoleChange(policyID);
+ Navigation.goBack(ROUTES.WORKSPACE_MEMBERS.getRoute(policyID));
+ };
+
+ return (
+ 0 && !canManageSelectedMemberRoles}
+ >
+
+ {
+ setDraftRole(value);
+ setRejectedSaveCount(0);
+ }}
+ allowedRoles={allowedRoles}
+ navigateBackTo={ROUTES.WORKSPACE_MEMBERS.getRoute(policyID)}
+ confirmButtonOptions={{
+ showButton: true,
+ text: translate('common.save'),
+ onConfirm: saveAndGoBack,
+ }}
+ errorMessage={rejectedSaveCount > 0 ? translate('common.error.pleaseSelectOne') : ''}
+ errorAnnouncementKey={rejectedSaveCount}
+ />
+
+
+ );
+}
+
+export default withPolicyAndFullscreenLoading(WorkspaceMembersRolePage);
diff --git a/src/pages/workspace/withPolicy.tsx b/src/pages/workspace/withPolicy.tsx
index d8814a89df3a..bc3574884af4 100644
--- a/src/pages/workspace/withPolicy.tsx
+++ b/src/pages/workspace/withPolicy.tsx
@@ -24,6 +24,7 @@ type PolicyRouteName =
| typeof SCREENS.WORKSPACE.PROFILE
| typeof SCREENS.WORKSPACE.MORE_FEATURES
| typeof SCREENS.WORKSPACE.MEMBERS
+ | typeof SCREENS.WORKSPACE.MEMBERS_ROLE
| typeof SCREENS.WORKSPACE.AGENTS
| typeof SCREENS.WORKSPACE.EXPENSIFY_CARD
| typeof SCREENS.WORKSPACE.COMPANY_CARDS
diff --git a/src/setup/index.ts b/src/setup/index.ts
index 6fb4f8a130fd..5dcd3aa3be21 100644
--- a/src/setup/index.ts
+++ b/src/setup/index.ts
@@ -105,6 +105,7 @@ export default function () {
ONYXKEYS.RAM_ONLY_WISE_KYC_REVIEW_EMBEDDED_LINK,
ONYXKEYS.COLLECTION.RAM_ONLY_ISSUE_NEW_EXPENSIFY_CARD,
ONYXKEYS.RAM_ONLY_DOMAIN_MEMBERS_SELECTED_FOR_MOVE,
+ ONYXKEYS.RAM_ONLY_WORKSPACE_MEMBERS_SELECTED_FOR_ROLE_CHANGE,
ONYXKEYS.RAM_ONLY_HAS_DISMISSED_CONCIERGE_NOTIFICATION_BANNER,
ONYXKEYS.RAM_ONLY_CORPAY_PAY_MODAL,
ONYXKEYS.RAM_ONLY_DYNAMIC_FORM_SENSITIVE_ANSWERS,
diff --git a/src/styles/index.ts b/src/styles/index.ts
index 077457e9713c..bc477f99b4a3 100644
--- a/src/styles/index.ts
+++ b/src/styles/index.ts
@@ -5820,6 +5820,13 @@ const staticStyles = (theme: ThemeColors) =>
paddingBottom: bulkActionBarHeight + CONST.BULK_ACTION_BAR.BOTTOM_OFFSET + CONST.BULK_ACTION_BAR.LIST_GAP,
},
+ // Keeps a row the browser scrolls into view, such as one reached with Tab, from settling underneath the bar
+ // (WCAG 2.4.11). The content spacing above only lifts the end of the list clear of it. Applied to the scrolling
+ // container, which is what the browser measures that scroll against.
+ bulkActionBarScrollPadding: {
+ scrollPaddingBottom: bulkActionBarHeight + CONST.BULK_ACTION_BAR.BOTTOM_OFFSET + CONST.BULK_ACTION_BAR.LIST_GAP,
+ },
+
// Wide enough for a three-digit count, so the bar does not resize as the selection grows past 9 or 99. A
// selection can cover far more rows than are on screen. Also keeps the width steady while the count loads.
bulkActionBarCount: {
diff --git a/src/types/onyx/WorkspaceMembersSelectedForRoleChange.ts b/src/types/onyx/WorkspaceMembersSelectedForRoleChange.ts
new file mode 100644
index 000000000000..b46024cfb427
--- /dev/null
+++ b/src/types/onyx/WorkspaceMembersSelectedForRoleChange.ts
@@ -0,0 +1,15 @@
+/**
+ * The workspace members picked for a bulk role change, handed to the role screen that applies it.
+ *
+ * RAM-only, so the role screen has nothing to act on when it is reached by a reload or a deep link rather than from
+ * the members table.
+ */
+type WorkspaceMembersSelectedForRoleChange = {
+ /** The workspace the members were picked on, so a stash left behind cannot be applied to another one */
+ policyID: string;
+
+ /** Logins of the picked members. Empty once the change has been saved, which is what clears the rows behind it. */
+ logins: string[];
+};
+
+export default WorkspaceMembersSelectedForRoleChange;
diff --git a/src/types/onyx/index.ts b/src/types/onyx/index.ts
index aa2cc9f68ebb..a8b48d437b89 100644
--- a/src/types/onyx/index.ts
+++ b/src/types/onyx/index.ts
@@ -254,6 +254,7 @@ import type WalletStatement from './WalletStatement';
import type WalletTerms from './WalletTerms';
import type WalletTransfer from './WalletTransfer';
import type {WiseKYCReviewEmbeddedLink} from './WiseKYCReview';
+import type WorkspaceMembersSelectedForRoleChange from './WorkspaceMembersSelectedForRoleChange';
export type {
FileObject,
@@ -427,6 +428,7 @@ export type {
WalletStatement,
WalletTerms,
WalletTransfer,
+ WorkspaceMembersSelectedForRoleChange,
SupportalPermissionDenied,
PurchaseList,
Purchases,
diff --git a/tests/ui/WorkspaceMembersTest.tsx b/tests/ui/WorkspaceMembersTest.tsx
index d9046a822038..0be94fe9c8c8 100644
--- a/tests/ui/WorkspaceMembersTest.tsx
+++ b/tests/ui/WorkspaceMembersTest.tsx
@@ -1,6 +1,5 @@
import {act, fireEvent, render, screen, waitFor, within} from '@testing-library/react-native';
-import ButtonWithDropdownMenu from '@components/ButtonWithDropdownMenu';
import ComposeProviders from '@components/ComposeProviders';
import HTMLEngineProvider from '@components/HTMLEngineProvider';
import {LocaleContextProvider} from '@components/LocaleContextProvider';
@@ -15,14 +14,17 @@ import type ResponsiveLayoutResult from '@hooks/useResponsiveLayout/types';
import createPlatformStackNavigator from '@libs/Navigation/PlatformStackNavigation/createPlatformStackNavigator';
-import type {WorkspaceSplitNavigatorParamList} from '@navigation/types';
+import type {SettingsNavigatorParamList, WorkspaceSplitNavigatorParamList} from '@navigation/types';
+import WorkspaceMembersRolePage from '@pages/workspace/members/WorkspaceMembersRolePage';
import WorkspaceMembersPage from '@pages/workspace/WorkspaceMembersPage';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import SCREENS from '@src/SCREENS';
+import type {ValueOf} from 'type-fest';
+
import {PortalProvider} from '@gorhom/portal';
import {NavigationContainer} from '@react-navigation/native';
import React from 'react';
@@ -58,6 +60,41 @@ const renderPage = (initialRouteName: typeof SCREENS.WORKSPACE.MEMBERS, initialP
);
};
+const RoleStack = createPlatformStackNavigator();
+
+const renderRolePage = (policyID: string) => {
+ return render(
+
+
+
+
+
+
+
+
+ ,
+ );
+};
+
+/**
+ * The wide layout offers the bulk actions in the floating bar over the table, which gives the first few an inline
+ * button and moves the rest behind "More". Opening that menu puts every action on screen at once, whichever half of
+ * the bar it landed in. Only the bar's button carries this label, so the page's own "More" button is not matched.
+ */
+const openBulkActions = async () => {
+ const moreButton = screen.queryByLabelText(TestHelper.translateLocal('common.more'));
+ if (!moreButton) {
+ return;
+ }
+
+ fireEvent.press(moreButton);
+ await waitForBatchedUpdatesWithAct();
+};
+
const selectCheckboxByMemberName = (memberName: string) => {
const memberEmailByName: Record = {
Owner: 'owner@gmail.com',
@@ -83,7 +120,6 @@ describe('WorkspaceMembers', () => {
const selfAccountID = 1206;
const selfEmail = 'test@example.com';
const ADMIN_OPTION = 'Admin User';
- const AUDITOR_OPTION = 'Auditor User';
const USER_OPTION = 'Member User';
const policy = {
...LHNTestUtils.getFakePolicy(),
@@ -136,399 +172,235 @@ describe('WorkspaceMembers', () => {
});
describe('Changing roles options', () => {
- it('should show Make member/auditor/card admin when admin is selected', async () => {
+ it('should offer one Change role action instead of one action per role', async () => {
+ // Given the members page with an admin selected
const {unmount} = renderPage(SCREENS.WORKSPACE.MEMBERS, {policyID: policy.id});
await waitForBatchedUpdatesWithAct();
- // Wait for initial render and verify members are visible
await waitFor(() => {
expect(screen.getByText(ADMIN_OPTION)).toBeOnTheScreen();
});
- // Select admin option by clicking the checkbox
selectCheckboxByMemberName('Admin');
- const dropdownMenuButtonTestID = 'WorkspaceMembersPage-header-dropdown-menu-button';
- // Wait for selection mode to be active and click the dropdown menu button
- await waitFor(() => {
- expect(screen.getByTestId(dropdownMenuButtonTestID)).toBeOnTheScreen();
- });
+ // When the actions the bar moved behind "More" are opened
+ await openBulkActions();
- // Click the "1 selected" button to open the menu
- const dropdownButton = screen.getByTestId(dropdownMenuButtonTestID);
- const bulkActionsDropdown = screen.UNSAFE_getAllByType(ButtonWithDropdownMenu).find(({props}) => props.testID === dropdownMenuButtonTestID);
- expect(bulkActionsDropdown?.props.shouldPopoverUseScrollView).toBe(true);
- fireEvent.press(dropdownButton);
-
- await waitForBatchedUpdatesWithAct();
-
- // Wait for menu items to be visible
- await waitFor(() => {
- const makeMemberText = TestHelper.translateLocal('workspace.people.makeMember', {count: 1});
- expect(screen.getByText(makeMemberText)).toBeOnTheScreen();
- });
-
- // Find and verify "Make member" dropdown menu item
- const makeMemberText = TestHelper.translateLocal('workspace.people.makeMember', {count: 1});
- const makeMemberMenuItem = screen.getByTestId(`PopoverMenuItem-${makeMemberText}`);
- expect(makeMemberMenuItem).toBeOnTheScreen();
-
- // Find and verify "Make auditor" dropdown menu item
- const makeAuditorText = TestHelper.translateLocal('workspace.people.makeAuditor', {count: 1});
- const makeAuditorMenuItem = screen.getByTestId(`PopoverMenuItem-${makeAuditorText}`);
- expect(makeAuditorMenuItem).toBeOnTheScreen();
-
- // Find and verify "Make guest" dropdown menu item
- const makeGuestText = TestHelper.translateLocal('workspace.people.makeGuest', {count: 1});
- const makeGuestMenuItem = screen.getByTestId(`PopoverMenuItem-${makeGuestText}`);
- expect(makeGuestMenuItem).toBeOnTheScreen();
-
- // Find and verify "Make card admin" dropdown menu item
- const makeCardAdminText = TestHelper.translateLocal('workspace.people.makeCardAdmin', {count: 1});
- const makeCardAdminMenuItem = screen.getByTestId(`PopoverMenuItem-${makeCardAdminText}`);
- expect(makeCardAdminMenuItem).toBeOnTheScreen();
-
- // Find and verify "Make admin" dropdown menu item is not present
- const makeAdminText = TestHelper.translateLocal('workspace.people.makeAdmin', {count: 1});
- const makeAdminMenuItem = screen.queryByTestId(`PopoverMenuItem-${makeAdminText}`);
- expect(makeAdminMenuItem).not.toBeOnTheScreen();
+ // Then the bar offers Change role alongside Remove, and none of the per-role actions it replaced
+ expect(screen.getByText(TestHelper.translateLocal('workspace.people.changeRole'))).toBeOnTheScreen();
+ expect(screen.getByText(TestHelper.translateLocal('workspace.people.removeMembersTitle', {count: 1}))).toBeOnTheScreen();
+ expect(screen.queryByText(TestHelper.translateLocal('workspace.people.makeMember', {count: 1}))).not.toBeOnTheScreen();
+ expect(screen.queryByText(TestHelper.translateLocal('workspace.people.makeAdmin', {count: 1}))).not.toBeOnTheScreen();
unmount();
await waitForBatchedUpdatesWithAct();
});
- it('should show Make admin/auditor/card admin when member is selected', async () => {
+ it('should not offer Change role on a workspace without paid roles', async () => {
+ // Given a Submit workspace, which has no roles to move a member between
+ await act(async () => {
+ await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, {type: CONST.POLICY.TYPE.SUBMIT});
+ });
+
const {unmount} = renderPage(SCREENS.WORKSPACE.MEMBERS, {policyID: policy.id});
await waitForBatchedUpdatesWithAct();
- // Wait for initial render and verify members are visible
await waitFor(() => {
expect(screen.getByText(USER_OPTION)).toBeOnTheScreen();
});
- // Select member option by clicking the checkbox
+ // When a member is selected and the actions are opened
selectCheckboxByMemberName('Member');
- const dropdownMenuButtonTestID = 'WorkspaceMembersPage-header-dropdown-menu-button';
-
- // Wait for selection mode to be active and click the dropdown menu button
- await waitFor(() => {
- expect(screen.getByTestId(dropdownMenuButtonTestID)).toBeOnTheScreen();
- });
-
- // Click the "1 selected" button to open the menu
- const dropdownButton = screen.getByTestId(dropdownMenuButtonTestID);
- fireEvent.press(dropdownButton);
+ await openBulkActions();
- await waitForBatchedUpdatesWithAct();
-
- // Wait for menu items to be visible
- await waitFor(() => {
- const makeAdminText = TestHelper.translateLocal('workspace.people.makeAdmin', {count: 1});
- expect(screen.getByText(makeAdminText)).toBeOnTheScreen();
- });
-
- // Find and verify "Make admin" dropdown menu item
- const makeAdminText = TestHelper.translateLocal('workspace.people.makeAdmin', {count: 1});
- const makeAdminMenuItem = screen.getByTestId(`PopoverMenuItem-${makeAdminText}`);
- expect(makeAdminMenuItem).toBeOnTheScreen();
-
- // Find and verify "Make auditor" dropdown menu item
- const makeAuditorText = TestHelper.translateLocal('workspace.people.makeAuditor', {count: 1});
- const makeAuditorMenuItem = screen.getByTestId(`PopoverMenuItem-${makeAuditorText}`);
- expect(makeAuditorMenuItem).toBeOnTheScreen();
-
- // Find and verify "Make card admin" dropdown menu item
- const makeCardAdminText = TestHelper.translateLocal('workspace.people.makeCardAdmin', {count: 1});
- const makeCardAdminMenuItem = screen.getByTestId(`PopoverMenuItem-${makeCardAdminText}`);
- expect(makeCardAdminMenuItem).toBeOnTheScreen();
-
- // Find and verify "Make member" dropdown menu item is not present
- const makeMemberText = TestHelper.translateLocal('workspace.people.makeMember', {count: 1});
- const makeMemberMenuItem = screen.queryByTestId(`PopoverMenuItem-${makeMemberText}`);
- expect(makeMemberMenuItem).not.toBeOnTheScreen();
+ // Then only Remove is offered
+ expect(screen.getByText(TestHelper.translateLocal('workspace.people.removeMembersTitle', {count: 1}))).toBeOnTheScreen();
+ expect(screen.queryByText(TestHelper.translateLocal('workspace.people.changeRole'))).not.toBeOnTheScreen();
unmount();
await waitForBatchedUpdatesWithAct();
});
+ });
- it('should show Make member/admin/card admin when auditor is selected', async () => {
- const {unmount} = renderPage(SCREENS.WORKSPACE.MEMBERS, {policyID: policy.id});
- await waitForBatchedUpdatesWithAct();
-
- // Wait for initial render and verify members are visible
- await waitFor(() => {
- expect(screen.getByText(AUDITOR_OPTION)).toBeOnTheScreen();
- });
-
- // Select auditor option by clicking the checkbox
- selectCheckboxByMemberName('Auditor');
- const dropdownMenuButtonTestID = 'WorkspaceMembersPage-header-dropdown-menu-button';
+ describe('Role selection screen', () => {
+ const roleName = (role: ValueOf) => TestHelper.translateLocal('workspace.common.roleName', role);
+ const memberRoleName = () => TestHelper.translateLocal('common.member');
- // Wait for selection mode to be active and click the dropdown menu button
- await waitFor(() => {
- expect(screen.getByTestId(dropdownMenuButtonTestID)).toBeOnTheScreen();
+ const selectForRoleChange = async (logins: string[]) => {
+ await act(async () => {
+ await Onyx.set(ONYXKEYS.RAM_ONLY_WORKSPACE_MEMBERS_SELECTED_FOR_ROLE_CHANGE, {policyID: policy.id, logins});
});
+ };
- // Click the "1 selected" button to open the menu
- const dropdownButton = screen.getByTestId(dropdownMenuButtonTestID);
- fireEvent.press(dropdownButton);
+ it('should offer every role the current member may assign', async () => {
+ // Given an admin selected for a role change on a Control workspace
+ await selectForRoleChange([adminEmail]);
+ const {unmount} = renderRolePage(policy.id);
await waitForBatchedUpdatesWithAct();
- // Wait for menu items to be visible
+ // Then the screen lists all of the Control roles, with the selection's shared role already picked
await waitFor(() => {
- const makeMemberText = TestHelper.translateLocal('workspace.people.makeMember', {count: 1});
- expect(screen.getByText(makeMemberText)).toBeOnTheScreen();
+ expect(screen.getByText(roleName(CONST.POLICY.ROLE.ADMIN))).toBeOnTheScreen();
});
-
- // Find and verify "Make member" dropdown menu item
- const makeMemberText = TestHelper.translateLocal('workspace.people.makeMember', {count: 1});
- const makeMemberMenuItem = screen.getByTestId(`PopoverMenuItem-${makeMemberText}`);
- expect(makeMemberMenuItem).toBeOnTheScreen();
-
- // Find and verify "Make admin" dropdown menu item
- const makeAdminText = TestHelper.translateLocal('workspace.people.makeAdmin', {count: 1});
- const makeAdminMenuItem = screen.getByTestId(`PopoverMenuItem-${makeAdminText}`);
- expect(makeAdminMenuItem).toBeOnTheScreen();
-
- // Find and verify "Make card admin" dropdown menu item
- const makeCardAdminText = TestHelper.translateLocal('workspace.people.makeCardAdmin', {count: 1});
- const makeCardAdminMenuItem = screen.getByTestId(`PopoverMenuItem-${makeCardAdminText}`);
- expect(makeCardAdminMenuItem).toBeOnTheScreen();
-
- // Find and verify "Make auditor" dropdown menu item is not present
- const makeAuditorText = TestHelper.translateLocal('workspace.people.makeAuditor', {count: 1});
- const makeAuditorMenuItem = screen.queryByTestId(`PopoverMenuItem-${makeAuditorText}`);
- expect(makeAuditorMenuItem).not.toBeOnTheScreen();
+ expect(screen.getByText(roleName(CONST.POLICY.ROLE.AUDITOR))).toBeOnTheScreen();
+ expect(screen.getByText(roleName(CONST.POLICY.ROLE.CARD_ADMIN))).toBeOnTheScreen();
+ expect(screen.getByText(roleName(CONST.POLICY.ROLE.PEOPLE_ADMIN))).toBeOnTheScreen();
+ expect(screen.getByText(roleName(CONST.POLICY.ROLE.PAYMENTS_ADMIN))).toBeOnTheScreen();
+ expect(screen.getByText(memberRoleName())).toBeOnTheScreen();
unmount();
await waitForBatchedUpdatesWithAct();
});
- it('should show Make member/admin/auditor/card admin when mix is selected', async () => {
- const {unmount} = renderPage(SCREENS.WORKSPACE.MEMBERS, {policyID: policy.id});
- await waitForBatchedUpdatesWithAct();
-
- // Wait for initial render and verify members are visible
- await waitFor(() => {
- expect(screen.getByText(AUDITOR_OPTION)).toBeOnTheScreen();
- });
- await waitFor(() => {
- expect(screen.getByText(ADMIN_OPTION)).toBeOnTheScreen();
- });
-
- // Select options by clicking the checkboxes
- selectCheckboxByMemberName('Auditor');
- selectCheckboxByMemberName('Admin');
- const dropdownMenuButtonTestID = 'WorkspaceMembersPage-header-dropdown-menu-button';
-
- // Wait for selection mode to be active and click the dropdown menu button
- await waitFor(() => {
- expect(screen.getByTestId(dropdownMenuButtonTestID)).toBeOnTheScreen();
+ it('should only offer member and auditor to a People Admin', async () => {
+ // Given a People Admin, who may assign no role above auditor
+ await act(async () => {
+ await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, {
+ role: CONST.POLICY.ROLE.PEOPLE_ADMIN,
+ employeeList: {[selfEmail]: {email: selfEmail, role: CONST.POLICY.ROLE.PEOPLE_ADMIN}},
+ });
});
+ await selectForRoleChange([userEmail]);
- // Click the "2 selected" button to open the menu
- const dropdownButton = screen.getByTestId(dropdownMenuButtonTestID);
- fireEvent.press(dropdownButton);
-
+ const {unmount} = renderRolePage(policy.id);
await waitForBatchedUpdatesWithAct();
- // Wait for menu items to be visible
+ // Then only the two roles they may assign are listed
await waitFor(() => {
- const makeMemberText = TestHelper.translateLocal('workspace.people.makeMember', {count: 2});
- expect(screen.getByText(makeMemberText)).toBeOnTheScreen();
+ expect(screen.getByText(roleName(CONST.POLICY.ROLE.AUDITOR))).toBeOnTheScreen();
});
-
- // Find and verify "Make members" dropdown menu item (plural form for 2 selected items)
- const makeMemberText = TestHelper.translateLocal('workspace.people.makeMember', {count: 2});
- const makeMemberMenuItem = screen.getByTestId(`PopoverMenuItem-${makeMemberText}`);
- expect(makeMemberMenuItem).toBeOnTheScreen();
-
- // Find and verify "Make admins" dropdown menu item (plural form for 2 selected items)
- const makeAdminText = TestHelper.translateLocal('workspace.people.makeAdmin', {count: 2});
- const makeAdminMenuItem = screen.getByTestId(`PopoverMenuItem-${makeAdminText}`);
- expect(makeAdminMenuItem).toBeOnTheScreen();
-
- // Find and verify "Make auditors" dropdown menu item (plural form for 2 selected items)
- const makeAuditorText = TestHelper.translateLocal('workspace.people.makeAuditor', {count: 2});
- const makeAuditorMenuItem = screen.getByTestId(`PopoverMenuItem-${makeAuditorText}`);
- expect(makeAuditorMenuItem).toBeOnTheScreen();
-
- // Find and verify "Make card admins" dropdown menu item (plural form for 2 selected items)
- const makeCardAdminText = TestHelper.translateLocal('workspace.people.makeCardAdmin', {count: 2});
- const makeCardAdminMenuItem = screen.getByTestId(`PopoverMenuItem-${makeCardAdminText}`);
- expect(makeCardAdminMenuItem).toBeOnTheScreen();
+ expect(screen.getByText(memberRoleName())).toBeOnTheScreen();
+ expect(screen.queryByText(roleName(CONST.POLICY.ROLE.ADMIN))).not.toBeOnTheScreen();
+ expect(screen.queryByText(roleName(CONST.POLICY.ROLE.CARD_ADMIN))).not.toBeOnTheScreen();
+ expect(screen.queryByText(roleName(CONST.POLICY.ROLE.PEOPLE_ADMIN))).not.toBeOnTheScreen();
unmount();
await waitForBatchedUpdatesWithAct();
});
- it('should only show member and auditor role actions for People Admin', async () => {
- const peopleAdminPolicy = {
- ...policy,
- role: CONST.POLICY.ROLE.PEOPLE_ADMIN,
- employeeList: {
- ...policy.employeeList,
- [selfEmail]: {email: selfEmail, role: CONST.POLICY.ROLE.PEOPLE_ADMIN},
- },
- };
- await act(async () => {
- await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, peopleAdminPolicy);
- });
+ it('should ask for a role rather than disabling Save when the selection has no role in common', async () => {
+ // Given a mixed selection, which leaves the screen with nothing picked
+ await selectForRoleChange([adminEmail, userEmail]);
- const {unmount} = renderPage(SCREENS.WORKSPACE.MEMBERS, {policyID: policy.id});
+ const {unmount} = renderRolePage(policy.id);
await waitForBatchedUpdatesWithAct();
- await waitFor(() => {
- expect(screen.getByText(USER_OPTION)).toBeOnTheScreen();
+ // When Save is pressed without picking a role
+ const saveButton = await screen.findByText(TestHelper.translateLocal('common.save'));
+ fireEvent.press(saveButton, {
+ nativeEvent: {},
+ type: 'press',
+ target: saveButton,
+ currentTarget: saveButton,
});
-
- selectCheckboxByMemberName('Member');
- fireEvent.press(screen.getByTestId('WorkspaceMembersPage-header-dropdown-menu-button'));
await waitForBatchedUpdatesWithAct();
- const removeText = TestHelper.translateLocal('workspace.people.removeMembersTitle', {count: 1});
- expect(screen.getByTestId(`PopoverMenuItem-${removeText}`)).toBeOnTheScreen();
-
- const makeAuditorText = TestHelper.translateLocal('workspace.people.makeAuditor', {count: 1});
- expect(screen.getByTestId(`PopoverMenuItem-${makeAuditorText}`)).toBeOnTheScreen();
-
- const makeAdminText = TestHelper.translateLocal('workspace.people.makeAdmin', {count: 1});
- expect(screen.queryByTestId(`PopoverMenuItem-${makeAdminText}`)).not.toBeOnTheScreen();
-
- const makeCardAdminText = TestHelper.translateLocal('workspace.people.makeCardAdmin', {count: 1});
- expect(screen.queryByTestId(`PopoverMenuItem-${makeCardAdminText}`)).not.toBeOnTheScreen();
-
- const makePeopleAdminText = TestHelper.translateLocal('workspace.people.makePeopleAdmin', {count: 1});
- expect(screen.queryByTestId(`PopoverMenuItem-${makePeopleAdminText}`)).not.toBeOnTheScreen();
-
- unmount();
- await waitForBatchedUpdatesWithAct();
- });
-
- it('should let People Admin make auditors members', async () => {
- const peopleAdminPolicy = {
- ...policy,
- role: CONST.POLICY.ROLE.PEOPLE_ADMIN,
- employeeList: {
- ...policy.employeeList,
- [selfEmail]: {email: selfEmail, role: CONST.POLICY.ROLE.PEOPLE_ADMIN},
- },
- };
- await act(async () => {
- await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, peopleAdminPolicy);
+ // Then the screen asks for one instead of leaving the button dead
+ await waitFor(() => {
+ expect(screen.getByText(TestHelper.translateLocal('common.error.pleaseSelectOne'))).toBeOnTheScreen();
});
- const {unmount} = renderPage(SCREENS.WORKSPACE.MEMBERS, {policyID: policy.id});
+ // When Save is pressed again with still nothing picked, the ask stays up rather than being counted as handled
+ fireEvent.press(saveButton, {
+ nativeEvent: {},
+ type: 'press',
+ target: saveButton,
+ currentTarget: saveButton,
+ });
await waitForBatchedUpdatesWithAct();
- await waitFor(() => {
- expect(screen.getByText(AUDITOR_OPTION)).toBeOnTheScreen();
- });
+ expect(screen.getByText(TestHelper.translateLocal('common.error.pleaseSelectOne'))).toBeOnTheScreen();
- selectCheckboxByMemberName('Auditor');
- fireEvent.press(screen.getByTestId('WorkspaceMembersPage-header-dropdown-menu-button'));
+ // When a role is then picked, the message clears
+ const auditorRow = screen.getByText(roleName(CONST.POLICY.ROLE.AUDITOR));
+ fireEvent.press(auditorRow, {
+ nativeEvent: {},
+ type: 'press',
+ target: auditorRow,
+ currentTarget: auditorRow,
+ });
await waitForBatchedUpdatesWithAct();
- const makeMemberText = TestHelper.translateLocal('workspace.people.makeMember', {count: 1});
- expect(screen.getByTestId(`PopoverMenuItem-${makeMemberText}`)).toBeOnTheScreen();
-
- const makeAdminText = TestHelper.translateLocal('workspace.people.makeAdmin', {count: 1});
- expect(screen.queryByTestId(`PopoverMenuItem-${makeAdminText}`)).not.toBeOnTheScreen();
+ expect(screen.queryByText(TestHelper.translateLocal('common.error.pleaseSelectOne'))).not.toBeOnTheScreen();
unmount();
await waitForBatchedUpdatesWithAct();
});
- it('should hide demotions but offer Make payments admin when the selected member is the Authorized Payer resolved via policy.reimburser', async () => {
- // Given a workspace whose Authorized Payer is an admin configured through policy.reimburser
- // (the canonical resolution) rather than achAccount.reimburser. Demotions to roles that cannot
- // pay must stay hidden, but changing to Payments Admin (the other valid payer role) must be offered.
+ it('should ignore a selection stashed on another workspace', async () => {
+ // Given an admin stashed for a role change on a different workspace, as backing out of this screen there would leave behind
await act(async () => {
- await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, {
- reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES,
- reimburser: adminEmail,
- });
+ await Onyx.set(ONYXKEYS.RAM_ONLY_WORKSPACE_MEMBERS_SELECTED_FOR_ROLE_CHANGE, {policyID: 'ANOTHER_WORKSPACE', logins: [adminEmail]});
});
- const {unmount} = renderPage(SCREENS.WORKSPACE.MEMBERS, {policyID: policy.id});
+ const {unmount} = renderRolePage(policy.id);
await waitForBatchedUpdatesWithAct();
- await waitFor(() => {
- expect(screen.getByText(ADMIN_OPTION)).toBeOnTheScreen();
+ // When Save is pressed
+ const saveButton = await screen.findByText(TestHelper.translateLocal('common.save'));
+ fireEvent.press(saveButton, {
+ nativeEvent: {},
+ type: 'press',
+ target: saveButton,
+ currentTarget: saveButton,
});
-
- // When that payer is bulk-selected and the actions dropdown is opened
- selectCheckboxByMemberName('Admin');
- fireEvent.press(await screen.findByTestId('WorkspaceMembersPage-header-dropdown-menu-button'));
await waitForBatchedUpdatesWithAct();
- // Then the Remove option is still available
- const removeText = TestHelper.translateLocal('workspace.people.removeMembersTitle', {count: 1});
+ // Then the stashed admin was never adopted, so the screen has nothing picked to save
await waitFor(() => {
- expect(screen.getByTestId(`PopoverMenuItem-${removeText}`)).toBeOnTheScreen();
+ expect(screen.getByText(TestHelper.translateLocal('common.error.pleaseSelectOne'))).toBeOnTheScreen();
});
- // ...the demotions that would strip the payer of pay capability are hidden
- const makeMemberText = TestHelper.translateLocal('workspace.people.makeMember', {count: 1});
- expect(screen.queryByTestId(`PopoverMenuItem-${makeMemberText}`)).not.toBeOnTheScreen();
-
- const makeAuditorText = TestHelper.translateLocal('workspace.people.makeAuditor', {count: 1});
- expect(screen.queryByTestId(`PopoverMenuItem-${makeAuditorText}`)).not.toBeOnTheScreen();
+ unmount();
+ await waitForBatchedUpdatesWithAct();
+ });
- const makeCardAdminText = TestHelper.translateLocal('workspace.people.makeCardAdmin', {count: 1});
- expect(screen.queryByTestId(`PopoverMenuItem-${makeCardAdminText}`)).not.toBeOnTheScreen();
+ it('should drop the selection when the screen is left without saving', async () => {
+ // Given an admin selected for a role change
+ await selectForRoleChange([adminEmail]);
- // ...but Make payments admin IS offered — Payments Admin is a valid payer role
- const makePaymentsAdminText = TestHelper.translateLocal('workspace.people.makePaymentsAdmin', {count: 1});
- expect(screen.getByTestId(`PopoverMenuItem-${makePaymentsAdminText}`)).toBeOnTheScreen();
+ const {unmount} = renderRolePage(policy.id);
+ await waitForBatchedUpdatesWithAct();
+ // When the screen is left without saving
unmount();
await waitForBatchedUpdatesWithAct();
+
+ // Then nothing is left for a later visit to act on, and the empty selection that reports a saved change is not written
+ const stashedSelection = await new Promise((resolve) => {
+ const connection = Onyx.connect({
+ key: ONYXKEYS.RAM_ONLY_WORKSPACE_MEMBERS_SELECTED_FOR_ROLE_CHANGE,
+ callback: (value) => {
+ Onyx.disconnect(connection);
+ resolve(value);
+ },
+ });
+ });
+ expect(stashedSelection).toBeUndefined();
});
- it('should offer Make workspace admin but hide demotions when the selected member is a Payments Admin who is the Authorized Payer', async () => {
- // Given a Payments Admin who is also the Authorized Payer. Admin and Payments Admin are both valid
- // payer roles, so promoting this payer to Admin keeps them a valid payer and must be offered.
- // Every demotion to a role that cannot pay (Member, Auditor, Card Admin) stays gated on the payer.
+ it('should only offer the roles that can pay when the selection holds the Authorized Payer', async () => {
+ // Given a selection holding the Authorized Payer, who must stay a valid payer
await act(async () => {
await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, {
reimbursementChoice: CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES,
- reimburser: userEmail,
- employeeList: {
- [userEmail]: {email: userEmail, role: CONST.POLICY.ROLE.PAYMENTS_ADMIN},
- },
+ reimburser: adminEmail,
});
});
+ await selectForRoleChange([adminEmail]);
- const {unmount} = renderPage(SCREENS.WORKSPACE.MEMBERS, {policyID: policy.id});
- await waitForBatchedUpdatesWithAct();
-
- await waitFor(() => {
- expect(screen.getByText(USER_OPTION)).toBeOnTheScreen();
- });
-
- // When that payer is bulk-selected and the actions dropdown is opened
- selectCheckboxByMemberName('Member');
- fireEvent.press(await screen.findByTestId('WorkspaceMembersPage-header-dropdown-menu-button'));
+ const {unmount} = renderRolePage(policy.id);
await waitForBatchedUpdatesWithAct();
- // Then the Remove option is still available
- const removeText = TestHelper.translateLocal('workspace.people.removeMembersTitle', {count: 1});
+ // Then only Admin and Payments Admin are offered, and every role that cannot pay is withheld
await waitFor(() => {
- expect(screen.getByTestId(`PopoverMenuItem-${removeText}`)).toBeOnTheScreen();
+ expect(screen.getByText(roleName(CONST.POLICY.ROLE.PAYMENTS_ADMIN))).toBeOnTheScreen();
});
-
- // ...and "Make workspace admin" IS offered — Admin is a valid payer role
- const makeAdminText = TestHelper.translateLocal('workspace.people.makeAdmin', {count: 1});
- expect(screen.getByTestId(`PopoverMenuItem-${makeAdminText}`)).toBeOnTheScreen();
-
- // ...but the demotions that would strip the payer of pay capability stay hidden
- const makeMemberText = TestHelper.translateLocal('workspace.people.makeMember', {count: 1});
- expect(screen.queryByTestId(`PopoverMenuItem-${makeMemberText}`)).not.toBeOnTheScreen();
-
- const makeAuditorText = TestHelper.translateLocal('workspace.people.makeAuditor', {count: 1});
- expect(screen.queryByTestId(`PopoverMenuItem-${makeAuditorText}`)).not.toBeOnTheScreen();
+ expect(screen.getByText(roleName(CONST.POLICY.ROLE.ADMIN))).toBeOnTheScreen();
+ expect(screen.queryByText(memberRoleName())).not.toBeOnTheScreen();
+ expect(screen.queryByText(roleName(CONST.POLICY.ROLE.AUDITOR))).not.toBeOnTheScreen();
+ expect(screen.queryByText(roleName(CONST.POLICY.ROLE.CARD_ADMIN))).not.toBeOnTheScreen();
unmount();
await waitForBatchedUpdatesWithAct();
@@ -545,9 +417,8 @@ describe('WorkspaceMembers', () => {
// Select all
fireEvent.press(screen.getByLabelText(TestHelper.translateLocal('workspace.common.selectAll')));
- // Open dropdown
- fireEvent.press(await screen.findByTestId('WorkspaceMembersPage-header-dropdown-menu-button'));
- await waitForBatchedUpdatesWithAct();
+ // Open the bulk actions
+ await openBulkActions();
// Click "Remove members"
const removeText = TestHelper.translateLocal('workspace.people.removeMembersTitle', {count: 3});
@@ -596,8 +467,7 @@ describe('WorkspaceMembers', () => {
// When the admin removes that approver
const row = await screen.findByLabelText(new RegExp(`^${approverWithoutDetails}`));
fireEvent.press(within(row).getByLabelText(TestHelper.translateLocal('common.select')));
- fireEvent.press(await screen.findByTestId('WorkspaceMembersPage-header-dropdown-menu-button'));
- await waitForBatchedUpdatesWithAct();
+ await openBulkActions();
const removeMenuItem = screen.getByText(TestHelper.translateLocal('workspace.people.removeMembersTitle', {count: 1}));
fireEvent.press(removeMenuItem, {nativeEvent: {}, type: 'press', target: removeMenuItem, currentTarget: removeMenuItem});
await waitForBatchedUpdatesWithAct();
@@ -628,11 +498,10 @@ describe('WorkspaceMembers', () => {
});
};
- const selectAdminAndOpenDropdown = async () => {
+ const selectAdminAndOpenBulkActions = async () => {
await screen.findByText(ADMIN_OPTION);
selectCheckboxByMemberName('Admin');
- fireEvent.press(await screen.findByTestId('WorkspaceMembersPage-header-dropdown-menu-button'));
- await waitForBatchedUpdatesWithAct();
+ await openBulkActions();
};
it('should show the unable-to-remove modal when removing a RuleBot enforcing agent rules', async () => {
@@ -641,7 +510,7 @@ describe('WorkspaceMembers', () => {
const {unmount} = renderPage(SCREENS.WORKSPACE.MEMBERS, {policyID: policy.id});
await waitForBatchedUpdatesWithAct();
- await selectAdminAndOpenDropdown();
+ await selectAdminAndOpenBulkActions();
const removeMenuItem = screen.getByText(TestHelper.translateLocal('workspace.people.removeMembersTitle', {count: 1}));
fireEvent.press(removeMenuItem, {
@@ -661,22 +530,35 @@ describe('WorkspaceMembers', () => {
});
it('should show the unable-to-change-role modal when demoting a RuleBot enforcing agent rules', async () => {
+ // Given the RuleBot selected for a role change
await makeAdminTheRuleBot();
+ await act(async () => {
+ await Onyx.set(ONYXKEYS.RAM_ONLY_WORKSPACE_MEMBERS_SELECTED_FOR_ROLE_CHANGE, {policyID: policy.id, logins: [adminEmail]});
+ });
- const {unmount} = renderPage(SCREENS.WORKSPACE.MEMBERS, {policyID: policy.id});
+ const {unmount} = renderRolePage(policy.id);
await waitForBatchedUpdatesWithAct();
- await selectAdminAndOpenDropdown();
+ // When a role below admin is picked and saved
+ const memberRow = await screen.findByText(TestHelper.translateLocal('common.member'));
+ fireEvent.press(memberRow, {
+ nativeEvent: {},
+ type: 'press',
+ target: memberRow,
+ currentTarget: memberRow,
+ });
+ await waitForBatchedUpdatesWithAct();
- const makeMemberMenuItem = screen.getByText(TestHelper.translateLocal('workspace.people.makeMember', {count: 1}));
- fireEvent.press(makeMemberMenuItem, {
+ const saveButton = screen.getByText(TestHelper.translateLocal('common.save'));
+ fireEvent.press(saveButton, {
nativeEvent: {},
type: 'press',
- target: makeMemberMenuItem,
- currentTarget: makeMemberMenuItem,
+ target: saveButton,
+ currentTarget: saveButton,
});
await waitForBatchedUpdatesWithAct();
+ // Then the role change is refused with the agent rules modal
await waitFor(() => {
expect(screen.getByText(TestHelper.translateLocal('workspace.rules.agentRules.unableToChangeRoleTitle'))).toBeOnTheScreen();
});
@@ -765,13 +647,14 @@ describe('WorkspaceMembers', () => {
};
fireEvent.press(getSelectAllCheckbox());
await waitForBatchedUpdatesWithAct();
- expect(screen.getByTestId('WorkspaceMembersPage-header-dropdown-menu-button')).toBeOnTheScreen();
+ const selectedLabel = TestHelper.translateLocal('workspace.common.selected', {count: 1});
+ expect(screen.getByText(selectedLabel)).toBeOnTheScreen();
// Then clearing the search drops the selection, because it only ever applied to the searched rows
fireEvent.changeText(searchInput, '');
await waitForBatchedUpdatesWithAct();
expect(screen.getByText(ADMIN_OPTION)).toBeOnTheScreen();
- expect(screen.queryByTestId('WorkspaceMembersPage-header-dropdown-menu-button')).not.toBeOnTheScreen();
+ expect(screen.queryByText(selectedLabel)).not.toBeOnTheScreen();
expect(getSelectAllCheckbox()).not.toBeChecked();
unmount();
diff --git a/tests/unit/NavigationFocusReturnTest.ts b/tests/unit/NavigationFocusReturnTest.ts
index df46623cd012..bc0c2ee529d3 100644
--- a/tests/unit/NavigationFocusReturnTest.ts
+++ b/tests/unit/NavigationFocusReturnTest.ts
@@ -40,6 +40,7 @@ const {resetForTests: resetHadTabNavigation, setupHadTabNavigation} = require<{
}>('../../src/libs/hadTabNavigation.ts');
const {
captureTriggerForRoute,
+ nominateTriggerFallback,
restoreTriggerForRoute,
handleStateChange,
resetForTests,
@@ -55,6 +56,7 @@ const {
teardownNavigationFocusReturn,
} = require<{
captureTriggerForRoute: (routeKey: string) => void;
+ nominateTriggerFallback: (element: HTMLElement | null) => void;
restoreTriggerForRoute: (routeKey: string) => boolean;
handleStateChange: (state: unknown) => void;
resetForTests: () => void;
@@ -3083,3 +3085,71 @@ describe('teardown / setup lifecycle', () => {
}
});
});
+
+describe('nominateTriggerFallback', () => {
+ beforeEach(() => {
+ simulateTab();
+ });
+
+ it('should restore the nominated element when the captured trigger has been removed', () => {
+ // Given a row the user came from, and a bulk action bar button that opened the route. The bar goes away with
+ // the selection the route cleared, so by the time the route pops its button is detached.
+ const row = appendButton();
+ const barButton = appendButton();
+ row.focus();
+ row.dispatchEvent(new FocusEvent('focusin', {bubbles: true}));
+ nominateTriggerFallback(row);
+ barButton.focus();
+ setLastInteractiveElementForTests(barButton);
+
+ captureTriggerForRoute('route-a');
+ barButton.remove();
+
+ // When the route pops
+ const rowSpy = jest.spyOn(row, 'focus');
+
+ // Then focus returns to the row rather than being lost for want of a candidate
+ expect(restoreTriggerForRoute('route-a')).toBe(true);
+ expect(rowSpy).toHaveBeenCalled();
+ });
+
+ it('should stop offering the element once the nomination is withdrawn', () => {
+ // Given a row nominated as the fallback and then withdrawn, which is what the bulk action bar does as it
+ // unmounts, before a bar button opens the route
+ const row = appendButton();
+ const barButton = appendButton();
+ nominateTriggerFallback(row);
+ nominateTriggerFallback(null);
+ barButton.focus();
+ setLastInteractiveElementForTests(barButton);
+
+ captureTriggerForRoute('route-a');
+ barButton.remove();
+
+ // When the route pops
+ // Then there is nothing left to restore to, as before the nomination existed
+ expect(restoreTriggerForRoute('route-a')).toBe(false);
+ });
+
+ it('should leave an active launcher as the fallback', () => {
+ // Given a popover launcher, which is the caller the existing fallback slot belongs to
+ const row = appendButton();
+ const launcher = appendButton();
+ const menuItem = appendButton();
+ nominateTriggerFallback(row);
+ setActivePopoverLauncher(launcher);
+ menuItem.focus();
+ menuItem.dispatchEvent(new FocusEvent('focusin', {bubbles: true}));
+
+ captureTriggerForRoute('route-a');
+ menuItem.remove();
+
+ // When the route pops
+ // Then the launcher is restored, and the nomination does not displace it
+ const launcherSpy = jest.spyOn(launcher, 'focus');
+ const rowSpy = jest.spyOn(row, 'focus');
+ expect(restoreTriggerForRoute('route-a')).toBe(true);
+ expect(launcherSpy).toHaveBeenCalled();
+ expect(rowSpy).not.toHaveBeenCalled();
+ });
+});
diff --git a/tests/unit/useBulkActionBarFocusTest.ts b/tests/unit/useBulkActionBarFocusTest.ts
new file mode 100644
index 000000000000..aa96bdef2d8a
--- /dev/null
+++ b/tests/unit/useBulkActionBarFocusTest.ts
@@ -0,0 +1,185 @@
+/**
+ * Tests for useBulkActionBarFocus (web), the hand-back of focus when the bulk action bar unmounts with the
+ * selection it describes. Runs against real DOM nodes in jsdom rather than a rendered bar, because the hook only
+ * ever reads the bar through a ref.
+ */
+
+import {act, renderHook} from '@testing-library/react-native';
+
+import type {RefObject} from 'react';
+
+// Jest resolves index.native.ts by default, and that one is a no-op.
+const useBulkActionBarFocus = require<{
+ default: (barRef: RefObject, isCoveredByScreen: boolean) => {suppressStrayFocusRing: () => void; isFocusInsideBar: boolean};
+}>('../../src/components/BulkActionBar/useBulkActionBarFocus/index.ts').default;
+const {setActivePopoverLauncher, markActivePopoverLauncherDeactivated, resetLauncherStackForTests} = require<{
+ setActivePopoverLauncher: (element: HTMLElement) => void;
+ markActivePopoverLauncherDeactivated: (element?: HTMLElement) => void;
+ resetLauncherStackForTests: () => void;
+}>('../../src/libs/LauncherStack.ts');
+
+function createButton(label: string) {
+ const button = document.createElement('button');
+ button.textContent = label;
+ document.body.appendChild(button);
+ return button;
+}
+
+/** jsdom fires focusin for `focus()`, which is what the hook listens on, so the real event is enough here. */
+function focus(element: HTMLElement) {
+ act(() => element.focus());
+}
+
+afterEach(() => {
+ resetLauncherStackForTests();
+ document.body.innerHTML = '';
+});
+
+describe('useBulkActionBarFocus', () => {
+ it('hands focus back to the table when the bar unmounts holding it', () => {
+ // Given a row that was focused before the bar appeared, and a bar that has since taken focus
+ const row = createButton('Row');
+ const bar = document.createElement('div');
+ const barButton = createButton('Change role');
+ bar.appendChild(barButton);
+ document.body.appendChild(bar);
+
+ const {unmount} = renderHook(() => useBulkActionBarFocus({current: bar}, false));
+
+ focus(row);
+ focus(barButton);
+
+ // When the action clears the selection and the bar goes away under the focused button
+ unmount();
+ bar.remove();
+
+ // Then focus is back on the row rather than lost to the document
+ expect(document.activeElement).toBe(row);
+ });
+
+ it('hands focus back to a checkbox that was already focused when the bar appeared', () => {
+ // Given the row checkbox that made the selection, which held focus before the bar existed and so fires no
+ // focusin the bar can see
+ const row = createButton('Row');
+ focus(row);
+
+ const bar = document.createElement('div');
+ const barButton = createButton('Clear selection');
+ bar.appendChild(barButton);
+ document.body.appendChild(bar);
+
+ const {unmount} = renderHook(() => useBulkActionBarFocus({current: bar}, false));
+
+ focus(barButton);
+
+ // When the action clears the selection and the bar goes away under the focused button
+ unmount();
+ bar.remove();
+
+ // Then focus is back on the checkbox rather than lost to the document
+ expect(document.activeElement).toBe(row);
+ });
+
+ it('hands focus back when an action run from another screen has already dropped it to the document', () => {
+ // Given a row focused on the table, and a screen opened over it that takes focus to its own Save button
+ const row = createButton('Row');
+ const bar = document.createElement('div');
+ document.body.appendChild(bar);
+ const save = createButton('Save');
+
+ const {rerender, unmount} = renderHook(({isCoveredByScreen}) => useBulkActionBarFocus({current: bar}, isCoveredByScreen), {initialProps: {isCoveredByScreen: false}});
+
+ focus(row);
+
+ rerender({isCoveredByScreen: true});
+ focus(save);
+
+ // When that screen saves and leaves, so its own controls are gone and focus has fallen to the document
+ // before the bar unmounts with the selection
+ act(() => save.remove());
+ expect(document.activeElement).toBe(document.body);
+
+ unmount();
+ bar.remove();
+
+ // Then focus is back on the row, not left on the document
+ expect(document.activeElement).toBe(row);
+ });
+
+ it('leaves focus alone when the bar unmounts without holding it', () => {
+ // Given a bar that unmounts while focus sits on a control of the table behind it
+ const row = createButton('Row');
+ const other = createButton('Other');
+ const bar = document.createElement('div');
+ document.body.appendChild(bar);
+
+ const {unmount} = renderHook(() => useBulkActionBarFocus({current: bar}, false));
+
+ focus(row);
+ focus(other);
+
+ // When the last row is deselected and the bar goes away
+ unmount();
+ bar.remove();
+
+ // Then the control the user is actually on keeps focus
+ expect(document.activeElement).toBe(other);
+ });
+
+ it('keeps the table target when focus passes through the bar\'s own "More" menu', () => {
+ // Given a row focused on the table, then the bar's "More" menu opened over it. The menu renders in a popover
+ // portalled to the body, so its items are outside the bar in the DOM but on the same screen.
+ const row = createButton('Row');
+ const bar = document.createElement('div');
+ document.body.appendChild(bar);
+ const moreButton = createButton('More');
+ bar.appendChild(moreButton);
+ const menuItem = createButton('Remove members');
+
+ const {unmount} = renderHook(() => useBulkActionBarFocus({current: bar}, false));
+
+ focus(row);
+ focus(moreButton);
+ act(() => setActivePopoverLauncher(moreButton));
+ focus(menuItem);
+
+ // When the chosen action clears the selection, so the menu has closed and the bar unmounts with it
+ act(() => {
+ markActivePopoverLauncherDeactivated(moreButton);
+ menuItem.remove();
+ });
+
+ unmount();
+ bar.remove();
+
+ // Then focus is back on the row, rather than lost because the menu item was remembered and is now gone
+ expect(document.activeElement).toBe(row);
+ });
+
+ it('hands focus back when the bar unmounts while its own "More" menu still holds focus', () => {
+ // Given a row focused on the table, then an action chosen from the bar's "More" menu. The action clears the
+ // selection from the menu item, so the bar unmounts before the popover has finished closing and focus is
+ // still on the item rather than back on the table.
+ const row = createButton('Row');
+ const bar = document.createElement('div');
+ document.body.appendChild(bar);
+ const moreButton = createButton('More');
+ bar.appendChild(moreButton);
+ const menuItem = createButton('Duplicate expenses');
+
+ const {unmount} = renderHook(() => useBulkActionBarFocus({current: bar}, false));
+
+ focus(row);
+ focus(moreButton);
+ act(() => setActivePopoverLauncher(moreButton));
+ focus(menuItem);
+
+ // When the bar goes away with the selection, taking the launcher the popover would otherwise return to
+ unmount();
+ bar.remove();
+ act(() => menuItem.remove());
+
+ // Then focus is back on the row, rather than aimed at a "More" button that left with the bar
+ expect(document.activeElement).toBe(row);
+ });
+});