Просмотр исходного кода

Merge pull request #14256 from overleaf/ab-unmanaged-remove-from-group-dropdown-action

[web] Add Remove from group action in dropdown for unmanaged/pending users

GitOrigin-RevId: daa66598e42befa2f8430bdf118e907a8758d60e
Alexandre Bourdin 3 лет назад
Родитель
Сommit
a4a5a08c31
17 измененных файлов с 445 добавлено и 348 удалено
  1. 4 9
      services/web/frontend/js/features/group-management/components/group-members-list.tsx
  2. 21 117
      services/web/frontend/js/features/group-management/components/group-members.tsx
  3. 19 4
      services/web/frontend/js/features/group-management/components/managed-users/managed-user-dropdown-button.tsx
  4. 4 6
      services/web/frontend/js/features/group-management/components/managed-users/managed-user-row.tsx
  5. 2 11
      services/web/frontend/js/features/group-management/components/managed-users/managed-users-list.tsx
  6. 194 0
      services/web/frontend/js/features/group-management/context/group-members-context.tsx
  7. 0 1
      services/web/frontend/js/features/group-management/hooks/use-user-selection.ts
  8. 7 1
      services/web/frontend/js/features/project-list/components/dropdown/menu-item-button.tsx
  9. 13 6
      services/web/frontend/js/infrastructure/promise.js
  10. 8 2
      services/web/frontend/js/pages/user/subscription/group-management/group-members.js
  11. 4 0
      services/web/frontend/stylesheets/modules/managed-users.less
  12. 7 2
      services/web/test/frontend/features/group-management/components/group-members.spec.tsx
  13. 15 5
      services/web/test/frontend/features/group-management/components/managed-users/managed-group-members.spec.tsx
  14. 73 13
      services/web/test/frontend/features/group-management/components/managed-users/managed-user-dropdown-button.spec.tsx
  15. 48 143
      services/web/test/frontend/features/group-management/components/managed-users/managed-user-row.spec.tsx
  16. 24 26
      services/web/test/frontend/features/group-management/components/managed-users/managed-users-list.spec.tsx
  17. 2 2
      services/web/types/group-management/user.ts

+ 4 - 9
services/web/frontend/js/features/group-management/components/group-members-list.tsx

@@ -1,25 +1,20 @@
 import { Col, Row } from 'react-bootstrap'
 import { useTranslation } from 'react-i18next'
-import { User } from '../../../../../types/group-management/user'
 import Tooltip from '../../../shared/components/tooltip'
+import { useGroupMembersContext } from '../context/group-members-context'
 import GroupMemberRow from './group-member-row'
 
 type GroupMembersListProps = {
   handleSelectAllClick: (e: any) => void
-  selectedUsers: User[]
-  users: User[]
-  selectUser: (user: User) => void
-  unselectUser: (user: User) => void
 }
 
 export default function GroupMembersList({
   handleSelectAllClick,
-  selectedUsers,
-  users,
-  selectUser,
-  unselectUser,
 }: GroupMembersListProps) {
   const { t } = useTranslation()
+  const { selectedUsers, users, selectUser, unselectUser } =
+    useGroupMembersContext()
+
   return (
     <ul className="list-unstyled structured-list">
       <li className="container-fluid">

+ 21 - 117
services/web/frontend/js/features/group-management/components/group-members.tsx

@@ -1,129 +1,38 @@
-import { useCallback, useMemo, useState } from 'react'
+import React, { useCallback, useState } from 'react'
 import { Button, Col, Form, FormControl, Row } from 'react-bootstrap'
 import { Trans, useTranslation } from 'react-i18next'
 import { User } from '../../../../../types/group-management/user'
-import {
-  deleteJSON,
-  FetchError,
-  postJSON,
-} from '../../../infrastructure/fetch-json'
 import MaterialIcon from '../../../shared/components/material-icon'
 import useWaitForI18n from '../../../shared/hooks/use-wait-for-i18n'
 import getMeta from '../../../utils/meta'
-import { parseEmails } from '../utils/emails'
-import ErrorAlert, { APIError } from './error-alert'
-import useUserSelection from '../hooks/use-user-selection'
+import { useGroupMembersContext } from '../context/group-members-context'
+import ErrorAlert from './error-alert'
 import ManagedUsersList from './managed-users/managed-users-list'
 import GroupMembersList from './group-members-list'
 
 export default function GroupMembers() {
   const { isReady } = useWaitForI18n()
   const { t } = useTranslation()
-
   const {
     users,
-    setUsers,
     selectedUsers,
     selectAllUsers,
     unselectAllUsers,
-    selectUser,
-    unselectUser,
-  } = useUserSelection(getMeta('ol-users', []))
-
+    addMembers,
+    removeMembers,
+    removeMemberLoading,
+    removeMemberError,
+    inviteMemberLoading,
+    inviteError,
+    paths,
+  } = useGroupMembersContext()
   const [emailString, setEmailString] = useState<string>('')
-  const [inviteUserInflightCount, setInviteUserInflightCount] = useState(0)
-  const [inviteError, setInviteError] = useState<APIError>()
-  const [removeMemberInflightCount, setRemoveMemberInflightCount] = useState(0)
-  const [removeMemberError, setRemoveMemberError] = useState<APIError>()
 
   const groupId: string = getMeta('ol-groupId')
   const groupName: string = getMeta('ol-groupName')
   const groupSize: number = getMeta('ol-groupSize')
   const managedUsersActive: any = getMeta('ol-managedUsersActive')
 
-  const paths = useMemo(
-    () => ({
-      addMember: `/manage/groups/${groupId}/invites`,
-      removeMember: `/manage/groups/${groupId}/user`,
-      removeInvite: `/manage/groups/${groupId}/invites`,
-      exportMembers: `/manage/groups/${groupId}/members/export`,
-    }),
-    [groupId]
-  )
-
-  const addMembers = useCallback(
-    e => {
-      e.preventDefault()
-      setInviteError(undefined)
-      const emails = parseEmails(emailString)
-      ;(async () => {
-        for (const email of emails) {
-          setInviteUserInflightCount(count => count + 1)
-          try {
-            const data = await postJSON<{ user: User }>(paths.addMember, {
-              body: {
-                email,
-              },
-            })
-            if (data.user) {
-              const alreadyListed = users.find(
-                user => user.email === data.user.email
-              )
-              if (!alreadyListed) {
-                setUsers(users => [...users, data.user])
-              }
-            }
-            setEmailString('')
-          } catch (error: unknown) {
-            console.error(error)
-            setInviteError((error as FetchError)?.data?.error || {})
-          }
-          setInviteUserInflightCount(count => count - 1)
-        }
-      })()
-    },
-    [emailString, paths.addMember, users, setUsers]
-  )
-
-  const removeMembers = useCallback(
-    e => {
-      e.preventDefault()
-      setRemoveMemberError(undefined)
-      ;(async () => {
-        for (const user of selectedUsers) {
-          if (user?.enrollment?.managedBy) {
-            continue
-          }
-          let url
-          if (paths.removeInvite && user.invite && user._id == null) {
-            url = `${paths.removeInvite}/${encodeURIComponent(user.email)}`
-          } else if (paths.removeMember && user._id) {
-            url = `${paths.removeMember}/${user._id}`
-          } else {
-            return
-          }
-          setRemoveMemberInflightCount(count => count + 1)
-          try {
-            await deleteJSON(url, {})
-            setUsers(users => users.filter(u => u !== user))
-            unselectUser(user)
-          } catch (error: unknown) {
-            console.error(error)
-            setRemoveMemberError((error as FetchError)?.data?.error || {})
-          }
-          setRemoveMemberInflightCount(count => count - 1)
-        }
-      })()
-    },
-    [
-      selectedUsers,
-      unselectUser,
-      setUsers,
-      paths.removeInvite,
-      paths.removeMember,
-    ]
-  )
-
   const handleSelectAllClick = useCallback(
     (e: React.ChangeEvent<HTMLInputElement>) => {
       if (e.target.checked) {
@@ -153,6 +62,11 @@ export default function GroupMembers() {
     )
   }
 
+  const onAddMembersSubmit = (e: React.FormEvent<Form>) => {
+    e.preventDefault()
+    addMembers(emailString)
+  }
+
   return (
     <div className="container">
       <Row>
@@ -178,7 +92,7 @@ export default function GroupMembers() {
                     />
                   </small>
                 )}
-                {removeMemberInflightCount > 0 ? (
+                {removeMemberLoading ? (
                   <Button bsStyle="danger" disabled>
                     {t('removing')}&hellip;
                   </Button>
@@ -199,20 +113,10 @@ export default function GroupMembers() {
               {managedUsersActive ? (
                 <ManagedUsersList
                   handleSelectAllClick={handleSelectAllClick}
-                  selectedUsers={selectedUsers}
-                  users={users}
-                  selectUser={selectUser}
-                  unselectUser={unselectUser}
                   groupId={groupId}
                 />
               ) : (
-                <GroupMembersList
-                  handleSelectAllClick={handleSelectAllClick}
-                  selectedUsers={selectedUsers}
-                  users={users}
-                  selectUser={selectUser}
-                  unselectUser={unselectUser}
-                />
+                <GroupMembersList handleSelectAllClick={handleSelectAllClick} />
               )}
             </div>
             <hr />
@@ -220,7 +124,7 @@ export default function GroupMembers() {
               <div className="add-more-members-form">
                 <p className="small">{t('add_more_members')}</p>
                 <ErrorAlert error={inviteError} />
-                <Form horizontal onSubmit={addMembers} className="form">
+                <Form horizontal onSubmit={onAddMembersSubmit} className="form">
                   <Row>
                     <Col xs={6}>
                       <FormControl
@@ -232,12 +136,12 @@ export default function GroupMembers() {
                       />
                     </Col>
                     <Col xs={4}>
-                      {inviteUserInflightCount > 0 ? (
+                      {inviteMemberLoading ? (
                         <Button bsStyle="primary" disabled>
                           {t('adding')}&hellip;
                         </Button>
                       ) : (
-                        <Button bsStyle="primary" onClick={addMembers}>
+                        <Button bsStyle="primary" onClick={onAddMembersSubmit}>
                           {t('add')}
                         </Button>
                       )}

+ 19 - 4
services/web/frontend/js/features/group-management/components/managed-users/managed-user-dropdown-button.tsx

@@ -3,6 +3,7 @@ import { User } from '../../../../../../types/group-management/user'
 import ControlledDropdown from '../../../../shared/components/controlled-dropdown'
 import { useTranslation } from 'react-i18next'
 import MenuItemButton from '../../../project-list/components/dropdown/menu-item-button'
+import { useGroupMembersContext } from '../../context/group-members-context'
 
 type ManagedUserDropdownButtonProps = {
   user: User
@@ -14,14 +15,19 @@ export default function ManagedUserDropdownButton({
   openOffboardingModalForUser,
 }: ManagedUserDropdownButtonProps) {
   const { t } = useTranslation()
+  const { removeMember } = useGroupMembersContext()
 
   const onDeleteUserClick = () => {
     openOffboardingModalForUser(user)
   }
 
+  const onRemoveFromGroup = () => {
+    removeMember(user)
+  }
+
   return (
     <span className="managed-user-actions">
-      <ControlledDropdown id={`managed-user-dropdown-${user._id}`}>
+      <ControlledDropdown id={`managed-user-dropdown-${user.email}`}>
         <Dropdown.Toggle
           bsStyle={null}
           className="btn btn-link action-btn"
@@ -33,18 +39,27 @@ export default function ManagedUserDropdownButton({
             aria-label={t('actions')}
           />
         </Dropdown.Toggle>
-        <Dropdown.Menu className="dropdown-menu-right">
+        <Dropdown.Menu className="dropdown-menu-right managed-users-dropdown-menu">
           {user.enrollment ? (
             <MenuItemButton
               className="delete-user-action"
+              data-testid="delete-user-action"
               onClick={onDeleteUserClick}
             >
               {t('delete_user')}
             </MenuItemButton>
-          ) : (
-            <MenuItem className="no-actions-available">
+          ) : user.isEntityAdmin ? (
+            <MenuItem data-testid="no-actions-available">
               <span className="text-muted">{t('no_actions')}</span>
             </MenuItem>
+          ) : (
+            <MenuItemButton
+              onClick={onRemoveFromGroup}
+              className="delete-user-action"
+              data-testid="remove-user-action"
+            >
+              {t('remove_from_group')}
+            </MenuItemButton>
           )}
         </Dropdown.Menu>
       </ControlledDropdown>

+ 4 - 6
services/web/frontend/js/features/group-management/components/managed-users/managed-user-row.tsx

@@ -4,26 +4,22 @@ import { Col, Row } from 'react-bootstrap'
 import { useTranslation } from 'react-i18next'
 import { User } from '../../../../../../types/group-management/user'
 import Badge from '../../../../shared/components/badge'
+import { useGroupMembersContext } from '../../context/group-members-context'
 import ManagedUserDropdownButton from './managed-user-dropdown-button'
 import Tooltip from '../../../../shared/components/tooltip'
 import ManagedUserStatus from './managed-user-status'
 
 type ManagedUserRowProps = {
   user: User
-  selectUser: (user: User) => void
-  unselectUser: (user: User) => void
-  selected: boolean
   openOffboardingModalForUser: (user: User) => void
 }
 
 export default function ManagedUserRow({
   user,
-  selectUser,
-  unselectUser,
-  selected,
   openOffboardingModalForUser,
 }: ManagedUserRowProps) {
   const { t } = useTranslation()
+  const { selectedUsers, selectUser, unselectUser } = useGroupMembersContext()
 
   const handleSelectUser = useCallback(
     (event, user) => {
@@ -36,6 +32,8 @@ export default function ManagedUserRow({
     [selectUser, unselectUser]
   )
 
+  const selected = selectedUsers.includes(user)
+
   return (
     <li
       key={`user-${user.email}`}

+ 2 - 11
services/web/frontend/js/features/group-management/components/managed-users/managed-users-list.tsx

@@ -2,31 +2,25 @@ import { Col, Row } from 'react-bootstrap'
 import { useTranslation } from 'react-i18next'
 import { User } from '../../../../../../types/group-management/user'
 import Tooltip from '../../../../shared/components/tooltip'
+import { useGroupMembersContext } from '../../context/group-members-context'
 import ManagedUserRow from './managed-user-row'
 import OffboardManagedUserModal from './offboard-managed-user-modal'
 import { useState } from 'react'
 
 type ManagedUsersListProps = {
   handleSelectAllClick: (e: any) => void
-  selectedUsers: User[]
-  users: User[]
-  selectUser: (user: User) => void
-  unselectUser: (user: User) => void
   groupId: string
 }
 
 export default function ManagedUsersList({
   handleSelectAllClick,
-  selectedUsers,
-  users,
-  selectUser,
-  unselectUser,
   groupId,
 }: ManagedUsersListProps) {
   const { t } = useTranslation()
   const [userToOffboard, setUserToOffboard] = useState<User | undefined>(
     undefined
   )
+  const { selectedUsers, users } = useGroupMembersContext()
 
   return (
     <div>
@@ -81,9 +75,6 @@ export default function ManagedUsersList({
           <ManagedUserRow
             key={user.email}
             user={user}
-            selectUser={selectUser}
-            unselectUser={unselectUser}
-            selected={selectedUsers.includes(user)}
             openOffboardingModalForUser={setUserToOffboard}
           />
         ))}

+ 194 - 0
services/web/frontend/js/features/group-management/context/group-members-context.tsx

@@ -0,0 +1,194 @@
+import {
+  ReactNode,
+  createContext,
+  useCallback,
+  useContext,
+  useMemo,
+  useState,
+} from 'react'
+import { User } from '../../../../../types/group-management/user'
+import {
+  deleteJSON,
+  FetchError,
+  postJSON,
+} from '../../../infrastructure/fetch-json'
+import { mapSeries } from '../../../infrastructure/promise'
+import getMeta from '../../../utils/meta'
+import { APIError } from '../components/error-alert'
+import useUserSelection from '../hooks/use-user-selection'
+import { parseEmails } from '../utils/emails'
+
+export type GroupMembersContextValue = {
+  users: User[]
+  selectedUsers: User[]
+  selectUser: (user: User) => void
+  selectAllUsers: () => void
+  unselectAllUsers: () => void
+  unselectUser: (user: User) => void
+  addMembers: (emailString: string) => void
+  removeMembers: (e: any) => void
+  removeMember: (user: User) => Promise<void>
+  removeMemberLoading: boolean
+  removeMemberError?: APIError
+  inviteMemberLoading: boolean
+  inviteError?: APIError
+  paths: { [key: string]: string }
+}
+
+export const GroupMembersContext = createContext<
+  GroupMembersContextValue | undefined
+>(undefined)
+
+type GroupMembersProviderProps = {
+  children: ReactNode
+}
+
+export function GroupMembersProvider({ children }: GroupMembersProviderProps) {
+  const {
+    users,
+    setUsers,
+    selectedUsers,
+    selectAllUsers,
+    unselectAllUsers,
+    selectUser,
+    unselectUser,
+  } = useUserSelection(getMeta('ol-users', []))
+
+  const [inviteUserInflightCount, setInviteUserInflightCount] = useState(0)
+  const [inviteError, setInviteError] = useState<APIError>()
+  const [removeMemberInflightCount, setRemoveMemberInflightCount] = useState(0)
+  const [removeMemberError, setRemoveMemberError] = useState<APIError>()
+
+  const groupId: string = getMeta('ol-groupId')
+
+  const paths = useMemo(
+    () => ({
+      addMember: `/manage/groups/${groupId}/invites`,
+      removeMember: `/manage/groups/${groupId}/user`,
+      removeInvite: `/manage/groups/${groupId}/invites`,
+      exportMembers: `/manage/groups/${groupId}/members/export`,
+    }),
+    [groupId]
+  )
+
+  const addMembers = useCallback(
+    emailString => {
+      setInviteError(undefined)
+      const emails = parseEmails(emailString)
+      mapSeries(emails, async email => {
+        setInviteUserInflightCount(count => count + 1)
+        try {
+          const data = await postJSON<{ user: User }>(paths.addMember, {
+            body: {
+              email,
+            },
+          })
+          if (data.user) {
+            const alreadyListed = users.find(
+              user => user.email === data.user.email
+            )
+            if (!alreadyListed) {
+              setUsers(users => [...users, data.user])
+            }
+          }
+        } catch (error: unknown) {
+          console.error(error)
+          setInviteError((error as FetchError)?.data?.error || {})
+        }
+        setInviteUserInflightCount(count => count - 1)
+      })
+    },
+    [paths.addMember, users, setUsers]
+  )
+
+  const removeMember = useCallback(
+    async user => {
+      let url
+      if (paths.removeInvite && user.invite && user._id == null) {
+        url = `${paths.removeInvite}/${encodeURIComponent(user.email)}`
+      } else if (paths.removeMember && user._id) {
+        url = `${paths.removeMember}/${user._id}`
+      } else {
+        return
+      }
+      setRemoveMemberInflightCount(count => count + 1)
+      try {
+        await deleteJSON(url, {})
+        setUsers(users => users.filter(u => u !== user))
+        unselectUser(user)
+      } catch (error: unknown) {
+        console.error(error)
+        setRemoveMemberError((error as FetchError)?.data?.error || {})
+      }
+      setRemoveMemberInflightCount(count => count - 1)
+    },
+    [unselectUser, setUsers, paths.removeInvite, paths.removeMember]
+  )
+
+  const removeMembers = useCallback(
+    e => {
+      e.preventDefault()
+      setRemoveMemberError(undefined)
+      ;(async () => {
+        for (const user of selectedUsers) {
+          if (user?.enrollment?.managedBy) {
+            continue
+          }
+          await removeMember(user)
+        }
+      })()
+    },
+    [selectedUsers, removeMember]
+  )
+
+  const value = useMemo<GroupMembersContextValue>(
+    () => ({
+      users,
+      selectedUsers,
+      selectAllUsers,
+      unselectAllUsers,
+      selectUser,
+      unselectUser,
+      addMembers,
+      removeMembers,
+      removeMember,
+      removeMemberLoading: removeMemberInflightCount > 0,
+      removeMemberError,
+      inviteMemberLoading: inviteUserInflightCount > 0,
+      inviteError,
+      paths,
+    }),
+    [
+      users,
+      selectedUsers,
+      selectAllUsers,
+      unselectAllUsers,
+      selectUser,
+      unselectUser,
+      addMembers,
+      removeMembers,
+      removeMember,
+      removeMemberInflightCount,
+      removeMemberError,
+      inviteUserInflightCount,
+      inviteError,
+      paths,
+    ]
+  )
+
+  return (
+    <GroupMembersContext.Provider value={value}>
+      {children}
+    </GroupMembersContext.Provider>
+  )
+}
+
+export function useGroupMembersContext() {
+  const context = useContext(GroupMembersContext)
+  if (!context) {
+    throw new Error(
+      'GroupMembersContext is only available inside GroupMembersProvider'
+    )
+  }
+  return context
+}

+ 0 - 1
services/web/frontend/js/features/group-management/hooks/use-user-selection.ts

@@ -6,7 +6,6 @@ export default function useUserSelection(initialUsers: User[]) {
   const [selectedUsers, setSelectedUsers] = useState<User[]>([])
 
   const selectAllUsers = () => setSelectedUsers(users)
-
   const unselectAllUsers = () => setSelectedUsers([])
 
   const selectUser = useCallback((user: User) => {

+ 7 - 1
services/web/frontend/js/features/project-list/components/dropdown/menu-item-button.tsx

@@ -12,10 +12,16 @@ export default function MenuItemButton({
   onClick,
   className,
   afterNode,
+  ...buttonProps
 }: MenuItemButtonProps) {
   return (
     <li role="presentation" className={className}>
-      <button className="menu-item-button" role="menuitem" onClick={onClick}>
+      <button
+        className="menu-item-button"
+        role="menuitem"
+        onClick={onClick}
+        {...buttonProps}
+      >
         {children}
       </button>
       {afterNode}

+ 13 - 6
services/web/frontend/js/infrastructure/promise.js

@@ -1,9 +1,16 @@
-// run `fn` in serie for all values, and resolve with an array of the resultss
-// inspired by https://stackoverflow.com/a/50506360/1314820
+/**
+ * run `fn` in serie for all values, and resolve with an array of the results
+ * inspired by https://stackoverflow.com/a/50506360/1314820
+ * @template T the input array's item type
+ * @template V the `fn` function's return type
+ * @param {T[]} values
+ * @param {(item: T) => Promise<V>} fn
+ * @returns {V[]}
+ */
 export function mapSeries(values, fn) {
-  return values.reduce((promiseChain, value) => {
-    return promiseChain.then(chainResults =>
-      fn(value).then(currentResult => [...chainResults, currentResult])
-    )
+  return values.reduce(async (promiseChain, value) => {
+    const chainResults = await promiseChain
+    const currentResult = await fn(value)
+    return [...chainResults, currentResult]
   }, Promise.resolve([]))
 }

+ 8 - 2
services/web/frontend/js/pages/user/subscription/group-management/group-members.js

@@ -1,8 +1,14 @@
 import '../base'
 import ReactDOM from 'react-dom'
-import Members from '../../../../features/group-management/components/group-members'
+import GroupMembers from '../../../../features/group-management/components/group-members'
+import { GroupMembersProvider } from '../../../../features/group-management/context/group-members-context'
 
 const element = document.getElementById('subscription-manage-group-root')
 if (element) {
-  ReactDOM.render(<Members />, element)
+  ReactDOM.render(
+    <GroupMembersProvider>
+      <GroupMembers />
+    </GroupMembersProvider>,
+    element
+  )
 }

+ 4 - 0
services/web/frontend/stylesheets/modules/managed-users.less

@@ -82,3 +82,7 @@
     vertical-align: text-bottom;
   }
 }
+
+.managed-users-dropdown-menu {
+  min-width: 200px;
+}

+ 7 - 2
services/web/test/frontend/features/group-management/components/group-members.spec.tsx

@@ -1,4 +1,5 @@
 import GroupMembers from '../../../../../frontend/js/features/group-management/components/group-members'
+import { GroupMembersProvider } from '../../../../../frontend/js/features/group-management/context/group-members-context'
 
 const JOHN_DOE = {
   _id: 'abc123def456',
@@ -27,13 +28,17 @@ const PATHS = {
 describe('group members, without managed users', function () {
   beforeEach(function () {
     cy.window().then(win => {
-      win.metaAttributesCache.set('ol-users', [JOHN_DOE, BOBBY_LAPOINTE])
       win.metaAttributesCache.set('ol-groupId', GROUP_ID)
       win.metaAttributesCache.set('ol-groupName', 'My Awesome Team')
       win.metaAttributesCache.set('ol-groupSize', 10)
+      win.metaAttributesCache.set('ol-users', [JOHN_DOE, BOBBY_LAPOINTE])
     })
 
-    cy.mount(<GroupMembers />)
+    cy.mount(
+      <GroupMembersProvider>
+        <GroupMembers />
+      </GroupMembersProvider>
+    )
   })
 
   it('renders the group members page', function () {

+ 15 - 5
services/web/test/frontend/features/group-management/components/managed-users/managed-group-members.spec.tsx

@@ -1,4 +1,5 @@
 import GroupMembers from '../../../../../../frontend/js/features/group-management/components/group-members'
+import { GroupMembersProvider } from '../../../../../../frontend/js/features/group-management/context/group-members-context'
 
 const GROUP_ID = '777fff777fff'
 const JOHN_DOE = {
@@ -48,11 +49,14 @@ describe('group members, with managed users', function () {
       win.metaAttributesCache.set('ol-groupId', GROUP_ID)
       win.metaAttributesCache.set('ol-groupName', 'My Awesome Team')
       win.metaAttributesCache.set('ol-groupSize', 10)
-      // Managed Users is active on this group
       win.metaAttributesCache.set('ol-managedUsersActive', true)
     })
 
-    cy.mount(<GroupMembers />)
+    cy.mount(
+      <GroupMembersProvider>
+        <GroupMembers />
+      </GroupMembersProvider>
+    )
   })
 
   it('renders the group members page', function () {
@@ -185,7 +189,9 @@ describe('group members, with managed users', function () {
       })
     })
 
-    cy.get('button').contains('Remove from group').should('not.exist')
+    cy.get('.page-header').within(() => {
+      cy.findByRole('button', { name: 'Remove from group' }).should('not.exist')
+    })
   })
 
   it('does not show the remove-member button if any of the selected users are managed', function () {
@@ -204,7 +210,9 @@ describe('group members, with managed users', function () {
       })
     })
 
-    cy.get('button').contains('Remove from group').should('not.exist')
+    cy.get('.page-header').within(() => {
+      cy.findByRole('button', { name: 'Remove from group' }).should('not.exist')
+    })
   })
 
   it('tries to remove a user and displays the error', function () {
@@ -217,7 +225,9 @@ describe('group members, with managed users', function () {
         cy.get('.select-item').check()
       })
     })
-    cy.get('button').contains('Remove from group').click()
+    cy.get('.page-header').within(() => {
+      cy.get('button').contains('Remove from group').click()
+    })
 
     cy.get('.alert').contains('Sorry, something went wrong')
   })

+ 73 - 13
services/web/test/frontend/features/group-management/components/managed-users/managed-user-dropdown-button.spec.tsx

@@ -1,5 +1,6 @@
 import ManagedUserDropdownButton from '../../../../../../frontend/js/features/group-management/components/managed-users/managed-user-dropdown-button'
 import sinon from 'sinon'
+import { GroupMembersProvider } from '../../../../../../frontend/js/features/group-management/context/group-members-context'
 
 describe('ManagedUserDropdownButton', function () {
   describe('with managed user', function () {
@@ -18,23 +19,31 @@ describe('ManagedUserDropdownButton', function () {
     }
 
     beforeEach(function () {
+      cy.window().then(win => {
+        win.metaAttributesCache.set('ol-users', [user])
+      })
+
       cy.mount(
-        <ManagedUserDropdownButton
-          user={user}
-          openOffboardingModalForUser={sinon.stub()}
-        />
+        <GroupMembersProvider>
+          <ManagedUserDropdownButton
+            user={user}
+            openOffboardingModalForUser={sinon.stub()}
+          />
+        </GroupMembersProvider>
       )
     })
 
     it('should render the button', function () {
-      cy.get(`#managed-user-dropdown-${user._id}`).should('exist')
+      cy.get('#managed-user-dropdown-some\\.user\\@example\\.com').should(
+        'exist'
+      )
       cy.get(`.action-btn`).should('exist')
     })
 
     it('should show the menu when the button is clicked', function () {
       cy.get('.action-btn').click()
-      cy.get('.delete-user-action').should('exist')
-      cy.get('.delete-user-action').then($el => {
+      cy.findByTestId('delete-user-action').should('exist')
+      cy.findByTestId('delete-user-action').then($el => {
         Cypress.dom.isVisible($el)
       })
     })
@@ -53,22 +62,73 @@ describe('ManagedUserDropdownButton', function () {
     }
 
     beforeEach(function () {
+      cy.window().then(win => {
+        win.metaAttributesCache.set('ol-users', [user])
+      })
+
+      cy.mount(
+        <GroupMembersProvider>
+          <ManagedUserDropdownButton
+            user={user}
+            openOffboardingModalForUser={sinon.stub()}
+          />
+        </GroupMembersProvider>
+      )
+    })
+
+    it('should render the button', function () {
+      cy.get('#managed-user-dropdown-some\\.user\\@example\\.com').should(
+        'exist'
+      )
+      cy.get(`.action-btn`).should('exist')
+    })
+
+    it('should show the menu when the button is clicked', function () {
+      cy.get('.action-btn').click()
+      cy.findByTestId('remove-user-action').should('exist')
+      cy.findByTestId('remove-user-action').then($el => {
+        Cypress.dom.isVisible($el)
+      })
+    })
+  })
+
+  describe('with group admin user', function () {
+    const user = {
+      _id: 'some-user',
+      email: 'some.user@example.com',
+      first_name: 'Some',
+      last_name: 'User',
+      invite: false,
+      last_active_at: new Date(),
+      enrollment: undefined,
+      isEntityAdmin: true,
+    }
+
+    beforeEach(function () {
+      cy.window().then(win => {
+        win.metaAttributesCache.set('ol-users', [user])
+      })
+
       cy.mount(
-        <ManagedUserDropdownButton
-          user={user}
-          openOffboardingModalForUser={sinon.stub()}
-        />
+        <GroupMembersProvider>
+          <ManagedUserDropdownButton
+            user={user}
+            openOffboardingModalForUser={sinon.stub()}
+          />
+        </GroupMembersProvider>
       )
     })
 
     it('should render the button', function () {
-      cy.get(`#managed-user-dropdown-${user._id}`).should('exist')
+      cy.get('#managed-user-dropdown-some\\.user\\@example\\.com').should(
+        'exist'
+      )
       cy.get(`.action-btn`).should('exist')
     })
 
     it('should show the (empty) menu when the button is clicked', function () {
       cy.get('.action-btn').click()
-      cy.get('.no-actions-available').should('exist')
+      cy.findByTestId('no-actions-available').should('exist')
     })
   })
 })

+ 48 - 143
services/web/test/frontend/features/group-management/components/managed-users/managed-user-row.spec.tsx

@@ -1,13 +1,11 @@
-import sinon, { SinonStub } from 'sinon'
+import sinon from 'sinon'
 import ManagedUserRow from '../../../../../../frontend/js/features/group-management/components/managed-users/managed-user-row'
+import { GroupMembersProvider } from '../../../../../../frontend/js/features/group-management/context/group-members-context'
 import { User } from '../../../../../../types/group-management/user'
 
 describe('ManagedUserRow', function () {
   describe('with an ordinary user', function () {
-    let user: User,
-      selectUser: SinonStub,
-      unselectUser: SinonStub,
-      selected: boolean
+    let user: User
 
     beforeEach(function () {
       user = {
@@ -20,18 +18,17 @@ describe('ManagedUserRow', function () {
         enrollment: undefined,
         isEntityAdmin: undefined,
       }
-      selectUser = sinon.stub()
-      unselectUser = sinon.stub()
-      selected = false
+      cy.window().then(win => {
+        win.metaAttributesCache.set('ol-users', [user])
+      })
 
       cy.mount(
-        <ManagedUserRow
-          user={user}
-          selectUser={selectUser}
-          unselectUser={unselectUser}
-          selected={selected}
-          openOffboardingModalForUser={sinon.stub()}
-        />
+        <GroupMembersProvider>
+          <ManagedUserRow
+            user={user}
+            openOffboardingModalForUser={sinon.stub()}
+          />
+        </GroupMembersProvider>
       )
     })
 
@@ -49,15 +46,14 @@ describe('ManagedUserRow', function () {
       // Managed status
       cy.get('.row').contains('Managed')
       // Dropdown button
-      cy.get(`#managed-user-dropdown-${user._id}`).should('exist')
+      cy.get('#managed-user-dropdown-some\\.user\\@example\\.com').should(
+        'exist'
+      )
     })
   })
 
   describe('with a pending invite', function () {
-    let user: User,
-      selectUser: SinonStub,
-      unselectUser: SinonStub,
-      selected: boolean
+    let user: User
 
     beforeEach(function () {
       user = {
@@ -70,18 +66,17 @@ describe('ManagedUserRow', function () {
         enrollment: undefined,
         isEntityAdmin: undefined,
       }
-      selectUser = sinon.stub()
-      unselectUser = sinon.stub()
-      selected = false
+      cy.window().then(win => {
+        win.metaAttributesCache.set('ol-users', [user])
+      })
 
       cy.mount(
-        <ManagedUserRow
-          user={user}
-          selectUser={selectUser}
-          unselectUser={unselectUser}
-          selected={selected}
-          openOffboardingModalForUser={sinon.stub()}
-        />
+        <GroupMembersProvider>
+          <ManagedUserRow
+            user={user}
+            openOffboardingModalForUser={sinon.stub()}
+          />
+        </GroupMembersProvider>
       )
     })
 
@@ -91,10 +86,7 @@ describe('ManagedUserRow', function () {
   })
 
   describe('with a group admin', function () {
-    let user: User,
-      selectUser: SinonStub,
-      unselectUser: SinonStub,
-      selected: boolean
+    let user: User
 
     beforeEach(function () {
       user = {
@@ -107,18 +99,17 @@ describe('ManagedUserRow', function () {
         enrollment: undefined,
         isEntityAdmin: true,
       }
-      selectUser = sinon.stub()
-      unselectUser = sinon.stub()
-      selected = false
+      cy.window().then(win => {
+        win.metaAttributesCache.set('ol-users', [user])
+      })
 
       cy.mount(
-        <ManagedUserRow
-          user={user}
-          selectUser={selectUser}
-          unselectUser={unselectUser}
-          selected={selected}
-          openOffboardingModalForUser={sinon.stub()}
-        />
+        <GroupMembersProvider>
+          <ManagedUserRow
+            user={user}
+            openOffboardingModalForUser={sinon.stub()}
+          />
+        </GroupMembersProvider>
       )
     })
 
@@ -127,49 +118,8 @@ describe('ManagedUserRow', function () {
     })
   })
 
-  describe('user is selected', function () {
-    let user: User,
-      selectUser: SinonStub,
-      unselectUser: SinonStub,
-      selected: boolean
-
-    beforeEach(function () {
-      user = {
-        _id: 'some-user',
-        email: 'some.user@example.com',
-        first_name: 'Some',
-        last_name: 'User',
-        invite: false,
-        last_active_at: new Date('2070-11-21T03:00:00'),
-        enrollment: undefined,
-        isEntityAdmin: undefined,
-      }
-      selectUser = sinon.stub()
-      unselectUser = sinon.stub()
-      // User is selected
-      selected = true
-
-      cy.mount(
-        <ManagedUserRow
-          user={user}
-          selectUser={selectUser}
-          unselectUser={unselectUser}
-          selected={selected}
-          openOffboardingModalForUser={sinon.stub()}
-        />
-      )
-    })
-
-    it('should render the selection box as selected', function () {
-      cy.get('.select-item').should('be.checked')
-    })
-  })
-
-  describe('selecting user row', function () {
-    let user: User,
-      selectUser: SinonStub,
-      unselectUser: SinonStub,
-      selected: boolean
+  describe('selecting and unselecting user row', function () {
+    let user: User
 
     beforeEach(function () {
       user = {
@@ -182,71 +132,26 @@ describe('ManagedUserRow', function () {
         enrollment: undefined,
         isEntityAdmin: undefined,
       }
-      selectUser = sinon.stub()
-      unselectUser = sinon.stub()
-      // User is not selected
-      selected = false
+      cy.window().then(win => {
+        win.metaAttributesCache.set('ol-users', [user])
+      })
 
       cy.mount(
-        <ManagedUserRow
-          user={user}
-          selectUser={selectUser}
-          unselectUser={unselectUser}
-          selected={selected}
-          openOffboardingModalForUser={sinon.stub()}
-        />
+        <GroupMembersProvider>
+          <ManagedUserRow
+            user={user}
+            openOffboardingModalForUser={sinon.stub()}
+          />
+        </GroupMembersProvider>
       )
     })
 
-    it('should select the user', function () {
+    it('should select and unselect the user', function () {
       cy.get('.select-item').should('not.be.checked')
       cy.get('.select-item').click()
-      cy.get('.select-item').then(() => {
-        expect(selectUser.called).to.equal(true)
-        expect(unselectUser.called).to.equal(false)
-      })
-    })
-  })
-
-  describe('un-selecting user row', function () {
-    let user: User,
-      selectUser: SinonStub,
-      unselectUser: SinonStub,
-      selected: boolean
-
-    beforeEach(function () {
-      user = {
-        _id: 'some-user',
-        email: 'some.user@example.com',
-        first_name: 'Some',
-        last_name: 'User',
-        invite: false,
-        last_active_at: new Date('2070-11-21T03:00:00'),
-        enrollment: undefined,
-        isEntityAdmin: undefined,
-      }
-      selectUser = sinon.stub()
-      unselectUser = sinon.stub()
-      // User is selected
-      selected = true
-
-      cy.mount(
-        <ManagedUserRow
-          user={user}
-          selectUser={selectUser}
-          unselectUser={unselectUser}
-          selected={selected}
-          openOffboardingModalForUser={sinon.stub()}
-        />
-      )
-    })
-
-    it('should select the user', function () {
       cy.get('.select-item').should('be.checked')
       cy.get('.select-item').click()
-      cy.get('.select-item').then(() => {
-        expect(unselectUser.called).to.equal(true)
-      })
+      cy.get('.select-item').should('not.be.checked')
     })
   })
 })

+ 24 - 26
services/web/test/frontend/features/group-management/components/managed-users/managed-users-list.spec.tsx

@@ -1,10 +1,11 @@
 import ManagedUsersList from '../../../../../../frontend/js/features/group-management/components/managed-users/managed-users-list'
-import { User } from '../../../../../../types/group-management/user'
+import { GroupMembersProvider } from '../../../../../../frontend/js/features/group-management/context/group-members-context'
 
 describe('ManagedUsersList', function () {
   const groupId = 'somegroup'
+
   describe('with users', function () {
-    const users: User[] = [
+    const users = [
       {
         _id: 'user-one',
         email: 'sarah.brennan@example.com',
@@ -26,21 +27,20 @@ describe('ManagedUsersList', function () {
         isEntityAdmin: undefined,
       },
     ]
-    const selectedUsers: User[] = []
-    const handleSelectAllClick = () => {}
-    const selectUser = () => {}
-    const unselectUser = () => {}
 
     beforeEach(function () {
+      cy.window().then(win => {
+        win.metaAttributesCache.set('ol-users', users)
+      })
+      const handleSelectAllClick = () => {}
+
       cy.mount(
-        <ManagedUsersList
-          users={users}
-          selectedUsers={selectedUsers}
-          handleSelectAllClick={handleSelectAllClick}
-          selectUser={selectUser}
-          unselectUser={unselectUser}
-          groupId={groupId}
-        />
+        <GroupMembersProvider>
+          <ManagedUsersList
+            handleSelectAllClick={handleSelectAllClick}
+            groupId={groupId}
+          />
+        </GroupMembersProvider>
       )
     })
 
@@ -70,23 +70,21 @@ describe('ManagedUsersList', function () {
       cy.get('.managed-users-list').contains(users[1].last_name)
     })
   })
+
   describe('empty user list', function () {
-    const users: User[] = []
-    const selectedUsers: User[] = []
     const handleSelectAllClick = () => {}
-    const selectUser = () => {}
-    const unselectUser = () => {}
 
     beforeEach(function () {
+      cy.window().then(win => {
+        win.metaAttributesCache.set('ol-users', [])
+      })
       cy.mount(
-        <ManagedUsersList
-          users={users}
-          selectedUsers={selectedUsers}
-          handleSelectAllClick={handleSelectAllClick}
-          selectUser={selectUser}
-          unselectUser={unselectUser}
-          groupId={groupId}
-        />
+        <GroupMembersProvider>
+          <ManagedUsersList
+            handleSelectAllClick={handleSelectAllClick}
+            groupId={groupId}
+          />
+        </GroupMembersProvider>
       )
     })
 

+ 2 - 2
services/web/types/group-management/user.ts

@@ -10,6 +10,6 @@ export type User = {
   last_name: string
   invite: boolean
   last_active_at: Date
-  enrollment: UserEnrollment | undefined
-  isEntityAdmin: boolean | undefined
+  enrollment?: UserEnrollment
+  isEntityAdmin?: boolean
 }