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); + }); +});