@@ -81,9 +75,6 @@ export default function ManagedUsersList({
))}
diff --git a/services/web/frontend/js/features/group-management/context/group-members-context.tsx b/services/web/frontend/js/features/group-management/context/group-members-context.tsx
new file mode 100644
index 0000000000..3b61020837
--- /dev/null
+++ b/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
+ 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()
+ const [removeMemberInflightCount, setRemoveMemberInflightCount] = useState(0)
+ const [removeMemberError, setRemoveMemberError] = useState()
+
+ 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(
+ () => ({
+ 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 (
+
+ {children}
+
+ )
+}
+
+export function useGroupMembersContext() {
+ const context = useContext(GroupMembersContext)
+ if (!context) {
+ throw new Error(
+ 'GroupMembersContext is only available inside GroupMembersProvider'
+ )
+ }
+ return context
+}
diff --git a/services/web/frontend/js/features/group-management/hooks/use-user-selection.ts b/services/web/frontend/js/features/group-management/hooks/use-user-selection.ts
index 19c4069b95..5e5b92e8b5 100644
--- a/services/web/frontend/js/features/group-management/hooks/use-user-selection.ts
+++ b/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([])
const selectAllUsers = () => setSelectedUsers(users)
-
const unselectAllUsers = () => setSelectedUsers([])
const selectUser = useCallback((user: User) => {
diff --git a/services/web/frontend/js/features/project-list/components/dropdown/menu-item-button.tsx b/services/web/frontend/js/features/project-list/components/dropdown/menu-item-button.tsx
index b743a4aeb2..b996d491c7 100644
--- a/services/web/frontend/js/features/project-list/components/dropdown/menu-item-button.tsx
+++ b/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 (
-