diff --git a/services/web/app/src/Features/UserMembership/UserMembershipHandler.js b/services/web/app/src/Features/UserMembership/UserMembershipHandler.js index 0632f6e50e..56d92e9c96 100644 --- a/services/web/app/src/Features/UserMembership/UserMembershipHandler.js +++ b/services/web/app/src/Features/UserMembership/UserMembershipHandler.js @@ -79,11 +79,7 @@ async function getPopulatedListOfMembers(entity, attributes) { } } - const users = await Promise.all( - userObjects.map(userObject => - UserMembershipViewModel.promises.buildAsync(userObject) - ) - ) + const users = await UserMembershipViewModel.promises.buildAsync(userObjects) for (const user of users) { if ( diff --git a/services/web/app/src/Features/UserMembership/UserMembershipViewModel.js b/services/web/app/src/Features/UserMembership/UserMembershipViewModel.js index 6c94f677b7..1d83a258ef 100644 --- a/services/web/app/src/Features/UserMembership/UserMembershipViewModel.js +++ b/services/web/app/src/Features/UserMembership/UserMembershipViewModel.js @@ -23,30 +23,59 @@ const UserMembershipViewModel = { } }, - buildAsync(userOrIdOrEmail, callback) { + buildAsync(userOrIdOrEmailArray, callback) { if (callback == null) { callback = function () {} } - if (!isObjectIdInstance(userOrIdOrEmail)) { - // userOrIdOrEmail is a user or an email and can be parsed by #build - return callback(null, UserMembershipViewModel.build(userOrIdOrEmail)) - } - const userId = userOrIdOrEmail - const projection = { - email: 1, - first_name: 1, - last_name: 1, - lastLoggedIn: 1, - lastActive: 1, - enrollment: 1, - } - return UserGetter.getUser(userId, projection, function (error, user) { - if (error != null || user == null) { - return callback(null, buildUserViewModelWithId(userId.toString())) + const userObjectIds = userOrIdOrEmailArray.filter(isObjectIdInstance) + + return UserGetter.getUsers( + userObjectIds, + { + email: 1, + first_name: 1, + last_name: 1, + lastLoggedIn: 1, + lastActive: 1, + enrollment: 1, + }, + function (error, users) { + const results = [] + + if (error != null) { + userOrIdOrEmailArray.forEach(item => { + if (isObjectIdInstance(item)) { + results.push(buildUserViewModelWithId(item.toString())) + } else { + // `item` is a user or an email and can be parsed by #build + results.push(UserMembershipViewModel.build(item)) + } + }) + } else { + const usersMap = new Map() + for (const user of users) { + usersMap.set(user._id.toString(), user) + } + + userOrIdOrEmailArray.forEach(item => { + if (isObjectIdInstance(item)) { + const user = usersMap.get(item.toString()) + if (user == null) { + results.push(buildUserViewModelWithId(item.toString())) + } else { + results.push(buildUserViewModel(user)) + } + } else { + // `item` is a user or an email and can be parsed by #build + results.push(UserMembershipViewModel.build(item)) + } + }) + } + + callback(null, results) } - return callback(null, buildUserViewModel(user)) - }) + ) }, } diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index 6e6dfdc5d4..44eedad104 100644 --- a/services/web/frontend/extracted-translations.json +++ b/services/web/frontend/extracted-translations.json @@ -1026,6 +1026,7 @@ "math_inline": "", "maximum_files_uploaded_together": "", "maybe_later": "", + "members_added": "", "members_management": "", "mendeley_dynamic_sync_description": "", "mendeley_groups_loading_error": "", diff --git a/services/web/frontend/js/features/group-management/components/group-members.tsx b/services/web/frontend/js/features/group-management/components/group-members.tsx index 2e87fe5051..c49fa62af0 100644 --- a/services/web/frontend/js/features/group-management/components/group-members.tsx +++ b/services/web/frontend/js/features/group-management/components/group-members.tsx @@ -13,6 +13,7 @@ import OLCard from '@/features/ui/components/ol/ol-card' import OLButton from '@/features/ui/components/ol/ol-button' import OLFormControl from '@/features/ui/components/ol/ol-form-control' import OLFormText from '@/features/ui/components/ol/ol-form-text' +import OLNotification from '@/features/ui/components/ol/ol-notification' export default function GroupMembers() { const { isReady } = useWaitForI18n() @@ -26,6 +27,7 @@ export default function GroupMembers() { removeMemberError, inviteMemberLoading, inviteError, + memberAdded, paths, } = useGroupMembersContext() const [emailString, setEmailString] = useState('') @@ -140,6 +142,13 @@ export default function GroupMembers() { data-testid="add-more-members-form" >

{t('invite_more_members')}

+ {memberAdded && ( + + )}
diff --git a/services/web/frontend/js/features/group-management/components/members-table/members-list.tsx b/services/web/frontend/js/features/group-management/components/members-table/members-list.tsx index 6be4c7cbda..8a46a225bb 100644 --- a/services/web/frontend/js/features/group-management/components/members-table/members-list.tsx +++ b/services/web/frontend/js/features/group-management/components/members-table/members-list.tsx @@ -1,4 +1,4 @@ -import { useState, useRef, useEffect } from 'react' +import { useState, useRef, useEffect, useMemo } from 'react' import { useTranslation } from 'react-i18next' import { User } from '../../../../../../types/group-management/user' import { useGroupMembersContext } from '../../context/group-members-context' @@ -13,6 +13,9 @@ import getMeta from '@/utils/meta' import UnlinkUserModal from './unlink-user-modal' import OLTable from '@/features/ui/components/ol/ol-table' import OLTooltip from '@/features/ui/components/ol/ol-tooltip' +import Pagination from '@/shared/components/pagination' + +const USERS_DISPLAY_LIMIT = 50 type ManagedUsersListProps = { groupId: string @@ -31,13 +34,30 @@ export default function MembersList({ groupId }: ManagedUsersListProps) { const managedUsersActive = getMeta('ol-managedUsersActive') const groupSSOActive = getMeta('ol-groupSSOActive') const tHeadRowRef = useRef(null) - const [colSpan, setColSpan] = useState(0) + const [pagination, setPagination] = useState({ currPage: 1, totalPages: 1 }) + + const usersForCurrentPage = useMemo( + () => + users.slice( + (pagination.currPage - 1) * USERS_DISPLAY_LIMIT, + pagination.currPage * USERS_DISPLAY_LIMIT + ), + [users, pagination.currPage] + ) + + const handlePageClick = ( + _e: React.MouseEvent, + page: number + ) => { + setPagination(p => ({ ...p, currPage: page })) + } useEffect(() => { - if (tHeadRowRef.current) { - setColSpan(tHeadRowRef.current.querySelectorAll('th').length) - } - }, []) + setPagination(p => ({ + ...p, + totalPages: Math.ceil(users.length / USERS_DISPLAY_LIMIT), + })) + }, [users.length]) return (
@@ -93,12 +113,17 @@ export default function MembersList({ groupId }: ManagedUsersListProps) { {users.length === 0 && ( - + {t('no_members')} )} - {users.map(user => ( + {usersForCurrentPage.map(user => ( + {pagination.totalPages > 1 && ( +
+ +
+ )} {userToOffboard && ( void inviteMemberLoading: boolean inviteError?: APIError + memberAdded: boolean paths: { [key: string]: string } } @@ -58,6 +59,7 @@ export function GroupMembersProvider({ children }: GroupMembersProviderProps) { const [inviteError, setInviteError] = useState() const [removeMemberInflightCount, setRemoveMemberInflightCount] = useState(0) const [removeMemberError, setRemoveMemberError] = useState() + const [memberAdded, setMemberAdded] = useState(false) const groupId = getMeta('ol-groupId') @@ -74,7 +76,9 @@ export function GroupMembersProvider({ children }: GroupMembersProviderProps) { const addMembers = useCallback( (emailString: string) => { setInviteError(undefined) + setMemberAdded(false) const emails = parseEmails(emailString) + let isError = false mapSeries(emails, async email => { setInviteUserInflightCount(count => count + 1) try { @@ -94,8 +98,15 @@ export function GroupMembersProvider({ children }: GroupMembersProviderProps) { } catch (error: unknown) { debugConsole.error(error) setInviteError((error as FetchError)?.data?.error || {}) + isError = true } - setInviteUserInflightCount(count => count - 1) + setInviteUserInflightCount(count => { + const newCount = count - 1 + if (newCount === 0 && !isError) { + setMemberAdded(true) + } + return newCount + }) }) }, [paths.addMember, users, setUsers] @@ -173,6 +184,7 @@ export function GroupMembersProvider({ children }: GroupMembersProviderProps) { removeMemberError, inviteMemberLoading: inviteUserInflightCount > 0, inviteError, + memberAdded, paths, }), [ @@ -191,6 +203,7 @@ export function GroupMembersProvider({ children }: GroupMembersProviderProps) { removeMemberError, inviteUserInflightCount, inviteError, + memberAdded, paths, ] ) diff --git a/services/web/locales/en.json b/services/web/locales/en.json index 3593bfab7b..34865477e4 100644 --- a/services/web/locales/en.json +++ b/services/web/locales/en.json @@ -1342,6 +1342,7 @@ "may": "May", "maybe_later": "Maybe later", "member_picker": "Select number of users for group plan", + "members_added": "Member(s) added.", "members_management": "Members management", "mendeley": "Mendeley", "mendeley_dynamic_sync_description": "With the Mendeley integration, you can import your references into __appName__. You can either import all your references at once or dynamically search your Mendeley library directly from __appName__.", diff --git a/services/web/test/frontend/features/group-management/components/members-table/members-list.spec.tsx b/services/web/test/frontend/features/group-management/components/members-table/members-list.spec.tsx index 0861ab6c34..32958b34d0 100644 --- a/services/web/test/frontend/features/group-management/components/members-table/members-list.spec.tsx +++ b/services/web/test/frontend/features/group-management/components/members-table/members-list.spec.tsx @@ -121,6 +121,16 @@ describe('MembersList', function () { users[1].last_name ) }) + it('should render the pagination navigation', function () { + cy.window().then(win => { + win.metaAttributesCache.set( + 'ol-users', + Array.from({ length: 50 }).flatMap(() => users.flat()) + ) + }) + mountManagedUsersList() + cy.findByRole('navigation', { name: /pagination navigation/i }) + }) }) describe('empty user list', function () { diff --git a/services/web/test/unit/src/UserMembership/UserMembershipHandlerTests.js b/services/web/test/unit/src/UserMembership/UserMembershipHandlerTests.js index f7b665aecd..3bf74c0ab7 100644 --- a/services/web/test/unit/src/UserMembership/UserMembershipHandlerTests.js +++ b/services/web/test/unit/src/UserMembership/UserMembershipHandlerTests.js @@ -48,7 +48,7 @@ describe('UserMembershipHandler', function () { this.UserMembershipViewModel = { promises: { - buildAsync: sinon.stub().resolves({ _id: 'mock-member-id' }), + buildAsync: sinon.stub().resolves([{ _id: 'mock-member-id' }]), }, build: sinon.stub().returns(this.newUser), } @@ -118,26 +118,26 @@ describe('UserMembershipHandler', function () { this.subscription, EntityConfigs.group ) - const expectedCallcount = - this.subscription.member_ids.length + - this.subscription.invited_emails.length + - this.subscription.teamInvites.length expect( - this.UserMembershipViewModel.promises.buildAsync.callCount - ).to.equal(expectedCallcount) + this.UserMembershipViewModel.promises.buildAsync + ).to.be.calledOnceWith( + this.subscription.invited_emails.concat( + this.subscription.teamInvites[0].email, + this.subscription.member_ids + ) + ) }) }) - describe('group mamagers', function () { + describe('group managers', function () { it('build view model for all managers', async function () { await this.UserMembershipHandler.promises.getUsers( this.subscription, EntityConfigs.groupManagers ) - const expectedCallcount = this.subscription.manager_ids.length expect( - this.UserMembershipViewModel.promises.buildAsync.callCount - ).to.equal(expectedCallcount) + this.UserMembershipViewModel.promises.buildAsync + ).to.be.calledOnceWith(this.subscription.manager_ids) }) }) @@ -147,11 +147,9 @@ describe('UserMembershipHandler', function () { this.institution, EntityConfigs.institution ) - - const expectedCallcount = this.institution.managerIds.length expect( - this.UserMembershipViewModel.promises.buildAsync.callCount - ).to.equal(expectedCallcount) + this.UserMembershipViewModel.promises.buildAsync + ).to.be.calledOnceWith(this.institution.managerIds) }) }) }) diff --git a/services/web/test/unit/src/UserMembership/UserMembershipViewModelTests.js b/services/web/test/unit/src/UserMembership/UserMembershipViewModelTests.js index a8ce9d158f..c5e21a5f48 100644 --- a/services/web/test/unit/src/UserMembership/UserMembershipViewModelTests.js +++ b/services/web/test/unit/src/UserMembership/UserMembershipViewModelTests.js @@ -25,7 +25,7 @@ const { describe('UserMembershipViewModel', function () { beforeEach(function () { - this.UserGetter = { getUser: sinon.stub() } + this.UserGetter = { getUsers: sinon.stub() } this.UserMembershipViewModel = SandboxedModule.require(modulePath, { requires: { 'mongodb-legacy': { ObjectId }, @@ -87,9 +87,10 @@ describe('UserMembershipViewModel', function () { }) it('build email', function (done) { + this.UserGetter.getUsers.yields(null, []) return this.UserMembershipViewModel.buildAsync( - this.email, - (error, viewModel) => { + [this.email], + (error, [viewModel]) => { assertCalledWith(this.UserMembershipViewModel.build, this.email) return done() } @@ -97,9 +98,10 @@ describe('UserMembershipViewModel', function () { }) it('build user', function (done) { + this.UserGetter.getUsers.yields(null, []) return this.UserMembershipViewModel.buildAsync( - this.user, - (error, viewModel) => { + [this.user], + (error, [viewModel]) => { assertCalledWith(this.UserMembershipViewModel.build, this.user) return done() } @@ -107,30 +109,34 @@ describe('UserMembershipViewModel', function () { }) it('build user id', function (done) { - this.UserGetter.getUser.yields(null, this.user) + const user = { + ...this.user, + _id: new ObjectId(), + } + this.UserGetter.getUsers.yields(null, [user]) return this.UserMembershipViewModel.buildAsync( - new ObjectId(), - (error, viewModel) => { + [user._id], + (error, [viewModel]) => { expect(error).not.to.exist assertNotCalled(this.UserMembershipViewModel.build) - expect(viewModel._id).to.equal(this.user._id) - expect(viewModel.email).to.equal(this.user.email) - expect(viewModel.first_name).to.equal(this.user.first_name) + expect(viewModel._id.toString()).to.equal(user._id.toString()) + expect(viewModel.email).to.equal(user.email) + expect(viewModel.first_name).to.equal(user.first_name) expect(viewModel.invite).to.equal(false) expect(viewModel.email).to.exist expect(viewModel.enrollment).to.exist - expect(viewModel.enrollment).to.deep.equal(this.user.enrollment) + expect(viewModel.enrollment).to.deep.equal(user.enrollment) return done() } ) }) it('build user id with error', function (done) { - this.UserGetter.getUser.yields(new Error('nope')) + this.UserGetter.getUsers.yields(new Error('nope'), []) const userId = new ObjectId() return this.UserMembershipViewModel.buildAsync( - userId, - (error, viewModel) => { + [userId], + (error, [viewModel]) => { expect(error).not.to.exist assertNotCalled(this.UserMembershipViewModel.build) expect(viewModel._id).to.equal(userId.toString())