Skip to content

Commit

Permalink
Merge pull request DSpace#2247 from 4Science/feature/CST-9636
Browse files Browse the repository at this point in the history
feat: added bulk access control management
  • Loading branch information
tdonohue committed Jun 16, 2023
2 parents e9b18d8 + 6e6b775 commit 5c8828f
Show file tree
Hide file tree
Showing 88 changed files with 2,540 additions and 87 deletions.
20 changes: 17 additions & 3 deletions src/app/access-control/access-control-routing.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,13 @@ import { GroupsRegistryComponent } from './group-registry/groups-registry.compon
import { GROUP_EDIT_PATH } from './access-control-routing-paths';
import { I18nBreadcrumbResolver } from '../core/breadcrumbs/i18n-breadcrumb.resolver';
import { GroupPageGuard } from './group-registry/group-page.guard';
import { GroupAdministratorGuard } from '../core/data/feature-authorization/feature-authorization-guard/group-administrator.guard';
import { SiteAdministratorGuard } from '../core/data/feature-authorization/feature-authorization-guard/site-administrator.guard';
import {
GroupAdministratorGuard
} from '../core/data/feature-authorization/feature-authorization-guard/group-administrator.guard';
import {
SiteAdministratorGuard
} from '../core/data/feature-authorization/feature-authorization-guard/site-administrator.guard';
import { BulkAccessComponent } from './bulk-access/bulk-access.component';

@NgModule({
imports: [
Expand Down Expand Up @@ -47,7 +52,16 @@ import { SiteAdministratorGuard } from '../core/data/feature-authorization/featu
},
data: { title: 'admin.access-control.groups.title.singleGroup', breadcrumbKey: 'admin.access-control.groups.singleGroup' },
canActivate: [GroupPageGuard]
}
},
{
path: 'bulk-access',
component: BulkAccessComponent,
resolve: {
breadcrumb: I18nBreadcrumbResolver
},
data: { title: 'admin.access-control.bulk-access.title', breadcrumbKey: 'admin.access-control.bulk-access' },
canActivate: [SiteAdministratorGuard]
},
])
]
})
Expand Down
12 changes: 12 additions & 0 deletions src/app/access-control/access-control.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ import { GroupsRegistryComponent } from './group-registry/groups-registry.compon
import { FormModule } from '../shared/form/form.module';
import { DYNAMIC_ERROR_MESSAGES_MATCHER, DynamicErrorMessagesMatcher } from '@ng-dynamic-forms/core';
import { AbstractControl } from '@angular/forms';
import { BulkAccessComponent } from './bulk-access/bulk-access.component';
import { NgbAccordionModule } from '@ng-bootstrap/ng-bootstrap';
import { BulkAccessBrowseComponent } from './bulk-access/browse/bulk-access-browse.component';
import { BulkAccessSettingsComponent } from './bulk-access/settings/bulk-access-settings.component';
import { SearchModule } from '../shared/search/search.module';
import { AccessControlFormModule } from '../shared/access-control-form-container/access-control-form.module';

/**
* Condition for displaying error messages on email form field
Expand All @@ -28,6 +34,9 @@ export const ValidateEmailErrorStateMatcher: DynamicErrorMessagesMatcher =
RouterModule,
AccessControlRoutingModule,
FormModule,
NgbAccordionModule,
SearchModule,
AccessControlFormModule,
],
exports: [
MembersListComponent,
Expand All @@ -39,6 +48,9 @@ export const ValidateEmailErrorStateMatcher: DynamicErrorMessagesMatcher =
GroupFormComponent,
SubgroupsListComponent,
MembersListComponent,
BulkAccessComponent,
BulkAccessBrowseComponent,
BulkAccessSettingsComponent,
],
providers: [
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
<ngb-accordion #acc="ngbAccordion" [activeIds]="'browse'">
<ngb-panel [id]="'browse'">
<ng-template ngbPanelHeader>
<div class="w-100 d-flex justify-content-between collapse-toggle" ngbPanelToggle (click)="acc.toggle('browse')"
data-test="browse">
<button type="button" class="btn btn-link p-0" (click)="$event.preventDefault()"
[attr.aria-expanded]="!acc.isExpanded('browse')"
aria-controls="collapsePanels">
{{ 'admin.access-control.bulk-access-browse.header' | translate }}
</button>
<div class="text-right d-flex">
<div class="ml-3 d-inline-block">
<span *ngIf="acc.isExpanded('browse')" class="fas fa-chevron-up fa-fw"></span>
<span *ngIf="!acc.isExpanded('browse')" class="fas fa-chevron-down fa-fw"></span>
</div>
</div>
</div>
</ng-template>
<ng-template ngbPanelContent>
<ul ngbNav #nav="ngbNav" [(activeId)]="activateId" class="nav-pills">
<li [ngbNavItem]="'search'">
<a ngbNavLink>{{'admin.access-control.bulk-access-browse.search.header' | translate}}</a>
<ng-template ngbNavContent>
<div class="mx-n3">
<ds-themed-search [configuration]="'administrativeBulkAccess'"
[selectable]="true"
[selectionConfig]="{ repeatable: true, listId: listId }"
[showThumbnails]="false"></ds-themed-search>
</div>
</ng-template>
</li>
<li [ngbNavItem]="'selected'">
<a ngbNavLink>
{{'admin.access-control.bulk-access-browse.selected.header' | translate: {number: ((objectsSelected$ | async)?.payload?.totalElements) ? (objectsSelected$ | async)?.payload?.totalElements : '0'} }}
</a>
<ng-template ngbNavContent>
<ds-pagination
[paginationOptions]="(paginationOptions$ | async)"
[pageInfoState]="(objectsSelected$|async)?.payload.pageInfo"
[collectionSize]="(objectsSelected$|async)?.payload?.totalElements"
[objects]="(objectsSelected$|async)"
[showPaginator]="false"
(prev)="pagePrev()"
(next)="pageNext()">
<ul *ngIf="(objectsSelected$|async)?.hasSucceeded" class="list-unstyled ml-4">
<li *ngFor='let object of (objectsSelected$|async)?.payload?.page | paginate: { itemsPerPage: (paginationOptions$ | async).pageSize,
currentPage: (paginationOptions$ | async).currentPage, totalItems: (objectsSelected$|async)?.payload?.page.length }; let i = index; let last = last '
class="mt-4 mb-4 d-flex"
[attr.data-test]="'list-object' | dsBrowserOnly">
<ds-selectable-list-item-control [index]="i"
[object]="object"
[selectionConfig]="{ repeatable: true, listId: listId }"></ds-selectable-list-item-control>
<ds-listable-object-component-loader [listID]="listId"
[index]="i"
[object]="object"
[showThumbnails]="false"
[viewMode]="'list'"></ds-listable-object-component-loader>
</li>
</ul>
</ds-pagination>
</ng-template>
</li>
</ul>
<div [ngbNavOutlet]="nav" class="mt-5"></div>
</ng-template>
</ngb-panel>
</ngb-accordion>
Empty file.
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { NO_ERRORS_SCHEMA } from '@angular/core';

import { of } from 'rxjs';
import { NgbAccordionModule, NgbNavModule } from '@ng-bootstrap/ng-bootstrap';
import { TranslateModule } from '@ngx-translate/core';

import { BulkAccessBrowseComponent } from './bulk-access-browse.component';
import { SelectableListService } from '../../../shared/object-list/selectable-list/selectable-list.service';
import { SelectableObject } from '../../../shared/object-list/selectable-list/selectable-list.service.spec';
import { PageInfo } from '../../../core/shared/page-info.model';
import { buildPaginatedList } from '../../../core/data/paginated-list.model';
import { createSuccessfulRemoteDataObject } from '../../../shared/remote-data.utils';

describe('BulkAccessBrowseComponent', () => {
let component: BulkAccessBrowseComponent;
let fixture: ComponentFixture<BulkAccessBrowseComponent>;

const listID1 = 'id1';
const value1 = 'Selected object';
const value2 = 'Another selected object';

const selected1 = new SelectableObject(value1);
const selected2 = new SelectableObject(value2);

const testSelection = { id: listID1, selection: [selected1, selected2] } ;

const selectableListService = jasmine.createSpyObj('SelectableListService', ['getSelectableList', 'deselectAll']);
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
imports: [
NgbAccordionModule,
NgbNavModule,
TranslateModule.forRoot()
],
declarations: [BulkAccessBrowseComponent],
providers: [ { provide: SelectableListService, useValue: selectableListService }, ],
schemas: [
NO_ERRORS_SCHEMA
]
}).compileComponents();
}));

beforeEach(() => {
fixture = TestBed.createComponent(BulkAccessBrowseComponent);
component = fixture.componentInstance;
(component as any).selectableListService.getSelectableList.and.returnValue(of(testSelection));
fixture.detectChanges();
});

afterEach(() => {
fixture.destroy();
component = null;
});

it('should create the component', () => {
expect(component).toBeTruthy();
});

it('should have an initial active nav id of "search"', () => {
expect(component.activateId).toEqual('search');
});

it('should have an initial pagination options object with default values', () => {
expect(component.paginationOptions$.getValue().id).toEqual('bas');
expect(component.paginationOptions$.getValue().pageSize).toEqual(5);
expect(component.paginationOptions$.getValue().currentPage).toEqual(1);
});

it('should have an initial remote data with a paginated list as value', () => {
const list = buildPaginatedList(new PageInfo({
'elementsPerPage': 5,
'totalElements': 2,
'totalPages': 1,
'currentPage': 1
}), [selected1, selected2]) ;
const rd = createSuccessfulRemoteDataObject(list);

expect(component.objectsSelected$.value).toEqual(rd);
});

});
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { Component, Input, OnDestroy, OnInit } from '@angular/core';

import { BehaviorSubject, Subscription } from 'rxjs';
import { distinctUntilChanged, map } from 'rxjs/operators';

import { SEARCH_CONFIG_SERVICE } from '../../../my-dspace-page/my-dspace-page.component';
import { SearchConfigurationService } from '../../../core/shared/search/search-configuration.service';
import { SelectableListService } from '../../../shared/object-list/selectable-list/selectable-list.service';
import { SelectableListState } from '../../../shared/object-list/selectable-list/selectable-list.reducer';
import { RemoteData } from '../../../core/data/remote-data';
import { buildPaginatedList, PaginatedList } from '../../../core/data/paginated-list.model';
import { ListableObject } from '../../../shared/object-collection/shared/listable-object.model';
import { createSuccessfulRemoteDataObject } from '../../../shared/remote-data.utils';
import { PageInfo } from '../../../core/shared/page-info.model';
import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model';
import { hasValue } from '../../../shared/empty.util';

@Component({
selector: 'ds-bulk-access-browse',
templateUrl: 'bulk-access-browse.component.html',
styleUrls: ['./bulk-access-browse.component.scss'],
providers: [
{
provide: SEARCH_CONFIG_SERVICE,
useClass: SearchConfigurationService
}
]
})
export class BulkAccessBrowseComponent implements OnInit, OnDestroy {

/**
* The selection list id
*/
@Input() listId!: string;

/**
* The active nav id
*/
activateId = 'search';

/**
* The list of the objects already selected
*/
objectsSelected$: BehaviorSubject<RemoteData<PaginatedList<ListableObject>>> = new BehaviorSubject<RemoteData<PaginatedList<ListableObject>>>(null);

/**
* The pagination options object used for the list of selected elements
*/
paginationOptions$: BehaviorSubject<PaginationComponentOptions> = new BehaviorSubject<PaginationComponentOptions>(Object.assign(new PaginationComponentOptions(), {
id: 'bas',
pageSize: 5,
currentPage: 1
}));

/**
* Array to track all subscriptions and unsubscribe them onDestroy
*/
private subs: Subscription[] = [];

constructor(private selectableListService: SelectableListService) {}

/**
* Subscribe to selectable list updates
*/
ngOnInit(): void {

this.subs.push(
this.selectableListService.getSelectableList(this.listId).pipe(
distinctUntilChanged(),
map((list: SelectableListState) => this.generatePaginatedListBySelectedElements(list))
).subscribe(this.objectsSelected$)
);
}

pageNext() {
this.paginationOptions$.next(Object.assign(new PaginationComponentOptions(), this.paginationOptions$.value, {
currentPage: this.paginationOptions$.value.currentPage + 1
}));
}

pagePrev() {
this.paginationOptions$.next(Object.assign(new PaginationComponentOptions(), this.paginationOptions$.value, {
currentPage: this.paginationOptions$.value.currentPage - 1
}));
}

private calculatePageCount(pageSize, totalCount = 0) {
// we suppose that if we have 0 items we want 1 empty page
return totalCount < pageSize ? 1 : Math.ceil(totalCount / pageSize);
}

/**
* Generate The RemoteData object containing the list of the selected elements
* @param list
* @private
*/
private generatePaginatedListBySelectedElements(list: SelectableListState): RemoteData<PaginatedList<ListableObject>> {
const pageInfo = new PageInfo({
elementsPerPage: this.paginationOptions$.value.pageSize,
totalElements: list?.selection.length,
totalPages: this.calculatePageCount(this.paginationOptions$.value.pageSize, list?.selection.length),
currentPage: this.paginationOptions$.value.currentPage
});
if (pageInfo.currentPage > pageInfo.totalPages) {
pageInfo.currentPage = pageInfo.totalPages;
this.paginationOptions$.next(Object.assign(new PaginationComponentOptions(), this.paginationOptions$.value, {
currentPage: pageInfo.currentPage
}));
}
return createSuccessfulRemoteDataObject(buildPaginatedList(pageInfo, list?.selection || []));
}

ngOnDestroy(): void {
this.subs
.filter((sub) => hasValue(sub))
.forEach((sub) => sub.unsubscribe());
this.selectableListService.deselectAll(this.listId);
}
}
19 changes: 19 additions & 0 deletions src/app/access-control/bulk-access/bulk-access.component.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<div class="container">
<ds-bulk-access-browse [listId]="listId"></ds-bulk-access-browse>
<div class="clearfix mb-3"></div>
<ds-bulk-access-settings #dsBulkSettings ></ds-bulk-access-settings>

<hr>

<div class="d-flex justify-content-end">
<button class="btn btn-outline-primary mr-3" (click)="reset()">
{{ 'access-control-cancel' | translate }}
</button>
<button class="btn btn-primary" [disabled]="!canExport()" (click)="submit()">
{{ 'access-control-execute' | translate }}
</button>
</div>
</div>



Empty file.
Loading

0 comments on commit 5c8828f

Please sign in to comment.