diff --git a/src/app/access-control/epeople-registry/eperson-form/eperson-form.component.html b/src/app/access-control/epeople-registry/eperson-form/eperson-form.component.html index b6d45a9cb9f..31b77ef44bb 100644 --- a/src/app/access-control/epeople-registry/eperson-form/eperson-form.component.html +++ b/src/app/access-control/epeople-registry/eperson-form/eperson-form.component.html @@ -74,6 +74,7 @@

{{messagePrefix + '.groupsEPersonIsMemberOf' | translate}}

{{messagePrefix + '.table.id' | translate}} {{messagePrefix + '.table.name' | translate}} {{messagePrefix + '.table.collectionOrCommunity' | translate}} + {{messagePrefix + '.table.remove' | translate}} @@ -89,6 +90,14 @@

{{messagePrefix + '.groupsEPersonIsMemberOf' | translate}}

{{ dsoNameService.getName((group.object | async)?.payload) }} + + + } @@ -109,4 +118,4 @@

{{messagePrefix + '.groupsEPersonIsMemberOf' | translate}}

} - + \ No newline at end of file diff --git a/src/app/access-control/epeople-registry/eperson-form/eperson-form.component.spec.ts b/src/app/access-control/epeople-registry/eperson-form/eperson-form.component.spec.ts index 012d30b68c9..8a4869a312c 100644 --- a/src/app/access-control/epeople-registry/eperson-form/eperson-form.component.spec.ts +++ b/src/app/access-control/epeople-registry/eperson-form/eperson-form.component.spec.ts @@ -48,7 +48,10 @@ import { NotificationsServiceStub } from '@dspace/core/testing/notifications-ser import { PaginationServiceStub } from '@dspace/core/testing/pagination-service.stub'; import { RouterStub } from '@dspace/core/testing/router.stub'; import { createPaginatedList } from '@dspace/core/testing/utils.test'; -import { createSuccessfulRemoteDataObject$ } from '@dspace/core/utilities/remote-data.utils'; +import { + createFailedRemoteDataObject$, + createSuccessfulRemoteDataObject$, +} from '@dspace/core/utilities/remote-data.utils'; import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; import { TranslateModule } from '@ngx-translate/core'; import { @@ -223,6 +226,7 @@ describe('EPersonFormComponent', () => { groupsDataService = jasmine.createSpyObj('groupsDataService', { findListByHref: createSuccessfulRemoteDataObject$(createPaginatedList([])), getGroupRegistryRouterLink: '', + deleteMemberFromGroup: 'deleteMemberFromGroup', }); groupRegistryService = jasmine.createSpyObj('GroupRegistryService', { startEditingNewGroup: jasmine.createSpy('startEditingNewGroup'), @@ -537,6 +541,52 @@ describe('EPersonFormComponent', () => { }); }); + describe('delete group from member', () => { + let successSpy: jasmine.Spy; + let errorSpy: jasmine.Spy; + + beforeEach(() => { + successSpy = spyOn((component as any).notificationsService, 'success'); + errorSpy = spyOn((component as any).notificationsService, 'error'); + }); + + it('should delete group from member and show notification', (done) => { + const group = { id: 'group1' } as any; + const activeEperson = EPersonMock; + + spyOn(component.epeopleRegistryService, 'getActiveEPerson').and.returnValue(of(activeEperson)); + (groupsDataService.deleteMemberFromGroup as jasmine.Spy) + .and.returnValue(createSuccessfulRemoteDataObject$(null)); + + spyOn(component.dsoNameService, 'getName').and.returnValue('Mock Group Name'); + + const notifySpy = spyOn(component, 'showNotifications').and.callFake(() => { + expect(groupsDataService.deleteMemberFromGroup).toHaveBeenCalledWith(group, activeEperson); + expect(notifySpy).toHaveBeenCalled(); + done(); + }); + + component.deleteGroupFromMember(group); + }); + + it('should show success notification on successful operation', () => { + const response = createSuccessfulRemoteDataObject$(null); + + component.showNotifications('deleteMembership', response, 'TestGroup', EPersonMock); + + expect(successSpy).toHaveBeenCalled(); + }); + + it('should show error notification when response hasSucceeded is false', () => { + const response = createFailedRemoteDataObject$(null); + + component.showNotifications('deleteMembership', response, 'TestGroup', EPersonMock); + + expect(errorSpy).toHaveBeenCalled(); + }); + }); + + describe('Reset Password', () => { let ePersonId; let ePersonEmail; diff --git a/src/app/access-control/epeople-registry/eperson-form/eperson-form.component.ts b/src/app/access-control/epeople-registry/eperson-form/eperson-form.component.ts index e14ab932b25..4f7652a92af 100644 --- a/src/app/access-control/epeople-registry/eperson-form/eperson-form.component.ts +++ b/src/app/access-control/epeople-registry/eperson-form/eperson-form.component.ts @@ -600,6 +600,38 @@ export class EPersonFormComponent implements OnInit, OnDestroy { } } + /** + * Deletes a given group from the Group list of the eperson currently being edited present in + * @param group group we want to delete as of which the current eperson being edited is member of + */ + deleteGroupFromMember(group: Group) { + this.epeopleRegistryService.getActiveEPerson().pipe(take(1)).subscribe((activeEPerson: EPerson) => { + if (activeEPerson != null) { + const response = this.groupsDataService.deleteMemberFromGroup(group, activeEPerson); + this.showNotifications('deleteMembership', response, this.dsoNameService.getName(group), activeEPerson); + } else { + this.notificationsService.error(this.translateService.get(this.messagePrefix + '.notification.failure.noActiveEPerson')); + } + }); + } + + /** + * Shows a notification based on the success/failure of the request + * @param messageSuffix Suffix for message + * @param response RestResponse observable containing success/failure request + * @param nameObject Object request was about + * @param activeEPerson EPerson currently being edited + */ + showNotifications(messageSuffix: string, response: Observable>, nameObject: string, activeEPerson: EPerson) { + response.pipe(getFirstCompletedRemoteData()).subscribe((rd: RemoteData) => { + if (rd.hasSucceeded) { + this.notificationsService.success(this.translateService.get(this.messagePrefix + '.notification.success.' + messageSuffix, { name: nameObject })); + } else { + this.notificationsService.error(this.translateService.get(this.messagePrefix + '.notification.failure.' + messageSuffix, { name: nameObject })); + } + }); + } + /** * Cancel the current edit when component is destroyed & unsub all subscriptions */ diff --git a/src/app/access-control/group-registry/group-form/members-list/members-list.component.html b/src/app/access-control/group-registry/group-form/members-list/members-list.component.html index 072b836d236..c3d63ff4007 100644 --- a/src/app/access-control/group-registry/group-form/members-list/members-list.component.html +++ b/src/app/access-control/group-registry/group-form/members-list/members-list.component.html @@ -2,6 +2,23 @@

{{messagePrefix + '.head' | translate}}

{{messagePrefix + '.headMembers' | translate}}

+ +
+
+
+ + + + +
+
+
+ +
+
@if ((ePeopleMembersOfGroup | async)?.totalElements > 0) { { } return createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), [])); }, + searchMembers(query: string, groupId: string, pagination, exact: boolean, currentMembers: boolean) { + return of(createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), []))); + }, clearEPersonRequests() { // empty }, @@ -282,4 +291,62 @@ describe('MembersListComponent', () => { }); }); + describe('test for searchMembers', () => { + let comp: any; + let ePersonDataServiceSpy: any; + let notificationsServiceSpy: any; + + beforeEach(() => { + ePersonDataServiceSpy = jasmine.createSpyObj('ePersonDataService', ['searchMembers']); + notificationsServiceSpy = jasmine.createSpyObj('notificationsService', ['error']); + + comp = component as any; + comp.ePersonDataService = ePersonDataServiceSpy; + comp.notificationsService = notificationsServiceSpy; + }); + + it('should search members and update ePeopleMembersOfGroup', fakeAsync(() => { + const fakeGroup = mockGroup; + const fakeMember = EPersonMock; + const fakePaginatedList = { pageInfo: { totalPages: 1 }, page: [fakeMember] }; + const fakeResponse = createSuccessfulRemoteDataObject$(fakePaginatedList); + + comp.groupBeingEdited = fakeGroup; + ePersonDataServiceSpy.searchMembers.and.returnValue(fakeResponse); + + spyOn(comp, 'isMemberOfGroup').and.returnValue(of(true)); + const groupSpy = spyOn(comp.ePeopleMembersOfGroup, 'next'); + + comp.searchMembers({ queryCurrentMembers: 'John' }); + tick(); + + expect(comp.ePersonDataService.searchMembers).toHaveBeenCalled(); + expect(groupSpy).toHaveBeenCalled(); + })); + + it('should show error notification when API call fails', fakeAsync(() => { + const fakeGroup = mockGroup; + comp.groupBeingEdited = fakeGroup; + + ePersonDataServiceSpy.searchMembers.and.returnValue(createFailedRemoteDataObject$('Server Error')); + + comp.searchMembers({ queryCurrentMembers: 'John' }); + tick(); + + expect(comp.notificationsService.error).toHaveBeenCalled(); + })); + + it('should reset the searchCurrentMembersForm and call searchMembers with empty query', () => { + comp.searchCurrentMembersForm = new FormGroup({ + queryCurrentMembers: new FormControl('John Doe'), + }); + + const searchSpy = spyOn(comp, 'searchMembers'); + comp.clearCurrentMembersFormAndResetResult(); + + expect(comp.searchCurrentMembersForm.value.queryCurrentMembers).toBe(''); + expect(searchSpy).toHaveBeenCalledWith({ queryCurrentMembers: '' }); + }); + }); + }); diff --git a/src/app/access-control/group-registry/group-form/members-list/members-list.component.ts b/src/app/access-control/group-registry/group-form/members-list/members-list.component.ts index fbed933cebd..1c382d9fe96 100644 --- a/src/app/access-control/group-registry/group-form/members-list/members-list.component.ts +++ b/src/app/access-control/group-registry/group-form/members-list/members-list.component.ts @@ -171,9 +171,15 @@ export class MembersListComponent implements OnInit, OnDestroy { // The search form searchForm; + // The current member search form + searchCurrentMembersForm; + // Current search in edit group - epeople search form currentSearchQuery: string; + // Current search in edit group - epeople current members search form + currentMembersSearchQuery: string; + // Whether or not user has done a EPeople search yet searchDone: boolean; @@ -194,12 +200,18 @@ export class MembersListComponent implements OnInit, OnDestroy { public dsoNameService: DSONameService, ) { this.currentSearchQuery = ''; + this.currentMembersSearchQuery = ''; } ngOnInit(): void { this.searchForm = this.formBuilder.group(({ query: '', })); + + this.searchCurrentMembersForm = this.formBuilder.group(({ + queryCurrentMembers: '', + })); + this.subs.set(SubKey.ActiveGroup, this.groupRegistryService.getActiveGroup().subscribe((activeGroup: Group) => { if (activeGroup != null) { this.groupBeingEdited = activeGroup; @@ -354,6 +366,57 @@ export class MembersListComponent implements OnInit, OnDestroy { })); } + /** + * Search all EPeople who are a member of the current group by name, email or metadata + * @param data Contains query param + */ + searchMembers(data: any) { + this.unsubFrom(SubKey.Members); + this.subs.set(SubKey.Members, + this.paginationService.getCurrentPagination(this.config.id, this.config).pipe( + switchMap((paginationOptions) => { + const query: string = data.queryCurrentMembers; + if (query != null && this.currentMembersSearchQuery !== query && this.groupBeingEdited) { + this.currentMembersSearchQuery = query; + this.paginationService.resetPage(this.config.id); + } + + return this.ePersonDataService.searchMembers(this.currentMembersSearchQuery, this.groupBeingEdited.id, { + currentPage: paginationOptions.currentPage, + elementsPerPage: paginationOptions.pageSize, + }, false, true); + }), + getAllCompletedRemoteData(), + map((rd: RemoteData) => { + if (rd.hasFailed) { + this.notificationsService.error(this.translateService.get(this.messagePrefix + '.notification.failure', { cause: rd.errorMessage })); + } else { + return rd; + } + }), + switchMap((epersonListRD: RemoteData>) => { + if (!epersonListRD || !epersonListRD.payload || !epersonListRD.payload.page) { + return of(buildPaginatedList(undefined, [])); + } // added null check + const dtos$ = observableCombineLatest([...epersonListRD.payload.page.map((member: EPerson) => { + const dto$: Observable = observableCombineLatest( + this.isMemberOfGroup(member), (isMember: ObservedValueOf>) => { + const epersonDtoModel: EpersonDtoModel = new EpersonDtoModel(); + epersonDtoModel.eperson = member; + epersonDtoModel.ableToDelete = isMember; + return epersonDtoModel; + }); + return dto$; + })]); + return dtos$.pipe(defaultIfEmpty([]), map((dtos: EpersonDtoModel[]) => { + return buildPaginatedList(epersonListRD.payload.pageInfo, dtos); + })); + }), + ).subscribe((paginatedListOfDTOs: PaginatedList) => { + this.ePeopleMembersOfGroup.next(paginatedListOfDTOs); + })); + } + /** * unsub all subscriptions */ @@ -391,4 +454,14 @@ export class MembersListComponent implements OnInit, OnDestroy { }); this.search({ query: '' }); } + + /** + * Reset all input-fields to be empty and search all search + */ + clearCurrentMembersFormAndResetResult() { + this.searchCurrentMembersForm.patchValue({ + queryCurrentMembers:'', + }); + this.searchMembers({ queryCurrentMembers: '' }); + } } diff --git a/src/app/core/eperson/eperson-data.service.spec.ts b/src/app/core/eperson/eperson-data.service.spec.ts index fa91811d27c..f1150088884 100644 --- a/src/app/core/eperson/eperson-data.service.spec.ts +++ b/src/app/core/eperson/eperson-data.service.spec.ts @@ -176,6 +176,36 @@ describe('EPersonDataService', () => { }); }); + describe('searchMembers', () => { + beforeEach(() => { + spyOn(service, 'searchBy').and.returnValue(createSuccessfulRemoteDataObject$(createPaginatedList([EPersonMock]))); + }); + + it('should build correct search params and call searchBy', () => { + const query = 'John'; + const groupId = 'group-123'; + const options = new FindListOptions(); + options.currentPage = 1; + options.elementsPerPage = 10; + + service.searchMembers(query, groupId, options); + + expect(service.searchBy).toHaveBeenCalledWith( + 'isMemberOf', + jasmine.objectContaining({ + searchParams: jasmine.arrayContaining([ + jasmine.objectContaining({ fieldName: 'query', fieldValue: query }), + jasmine.objectContaining({ fieldName: 'group', fieldValue: groupId }), + ]), + currentPage: 1, + elementsPerPage: 10, + }), + true, + true, + ); + }); + }); + describe('updateEPerson', () => { beforeEach(() => { spyOn(service, 'findByHref').and.returnValue(createSuccessfulRemoteDataObject$(EPersonMock)); diff --git a/src/app/core/eperson/eperson-data.service.ts b/src/app/core/eperson/eperson-data.service.ts index bdaad104750..57c737f8432 100644 --- a/src/app/core/eperson/eperson-data.service.ts +++ b/src/app/core/eperson/eperson-data.service.ts @@ -226,6 +226,34 @@ export class EPersonDataService extends IdentifiableDataService impleme return this.searchBy('isNotMemberOf', findListOptions, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); } + /** + * Searches for all EPerons which are a member of a given group, via a passed in query + * (searches all EPerson metadata and by exact UUID). + * Endpoint used: /eperson/epesons/search/isMemberOf?query=<:string>&group=<:uuid> + * @param query search query param + * @param group UUID of group to include results from. Members of this group will only be returned. + * @param options + * @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's + * no valid cached version. Defaults to true + * @param reRequestOnStale Whether or not the request should automatically be re- + * requested after the response becomes stale + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which + * {@link HALLink}s should be automatically resolved + */ + public searchMembers(query: string, group: string, options?: FindListOptions, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig[]): Observable>> { + const searchParams = [new RequestParam('query', query), new RequestParam('group', group)]; + let findListOptions = new FindListOptions(); + if (options) { + findListOptions = Object.assign(new FindListOptions(), options); + } + if (findListOptions.searchParams) { + findListOptions.searchParams = [...findListOptions.searchParams, ...searchParams]; + } else { + findListOptions.searchParams = searchParams; + } + return this.searchBy('isMemberOf', findListOptions, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); + } + /** * Add a new patch to the object cache * The patch is derived from the differences between the given object and its version in the object cache diff --git a/src/app/workflowitems-edit-page/advanced-workflow-action/advanced-workflow-action-select-reviewer/reviewers-list/reviewers-list.component.ts b/src/app/workflowitems-edit-page/advanced-workflow-action/advanced-workflow-action-select-reviewer/reviewers-list/reviewers-list.component.ts index 80db2edc98d..dac95689d12 100644 --- a/src/app/workflowitems-edit-page/advanced-workflow-action/advanced-workflow-action-select-reviewer/reviewers-list/reviewers-list.component.ts +++ b/src/app/workflowitems-edit-page/advanced-workflow-action/advanced-workflow-action-select-reviewer/reviewers-list/reviewers-list.component.ts @@ -15,6 +15,7 @@ import { import { ReactiveFormsModule, UntypedFormBuilder, + UntypedFormGroup, } from '@angular/forms'; import { Router, @@ -93,6 +94,8 @@ export class ReviewersListComponent extends MembersListComponent implements OnIn selectedReviewers: EPerson[] = []; + searchCurrentMembersForm: UntypedFormGroup; + constructor( protected groupService: GroupDataService, protected groupRegistryService: GroupRegistryService, @@ -112,6 +115,10 @@ export class ReviewersListComponent extends MembersListComponent implements OnIn scope: 'metadata', query: '', })); + + this.searchCurrentMembersForm = this.formBuilder.group(({ + queryCurrentMembers: '', + })); } ngOnChanges(changes: SimpleChanges): void { diff --git a/src/assets/i18n/en.json5 b/src/assets/i18n/en.json5 index c0b620d24d7..aa4cca29064 100644 --- a/src/assets/i18n/en.json5 +++ b/src/assets/i18n/en.json5 @@ -357,6 +357,12 @@ "admin.access-control.epeople.form.table.collectionOrCommunity": "Collection/Community", + "admin.access-control.epeople.form.table.remove": "Remove", + + "admin.access-control.epeople.form.table.edit.buttons.removegroup": "Remove member from group \"{{name}}\"", + + "admin.access-control.epeople.form.notification.success.deleteMembership": "Successfully removed member from group \" {{name}} \"", + "admin.access-control.epeople.form.memberOfNoGroups": "This EPerson is not a member of any groups", "admin.access-control.epeople.form.goToGroups": "Add to groups",