diff --git a/assets/images/make-admin.svg b/assets/images/make-admin.svg
new file mode 100644
index 000000000000..383708e0523c
--- /dev/null
+++ b/assets/images/make-admin.svg
@@ -0,0 +1,12 @@
+
+
+
diff --git a/assets/images/remove-members.svg b/assets/images/remove-members.svg
new file mode 100644
index 000000000000..e9d7e08f5e5e
--- /dev/null
+++ b/assets/images/remove-members.svg
@@ -0,0 +1,8 @@
+
+
+
diff --git a/src/CONST.ts b/src/CONST.ts
index 7fa3d158e12e..6626b798d314 100755
--- a/src/CONST.ts
+++ b/src/CONST.ts
@@ -1388,6 +1388,11 @@ const CONST = {
},
ID_FAKE: '_FAKE_',
EMPTY: 'EMPTY',
+ MEMBERS_BULK_ACTION_TYPES: {
+ REMOVE: 'remove',
+ MAKE_MEMBER: 'makeMember',
+ MAKE_ADMIN: 'makeAdmin',
+ },
},
CUSTOM_UNITS: {
diff --git a/src/components/Button/index.tsx b/src/components/Button/index.tsx
index 1777b239e714..a25c7ff7129c 100644
--- a/src/components/Button/index.tsx
+++ b/src/components/Button/index.tsx
@@ -315,7 +315,7 @@ function Button(
large ? styles.buttonLarge : undefined,
success ? styles.buttonSuccess : undefined,
danger ? styles.buttonDanger : undefined,
- isDisabled && (success || danger) ? styles.buttonOpacityDisabled : undefined,
+ isDisabled ? styles.buttonOpacityDisabled : undefined,
isDisabled && !danger && !success ? styles.buttonDisabled : undefined,
shouldRemoveRightBorderRadius ? styles.noRightBorderRadius : undefined,
shouldRemoveLeftBorderRadius ? styles.noLeftBorderRadius : undefined,
diff --git a/src/components/ButtonWithDropdownMenu.tsx b/src/components/ButtonWithDropdownMenu/index.tsx
similarity index 66%
rename from src/components/ButtonWithDropdownMenu.tsx
rename to src/components/ButtonWithDropdownMenu/index.tsx
index 9466da601825..61d3409c65ab 100644
--- a/src/components/ButtonWithDropdownMenu.tsx
+++ b/src/components/ButtonWithDropdownMenu/index.tsx
@@ -1,77 +1,26 @@
-import type {RefObject} from 'react';
import React, {useEffect, useRef, useState} from 'react';
-import type {GestureResponderEvent, StyleProp, ViewStyle} from 'react-native';
import {View} from 'react-native';
-import type {ValueOf} from 'type-fest';
+import Button from '@components/Button';
+import Icon from '@components/Icon';
+import * as Expensicons from '@components/Icon/Expensicons';
+import PopoverMenu from '@components/PopoverMenu';
import useStyleUtils from '@hooks/useStyleUtils';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
import useWindowDimensions from '@hooks/useWindowDimensions';
-import type {AnchorPosition} from '@styles/index';
+import variables from '@styles/variables';
import CONST from '@src/CONST';
-import type AnchorAlignment from '@src/types/utils/AnchorAlignment';
-import type DeepValueOf from '@src/types/utils/DeepValueOf';
-import type IconAsset from '@src/types/utils/IconAsset';
-import Button from './Button';
-import Icon from './Icon';
-import * as Expensicons from './Icon/Expensicons';
-import PopoverMenu from './PopoverMenu';
+import type {AnchorPosition} from '@src/styles';
+import type {ButtonWithDropdownMenuProps} from './types';
-type PaymentType = DeepValueOf;
-
-type DropdownOption = {
- value: PaymentType;
- text: string;
- icon: IconAsset;
- iconWidth?: number;
- iconHeight?: number;
- iconDescription?: string;
-};
-
-type ButtonWithDropdownMenuProps = {
- /** Text to display for the menu header */
- menuHeaderText?: string;
-
- /** Callback to execute when the main button is pressed */
- onPress: (event: GestureResponderEvent | KeyboardEvent | undefined, value: PaymentType) => void;
-
- /** Callback to execute when a dropdown option is selected */
- onOptionSelected?: (option: DropdownOption) => void;
-
- /** Call the onPress function on main button when Enter key is pressed */
- pressOnEnter?: boolean;
-
- /** Whether we should show a loading state for the main button */
- isLoading?: boolean;
-
- /** The size of button size */
- buttonSize: ValueOf;
-
- /** Should the confirmation button be disabled? */
- isDisabled?: boolean;
-
- /** Additional styles to add to the component */
- style?: StyleProp;
-
- /** Menu options to display */
- /** e.g. [{text: 'Pay with Expensify', icon: Wallet}] */
- options: DropdownOption[];
-
- /** The anchor alignment of the popover menu */
- anchorAlignment?: AnchorAlignment;
-
- /* ref for the button */
- buttonRef: RefObject;
-
- /** The priority to assign the enter key event listener to buttons. 0 is the highest priority. */
- enterKeyEventListenerPriority?: number;
-};
-
-function ButtonWithDropdownMenu({
+function ButtonWithDropdownMenu({
+ success = false,
isLoading = false,
isDisabled = false,
pressOnEnter = false,
+ shouldAlwaysShowDropdownMenu = false,
menuHeaderText = '',
+ customText,
style,
buttonSize = CONST.DROPDOWN_BUTTON_SIZE.MEDIUM,
anchorAlignment = {
@@ -83,7 +32,7 @@ function ButtonWithDropdownMenu({
options,
onOptionSelected,
enterKeyEventListenerPriority = 0,
-}: ButtonWithDropdownMenuProps) {
+}: ButtonWithDropdownMenuProps) {
const theme = useTheme();
const styles = useThemeStyles();
const StyleUtils = useStyleUtils();
@@ -118,27 +67,27 @@ function ButtonWithDropdownMenu({
return (
- {options.length > 1 ? (
+ {shouldAlwaysShowDropdownMenu || options.length > 1 ? (
) : (
)}
- {options.length > 1 && popoverAnchorPosition && (
+ {(shouldAlwaysShowDropdownMenu || options.length > 1) && popoverAnchorPosition && (
setIsMenuVisible(false)}
@@ -187,10 +138,12 @@ function ButtonWithDropdownMenu({
headerText={menuHeaderText}
menuItems={options.map((item, index) => ({
...item,
- onSelected: () => {
- onOptionSelected?.(item);
- setSelectedItemIndex(index);
- },
+ onSelected:
+ item.onSelected ??
+ (() => {
+ onOptionSelected?.(item);
+ setSelectedItemIndex(index);
+ }),
}))}
/>
)}
@@ -200,4 +153,4 @@ function ButtonWithDropdownMenu({
ButtonWithDropdownMenu.displayName = 'ButtonWithDropdownMenu';
-export default React.memo(ButtonWithDropdownMenu);
+export default ButtonWithDropdownMenu;
diff --git a/src/components/ButtonWithDropdownMenu/types.ts b/src/components/ButtonWithDropdownMenu/types.ts
new file mode 100644
index 000000000000..9975c10c13c3
--- /dev/null
+++ b/src/components/ButtonWithDropdownMenu/types.ts
@@ -0,0 +1,71 @@
+import type {RefObject} from 'react';
+import type {GestureResponderEvent, StyleProp, View, ViewStyle} from 'react-native';
+import type {ValueOf} from 'type-fest';
+import type CONST from '@src/CONST';
+import type AnchorAlignment from '@src/types/utils/AnchorAlignment';
+import type DeepValueOf from '@src/types/utils/DeepValueOf';
+import type IconAsset from '@src/types/utils/IconAsset';
+
+type PaymentType = DeepValueOf;
+
+type WorkspaceMemberBulkActionType = DeepValueOf;
+
+type DropdownOption = {
+ value: TValueType;
+ text: string;
+ icon: IconAsset;
+ iconWidth?: number;
+ iconHeight?: number;
+ iconDescription?: string;
+ onSelected?: () => void;
+};
+
+type ButtonWithDropdownMenuProps = {
+ /** The custom text to display on the main button instead of selected option */
+ customText?: string;
+
+ /** Text to display for the menu header */
+ menuHeaderText?: string;
+
+ /** Callback to execute when the main button is pressed */
+ onPress: (event: GestureResponderEvent | KeyboardEvent | undefined, value: TValueType) => void;
+
+ /** Callback to execute when a dropdown option is selected */
+ onOptionSelected?: (option: DropdownOption) => void;
+
+ /** Call the onPress function on main button when Enter key is pressed */
+ pressOnEnter?: boolean;
+
+ /** Whether we should show a loading state for the main button */
+ isLoading?: boolean;
+
+ /** The size of button size */
+ buttonSize: ValueOf;
+
+ /** Should the confirmation button be disabled? */
+ isDisabled?: boolean;
+
+ /** Additional styles to add to the component */
+ style?: StyleProp;
+
+ /** Menu options to display */
+ /** e.g. [{text: 'Pay with Expensify', icon: Wallet}] */
+ options: Array>;
+
+ /** The anchor alignment of the popover menu */
+ anchorAlignment?: AnchorAlignment;
+
+ /* ref for the button */
+ buttonRef: RefObject;
+
+ /** The priority to assign the enter key event listener to buttons. 0 is the highest priority. */
+ enterKeyEventListenerPriority?: number;
+
+ /** Whether the button should use success style or not */
+ success?: boolean;
+
+ /** Whether the dropdown menu should be shown even if it has only one option */
+ shouldAlwaysShowDropdownMenu?: boolean;
+};
+
+export type {PaymentType, WorkspaceMemberBulkActionType, DropdownOption, ButtonWithDropdownMenuProps};
diff --git a/src/components/Icon/Expensicons.ts b/src/components/Icon/Expensicons.ts
index 4db22dee8256..6c6c1b86eee1 100644
--- a/src/components/Icon/Expensicons.ts
+++ b/src/components/Icon/Expensicons.ts
@@ -92,6 +92,7 @@ import Lock from '@assets/images/lock.svg';
import Luggage from '@assets/images/luggage.svg';
import MagnifyingGlass from '@assets/images/magnifying-glass.svg';
import Mail from '@assets/images/mail.svg';
+import MakeAdmin from '@assets/images/make-admin.svg';
import Megaphone from '@assets/images/megaphone.svg';
import Menu from '@assets/images/menu.svg';
import Meter from '@assets/images/meter.svg';
@@ -118,6 +119,7 @@ import QrCode from '@assets/images/qrcode.svg';
import QuestionMark from '@assets/images/question-mark-circle.svg';
import ReceiptSearch from '@assets/images/receipt-search.svg';
import Receipt from '@assets/images/receipt.svg';
+import RemoveMembers from '@assets/images/remove-members.svg';
import Rotate from '@assets/images/rotate-image.svg';
import RotateLeft from '@assets/images/rotate-left.svg';
import Scan from '@assets/images/scan.svg';
@@ -242,6 +244,7 @@ export {
Luggage,
MagnifyingGlass,
Mail,
+ MakeAdmin,
Menu,
Meter,
Megaphone,
@@ -269,6 +272,7 @@ export {
QrCode,
QuestionMark,
Receipt,
+ RemoveMembers,
ReceiptSearch,
Rotate,
RotateLeft,
diff --git a/src/components/MoneyTemporaryForRefactorRequestConfirmationList.js b/src/components/MoneyTemporaryForRefactorRequestConfirmationList.js
index 92313d03ae2a..03dde8f765a1 100755
--- a/src/components/MoneyTemporaryForRefactorRequestConfirmationList.js
+++ b/src/components/MoneyTemporaryForRefactorRequestConfirmationList.js
@@ -598,6 +598,7 @@ function MoneyTemporaryForRefactorRequestConfirmationList({
/>
) : (
confirm(value)}
diff --git a/src/components/SelectionList/BaseListItem.tsx b/src/components/SelectionList/BaseListItem.tsx
index 98b1999625ee..411dd3a09f71 100644
--- a/src/components/SelectionList/BaseListItem.tsx
+++ b/src/components/SelectionList/BaseListItem.tsx
@@ -4,6 +4,7 @@ import Icon from '@components/Icon';
import * as Expensicons from '@components/Icon/Expensicons';
import OfflineWithFeedback from '@components/OfflineWithFeedback';
import PressableWithFeedback from '@components/Pressable/PressableWithFeedback';
+import useHover from '@hooks/useHover';
import useStyleUtils from '@hooks/useStyleUtils';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
@@ -31,6 +32,7 @@ function BaseListItem({
const theme = useTheme();
const styles = useThemeStyles();
const StyleUtils = useStyleUtils();
+ const {hovered, bind} = useHover();
const rightHandSideComponentRender = () => {
if (canSelectMultiple || !rightHandSideComponent) {
@@ -60,60 +62,59 @@ function BaseListItem({
errorRowStyles={styles.ph5}
>
onSelectRow(item)}
disabled={isDisabled}
accessibilityLabel={item.text}
role={CONST.ROLE.BUTTON}
hoverDimmingValue={1}
- hoverStyle={styles.hoveredComponentBG}
+ hoverStyle={!item.isSelected && styles.hoveredComponentBG}
dataSet={{[CONST.SELECTION_SCRAPER_HIDDEN_ELEMENT]: true}}
onMouseDown={shouldPreventDefaultFocusOnSelectRow ? (e) => e.preventDefault() : undefined}
nativeID={keyForList}
style={pressableStyle}
>
- {({hovered}) => (
- <>
-
- {canSelectMultiple && (
-
-
- {item.isSelected && (
-
- )}
-
-
- )}
+
+ {canSelectMultiple && (
+
+
+ {item.isSelected && (
+
+ )}
+
+
+ )}
- {typeof children === 'function' ? children(hovered) : children}
+ {typeof children === 'function' ? children(hovered) : children}
- {!canSelectMultiple && item.isSelected && !rightHandSideComponent && (
-
-
-
-
-
- )}
- {rightHandSideComponentRender()}
+ {!canSelectMultiple && item.isSelected && !rightHandSideComponent && (
+
+
+
+
- {FooterComponent}
- >
- )}
+ )}
+ {rightHandSideComponentRender()}
+
+ {FooterComponent}
);
diff --git a/src/components/SelectionList/RadioListItem.tsx b/src/components/SelectionList/RadioListItem.tsx
index a053de301fc1..be2302d21b89 100644
--- a/src/components/SelectionList/RadioListItem.tsx
+++ b/src/components/SelectionList/RadioListItem.tsx
@@ -43,7 +43,7 @@ function RadioListItem({
)}
diff --git a/src/components/SelectionList/TableListItem.tsx b/src/components/SelectionList/TableListItem.tsx
index e5f230065455..164c28871d6d 100644
--- a/src/components/SelectionList/TableListItem.tsx
+++ b/src/components/SelectionList/TableListItem.tsx
@@ -30,7 +30,7 @@ function TableListItem({
return (
)}
diff --git a/src/components/SelectionList/UserListItem.tsx b/src/components/SelectionList/UserListItem.tsx
index fc39bb4b1259..0cfe4c1a509a 100644
--- a/src/components/SelectionList/UserListItem.tsx
+++ b/src/components/SelectionList/UserListItem.tsx
@@ -82,7 +82,7 @@ function UserListItem({
)}
diff --git a/src/components/SettlementButton.tsx b/src/components/SettlementButton.tsx
index 50bfcd4cc8be..8e5f32f9cd20 100644
--- a/src/components/SettlementButton.tsx
+++ b/src/components/SettlementButton.tsx
@@ -19,6 +19,7 @@ import type {PaymentMethodType} from '@src/types/onyx/OriginalMessage';
import type AnchorAlignment from '@src/types/utils/AnchorAlignment';
import type {EmptyObject} from '@src/types/utils/EmptyObject';
import ButtonWithDropdownMenu from './ButtonWithDropdownMenu';
+import type {PaymentType} from './ButtonWithDropdownMenu/types';
import * as Expensicons from './Icon/Expensicons';
import KYCWall from './KYCWall';
@@ -224,7 +225,8 @@ function SettlementButton({
shouldShowPersonalBankAccountOption={shouldShowPersonalBankAccountOption}
>
{(triggerKYCFlow, buttonRef) => (
-
+ success
buttonRef={buttonRef}
isDisabled={isDisabled}
isLoading={isLoading}
diff --git a/src/components/TextWithTooltip/index.native.tsx b/src/components/TextWithTooltip/index.native.tsx
index 3780df6362be..b857ded2588b 100644
--- a/src/components/TextWithTooltip/index.native.tsx
+++ b/src/components/TextWithTooltip/index.native.tsx
@@ -2,10 +2,10 @@ import React from 'react';
import Text from '@components/Text';
import type TextWithTooltipProps from './types';
-function TextWithTooltip({text, textStyles, numberOfLines = 1}: TextWithTooltipProps) {
+function TextWithTooltip({text, style, numberOfLines = 1}: TextWithTooltipProps) {
return (
{text}
diff --git a/src/components/TextWithTooltip/index.tsx b/src/components/TextWithTooltip/index.tsx
index 96721488c6db..704ffdb8bb1c 100644
--- a/src/components/TextWithTooltip/index.tsx
+++ b/src/components/TextWithTooltip/index.tsx
@@ -7,7 +7,7 @@ type LayoutChangeEvent = {
target: HTMLElement;
};
-function TextWithTooltip({text, shouldShowTooltip, textStyles, numberOfLines = 1}: TextWithTooltipProps) {
+function TextWithTooltip({text, shouldShowTooltip, style, numberOfLines = 1}: TextWithTooltipProps) {
const [showTooltip, setShowTooltip] = useState(false);
return (
@@ -16,7 +16,7 @@ function TextWithTooltip({text, shouldShowTooltip, textStyles, numberOfLines = 1
text={text}
>
{
const target = (e.nativeEvent as unknown as LayoutChangeEvent).target;
diff --git a/src/components/TextWithTooltip/types.ts b/src/components/TextWithTooltip/types.ts
index 19c0b0dca6ed..8169df911945 100644
--- a/src/components/TextWithTooltip/types.ts
+++ b/src/components/TextWithTooltip/types.ts
@@ -7,8 +7,8 @@ type TextWithTooltipProps = {
/** Whether to show the toolip text */
shouldShowTooltip: boolean;
- /** Additional text styles */
- textStyles?: StyleProp;
+ /** Additional styles */
+ style?: StyleProp;
/** Custom number of lines for text wrapping */
numberOfLines?: number;
diff --git a/src/hooks/useHover.ts b/src/hooks/useHover.ts
new file mode 100644
index 000000000000..3e65979f6740
--- /dev/null
+++ b/src/hooks/useHover.ts
@@ -0,0 +1,14 @@
+import {useState} from 'react';
+
+const useHover = () => {
+ const [hovered, setHovered] = useState(false);
+ return {
+ hovered,
+ bind: {
+ onMouseEnter: () => setHovered(true),
+ onMouseLeave: () => setHovered(false),
+ },
+ };
+};
+
+export default useHover;
diff --git a/src/languages/en.ts b/src/languages/en.ts
index f812ff55851a..2a0139c64c07 100755
--- a/src/languages/en.ts
+++ b/src/languages/en.ts
@@ -1789,9 +1789,12 @@ export default {
},
people: {
genericFailureMessage: 'An error occurred removing a user from the workspace, please try again.',
- removeMembersPrompt: 'Are you sure you want to remove the selected members from your workspace?',
+ removeMembersPrompt: 'Are you sure you want to remove these members?',
removeMembersTitle: 'Remove members',
+ makeMember: 'Make member',
+ makeAdmin: 'Make admin',
selectAll: 'Select all',
+ selected: ({selectedNumber}) => `${selectedNumber} selected`,
error: {
genericAdd: 'There was a problem adding this workspace member.',
cannotRemove: 'You cannot remove yourself or the workspace owner.',
diff --git a/src/languages/es.ts b/src/languages/es.ts
index e8fe2a1e3c71..20f4cf8aeac8 100644
--- a/src/languages/es.ts
+++ b/src/languages/es.ts
@@ -1813,9 +1813,12 @@ export default {
},
people: {
genericFailureMessage: 'Se ha producido un error al intentar eliminar a un usuario del espacio de trabajo. Por favor, inténtalo más tarde.',
- removeMembersPrompt: '¿Estás seguro que quieres eliminar a los miembros seleccionados de tu espacio de trabajo?',
+ removeMembersPrompt: '¿Estás seguro de que deseas eliminar a estos miembros?',
removeMembersTitle: 'Eliminar miembros',
+ makeMember: 'Hacer miembro',
+ makeAdmin: 'Hacer administrador',
selectAll: 'Seleccionar todo',
+ selected: ({selectedNumber}) => `${selectedNumber} seleccionados`,
error: {
genericAdd: 'Ha ocurrido un problema al añadir el miembro al espacio de trabajo.',
cannotRemove: 'No puedes eliminarte ni a ti mismo ni al dueño del espacio de trabajo.',
diff --git a/src/libs/API/parameters/UpdateWorkspaceMembersRoleParams.ts b/src/libs/API/parameters/UpdateWorkspaceMembersRoleParams.ts
new file mode 100644
index 000000000000..3686aab425d7
--- /dev/null
+++ b/src/libs/API/parameters/UpdateWorkspaceMembersRoleParams.ts
@@ -0,0 +1,6 @@
+type UpdateWorkspaceMembersRoleParams = {
+ policyID: string;
+ employees: string;
+};
+
+export default UpdateWorkspaceMembersRoleParams;
diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts
index ccb42ed44e74..4fbc597b8186 100644
--- a/src/libs/API/parameters/index.ts
+++ b/src/libs/API/parameters/index.ts
@@ -145,6 +145,7 @@ export type {default as UnHoldMoneyRequestParams} from './UnHoldMoneyRequestPara
export type {default as CancelPaymentParams} from './CancelPaymentParams';
export type {default as AcceptACHContractForBankAccount} from './AcceptACHContractForBankAccount';
export type {default as UpdateWorkspaceDescriptionParams} from './UpdateWorkspaceDescriptionParams';
+export type {default as UpdateWorkspaceMembersRoleParams} from './UpdateWorkspaceMembersRoleParams';
export type {default as SetWorkspaceCategoriesEnabledParams} from './SetWorkspaceCategoriesEnabledParams';
export type {default as SetWorkspaceRequiresCategoryParams} from './SetWorkspaceRequiresCategoryParams';
export type {default as SetWorkspaceAutoReportingParams} from './SetWorkspaceAutoReportingParams';
diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts
index fd530d648fc2..ba49bc5fa27b 100644
--- a/src/libs/API/types.ts
+++ b/src/libs/API/types.ts
@@ -111,6 +111,7 @@ const WRITE_COMMANDS = {
UPDATE_WORKSPACE_GENERAL_SETTINGS: 'UpdateWorkspaceGeneralSettings',
UPDATE_WORKSPACE_CUSTOM_UNIT_AND_RATE: 'UpdateWorkspaceCustomUnitAndRate',
UPDATE_WORKSPACE_DESCRIPTION: 'UpdateWorkspaceDescription',
+ UPDATE_WORKSPACE_MEMBERS_ROLE: 'UpdateWorkspaceMembersRole',
CREATE_WORKSPACE: 'CreateWorkspace',
CREATE_WORKSPACE_FROM_IOU_PAYMENT: 'CreateWorkspaceFromIOUPayment',
SET_WORKSPACE_CATEGORIES_ENABLED: 'SetWorkspaceCategoriesEnabled',
@@ -258,6 +259,7 @@ type WriteCommandParameters = {
[WRITE_COMMANDS.DELETE_WORKSPACE_AVATAR]: Parameters.DeleteWorkspaceAvatarParams;
[WRITE_COMMANDS.UPDATE_WORKSPACE_GENERAL_SETTINGS]: Parameters.UpdateWorkspaceGeneralSettingsParams;
[WRITE_COMMANDS.UPDATE_WORKSPACE_DESCRIPTION]: Parameters.UpdateWorkspaceDescriptionParams;
+ [WRITE_COMMANDS.UPDATE_WORKSPACE_MEMBERS_ROLE]: Parameters.UpdateWorkspaceMembersRoleParams;
[WRITE_COMMANDS.UPDATE_WORKSPACE_CUSTOM_UNIT_AND_RATE]: Parameters.UpdateWorkspaceCustomUnitAndRateParams;
[WRITE_COMMANDS.CREATE_WORKSPACE]: Parameters.CreateWorkspaceParams;
[WRITE_COMMANDS.CREATE_WORKSPACE_FROM_IOU_PAYMENT]: Parameters.CreateWorkspaceFromIOUPaymentParams;
diff --git a/src/libs/actions/Policy.ts b/src/libs/actions/Policy.ts
index 9c270c270780..313c4deb9934 100644
--- a/src/libs/actions/Policy.ts
+++ b/src/libs/actions/Policy.ts
@@ -28,6 +28,7 @@ import type {
UpdateWorkspaceCustomUnitAndRateParams,
UpdateWorkspaceDescriptionParams,
UpdateWorkspaceGeneralSettingsParams,
+ UpdateWorkspaceMembersRoleParams,
} from '@libs/API/parameters';
import {READ_COMMANDS, WRITE_COMMANDS} from '@libs/API/types';
import DateUtils from '@libs/DateUtils';
@@ -97,6 +98,12 @@ type NewCustomUnit = {
rates: Rate;
};
+type WorkspaceMembersRoleData = {
+ accountID: number;
+ email: string;
+ role: typeof CONST.POLICY.ROLE.ADMIN | typeof CONST.POLICY.ROLE.USER;
+};
+
const allPolicies: OnyxCollection = {};
Onyx.connect({
key: ONYXKEYS.COLLECTION.POLICY,
@@ -718,6 +725,66 @@ function removeMembers(accountIDs: number[], policyID: string) {
API.write(WRITE_COMMANDS.DELETE_MEMBERS_FROM_WORKSPACE, params, {optimisticData, successData, failureData});
}
+function updateWorkspaceMembersRole(policyID: string, accountIDs: number[], newRole: typeof CONST.POLICY.ROLE.ADMIN | typeof CONST.POLICY.ROLE.USER) {
+ const previousPolicyMembers = {...allPolicyMembers};
+ const memberRoles: WorkspaceMembersRoleData[] = accountIDs.reduce((result: WorkspaceMembersRoleData[], accountID: number) => {
+ if (!allPersonalDetails?.[accountID]?.login) {
+ return result;
+ }
+
+ result.push({
+ accountID,
+ email: allPersonalDetails?.[accountID]?.login ?? '',
+ role: newRole,
+ });
+
+ return result;
+ }, []);
+
+ const optimisticData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.POLICY_MEMBERS}${policyID}`,
+ value: {
+ ...memberRoles.reduce((member: Record, current) => {
+ // eslint-disable-next-line no-param-reassign
+ member[current.accountID] = {role: current?.role};
+ return member;
+ }, {}),
+ errors: null,
+ },
+ },
+ ];
+
+ const successData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.POLICY_MEMBERS}${policyID}`,
+ value: {
+ errors: null,
+ },
+ },
+ ];
+
+ const failureData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.POLICY_MEMBERS}${policyID}`,
+ value: {
+ ...(previousPolicyMembers[`${ONYXKEYS.COLLECTION.POLICY_MEMBERS}${policyID}`] as Record),
+ errors: ErrorUtils.getMicroSecondOnyxError('workspace.editor.genericFailureMessage'),
+ },
+ },
+ ];
+
+ const params: UpdateWorkspaceMembersRoleParams = {
+ policyID,
+ employees: JSON.stringify(memberRoles.map((item) => ({email: item.email, role: item.role}))),
+ };
+
+ API.write(WRITE_COMMANDS.UPDATE_WORKSPACE_MEMBERS_ROLE, params, {optimisticData, successData, failureData});
+}
+
/**
* Optimistically create a chat for each member of the workspace, creates both optimistic and success data for onyx.
*
@@ -2422,6 +2489,7 @@ function clearCategoryErrors(policyID: string, categoryName: string) {
export {
removeMembers,
+ updateWorkspaceMembersRole,
addMembersToWorkspace,
isAdminOfFreePolicy,
hasActiveFreePolicy,
diff --git a/src/pages/workspace/WorkspaceMembersPage.tsx b/src/pages/workspace/WorkspaceMembersPage.tsx
index 3bf5a1b130fe..718f2b6d9ad2 100644
--- a/src/pages/workspace/WorkspaceMembersPage.tsx
+++ b/src/pages/workspace/WorkspaceMembersPage.tsx
@@ -8,6 +8,8 @@ import {withOnyx} from 'react-native-onyx';
import Badge from '@components/Badge';
import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView';
import Button from '@components/Button';
+import ButtonWithDropdownMenu from '@components/ButtonWithDropdownMenu';
+import type {DropdownOption, WorkspaceMemberBulkActionType} from '@components/ButtonWithDropdownMenu/types';
import ConfirmModal from '@components/ConfirmModal';
import HeaderWithBackButton from '@components/HeaderWithBackButton';
import * as Expensicons from '@components/Icon/Expensicons';
@@ -83,6 +85,7 @@ function WorkspaceMembersPage({policyMembers, personalDetails, route, policy, se
const prevPersonalDetails = usePrevious(personalDetails);
const {translate, formatPhoneNumber, preferredLocale} = useLocalize();
const {isSmallScreenWidth} = useWindowDimensions();
+ const dropdownButtonRef = useRef(null);
/**
* Get filtered personalDetails list with current policyMembers
@@ -301,6 +304,8 @@ function WorkspaceMembersPage({policyMembers, personalDetails, route, policy, se
}
}
+ const isSelected = selectedEmployees.includes(accountID);
+
const isOwner = policy?.owner === details.login;
const isAdmin = session?.email === details.login || policyMember.role === CONST.POLICY.ROLE.ADMIN;
@@ -310,7 +315,7 @@ function WorkspaceMembersPage({policyMembers, personalDetails, route, policy, se
);
}
@@ -318,7 +323,7 @@ function WorkspaceMembersPage({policyMembers, personalDetails, route, policy, se
result.push({
keyForList: accountIDKey,
accountID,
- isSelected: selectedEmployees.includes(accountID),
+ isSelected,
isDisabled:
accountID === session?.accountID ||
details.login === policy?.owner ||
@@ -358,7 +363,7 @@ function WorkspaceMembersPage({policyMembers, personalDetails, route, policy, se
const getHeaderContent = () => (
<>
- {translate('workspace.people.membersListTitle')}
+ {translate('workspace.people.membersListTitle')}
{!isEmptyObject(invitedPrimaryToSecondaryLogins) && (
(
-
+
- {translate('common.member')}
+ {translate('common.member')}
{translate('common.role')}
@@ -382,26 +387,73 @@ function WorkspaceMembersPage({policyMembers, personalDetails, route, policy, se
);
+ const changeUserRole = (role: typeof CONST.POLICY.ROLE.ADMIN | typeof CONST.POLICY.ROLE.USER) => {
+ if (!isEmptyObject(errors)) {
+ return;
+ }
+
+ const accountIDsToUpdate = selectedEmployees.filter((id) => policyMembers?.[id].role !== role);
+
+ Policy.updateWorkspaceMembersRole(route.params.policyID, accountIDsToUpdate, role);
+ setSelectedEmployees([]);
+ };
+
+ const getBulkActionsButtonOptions = () => {
+ const options: Array> = [
+ {
+ text: translate('workspace.people.removeMembersTitle'),
+ value: CONST.POLICY.MEMBERS_BULK_ACTION_TYPES.REMOVE,
+ icon: Expensicons.RemoveMembers,
+ onSelected: askForConfirmationToRemove,
+ },
+ ];
+
+ if (selectedEmployees.find((employee) => policyMembers?.[employee]?.role === CONST.POLICY.ROLE.ADMIN)) {
+ options.push({
+ text: translate('workspace.people.makeMember'),
+ value: CONST.POLICY.MEMBERS_BULK_ACTION_TYPES.MAKE_MEMBER,
+ icon: Expensicons.User,
+ onSelected: () => changeUserRole(CONST.POLICY.ROLE.USER),
+ });
+ }
+
+ if (selectedEmployees.find((employee) => policyMembers?.[employee]?.role === CONST.POLICY.ROLE.USER)) {
+ options.push({
+ text: translate('workspace.people.makeAdmin'),
+ value: CONST.POLICY.MEMBERS_BULK_ACTION_TYPES.MAKE_ADMIN,
+ icon: Expensicons.MakeAdmin,
+ onSelected: () => changeUserRole(CONST.POLICY.ROLE.ADMIN),
+ });
+ }
+
+ return options;
+ };
+
const getHeaderButtons = () => (
-
-
-
+
+ {selectedEmployees.length > 0 ? (
+
+ shouldAlwaysShowDropdownMenu
+ pressOnEnter
+ customText={translate('workspace.people.selected', {selectedNumber: selectedEmployees.length})}
+ buttonSize={CONST.DROPDOWN_BUTTON_SIZE.MEDIUM}
+ onPress={() => null}
+ options={getBulkActionsButtonOptions()}
+ buttonRef={dropdownButtonRef}
+ style={[isSmallScreenWidth && styles.flexGrow1]}
+ />
+ ) : (
+
+ )}
);
diff --git a/src/styles/index.ts b/src/styles/index.ts
index 7b1e2e78fe81..405a05cfce78 100644
--- a/src/styles/index.ts
+++ b/src/styles/index.ts
@@ -649,9 +649,21 @@ const styles = (theme: ThemeColors) =>
},
buttonDivider: {
- height: variables.dropDownButtonDividerHeight,
- borderWidth: 0.7,
- borderColor: theme.textLight,
+ borderRightWidth: 1,
+ borderRightColor: theme.buttonHoveredBG,
+ ...sizing.h100,
+ },
+
+ buttonSuccessDivider: {
+ borderRightWidth: 1,
+ borderRightColor: theme.successHover,
+ ...sizing.h100,
+ },
+
+ buttonDangerDivider: {
+ borderRightWidth: 1,
+ borderRightColor: theme.dangerHover,
+ ...sizing.h100,
},
noBorderRadius: {
diff --git a/src/styles/theme/themes/dark.ts b/src/styles/theme/themes/dark.ts
index 31716d75dd05..52a9e2dd2cd7 100644
--- a/src/styles/theme/themes/dark.ts
+++ b/src/styles/theme/themes/dark.ts
@@ -28,6 +28,7 @@ const darkTheme = {
buttonDefaultBG: colors.productDark400,
buttonHoveredBG: colors.productDark500,
buttonPressedBG: colors.productDark600,
+ buttonSuccessText: colors.productLight100,
danger: colors.red,
dangerHover: colors.redHover,
dangerPressed: colors.redHover,
diff --git a/src/styles/theme/themes/light.ts b/src/styles/theme/themes/light.ts
index fecd8749aebb..7fb04a2dad77 100644
--- a/src/styles/theme/themes/light.ts
+++ b/src/styles/theme/themes/light.ts
@@ -28,6 +28,7 @@ const lightTheme = {
buttonDefaultBG: colors.productLight400,
buttonHoveredBG: colors.productLight500,
buttonPressedBG: colors.productLight600,
+ buttonSuccessText: colors.productLight100,
danger: colors.red,
dangerHover: colors.redHover,
dangerPressed: colors.redHover,
diff --git a/src/styles/theme/types.ts b/src/styles/theme/types.ts
index 8340682725d4..35a48e39805d 100644
--- a/src/styles/theme/types.ts
+++ b/src/styles/theme/types.ts
@@ -30,6 +30,7 @@ type ThemeColors = {
buttonDefaultBG: Color;
buttonHoveredBG: Color;
buttonPressedBG: Color;
+ buttonSuccessText: Color;
danger: Color;
dangerHover: Color;
dangerPressed: Color;