import { AsyncPipe } from '@angular/common'; import { Component, OnDestroy, OnInit, } from '@angular/core'; import { ReactiveFormsModule, UntypedFormBuilder, } from '@angular/forms'; import { RouterLink } from '@angular/router'; import { NgbModal, NgbTooltipModule, } from '@ng-bootstrap/ng-bootstrap'; import { TranslateModule, TranslateService, } from '@ngx-translate/core'; import { BehaviorSubject, combineLatest as observableCombineLatest, EMPTY, Observable, of, Subscription, } from 'rxjs'; import { catchError, defaultIfEmpty, map, switchMap, takeUntil, tap, } from 'rxjs/operators'; import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service'; import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service'; import { FeatureID } from '../../core/data/feature-authorization/feature-id'; import { buildPaginatedList, PaginatedList, } from '../../core/data/paginated-list.model'; import { RemoteData } from '../../core/data/remote-data'; import { RequestService } from '../../core/data/request.service'; import { EPersonDataService } from '../../core/eperson/eperson-data.service'; import { GroupDataService } from '../../core/eperson/group-data.service'; import { EPerson } from '../../core/eperson/models/eperson.model'; import { Group } from '../../core/eperson/models/group.model'; import { GroupDtoModel } from '../../core/eperson/models/group-dto.model'; import { PaginationService } from '../../core/pagination/pagination.service'; import { RouteService } from '../../core/services/route.service'; import { DSpaceObject } from '../../core/shared/dspace-object.model'; import { NoContent } from '../../core/shared/NoContent.model'; import { getAllSucceededRemoteData, getFirstCompletedRemoteData, getFirstSucceededRemoteData, getRemoteDataPayload, } from '../../core/shared/operators'; import { PageInfo } from '../../core/shared/page-info.model'; import { BtnDisabledDirective } from '../../shared/btn-disabled.directive'; import { ConfirmationModalComponent } from '../../shared/confirmation-modal/confirmation-modal.component'; import { hasValue } from '../../shared/empty.util'; import { ThemedLoadingComponent } from '../../shared/loading/themed-loading.component'; import { NotificationsService } from '../../shared/notifications/notifications.service'; import { PaginationComponent } from '../../shared/pagination/pagination.component'; import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model'; import { followLink } from '../../shared/utils/follow-link-config.model'; @Component({ selector: 'ds-groups-registry', templateUrl: './groups-registry.component.html', imports: [ AsyncPipe, BtnDisabledDirective, NgbTooltipModule, PaginationComponent, ReactiveFormsModule, RouterLink, ThemedLoadingComponent, TranslateModule, ], standalone: true, }) /** * A component used for managing all existing groups within the repository. * The admin can create, edit or delete groups here. */ export class GroupsRegistryComponent implements OnInit, OnDestroy { messagePrefix = 'admin.access-control.groups.'; /** * Pagination config used to display the list of groups */ config: PaginationComponentOptions = Object.assign(new PaginationComponentOptions(), { id: 'gl', pageSize: 5, currentPage: 1, }); /** * A BehaviorSubject with the list of GroupDtoModel objects made from the Groups in the repository or * as the result of the search */ groupsDto$: BehaviorSubject> = new BehaviorSubject>({} as any); deletedGroupsIds: string[] = []; /** * An observable for the pageInfo, needed to pass to the pagination component */ pageInfoState$: BehaviorSubject = new BehaviorSubject(undefined); // The search form searchForm; /** * A boolean representing if a search is pending */ loading$: BehaviorSubject = new BehaviorSubject(false); // Current search in groups registry currentSearchQuery: string; /** * The subscription for the search method */ searchSub: Subscription; paginationSub: Subscription; /** * List of subscriptions */ subs: Subscription[] = []; constructor(public groupService: GroupDataService, private ePersonDataService: EPersonDataService, private dSpaceObjectDataService: DSpaceObjectDataService, private translateService: TranslateService, private notificationsService: NotificationsService, private formBuilder: UntypedFormBuilder, protected routeService: RouteService, private authorizationService: AuthorizationDataService, private paginationService: PaginationService, public requestService: RequestService, public dsoNameService: DSONameService, private modalService: NgbModal, ) { this.currentSearchQuery = ''; this.searchForm = this.formBuilder.group(({ query: this.currentSearchQuery, })); } ngOnInit() { this.search({ query: this.currentSearchQuery }); } /** * Search in the groups (searches by group name and by uuid exact match) * @param data Contains query param */ search(data: any) { if (hasValue(this.searchSub)) { this.searchSub.unsubscribe(); this.subs = this.subs.filter((sub: Subscription) => sub !== this.searchSub); } this.searchSub = this.paginationService.getCurrentPagination(this.config.id, this.config).pipe( tap(() => this.loading$.next(true)), switchMap((paginationOptions) => { const query: string = data.query; if (query != null && this.currentSearchQuery !== query) { this.currentSearchQuery = query; this.paginationService.updateRouteWithUrl(this.config.id, [], { page: 1 }); } return this.groupService.searchGroups(this.currentSearchQuery.trim(), { currentPage: paginationOptions.currentPage, elementsPerPage: paginationOptions.pageSize, }, true, true, followLink('object')); }), getAllSucceededRemoteData(), getRemoteDataPayload(), switchMap((groups: PaginatedList) => { if (groups.page.length === 0) { return of(buildPaginatedList(groups.pageInfo, [])); } return this.authorizationService.isAuthorized(FeatureID.AdministratorOf).pipe( switchMap((isSiteAdmin: boolean) => { return observableCombineLatest([...groups.page.map((group: Group) => { if (hasValue(group) && !this.deletedGroupsIds.includes(group.id)) { return observableCombineLatest([ this.authorizationService.isAuthorized(FeatureID.CanDelete, group.self), this.canManageGroup$(isSiteAdmin, group), this.hasLinkedDSO(group), this.getSubgroups(group), this.getMembers(group), ]).pipe( map(([canDelete, canManageGroup, hasLinkedDSO, subgroups, members]: [boolean, boolean, boolean, RemoteData>, RemoteData>]) => { const groupDtoModel: GroupDtoModel = new GroupDtoModel(); groupDtoModel.ableToDelete = canDelete && !hasLinkedDSO; groupDtoModel.ableToEdit = canManageGroup; groupDtoModel.group = group; groupDtoModel.subgroups = subgroups.payload; groupDtoModel.epersons = members.payload; return groupDtoModel; }, ), ); } else { return EMPTY; } })]).pipe(defaultIfEmpty([]), map((dtos: GroupDtoModel[]) => { return buildPaginatedList(groups.pageInfo, dtos); })); }), ); }), ).subscribe((value: PaginatedList) => { this.groupsDto$.next(value); this.pageInfoState$.next(value.pageInfo); this.loading$.next(false); }); this.subs.push(this.searchSub); } canManageGroup$(isSiteAdmin: boolean, group: Group): Observable { if (isSiteAdmin) { return of(true); } else { return this.authorizationService.isAuthorized(FeatureID.CanManageGroup, group.self); } } /** * Delete Group */ deleteGroup(group: GroupDtoModel) { if (hasValue(group.group.id)) { this.groupService.delete(group.group.id).pipe(getFirstCompletedRemoteData()) .subscribe((rd: RemoteData) => { if (rd.hasSucceeded) { this.deletedGroupsIds = [...this.deletedGroupsIds, group.group.id]; this.notificationsService.success(this.translateService.get(this.messagePrefix + 'notification.deleted.success', { name: this.dsoNameService.getName(group.group) })); } else { this.notificationsService.error( this.translateService.get(this.messagePrefix + 'notification.deleted.failure.title', { name: this.dsoNameService.getName(group.group) }), this.translateService.get(this.messagePrefix + 'notification.deleted.failure.content', { cause: rd.errorMessage })); } }); } } /** * Get the members (epersons embedded value of a group) * NOTE: At this time we only grab the *first* member in order to receive the `totalElements` value * needed for our HTML template. * @param group */ getMembers(group: Group): Observable>> { return this.ePersonDataService.findListByHref(group._links.epersons.href, { currentPage: 1, elementsPerPage: 1, }).pipe(getFirstSucceededRemoteData()); } /** * Get the subgroups (groups embedded value of a group) * NOTE: At this time we only grab the *first* subgroup in order to receive the `totalElements` value * needed for our HTML template. * @param group */ getSubgroups(group: Group): Observable>> { return this.groupService.findListByHref(group._links.subgroups.href, { currentPage: 1, elementsPerPage: 1, }).pipe(getFirstSucceededRemoteData()); } /** * Check if group has a linked object (community or collection linked to a workflow group) * @param group */ hasLinkedDSO(group: Group): Observable { return this.dSpaceObjectDataService.findByHref(group._links.object.href).pipe( getFirstSucceededRemoteData(), map((rd: RemoteData) => hasValue(rd) && hasValue(rd.payload)), catchError(() => of(false)), ); } /** * Reset all input-fields to be empty and search all search */ clearFormAndResetResult() { this.searchForm.patchValue({ query: '', }); this.search({ query: '' }); } /** * Unsub all subscriptions */ ngOnDestroy(): void { this.cleanupSubscribes(); this.paginationService.clearPagination(this.config.id); } cleanupSubscribes() { if (hasValue(this.paginationSub)) { this.paginationSub.unsubscribe(); } this.subs.filter((sub) => hasValue(sub)).forEach((sub) => sub.unsubscribe()); this.paginationService.clearPagination(this.config.id); } confirmDelete(group: GroupDtoModel): void { const modalRef = this.modalService.open(ConfirmationModalComponent); modalRef.componentInstance.name = this.dsoNameService.getName(group.group); modalRef.componentInstance.headerLabel = 'admin.access-control.epeople.table.edit.buttons.remove.modal.header'; modalRef.componentInstance.infoLabel = 'admin.access-control.epeople.table.edit.buttons.remove.modal.info'; modalRef.componentInstance.cancelLabel = 'admin.access-control.epeople.table.edit.buttons.remove.modal.cancel'; modalRef.componentInstance.confirmLabel = 'admin.access-control.epeople.table.edit.buttons.remove.modal.confirm'; modalRef.componentInstance.brandColor = 'danger'; modalRef.componentInstance.confirmIcon = 'fas fa-trash'; const modalSub: Subscription = modalRef.componentInstance.response.pipe( takeUntil(modalRef.closed), ).subscribe((result: boolean) => { if (result === true) { this.deleteGroup(group); } }); void modalRef.result.then().finally(() => { modalRef.close(); if (modalSub && !modalSub.closed) { modalSub.unsubscribe(); } }); } }