Merge branch 'main' into fix_com_col_refresh

This commit is contained in:
Corrado Lombardi
2020-10-12 16:36:23 +02:00
90 changed files with 2662 additions and 341 deletions

View File

@@ -4,7 +4,7 @@
<h2 id="header" class="border-bottom pb-2">{{labelPrefix + 'head' | translate}}</h2>
<ds-eperson-form *ngIf="isEPersonFormShown" (submitForm)="forceUpdateEPeople()"
<ds-eperson-form *ngIf="isEPersonFormShown" (submitForm)="reset()"
(cancelForm)="isEPersonFormShown = false"></ds-eperson-form>
<div *ngIf="!isEPersonFormShown">
@@ -40,10 +40,10 @@
</form>
<ds-pagination
*ngIf="(ePeople | async)?.payload?.totalElements > 0"
*ngIf="(ePeopleDto$ | async)?.totalElements > 0"
[paginationOptions]="config"
[pageInfoState]="(ePeople | async)?.payload"
[collectionSize]="(ePeople | async)?.payload?.totalElements"
[pageInfoState]="pageInfoState$"
[collectionSize]="(pageInfoState$ | async)?.totalElements"
[hideGear]="true"
[hidePagerWhenSinglePage]="true"
(pageChange)="onPageChange($event)">
@@ -59,21 +59,21 @@
</tr>
</thead>
<tbody>
<tr *ngFor="let eperson of (ePeople | async)?.payload?.page"
[ngClass]="{'table-primary' : isActive(eperson) | async}">
<td>{{eperson.id}}</td>
<td>{{eperson.name}}</td>
<td>{{eperson.email}}</td>
<tr *ngFor="let epersonDto of (ePeopleDto$ | async)?.page"
[ngClass]="{'table-primary' : isActive(epersonDto.eperson) | async}">
<td>{{epersonDto.eperson.id}}</td>
<td>{{epersonDto.eperson.name}}</td>
<td>{{epersonDto.eperson.email}}</td>
<td>
<div class="btn-group edit-field">
<button (click)="toggleEditEPerson(eperson)"
<button class="delete-button" (click)="toggleEditEPerson(epersonDto.eperson)"
class="btn btn-outline-primary btn-sm access-control-editEPersonButton"
title="{{labelPrefix + 'table.edit.buttons.edit' | translate: {name: eperson.name} }}">
title="{{labelPrefix + 'table.edit.buttons.edit' | translate: {name: epersonDto.eperson.name} }}">
<i class="fas fa-edit fa-fw"></i>
</button>
<button (click)="deleteEPerson(eperson)"
<button [disabled]="!epersonDto.ableToDelete" (click)="deleteEPerson(epersonDto.eperson)"
class="btn btn-outline-danger btn-sm access-control-deleteEPersonButton"
title="{{labelPrefix + 'table.edit.buttons.remove' | translate: {name: eperson.name} }}">
title="{{labelPrefix + 'table.edit.buttons.remove' | translate: {name: epersonDto.eperson.name} }}">
<i class="fas fa-trash-alt fa-fw"></i>
</button>
</div>
@@ -85,7 +85,7 @@
</ds-pagination>
<div *ngIf="(ePeople | async)?.payload?.totalElements == 0" class="alert alert-info w-100 mb-2" role="alert">
<div *ngIf="(pageInfoState$ | async)?.totalElements == 0" class="alert alert-info w-100 mb-2" role="alert">
{{labelPrefix + 'no-items' | translate}}
</div>
</div>

View File

@@ -24,6 +24,8 @@ import { getMockTranslateService } from '../../../shared/mocks/translate.service
import { TranslateLoaderMock } from '../../../shared/mocks/translate-loader.mock';
import { NotificationsServiceStub } from '../../../shared/testing/notifications-service.stub';
import { RouterStub } from '../../../shared/testing/router.stub';
import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service';
import { RequestService } from '../../../core/data/request.service';
describe('EPeopleRegistryComponent', () => {
let component: EPeopleRegistryComponent;
@@ -33,6 +35,8 @@ describe('EPeopleRegistryComponent', () => {
let mockEPeople;
let ePersonDataServiceStub: any;
let authorizationService: AuthorizationDataService;
let modalService;
beforeEach(async(() => {
mockEPeople = [EPersonMock, EPersonMock2];
@@ -82,6 +86,9 @@ describe('EPeopleRegistryComponent', () => {
return '/admin/access-control/epeople';
}
};
authorizationService = jasmine.createSpyObj('authorizationService', {
isAuthorized: observableOf(true)
});
builderService = getMockFormBuilderService();
translateService = getMockTranslateService();
TestBed.configureTestingModule({
@@ -94,11 +101,13 @@ describe('EPeopleRegistryComponent', () => {
}),
],
declarations: [EPeopleRegistryComponent],
providers: [EPeopleRegistryComponent,
providers: [
{ provide: EPersonDataService, useValue: ePersonDataServiceStub },
{ provide: NotificationsService, useValue: new NotificationsServiceStub() },
{ provide: AuthorizationDataService, useValue: authorizationService },
{ provide: FormBuilderService, useValue: builderService },
{ provide: Router, useValue: new RouterStub() },
{ provide: RequestService, useValue: jasmine.createSpyObj('requestService', ['removeByHrefSubstring'])}
],
schemas: [NO_ERRORS_SCHEMA]
}).compileComponents();
@@ -107,12 +116,14 @@ describe('EPeopleRegistryComponent', () => {
beforeEach(() => {
fixture = TestBed.createComponent(EPeopleRegistryComponent);
component = fixture.componentInstance;
modalService = (component as any).modalService;
spyOn(modalService, 'open').and.returnValue(Object.assign({ componentInstance: Object.assign({ response: observableOf(true) }) }));
fixture.detectChanges();
});
it('should create EPeopleRegistryComponent', inject([EPeopleRegistryComponent], (comp: EPeopleRegistryComponent) => {
expect(comp).toBeDefined();
}));
it('should create EPeopleRegistryComponent', () => {
expect(component).toBeDefined();
});
it('should display list of ePeople', () => {
const ePeopleIdsFound = fixture.debugElement.queryAll(By.css('#epeople tr td:first-child'));
@@ -215,4 +226,20 @@ describe('EPeopleRegistryComponent', () => {
});
});
describe('delete EPerson button when the isAuthorized returns false', () => {
let ePeopleDeleteButton;
beforeEach(() => {
authorizationService = jasmine.createSpyObj('authorizationService', {
isAuthorized: observableOf(false)
});
});
it ('should be disabled', () => {
ePeopleDeleteButton = fixture.debugElement.queryAll(By.css('#epeople tr td div button.delete-button'));
ePeopleDeleteButton.forEach((deleteButton) => {
expect(deleteButton.nativeElement.disabled).toBe(true);
});
})
})
});

View File

@@ -2,9 +2,9 @@ import { Component, OnDestroy, OnInit } from '@angular/core';
import { FormBuilder } from '@angular/forms';
import { Router } from '@angular/router';
import { TranslateService } from '@ngx-translate/core';
import { Observable } from 'rxjs';
import { BehaviorSubject, combineLatest, Observable } from 'rxjs';
import { Subscription } from 'rxjs/internal/Subscription';
import { map, take } from 'rxjs/operators';
import { map, switchMap, take } from 'rxjs/operators';
import { PaginatedList } from '../../../core/data/paginated-list';
import { RemoteData } from '../../../core/data/remote-data';
import { EPersonDataService } from '../../../core/eperson/eperson-data.service';
@@ -12,6 +12,16 @@ import { EPerson } from '../../../core/eperson/models/eperson.model';
import { hasValue } from '../../../shared/empty.util';
import { NotificationsService } from '../../../shared/notifications/notifications.service';
import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model';
import { EpersonDtoModel } from '../../../core/eperson/models/eperson-dto.model';
import { FeatureID } from '../../../core/data/feature-authorization/feature-id';
import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service';
import { getAllSucceededRemoteDataPayload } from '../../../core/shared/operators';
import { ErrorResponse, RestResponse } from '../../../core/cache/response.models';
import { ConfirmationModalComponent } from '../../../shared/confirmation-modal/confirmation-modal.component';
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
import { RequestService } from '../../../core/data/request.service';
import { filter } from 'rxjs/internal/operators/filter';
import { PageInfo } from '../../../core/shared/page-info.model';
@Component({
selector: 'ds-epeople-registry',
@@ -28,7 +38,17 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy {
/**
* A list of all the current EPeople within the repository or the result of the search
*/
ePeople: Observable<RemoteData<PaginatedList<EPerson>>>;
ePeople$: BehaviorSubject<RemoteData<PaginatedList<EPerson>>> = new BehaviorSubject<RemoteData<PaginatedList<EPerson>>>({} as any);
/**
* A BehaviorSubject with the list of EpersonDtoModel objects made from the EPeople in the repository or
* as the result of the search
*/
ePeopleDto$: BehaviorSubject<PaginatedList<EpersonDtoModel>> = new BehaviorSubject<PaginatedList<EpersonDtoModel>>({} as any);
/**
* An observable for the pageInfo, needed to pass to the pagination component
*/
pageInfoState$: BehaviorSubject<PageInfo> = new BehaviorSubject<PageInfo>(undefined);
/**
* Pagination config used to display the list of epeople
@@ -59,8 +79,11 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy {
constructor(private epersonService: EPersonDataService,
private translateService: TranslateService,
private notificationsService: NotificationsService,
private authorizationService: AuthorizationDataService,
private formBuilder: FormBuilder,
private router: Router) {
private router: Router,
private modalService: NgbModal,
public requestService: RequestService) {
this.currentSearchQuery = '';
this.currentSearchScope = 'metadata';
this.searchForm = this.formBuilder.group(({
@@ -70,6 +93,13 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy {
}
ngOnInit() {
this.initialisePage();
}
/**
* This method will initialise the page
*/
initialisePage() {
this.isEPersonFormShown = false;
this.search({ scope: this.currentSearchScope, query: this.currentSearchQuery });
this.subs.push(this.epersonService.getActiveEPerson().subscribe((eperson: EPerson) => {
@@ -84,18 +114,10 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy {
* @param event
*/
onPageChange(event) {
this.config.currentPage = event;
this.search({ scope: this.currentSearchScope, query: this.currentSearchQuery })
}
/**
* Force-update the list of EPeople by first clearing the cache related to EPeople, then performing
* a new REST call
*/
public forceUpdateEPeople() {
this.epersonService.clearEPersonRequests();
this.isEPersonFormShown = false;
this.search({ query: '', scope: 'metadata' })
if (this.config.currentPage !== event) {
this.config.currentPage = event;
this.search({ scope: this.currentSearchScope, query: this.currentSearchQuery })
}
}
/**
@@ -115,10 +137,33 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy {
this.currentSearchScope = scope;
this.config.currentPage = 1;
}
this.ePeople = this.epersonService.searchByScope(this.currentSearchScope, this.currentSearchQuery, {
this.subs.push(this.epersonService.searchByScope(this.currentSearchScope, this.currentSearchQuery, {
currentPage: this.config.currentPage,
elementsPerPage: this.config.pageSize
});
}).subscribe((peopleRD) => {
this.ePeople$.next(peopleRD)
}
));
this.subs.push(this.ePeople$.pipe(
getAllSucceededRemoteDataPayload(),
switchMap((epeople) => {
return combineLatest(...epeople.page.map((eperson) => {
return this.authorizationService.isAuthorized(FeatureID.CanDelete, hasValue(eperson) ? eperson.self : undefined).pipe(
map((authorized) => {
const epersonDtoModel: EpersonDtoModel = new EpersonDtoModel();
epersonDtoModel.ableToDelete = authorized;
epersonDtoModel.eperson = eperson;
return epersonDtoModel;
})
);
})).pipe(map((dtos: EpersonDtoModel[]) => {
return new PaginatedList(epeople.pageInfo, dtos);
}))
})).subscribe((value) => {
this.ePeopleDto$.next(value);
this.pageInfoState$.next(value.pageInfo);
}));
}
/**
@@ -160,16 +205,26 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy {
*/
deleteEPerson(ePerson: EPerson) {
if (hasValue(ePerson.id)) {
this.epersonService.deleteEPerson(ePerson).pipe(take(1)).subscribe((success: boolean) => {
if (success) {
this.notificationsService.success(this.translateService.get(this.labelPrefix + 'notification.deleted.success', { name: ePerson.name }));
this.forceUpdateEPeople();
} else {
this.notificationsService.error(this.translateService.get(this.labelPrefix + 'notification.deleted.failure', { name: ePerson.name }));
}
this.epersonService.cancelEditEPerson();
this.isEPersonFormShown = false;
})
const modalRef = this.modalService.open(ConfirmationModalComponent);
modalRef.componentInstance.dso = ePerson;
modalRef.componentInstance.headerLabel = 'confirmation-modal.delete-eperson.header';
modalRef.componentInstance.infoLabel = 'confirmation-modal.delete-eperson.info';
modalRef.componentInstance.cancelLabel = 'confirmation-modal.delete-eperson.cancel';
modalRef.componentInstance.confirmLabel = 'confirmation-modal.delete-eperson.confirm';
modalRef.componentInstance.response.pipe(take(1)).subscribe((confirm: boolean) => {
if (confirm) {
if (hasValue(ePerson.id)) {
this.epersonService.deleteEPerson(ePerson).pipe(take(1)).subscribe((restResponse: RestResponse) => {
if (restResponse.isSuccessful) {
this.notificationsService.success(this.translateService.get(this.labelPrefix + 'notification.deleted.success', { name: ePerson.name }));
this.reset();
} else {
const errorResponse = restResponse as ErrorResponse;
this.notificationsService.error('Error occured when trying to delete EPerson with id: ' + ePerson.id + ' with code: ' + errorResponse.statusCode + ' and message: ' + errorResponse.errorMessage);
}
})
}}
});
}
}
@@ -177,6 +232,10 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy {
* Unsub all subscriptions
*/
ngOnDestroy(): void {
this.cleanupSubscribes();
}
cleanupSubscribes() {
this.subs.filter((sub) => hasValue(sub)).forEach((sub) => sub.unsubscribe());
}
@@ -199,4 +258,18 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy {
});
this.search({ query: '' });
}
/**
* This method will ensure that the page gets reset and that the cache is cleared
*/
reset() {
this.epersonService.getBrowseEndpoint().pipe(
switchMap((href) => this.requestService.removeByHrefSubstring(href)),
filter((isCached) => isCached),
take(1)
).subscribe(() => {
this.cleanupSubscribes();
this.initialisePage();
});
}
}

View File

@@ -17,7 +17,7 @@
<button class="btn btn-light" [disabled]="!(canReset$ | async)">
<i class="fa fa-key"></i> {{'admin.access-control.epeople.actions.reset' | translate}}
</button>
<button class="btn btn-light" [disabled]="!(canDelete$ | async)">
<button class="btn btn-light delete-button" [disabled]="!(canDelete$ | async)" (click)="delete()">
<i class="fa fa-trash"></i> {{'admin.access-control.epeople.actions.delete' | translate}}
</button>
<button *ngIf="!isImpersonated" class="btn btn-light" [ngClass]="{'d-none' : !(canImpersonate$ | async)}" (click)="impersonate()">

View File

@@ -1,34 +1,25 @@
import { HttpClient } from '@angular/common/http';
import { Store } from '@ngrx/store';
import { of as observableOf } from 'rxjs';
import { CommonModule } from '@angular/common';
import { NO_ERRORS_SCHEMA } from '@angular/core';
import { async, ComponentFixture, inject, TestBed } from '@angular/core/testing';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { BrowserModule } from '@angular/platform-browser';
import { BrowserModule, By } from '@angular/platform-browser';
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
import { TranslateLoader, TranslateModule, TranslateService } from '@ngx-translate/core';
import { TranslateLoader, TranslateModule } from '@ngx-translate/core';
import { Observable } from 'rxjs/internal/Observable';
import { RemoteDataBuildService } from '../../../../core/cache/builders/remote-data-build.service';
import { ObjectCacheService } from '../../../../core/cache/object-cache.service';
import { RestResponse } from '../../../../core/cache/response.models';
import { DSOChangeAnalyzer } from '../../../../core/data/dso-change-analyzer.service';
import { PaginatedList } from '../../../../core/data/paginated-list';
import { RemoteData } from '../../../../core/data/remote-data';
import { FindListOptions } from '../../../../core/data/request.models';
import { EPersonDataService } from '../../../../core/eperson/eperson-data.service';
import { EPerson } from '../../../../core/eperson/models/eperson.model';
import { HALEndpointService } from '../../../../core/shared/hal-endpoint.service';
import { PageInfo } from '../../../../core/shared/page-info.model';
import { UUIDService } from '../../../../core/shared/uuid.service';
import { FormBuilderService } from '../../../../shared/form/builder/form-builder.service';
import { NotificationsService } from '../../../../shared/notifications/notifications.service';
import { EPeopleRegistryComponent } from '../epeople-registry.component';
import { EPersonFormComponent } from './eperson-form.component';
import { EPersonMock, EPersonMock2 } from '../../../../shared/testing/eperson.mock';
import { createSuccessfulRemoteDataObject$ } from '../../../../shared/remote-data.utils';
import { getMockFormBuilderService } from '../../../../shared/mocks/form-builder-service.mock';
import { getMockTranslateService } from '../../../../shared/mocks/translate.service.mock';
import { NotificationsServiceStub } from '../../../../shared/testing/notifications-service.stub';
import { TranslateLoaderMock } from '../../../../shared/mocks/translate-loader.mock';
import { AuthService } from '../../../../core/auth/auth.service';
@@ -36,11 +27,11 @@ import { AuthServiceStub } from '../../../../shared/testing/auth-service.stub';
import { AuthorizationDataService } from '../../../../core/data/feature-authorization/authorization-data.service';
import { GroupDataService } from '../../../../core/eperson/group-data.service';
import { createPaginatedList } from '../../../../shared/testing/utils.test';
import { RequestService } from '../../../../core/data/request.service';
describe('EPersonFormComponent', () => {
let component: EPersonFormComponent;
let fixture: ComponentFixture<EPersonFormComponent>;
let translateService: TranslateService;
let builderService: FormBuilderService;
let mockEPeople;
@@ -111,7 +102,6 @@ describe('EPersonFormComponent', () => {
}
};
builderService = getMockFormBuilderService();
translateService = getMockTranslateService();
authService = new AuthServiceStub();
authorizationService = jasmine.createSpyObj('authorizationService', {
isAuthorized: observableOf(true)
@@ -129,22 +119,15 @@ describe('EPersonFormComponent', () => {
}
}),
],
declarations: [EPeopleRegistryComponent, EPersonFormComponent],
providers: [EPersonFormComponent,
declarations: [EPersonFormComponent],
providers: [
{ provide: EPersonDataService, useValue: ePersonDataServiceStub },
{ provide: NotificationsService, useValue: new NotificationsServiceStub() },
{ provide: GroupDataService, useValue: groupsDataService },
{ provide: FormBuilderService, useValue: builderService },
{ provide: DSOChangeAnalyzer, useValue: {} },
{ provide: HttpClient, useValue: {} },
{ provide: ObjectCacheService, useValue: {} },
{ provide: UUIDService, useValue: {} },
{ provide: Store, useValue: {} },
{ provide: RemoteDataBuildService, useValue: {} },
{ provide: HALEndpointService, useValue: {} },
{ provide: NotificationsService, useValue: new NotificationsServiceStub() },
{ provide: AuthService, useValue: authService },
{ provide: AuthorizationDataService, useValue: authorizationService },
{ provide: GroupDataService, useValue: groupsDataService },
EPeopleRegistryComponent
{ provide: RequestService, useValue: jasmine.createSpyObj('requestService', ['removeByHrefSubstring'])}
],
schemas: [NO_ERRORS_SCHEMA]
}).compileComponents();
@@ -156,9 +139,9 @@ describe('EPersonFormComponent', () => {
fixture.detectChanges();
});
it('should create EPersonFormComponent', inject([EPersonFormComponent], (comp: EPersonFormComponent) => {
expect(comp).toBeDefined();
}));
it('should create EPersonFormComponent', () => {
expect(component).toBeDefined();
});
describe('when submitting the form', () => {
let firstName;
@@ -283,4 +266,53 @@ describe('EPersonFormComponent', () => {
});
});
describe('delete', () => {
let ePersonId;
let eperson: EPerson;
let modalService;
beforeEach(() => {
spyOn(authService, 'impersonate').and.callThrough();
ePersonId = 'testEPersonId';
eperson = EPersonMock;
component.epersonInitial = eperson;
component.canDelete$ = observableOf(true);
spyOn(component.epersonService, 'getActiveEPerson').and.returnValue(observableOf(eperson));
modalService = (component as any).modalService;
spyOn(modalService, 'open').and.returnValue(Object.assign({ componentInstance: Object.assign({ response: observableOf(true) }) }));
fixture.detectChanges()
});
it ('the delete button should be active if the eperson can be deleted', () => {
const deleteButton = fixture.debugElement.query(By.css('.delete-button'));
expect(deleteButton.nativeElement.disabled).toBe(false);
});
it ('the delete button should be disabled if the eperson cannot be deleted', () => {
component.canDelete$ = observableOf(false);
fixture.detectChanges()
const deleteButton = fixture.debugElement.query(By.css('.delete-button'));
expect(deleteButton.nativeElement.disabled).toBe(true);
});
it ('should call the epersonFormComponent delete when clicked on the button' , () => {
spyOn(component, 'delete').and.stub();
spyOn(component.epersonService, 'deleteEPerson').and.returnValue(observableOf(new RestResponse(true, 204, 'No Content')));
const deleteButton = fixture.debugElement.query(By.css('.delete-button'));
deleteButton.triggerEventHandler('click', null);
expect(component.delete).toHaveBeenCalled();
});
it ('should call the epersonService delete when clicked on the button' , () => {
// ePersonDataServiceStub.activeEPerson = eperson;
spyOn(component.epersonService, 'deleteEPerson').and.returnValue(observableOf(new RestResponse(true, 204, 'No Content')));
const deleteButton = fixture.debugElement.query(By.css('.delete-button'));
expect(deleteButton.nativeElement.disabled).toBe(false);
deleteButton.triggerEventHandler('click', null);
fixture.detectChanges()
expect(component.epersonService.deleteEPerson).toHaveBeenCalledWith(eperson);
});
})
});

View File

@@ -25,6 +25,9 @@ import { PaginationComponentOptions } from '../../../../shared/pagination/pagina
import { AuthService } from '../../../../core/auth/auth.service';
import { AuthorizationDataService } from '../../../../core/data/feature-authorization/authorization-data.service';
import { FeatureID } from '../../../../core/data/feature-authorization/feature-id';
import { ConfirmationModalComponent } from '../../../../shared/confirmation-modal/confirmation-modal.component';
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
import { RequestService } from '../../../../core/data/request.service';
@Component({
selector: 'ds-eperson-form',
@@ -116,9 +119,8 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
/**
* Observable whether or not the admin is allowed to delete the EPerson
* TODO: Initialize the observable once the REST API supports this (currently hardcoded to return false)
*/
canDelete$: Observable<boolean> = of(false);
canDelete$: Observable<boolean>;
/**
* Observable whether or not the admin is allowed to impersonate the EPerson
@@ -160,7 +162,9 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
private translateService: TranslateService,
private notificationsService: NotificationsService,
private authService: AuthService,
private authorizationService: AuthorizationDataService) {
private authorizationService: AuthorizationDataService,
private modalService: NgbModal,
public requestService: RequestService) {
this.subs.push(this.epersonService.getActiveEPerson().subscribe((eperson: EPerson) => {
this.epersonInitial = eperson;
if (hasValue(eperson)) {
@@ -170,13 +174,20 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
}
ngOnInit() {
this.initialisePage();
}
/**
* This method will initialise the page
*/
initialisePage() {
combineLatest(
this.translateService.get(`${this.messagePrefix}.firstName`),
this.translateService.get(`${this.messagePrefix}.lastName`),
this.translateService.get(`${this.messagePrefix}.email`),
this.translateService.get(`${this.messagePrefix}.canLogIn`),
this.translateService.get(`${this.messagePrefix}.requireCertificate`),
this.translateService.get(`${this.messagePrefix}.emailHint`),
this.translateService.get(`${this.messagePrefix}.firstName`),
this.translateService.get(`${this.messagePrefix}.lastName`),
this.translateService.get(`${this.messagePrefix}.email`),
this.translateService.get(`${this.messagePrefix}.canLogIn`),
this.translateService.get(`${this.messagePrefix}.requireCertificate`),
this.translateService.get(`${this.messagePrefix}.emailHint`),
).subscribe(([firstName, lastName, email, canLogIn, requireCertificate, emailHint]) => {
this.firstName = new DynamicInputModel({
id: 'firstName',
@@ -208,19 +219,19 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
hint: emailHint
});
this.canLogIn = new DynamicCheckboxModel(
{
id: 'canLogIn',
label: canLogIn,
name: 'canLogIn',
value: (this.epersonInitial != null ? this.epersonInitial.canLogIn : true)
});
{
id: 'canLogIn',
label: canLogIn,
name: 'canLogIn',
value: (this.epersonInitial != null ? this.epersonInitial.canLogIn : true)
});
this.requireCertificate = new DynamicCheckboxModel(
{
id: 'requireCertificate',
label: requireCertificate,
name: 'requireCertificate',
value: (this.epersonInitial != null ? this.epersonInitial.requireCertificate : false)
});
{
id: 'requireCertificate',
label: requireCertificate,
name: 'requireCertificate',
value: (this.epersonInitial != null ? this.epersonInitial.requireCertificate : false)
});
this.formModel = [
this.firstName,
this.lastName,
@@ -245,7 +256,10 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
});
}));
this.canImpersonate$ = this.epersonService.getActiveEPerson().pipe(
switchMap((eperson) => this.authorizationService.isAuthorized(FeatureID.LoginOnBehalfOf, hasValue(eperson) ? eperson.self : undefined))
switchMap((eperson) => this.authorizationService.isAuthorized(FeatureID.LoginOnBehalfOf, hasValue(eperson) ? eperson.self : undefined))
);
this.canDelete$ = this.epersonService.getActiveEPerson().pipe(
switchMap((eperson) => this.authorizationService.isAuthorized(FeatureID.CanDelete, hasValue(eperson) ? eperson.self : undefined))
);
});
}
@@ -405,6 +419,35 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
this.isImpersonated = true;
}
/**
* Deletes the EPerson from the Repository. The EPerson will be the only that this form is showing.
* It'll either show a success or error message depending on whether the delete was successful or not.
*/
delete() {
this.epersonService.getActiveEPerson().pipe(take(1)).subscribe((eperson: EPerson) => {
const modalRef = this.modalService.open(ConfirmationModalComponent);
modalRef.componentInstance.dso = eperson;
modalRef.componentInstance.headerLabel = 'confirmation-modal.delete-eperson.header';
modalRef.componentInstance.infoLabel = 'confirmation-modal.delete-eperson.info';
modalRef.componentInstance.cancelLabel = 'confirmation-modal.delete-eperson.cancel';
modalRef.componentInstance.confirmLabel = 'confirmation-modal.delete-eperson.confirm';
modalRef.componentInstance.response.pipe(take(1)).subscribe((confirm: boolean) => {
if (confirm) {
if (hasValue(eperson.id)) {
this.epersonService.deleteEPerson(eperson).pipe(take(1)).subscribe((restResponse: RestResponse) => {
if (restResponse.isSuccessful) {
this.notificationsService.success(this.translateService.get(this.labelPrefix + 'notification.deleted.success', { name: eperson.name }));
this.reset();
} else {
this.notificationsService.error('Error occured when trying to delete EPerson with id: ' + eperson.id + ' with code: ' + restResponse.statusCode + ' and message: ' + restResponse.statusText);
}
this.cancelForm.emit();
})
}}
});
})
}
/**
* Stop impersonating the EPerson
*/
@@ -420,4 +463,14 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
this.onCancel();
this.subs.filter((sub) => hasValue(sub)).forEach((sub) => sub.unsubscribe());
}
/**
* This method will ensure that the page gets reset and that the cache is cleared
*/
reset() {
this.epersonService.getActiveEPerson().pipe(take(1)).subscribe((eperson: EPerson) => {
this.requestService.removeByHrefSubstring(eperson.self);
});
this.initialisePage();
}
}

View File

@@ -20,6 +20,8 @@ import {
COLLECTION_CREATE_PATH
} from './collection-page-routing-paths';
import { CollectionPageAdministratorGuard } from './collection-page-administrator.guard';
import { MenuItemType } from '../shared/menu/initial-menus-state';
import { LinkMenuItemModel } from '../shared/menu/menu-item/models/link.model';
@NgModule({
imports: [
@@ -69,7 +71,21 @@ import { CollectionPageAdministratorGuard } from './collection-page-administrato
pathMatch: 'full',
canActivate: [AuthenticatedGuard]
}
]
],
data: {
menu: {
public: [{
id: 'statistics_collection_:id',
active: true,
visible: true,
model: {
type: MenuItemType.LINK,
text: 'menu.section.statistics',
link: 'statistics/collections/:id/',
} as LinkMenuItemModel,
}],
},
},
},
])
],

View File

@@ -12,6 +12,8 @@ import { DSOBreadcrumbsService } from '../core/breadcrumbs/dso-breadcrumbs.servi
import { LinkService } from '../core/cache/builders/link.service';
import { COMMUNITY_EDIT_PATH, COMMUNITY_CREATE_PATH } from './community-page-routing-paths';
import { CommunityPageAdministratorGuard } from './community-page-administrator.guard';
import { MenuItemType } from '../shared/menu/initial-menus-state';
import { LinkMenuItemModel } from '../shared/menu/menu-item/models/link.model';
@NgModule({
imports: [
@@ -45,7 +47,21 @@ import { CommunityPageAdministratorGuard } from './community-page-administrator.
component: CommunityPageComponent,
pathMatch: 'full',
}
]
],
data: {
menu: {
public: [{
id: 'statistics_community_:id',
active: true,
visible: true,
model: {
type: MenuItemType.LINK,
text: 'menu.section.statistics',
link: 'statistics/communities/:id/',
} as LinkMenuItemModel,
}],
},
},
},
])
],

View File

@@ -3,6 +3,8 @@ import { RouterModule } from '@angular/router';
import { HomePageComponent } from './home-page.component';
import { HomePageResolver } from './home-page.resolver';
import { MenuItemType } from '../shared/menu/initial-menus-state';
import { LinkMenuItemModel } from '../shared/menu/menu-item/models/link.model';
@NgModule({
imports: [
@@ -11,7 +13,21 @@ import { HomePageResolver } from './home-page.resolver';
path: '',
component: HomePageComponent,
pathMatch: 'full',
data: {title: 'home.title'},
data: {
title: 'home.title',
menu: {
public: [{
id: 'statistics_site',
active: true,
visible: true,
model: {
type: MenuItemType.LINK,
text: 'menu.section.statistics',
link: 'statistics',
} as LinkMenuItemModel,
}],
},
},
resolve: {
site: HomePageResolver
}

View File

@@ -22,13 +22,14 @@ import { FieldChangeType } from '../../../core/data/object-updates/object-update
import { MetadatumViewModel } from '../../../core/shared/metadata.models';
import { RegistryService } from '../../../core/registry/registry.service';
import { PaginatedList } from '../../../core/data/paginated-list';
import { Metadata } from '../../../core/shared/metadata.utils';
import { MetadataSchema } from '../../../core/metadata/metadata-schema.model';
import { MetadataField } from '../../../core/metadata/metadata-field.model';
import {
createSuccessfulRemoteDataObject,
createSuccessfulRemoteDataObject$
} from '../../../shared/remote-data.utils';
import { ObjectCacheService } from '../../../core/cache/object-cache.service';
import { DSOSuccessResponse } from '../../../core/cache/response.models';
let comp: any;
let fixture: ComponentFixture<ItemMetadataComponent>;
@@ -43,6 +44,7 @@ const router = new RouterStub();
let metadataFieldService;
let paginatedMetadataFields;
let routeStub;
let objectCacheService;
const mdSchema = Object.assign(new MetadataSchema(), { prefix: 'dc' });
const mdField1 = Object.assign(new MetadataField(), {
@@ -101,6 +103,8 @@ const fieldUpdate3 = {
changeType: undefined
};
const operation1 = { op: 'remove', path: '/metadata/dc.title/1' };
let scheduler: TestScheduler;
let item;
describe('ItemMetadataComponent', () => {
@@ -119,7 +123,9 @@ describe('ItemMetadataComponent', () => {
;
itemService = jasmine.createSpyObj('itemService', {
update: createSuccessfulRemoteDataObject$(item),
commitUpdates: {}
commitUpdates: {},
patch: observableOf(new DSOSuccessResponse(['item-selflink'], 200, 'OK')),
findByHref: createSuccessfulRemoteDataObject$(item)
});
routeStub = {
data: observableOf({}),
@@ -148,9 +154,13 @@ describe('ItemMetadataComponent', () => {
getLastModified: observableOf(date),
hasUpdates: observableOf(true),
isReinstatable: observableOf(false), // should always return something --> its in ngOnInit
isValidPage: observableOf(true)
isValidPage: observableOf(true),
createPatch: observableOf([
operation1
])
}
);
objectCacheService = jasmine.createSpyObj('objectCacheService', ['addPatch']);
TestBed.configureTestingModule({
imports: [SharedModule, TranslateModule.forRoot()],
@@ -162,6 +172,7 @@ describe('ItemMetadataComponent', () => {
{ provide: ActivatedRoute, useValue: routeStub },
{ provide: NotificationsService, useValue: notificationsService },
{ provide: RegistryService, useValue: metadataFieldService },
{ provide: ObjectCacheService, useValue: objectCacheService },
], schemas: [
NO_ERRORS_SCHEMA
]
@@ -215,8 +226,8 @@ describe('ItemMetadataComponent', () => {
});
it('it should call reinstateFieldUpdates on the objectUpdatesService with the correct url and metadata', () => {
expect(objectUpdatesService.getUpdatedFields).toHaveBeenCalledWith(url, comp.item.metadataAsList);
expect(itemService.update).toHaveBeenCalledWith(Object.assign(comp.item, { metadata: Metadata.toMetadataMap(comp.item.metadataAsList) }));
expect(objectUpdatesService.createPatch).toHaveBeenCalledWith(url);
expect(itemService.patch).toHaveBeenCalledWith(comp.item, [ operation1 ]);
expect(objectUpdatesService.getFieldUpdates).toHaveBeenCalledWith(url, comp.item.metadataAsList);
});
});

View File

@@ -4,19 +4,19 @@ import { ItemDataService } from '../../../core/data/item-data.service';
import { ObjectUpdatesService } from '../../../core/data/object-updates/object-updates.service';
import { ActivatedRoute, Router } from '@angular/router';
import { cloneDeep } from 'lodash';
import { Observable } from 'rxjs';
import { Identifiable } from '../../../core/data/object-updates/object-updates.reducer';
import { first, switchMap, tap } from 'rxjs/operators';
import { getSucceededRemoteData } from '../../../core/shared/operators';
import { RemoteData } from '../../../core/data/remote-data';
import { NotificationsService } from '../../../shared/notifications/notifications.service';
import { TranslateService } from '@ngx-translate/core';
import { MetadataValue, MetadatumViewModel } from '../../../core/shared/metadata.models';
import { Metadata } from '../../../core/shared/metadata.utils';
import { AbstractItemUpdateComponent } from '../abstract-item-update/abstract-item-update.component';
import { UpdateDataService } from '../../../core/data/update-data.service';
import { hasNoValue, hasValue } from '../../../shared/empty.util';
import { hasNoValue, hasValue, isNotEmpty } from '../../../shared/empty.util';
import { AlertType } from '../../../shared/alert/aletr-type';
import { Operation } from 'fast-json-patch';
import { METADATA_PATCH_OPERATION_SERVICE_TOKEN } from '../../../core/data/object-updates/patch-operation-service/metadata-patch-operation.service';
import { DSOSuccessResponse, ErrorResponse } from '../../../core/cache/response.models';
@Component({
selector: 'ds-item-metadata',
@@ -87,7 +87,7 @@ export class ItemMetadataComponent extends AbstractItemUpdateComponent {
* Sends all initial values of this item to the object updates service
*/
public initializeOriginalFields() {
this.objectUpdatesService.initialize(this.url, this.item.metadataAsList, this.item.lastModified);
this.objectUpdatesService.initialize(this.url, this.item.metadataAsList, this.item.lastModified, METADATA_PATCH_OPERATION_SERVICE_TOKEN);
}
/**
@@ -97,15 +97,23 @@ export class ItemMetadataComponent extends AbstractItemUpdateComponent {
public submit() {
this.isValid().pipe(first()).subscribe((isValid) => {
if (isValid) {
const metadata$: Observable<Identifiable[]> = this.objectUpdatesService.getUpdatedFields(this.url, this.item.metadataAsList) as Observable<MetadatumViewModel[]>;
metadata$.pipe(
this.objectUpdatesService.createPatch(this.url).pipe(
first(),
switchMap((metadata: MetadatumViewModel[]) => {
const updatedItem: Item = Object.assign(cloneDeep(this.item), { metadata: Metadata.toMetadataMap(metadata) });
return this.updateService.update(updatedItem);
}),
tap(() => this.updateService.commitUpdates()),
getSucceededRemoteData()
switchMap((patch: Operation[]) => {
return this.updateService.patch(this.item, patch).pipe(
tap((response) => {
if (!response.isSuccessful) {
this.notificationsService.error(this.getNotificationTitle('error'), (response as ErrorResponse).errorMessage);
}
}),
switchMap((response: DSOSuccessResponse) => {
if (isNotEmpty(response.resourceSelfLinks)) {
return this.itemService.findByHref(response.resourceSelfLinks[0]);
}
}),
getSucceededRemoteData()
);
})
).subscribe(
(rd: RemoteData<Item>) => {
this.item = rd.payload;

View File

@@ -1,87 +1,87 @@
<ds-metadata-field-wrapper [label]="label | translate">
<div *ngVar="(originals$ | async)?.payload as originals">
<h5 class="simple-view-element-header">{{"item.page.filesection.original.bundle" | translate}}</h5>
<ds-pagination *ngIf="originals?.page?.length > 0"
[hideGear]="true"
[hidePagerWhenSinglePage]="true"
[paginationOptions]="originalOptions"
[pageInfoState]="originals"
[collectionSize]="originals?.totalElements"
[disableRouteParameterUpdate]="true"
(pageChange)="switchOriginalPage($event)">
<div *ngIf="hasValuesInBundle(originals)">
<h5 class="simple-view-element-header">{{"item.page.filesection.original.bundle" | translate}}</h5>
<ds-pagination *ngIf="originals?.page?.length > 0"
[hideGear]="true"
[hidePagerWhenSinglePage]="true"
[paginationOptions]="originalOptions"
[pageInfoState]="originals"
[collectionSize]="originals?.totalElements"
[disableRouteParameterUpdate]="true"
(pageChange)="switchOriginalPage($event)">
<div class="file-section row" *ngFor="let file of originals?.page;">
<div class="col-3">
<ds-thumbnail [thumbnail]="(file.thumbnail | async)?.payload"></ds-thumbnail>
</div>
<div class="col-7">
<dl class="row">
<dt class="col-md-4">{{"item.page.filesection.name" | translate}}</dt>
<dd class="col-md-8">{{file.name}}</dd>
<div class="file-section row" *ngFor="let file of originals?.page;">
<div class="col-3">
<ds-thumbnail [thumbnail]="(file.thumbnail | async)?.payload"></ds-thumbnail>
<dt class="col-md-4">{{"item.page.filesection.size" | translate}}</dt>
<dd class="col-md-8">{{(file.sizeBytes) | dsFileSize }}</dd>
<dt class="col-md-4">{{"item.page.filesection.format" | translate}}</dt>
<dd class="col-md-8">{{(file.format | async)?.payload?.description}}</dd>
<dt class="col-md-4">{{"item.page.filesection.description" | translate}}</dt>
<dd class="col-md-8">{{file.firstMetadataValue("dc.description")}}</dd>
</dl>
</div>
<div class="col-2">
<ds-file-download-link [href]="file._links.content.href" [download]="file.name">
{{"item.page.filesection.download" | translate}}
</ds-file-download-link>
</div>
</div>
<div class="col-7">
<dl class="row">
<dt class="col-md-4">{{"item.page.filesection.name" | translate}}</dt>
<dd class="col-md-8">{{file.name}}</dd>
<dt class="col-md-4">{{"item.page.filesection.size" | translate}}</dt>
<dd class="col-md-8">{{(file.sizeBytes) | dsFileSize }}</dd>
<dt class="col-md-4">{{"item.page.filesection.format" | translate}}</dt>
<dd class="col-md-8">{{(file.format | async)?.payload?.description}}</dd>
<dt class="col-md-4">{{"item.page.filesection.description" | translate}}</dt>
<dd class="col-md-8">{{file.firstMetadataValue("dc.description")}}</dd>
</dl>
</div>
<div class="col-2">
<ds-file-download-link [href]="file._links.content.href" [download]="file.name">
{{"item.page.filesection.download" | translate}}
</ds-file-download-link>
</div>
</div>
</ds-pagination>
</ds-pagination>
</div>
</div>
<div *ngVar="(licenses$ | async)?.payload as licenses">
<h5 class="simple-view-element-header">{{"item.page.filesection.license.bundle" | translate}}</h5>
<ds-pagination *ngIf="licenses?.page?.length > 0"
[hideGear]="true"
[hidePagerWhenSinglePage]="true"
[paginationOptions]="licenseOptions"
[pageInfoState]="licenses"
[collectionSize]="licenses?.totalElements"
[disableRouteParameterUpdate]="true"
(pageChange)="switchLicensePage($event)">
<div *ngIf="hasValuesInBundle(licenses)">
<h5 class="simple-view-element-header">{{"item.page.filesection.license.bundle" | translate}}</h5>
<ds-pagination *ngIf="licenses?.page?.length > 0"
[hideGear]="true"
[hidePagerWhenSinglePage]="true"
[paginationOptions]="licenseOptions"
[pageInfoState]="licenses"
[collectionSize]="licenses?.totalElements"
[disableRouteParameterUpdate]="true"
(pageChange)="switchLicensePage($event)">
<div class="file-section row" *ngFor="let file of licenses?.page;">
<div class="col-3">
<ds-thumbnail [thumbnail]="(file.thumbnail | async)?.payload"></ds-thumbnail>
</div>
<div class="col-7">
<dl class="row">
<dt class="col-md-4">{{"item.page.filesection.name" | translate}}</dt>
<dd class="col-md-8">{{file.name}}</dd>
<div class="file-section row" *ngFor="let file of licenses?.page;">
<div class="col-3">
<ds-thumbnail [thumbnail]="(file.thumbnail | async)?.payload"></ds-thumbnail>
<dt class="col-md-4">{{"item.page.filesection.size" | translate}}</dt>
<dd class="col-md-8">{{(file.sizeBytes) | dsFileSize }}</dd>
<dt class="col-md-4">{{"item.page.filesection.format" | translate}}</dt>
<dd class="col-md-8">{{(file.format | async)?.payload?.description}}</dd>
<dt class="col-md-4">{{"item.page.filesection.description" | translate}}</dt>
<dd class="col-md-8">{{file.firstMetadataValue("dc.description")}}</dd>
</dl>
</div>
<div class="col-2">
<ds-file-download-link [href]="file._links.content.href" [download]="file.name">
{{"item.page.filesection.download" | translate}}
</ds-file-download-link>
</div>
</div>
<div class="col-7">
<dl class="row">
<dt class="col-md-4">{{"item.page.filesection.name" | translate}}</dt>
<dd class="col-md-8">{{file.name}}</dd>
<dt class="col-md-4">{{"item.page.filesection.size" | translate}}</dt>
<dd class="col-md-8">{{(file.sizeBytes) | dsFileSize }}</dd>
<dt class="col-md-4">{{"item.page.filesection.format" | translate}}</dt>
<dd class="col-md-8">{{(file.format | async)?.payload?.description}}</dd>
<dt class="col-md-4">{{"item.page.filesection.description" | translate}}</dt>
<dd class="col-md-8">{{file.firstMetadataValue("dc.description")}}</dd>
</dl>
</div>
<div class="col-2">
<ds-file-download-link [href]="file._links.content.href" [download]="file.name">
{{"item.page.filesection.download" | translate}}
</ds-file-download-link>
</div>
</div>
</ds-pagination>
</ds-pagination>
</div>
</div>
</ds-metadata-field-wrapper>

View File

@@ -14,6 +14,8 @@ import {Bitstream} from '../../../../core/shared/bitstream.model';
import {of as observableOf} from 'rxjs';
import {MockBitstreamFormat1} from '../../../../shared/mocks/item.mock';
import {By} from '@angular/platform-browser';
import { NotificationsService } from '../../../../shared/notifications/notifications.service';
import { NotificationsServiceStub } from '../../../../shared/testing/notifications-service.stub';
describe('FullFileSectionComponent', () => {
let comp: FullFileSectionComponent;
@@ -61,7 +63,8 @@ describe('FullFileSectionComponent', () => {
}), BrowserAnimationsModule],
declarations: [FullFileSectionComponent, VarDirective, FileSizePipe, MetadataFieldWrapperComponent],
providers: [
{provide: BitstreamDataService, useValue: bitstreamDataService}
{provide: BitstreamDataService, useValue: bitstreamDataService},
{provide: NotificationsService, useValue: new NotificationsServiceStub()}
],
schemas: [NO_ERRORS_SCHEMA]

View File

@@ -10,6 +10,10 @@ import { PaginationComponentOptions } from '../../../../shared/pagination/pagina
import { PaginatedList } from '../../../../core/data/paginated-list';
import { RemoteData } from '../../../../core/data/remote-data';
import { switchMap } from 'rxjs/operators';
import { NotificationsService } from '../../../../shared/notifications/notifications.service';
import { TranslateService } from '@ngx-translate/core';
import { hasValue, isEmpty } from '../../../../shared/empty.util';
import { tap } from 'rxjs/internal/operators/tap';
/**
* This component renders the file section of the item
@@ -31,14 +35,14 @@ export class FullFileSectionComponent extends FileSectionComponent implements On
licenses$: Observable<RemoteData<PaginatedList<Bitstream>>>;
pageSize = 5;
originalOptions = Object.assign(new PaginationComponentOptions(),{
originalOptions = Object.assign(new PaginationComponentOptions(), {
id: 'original-bitstreams-options',
currentPage: 1,
pageSize: this.pageSize
});
originalCurrentPage$ = new BehaviorSubject<number>(1);
licenseOptions = Object.assign(new PaginationComponentOptions(),{
licenseOptions = Object.assign(new PaginationComponentOptions(), {
id: 'license-bitstreams-options',
currentPage: 1,
pageSize: this.pageSize
@@ -46,9 +50,11 @@ export class FullFileSectionComponent extends FileSectionComponent implements On
licenseCurrentPage$ = new BehaviorSubject<number>(1);
constructor(
bitstreamDataService: BitstreamDataService
bitstreamDataService: BitstreamDataService,
protected notificationsService: NotificationsService,
protected translateService: TranslateService
) {
super(bitstreamDataService);
super(bitstreamDataService, notificationsService, translateService);
}
ngOnInit(): void {
@@ -57,21 +63,33 @@ export class FullFileSectionComponent extends FileSectionComponent implements On
initialize(): void {
this.originals$ = this.originalCurrentPage$.pipe(
switchMap((pageNumber: number) => this.bitstreamDataService.findAllByItemAndBundleName(
this.item,
'ORIGINAL',
{ elementsPerPage: this.pageSize, currentPage: pageNumber },
followLink( 'format')
))
switchMap((pageNumber: number) => this.bitstreamDataService.findAllByItemAndBundleName(
this.item,
'ORIGINAL',
{elementsPerPage: this.pageSize, currentPage: pageNumber},
followLink('format')
)),
tap((rd: RemoteData<PaginatedList<Bitstream>>) => {
if (hasValue(rd.error)) {
this.notificationsService.error(this.translateService.get('file-section.error.header'), `${rd.error.statusCode} ${rd.error.message}`);
}
}
)
);
this.licenses$ = this.licenseCurrentPage$.pipe(
switchMap((pageNumber: number) => this.bitstreamDataService.findAllByItemAndBundleName(
this.item,
'LICENSE',
{ elementsPerPage: this.pageSize, currentPage: pageNumber },
followLink( 'format')
))
switchMap((pageNumber: number) => this.bitstreamDataService.findAllByItemAndBundleName(
this.item,
'LICENSE',
{elementsPerPage: this.pageSize, currentPage: pageNumber},
followLink('format')
)),
tap((rd: RemoteData<PaginatedList<Bitstream>>) => {
if (hasValue(rd.error)) {
this.notificationsService.error(this.translateService.get('file-section.error.header'), `${rd.error.statusCode} ${rd.error.message}`);
}
}
)
);
}
@@ -93,4 +111,8 @@ export class FullFileSectionComponent extends FileSectionComponent implements On
this.licenseOptions.currentPage = page;
this.licenseCurrentPage$.next(page);
}
hasValuesInBundle(bundle: PaginatedList<Bitstream>) {
return hasValue(bundle) && !isEmpty(bundle.page);
}
}

View File

@@ -11,6 +11,8 @@ import { LinkService } from '../core/cache/builders/link.service';
import { UploadBitstreamComponent } from './bitstreams/upload/upload-bitstream.component';
import { UPLOAD_BITSTREAM_PATH, ITEM_EDIT_PATH } from './item-page-routing-paths';
import { ItemPageAdministratorGuard } from './item-page-administrator.guard';
import { MenuItemType } from '../shared/menu/initial-menus-state';
import { LinkMenuItemModel } from '../shared/menu/menu-item/models/link.model';
@NgModule({
imports: [
@@ -43,6 +45,20 @@ import { ItemPageAdministratorGuard } from './item-page-administrator.guard';
canActivate: [AuthenticatedGuard]
}
],
data: {
menu: {
public: [{
id: 'statistics_item_:id',
active: true,
visible: true,
model: {
type: MenuItemType.LINK,
text: 'menu.section.statistics',
link: 'statistics/items/:id/',
} as LinkMenuItemModel,
}],
},
},
}
])
],

View File

@@ -15,6 +15,8 @@ import {FileSizePipe} from '../../../../shared/utils/file-size-pipe';
import {PageInfo} from '../../../../core/shared/page-info.model';
import {MetadataFieldWrapperComponent} from '../../../field-components/metadata-field-wrapper/metadata-field-wrapper.component';
import {createPaginatedList} from '../../../../shared/testing/utils.test';
import { NotificationsService } from '../../../../shared/notifications/notifications.service';
import { NotificationsServiceStub } from '../../../../shared/testing/notifications-service.stub';
describe('FileSectionComponent', () => {
let comp: FileSectionComponent;
@@ -62,7 +64,8 @@ describe('FileSectionComponent', () => {
}), BrowserAnimationsModule],
declarations: [FileSectionComponent, VarDirective, FileSizePipe, MetadataFieldWrapperComponent],
providers: [
{provide: BitstreamDataService, useValue: bitstreamDataService}
{provide: BitstreamDataService, useValue: bitstreamDataService},
{provide: NotificationsService, useValue: new NotificationsServiceStub()}
],
schemas: [NO_ERRORS_SCHEMA]

View File

@@ -4,10 +4,12 @@ import { BitstreamDataService } from '../../../../core/data/bitstream-data.servi
import { Bitstream } from '../../../../core/shared/bitstream.model';
import { Item } from '../../../../core/shared/item.model';
import { filter, takeWhile } from 'rxjs/operators';
import { filter, take } from 'rxjs/operators';
import { RemoteData } from '../../../../core/data/remote-data';
import { hasNoValue, hasValue } from '../../../../shared/empty.util';
import { hasValue } from '../../../../shared/empty.util';
import { PaginatedList } from '../../../../core/data/paginated-list';
import { NotificationsService } from '../../../../shared/notifications/notifications.service';
import { TranslateService } from '@ngx-translate/core';
/**
* This component renders the file section of the item
@@ -36,7 +38,9 @@ export class FileSectionComponent implements OnInit {
pageSize = 5;
constructor(
protected bitstreamDataService: BitstreamDataService
protected bitstreamDataService: BitstreamDataService,
protected notificationsService: NotificationsService,
protected translateService: TranslateService
) {
}
@@ -58,14 +62,21 @@ export class FileSectionComponent implements OnInit {
} else {
this.currentPage++;
}
this.bitstreamDataService.findAllByItemAndBundleName(this.item, 'ORIGINAL', { currentPage: this.currentPage, elementsPerPage: this.pageSize }).pipe(
filter((bitstreamsRD: RemoteData<PaginatedList<Bitstream>>) => hasValue(bitstreamsRD)),
takeWhile((bitstreamsRD: RemoteData<PaginatedList<Bitstream>>) => hasNoValue(bitstreamsRD.payload) && hasNoValue(bitstreamsRD.error), true)
this.bitstreamDataService.findAllByItemAndBundleName(this.item, 'ORIGINAL', {
currentPage: this.currentPage,
elementsPerPage: this.pageSize
}).pipe(
filter((bitstreamsRD: RemoteData<PaginatedList<Bitstream>>) => hasValue(bitstreamsRD) && (hasValue(bitstreamsRD.error) || hasValue(bitstreamsRD.payload))),
take(1),
).subscribe((bitstreamsRD: RemoteData<PaginatedList<Bitstream>>) => {
const current: Bitstream[] = this.bitstreams$.getValue();
this.bitstreams$.next([...current, ...bitstreamsRD.payload.page]);
this.isLoading = false;
this.isLastPage = this.currentPage === bitstreamsRD.payload.totalPages;
if (bitstreamsRD.error) {
this.notificationsService.error(this.translateService.get('file-section.error.header'), `${bitstreamsRD.error.statusCode} ${bitstreamsRD.error.message}`);
} else if (hasValue(bitstreamsRD.payload)) {
const current: Bitstream[] = this.bitstreams$.getValue();
this.bitstreams$.next([...current, ...bitstreamsRD.payload.page]);
this.isLoading = false;
this.isLastPage = this.currentPage === bitstreamsRD.payload.totalPages;
}
});
}
}

View File

@@ -69,6 +69,10 @@ import { SiteRegisterGuard } from './core/data/feature-authorization/feature-aut
{ path: 'processes', loadChildren: './process-page/process-page.module#ProcessPageModule', canActivate: [AuthenticatedGuard, EndUserAgreementCurrentUserGuard] },
{ path: INFO_MODULE_PATH, loadChildren: './info/info.module#InfoModule' },
{ path: UNAUTHORIZED_PATH, component: UnauthorizedComponent },
{
path: 'statistics',
loadChildren: './statistics-page/statistics-page-routing.module#StatisticsPageRoutingModule',
},
{ path: '**', pathMatch: 'full', component: PageNotFoundComponent },
]}
],

View File

@@ -270,7 +270,7 @@ export class ObjectCacheService {
/**
* Add operations to the existing list of operations for an ObjectCacheEntry
* Makes sure the ServerSyncBuffer for this ObjectCacheEntry is updated
* @param {string} uuid
* @param selfLink
* the uuid of the ObjectCacheEntry
* @param {Operation[]} patch
* list of operations to perform
@@ -295,8 +295,8 @@ export class ObjectCacheService {
/**
* Apply the existing operations on an ObjectCacheEntry in the store
* NB: this does not make any server side changes
* @param {string} uuid
* the uuid of the ObjectCacheEntry
* @param selfLink
* the link of the ObjectCacheEntry
*/
private applyPatchesToCachedObject(selfLink: string) {
this.store.dispatch(new ApplyPatchObjectCacheAction(selfLink));

View File

@@ -171,6 +171,7 @@ import { EndUserAgreementCurrentUserGuard } from './end-user-agreement/end-user-
import { EndUserAgreementCookieGuard } from './end-user-agreement/end-user-agreement-cookie.guard';
import { EndUserAgreementService } from './end-user-agreement/end-user-agreement.service';
import { SiteRegisterGuard } from './data/feature-authorization/feature-authorization-guard/site-register.guard';
import { UsageReport } from './statistics/models/usage-report.model';
/**
* When not in production, endpoint responses can be mocked for testing purposes
@@ -371,7 +372,8 @@ export const models =
Vocabulary,
VocabularyEntry,
VocabularyEntryDetail,
ConfigurationProperty
ConfigurationProperty,
UsageReport,
];
@NgModule({

View File

@@ -30,6 +30,8 @@ import { RestResponse } from '../cache/response.models';
import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service';
import { configureRequest, getResponseFromEntry } from '../shared/operators';
import { combineLatest as observableCombineLatest } from 'rxjs';
import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils';
import { PageInfo } from '../shared/page-info.model';
/**
* A service to retrieve {@link Bitstream}s from the REST API
@@ -165,8 +167,10 @@ export class BitstreamDataService extends DataService<Bitstream> {
public findAllByItemAndBundleName(item: Item, bundleName: string, options?: FindListOptions, ...linksToFollow: Array<FollowLinkConfig<Bitstream>>): Observable<RemoteData<PaginatedList<Bitstream>>> {
return this.bundleService.findByItemAndName(item, bundleName).pipe(
switchMap((bundleRD: RemoteData<Bundle>) => {
if (hasValue(bundleRD.payload)) {
if (bundleRD.hasSucceeded && hasValue(bundleRD.payload)) {
return this.findAllByBundle(bundleRD.payload, options, ...linksToFollow);
} else if (!bundleRD.hasSucceeded && bundleRD.error.statusCode === 404) {
return createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), []))
} else {
return [bundleRD as any];
}

View File

@@ -22,6 +22,7 @@ import { FindListOptions, GetRequest } from './request.models';
import { RequestService } from './request.service';
import { PaginatedSearchOptions } from '../../shared/search/paginated-search-options.model';
import { Bitstream } from '../shared/bitstream.model';
import { RemoteDataError } from './remote-data-error';
/**
* A service to retrieve {@link Bundle}s from the REST API
@@ -71,13 +72,17 @@ export class BundleDataService extends DataService<Bundle> {
if (hasValue(rd.payload) && hasValue(rd.payload.page)) {
const matchingBundle = rd.payload.page.find((bundle: Bundle) =>
bundle.name === bundleName);
return new RemoteData(
false,
false,
true,
undefined,
matchingBundle
);
if (hasValue(matchingBundle)) {
return new RemoteData(
false,
false,
true,
undefined,
matchingBundle
);
} else {
return new RemoteData(false, false, false, new RemoteDataError(404, 'Not found', `The bundle with name ${bundleName} was not found.` ))
}
} else {
return rd as any;
}

View File

@@ -4,6 +4,7 @@
export enum FeatureID {
LoginOnBehalfOf = 'loginOnBehalfOf',
AdministratorOf = 'administratorOf',
CanDelete = 'canDelete',
WithdrawItem = 'withdrawItem',
ReinstateItem = 'reinstateItem',
EPersonRegistration = 'epersonRegistration',

View File

@@ -20,6 +20,7 @@ import { switchMap, map } from 'rxjs/operators';
import { BundleDataService } from './bundle-data.service';
import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model';
import { RestResponse } from '../cache/response.models';
import { Operation } from 'fast-json-patch';
/* tslint:disable:max-classes-per-file */
/**
@@ -165,6 +166,10 @@ export class ItemTemplateDataService implements UpdateDataService<Item> {
return this.dataService.update(object);
}
patch(dso: Item, operations: Operation[]): Observable<RestResponse> {
return this.dataService.patch(dso, operations);
}
/**
* Find an item template by collection ID
* @param collectionID

View File

@@ -2,6 +2,8 @@ import {type} from '../../../shared/ngrx/type';
import {Action} from '@ngrx/store';
import {Identifiable} from './object-updates.reducer';
import {INotification} from '../../../shared/notifications/models/notification.model';
import { InjectionToken } from '@angular/core';
import { PatchOperationService } from './patch-operation-service/patch-operation.service';
/**
* The list of ObjectUpdatesAction type definitions
@@ -38,7 +40,8 @@ export class InitializeFieldsAction implements Action {
payload: {
url: string,
fields: Identifiable[],
lastModified: Date
lastModified: Date,
patchOperationServiceToken?: InjectionToken<PatchOperationService>
};
/**
@@ -48,16 +51,15 @@ export class InitializeFieldsAction implements Action {
* the unique url of the page for which the fields are being initialized
* @param fields The identifiable fields of which the updates are kept track of
* @param lastModified The last modified date of the object that belongs to the page
* @param order A custom order to keep track of objects moving around
* @param pageSize The page size used to fill empty pages for the custom order
* @param page The first page to populate in the custom order
* @param patchOperationServiceToken An InjectionToken referring to the {@link PatchOperationService} used for creating a patch
*/
constructor(
url: string,
fields: Identifiable[],
lastModified: Date
lastModified: Date,
patchOperationServiceToken?: InjectionToken<PatchOperationService>
) {
this.payload = { url, fields, lastModified };
this.payload = { url, fields, lastModified, patchOperationServiceToken };
}
}

View File

@@ -231,7 +231,8 @@ describe('objectUpdatesReducer', () => {
},
fieldUpdates: {},
virtualMetadataSources: {},
lastModified: modDate
lastModified: modDate,
patchOperationServiceToken: undefined
}
};
const newState = objectUpdatesReducer(testState, action);

View File

@@ -14,6 +14,8 @@ import {
} from './object-updates.actions';
import { hasNoValue, hasValue } from '../../../shared/empty.util';
import {Relationship} from '../../shared/item-relationships/relationship.model';
import { InjectionToken } from '@angular/core';
import { PatchOperationService } from './patch-operation-service/patch-operation.service';
/**
* Path where discarded objects are saved
@@ -48,7 +50,7 @@ export interface Identifiable {
*/
export interface FieldUpdate {
field: Identifiable,
changeType: FieldChangeType
changeType: FieldChangeType,
}
/**
@@ -89,6 +91,7 @@ export interface ObjectUpdatesEntry {
fieldUpdates: FieldUpdates;
virtualMetadataSources: VirtualMetadataSources;
lastModified: Date;
patchOperationServiceToken?: InjectionToken<PatchOperationService>;
}
/**
@@ -163,6 +166,7 @@ function initializeFieldsUpdate(state: any, action: InitializeFieldsAction) {
const url: string = action.payload.url;
const fields: Identifiable[] = action.payload.fields;
const lastModifiedServer: Date = action.payload.lastModified;
const patchOperationServiceToken: InjectionToken<PatchOperationService> = action.payload.patchOperationServiceToken;
const fieldStates = createInitialFieldStates(fields);
const newPageState = Object.assign(
{},
@@ -170,7 +174,8 @@ function initializeFieldsUpdate(state: any, action: InitializeFieldsAction) {
{ fieldStates: fieldStates },
{ fieldUpdates: {} },
{ virtualMetadataSources: {} },
{ lastModified: lastModifiedServer }
{ lastModified: lastModifiedServer },
{ patchOperationServiceToken }
);
return Object.assign({}, state, { [url]: newPageState });
}

View File

@@ -12,6 +12,7 @@ import { Notification } from '../../../shared/notifications/models/notification.
import { NotificationType } from '../../../shared/notifications/models/notification-type';
import { OBJECT_UPDATES_TRASH_PATH } from './object-updates.reducer';
import {Relationship} from '../../shared/item-relationships/relationship.model';
import { Injector } from '@angular/core';
describe('ObjectUpdatesService', () => {
let service: ObjectUpdatesService;
@@ -31,6 +32,9 @@ describe('ObjectUpdatesService', () => {
};
const modDate = new Date(2010, 2, 11);
const injectionToken = 'fake-injection-token';
let patchOperationService;
let injector: Injector;
beforeEach(() => {
const fieldStates = {
@@ -40,11 +44,17 @@ describe('ObjectUpdatesService', () => {
};
const objectEntry = {
fieldStates, fieldUpdates, lastModified: modDate, virtualMetadataSources: {}
fieldStates, fieldUpdates, lastModified: modDate, virtualMetadataSources: {}, patchOperationServiceToken: injectionToken
};
store = new Store<CoreState>(undefined, undefined, undefined);
spyOn(store, 'dispatch');
service = new ObjectUpdatesService(store);
patchOperationService = jasmine.createSpyObj('patchOperationService', {
fieldUpdatesToPatchOperations: []
});
injector = jasmine.createSpyObj('injector', {
get: patchOperationService
});
service = new ObjectUpdatesService(store, injector);
spyOn(service as any, 'getObjectEntry').and.returnValue(observableOf(objectEntry));
spyOn(service as any, 'getFieldState').and.callFake((uuid) => {
@@ -277,4 +287,26 @@ describe('ObjectUpdatesService', () => {
});
});
describe('createPatch', () => {
let result$;
beforeEach(() => {
result$ = service.createPatch(url);
});
it('should inject the service using the token stored in the entry', (done) => {
result$.subscribe(() => {
expect(injector.get).toHaveBeenCalledWith(injectionToken);
done();
});
});
it('should create a patch from the fieldUpdates using the injected service', (done) => {
result$.subscribe(() => {
expect(patchOperationService.fieldUpdatesToPatchOperations).toHaveBeenCalledWith(fieldUpdates);
done();
});
});
});
});

View File

@@ -1,4 +1,4 @@
import { Injectable } from '@angular/core';
import { Injectable, InjectionToken, Injector } from '@angular/core';
import { createSelector, MemoizedSelector, select, Store } from '@ngrx/store';
import { CoreState } from '../../core.reducers';
import { coreSelector } from '../../core.selectors';
@@ -26,6 +26,8 @@ import {
import { distinctUntilChanged, filter, map, switchMap } from 'rxjs/operators';
import { hasNoValue, hasValue, isEmpty, isNotEmpty, isNotEmptyOperator } from '../../../shared/empty.util';
import { INotification } from '../../../shared/notifications/models/notification.model';
import { Operation } from 'fast-json-patch';
import { PatchOperationService } from './patch-operation-service/patch-operation.service';
function objectUpdatesStateSelector(): MemoizedSelector<CoreState, ObjectUpdatesState> {
return createSelector(coreSelector, (state: CoreState) => state['cache/object-updates']);
@@ -48,7 +50,8 @@ function virtualMetadataSourceSelector(url: string, source: string): MemoizedSel
*/
@Injectable()
export class ObjectUpdatesService {
constructor(private store: Store<CoreState>) {
constructor(private store: Store<CoreState>,
private injector: Injector) {
}
/**
@@ -56,9 +59,10 @@ export class ObjectUpdatesService {
* @param url The page's URL for which the changes are being mapped
* @param fields The initial fields for the page's object
* @param lastModified The date the object was last modified
* @param patchOperationServiceToken An InjectionToken referring to the {@link PatchOperationService} used for creating a patch
*/
initialize(url, fields: Identifiable[], lastModified: Date): void {
this.store.dispatch(new InitializeFieldsAction(url, fields, lastModified));
initialize(url, fields: Identifiable[], lastModified: Date, patchOperationServiceToken?: InjectionToken<PatchOperationService>): void {
this.store.dispatch(new InitializeFieldsAction(url, fields, lastModified, patchOperationServiceToken));
}
/**
@@ -339,4 +343,22 @@ export class ObjectUpdatesService {
getLastModified(url: string): Observable<Date> {
return this.getObjectEntry(url).pipe(map((entry: ObjectUpdatesEntry) => entry.lastModified));
}
/**
* Create a patch from the current object-updates state
* The {@link ObjectUpdatesEntry} should contain a patchOperationServiceToken, in order to define how a patch should
* be created. If it doesn't, an empty patch will be returned.
* @param url The URL of the page for which the patch should be created
*/
createPatch(url: string): Observable<Operation[]> {
return this.getObjectEntry(url).pipe(
map((entry) => {
let patch = [];
if (hasValue(entry.patchOperationServiceToken)) {
patch = this.injector.get(entry.patchOperationServiceToken).fieldUpdatesToPatchOperations(entry.fieldUpdates);
}
return patch;
})
);
}
}

View File

@@ -0,0 +1,252 @@
import { MetadataPatchOperationService } from './metadata-patch-operation.service';
import { FieldUpdates } from '../object-updates.reducer';
import { Operation } from 'fast-json-patch';
import { FieldChangeType } from '../object-updates.actions';
import { MetadatumViewModel } from '../../../shared/metadata.models';
describe('MetadataPatchOperationService', () => {
let service: MetadataPatchOperationService;
beforeEach(() => {
service = new MetadataPatchOperationService();
});
describe('fieldUpdatesToPatchOperations', () => {
let fieldUpdates: FieldUpdates;
let expected: Operation[];
let result: Operation[];
describe('when fieldUpdates contains a single remove', () => {
beforeEach(() => {
fieldUpdates = Object.assign({
update1: {
field: Object.assign(new MetadatumViewModel(), {
key: 'dc.title',
value: 'Deleted title',
place: 0
}),
changeType: FieldChangeType.REMOVE
}
});
expected = [
{ op: 'remove', path: '/metadata/dc.title/0' }
] as any[];
result = service.fieldUpdatesToPatchOperations(fieldUpdates);
});
it('should contain a single remove operation with the correct path', () => {
expect(result).toEqual(expected);
});
});
describe('when fieldUpdates contains a single add', () => {
beforeEach(() => {
fieldUpdates = Object.assign({
update1: {
field: Object.assign(new MetadatumViewModel(), {
key: 'dc.title',
value: 'Added title',
place: 0
}),
changeType: FieldChangeType.ADD
}
});
expected = [
{ op: 'add', path: '/metadata/dc.title/-', value: [ { value: 'Added title', language: undefined } ] }
] as any[];
result = service.fieldUpdatesToPatchOperations(fieldUpdates);
});
it('should contain a single add operation with the correct path', () => {
expect(result).toEqual(expected);
});
});
describe('when fieldUpdates contains a single update', () => {
beforeEach(() => {
fieldUpdates = Object.assign({
update1: {
field: Object.assign(new MetadatumViewModel(), {
key: 'dc.title',
value: 'Changed title',
place: 0
}),
changeType: FieldChangeType.UPDATE
}
});
expected = [
{ op: 'replace', path: '/metadata/dc.title/0', value: { value: 'Changed title', language: undefined } }
] as any[];
result = service.fieldUpdatesToPatchOperations(fieldUpdates);
});
it('should contain a single replace operation with the correct path', () => {
expect(result).toEqual(expected);
});
});
describe('when fieldUpdates contains multiple removes with incrementing indexes', () => {
beforeEach(() => {
fieldUpdates = Object.assign({
update1: {
field: Object.assign(new MetadatumViewModel(), {
key: 'dc.title',
value: 'First deleted title',
place: 0
}),
changeType: FieldChangeType.REMOVE
},
update2: {
field: Object.assign(new MetadatumViewModel(), {
key: 'dc.title',
value: 'Second deleted title',
place: 1
}),
changeType: FieldChangeType.REMOVE
},
update3: {
field: Object.assign(new MetadatumViewModel(), {
key: 'dc.title',
value: 'Third deleted title',
place: 2
}),
changeType: FieldChangeType.REMOVE
}
});
expected = [
{ op: 'remove', path: '/metadata/dc.title/0' },
{ op: 'remove', path: '/metadata/dc.title/0' },
{ op: 'remove', path: '/metadata/dc.title/0' }
] as any[];
result = service.fieldUpdatesToPatchOperations(fieldUpdates);
});
it('should contain all the remove operations on the same index', () => {
expect(result).toEqual(expected);
});
});
describe('when fieldUpdates contains multiple removes with decreasing indexes', () => {
beforeEach(() => {
fieldUpdates = Object.assign({
update1: {
field: Object.assign(new MetadatumViewModel(), {
key: 'dc.title',
value: 'Third deleted title',
place: 2
}),
changeType: FieldChangeType.REMOVE
},
update2: {
field: Object.assign(new MetadatumViewModel(), {
key: 'dc.title',
value: 'Second deleted title',
place: 1
}),
changeType: FieldChangeType.REMOVE
},
update3: {
field: Object.assign(new MetadatumViewModel(), {
key: 'dc.title',
value: 'First deleted title',
place: 0
}),
changeType: FieldChangeType.REMOVE
}
});
expected = [
{ op: 'remove', path: '/metadata/dc.title/2' },
{ op: 'remove', path: '/metadata/dc.title/1' },
{ op: 'remove', path: '/metadata/dc.title/0' }
] as any[];
result = service.fieldUpdatesToPatchOperations(fieldUpdates);
});
it('should contain all the remove operations with their corresponding indexes', () => {
expect(result).toEqual(expected);
});
});
describe('when fieldUpdates contains multiple removes with random indexes', () => {
beforeEach(() => {
fieldUpdates = Object.assign({
update1: {
field: Object.assign(new MetadatumViewModel(), {
key: 'dc.title',
value: 'Second deleted title',
place: 1
}),
changeType: FieldChangeType.REMOVE
},
update2: {
field: Object.assign(new MetadatumViewModel(), {
key: 'dc.title',
value: 'Third deleted title',
place: 2
}),
changeType: FieldChangeType.REMOVE
},
update3: {
field: Object.assign(new MetadatumViewModel(), {
key: 'dc.title',
value: 'First deleted title',
place: 0
}),
changeType: FieldChangeType.REMOVE
}
});
expected = [
{ op: 'remove', path: '/metadata/dc.title/1' },
{ op: 'remove', path: '/metadata/dc.title/1' },
{ op: 'remove', path: '/metadata/dc.title/0' }
] as any[];
result = service.fieldUpdatesToPatchOperations(fieldUpdates);
});
it('should contain all the remove operations with the correct indexes taking previous operations into account', () => {
expect(result).toEqual(expected);
});
});
describe('when fieldUpdates contains multiple removes and updates with random indexes', () => {
beforeEach(() => {
fieldUpdates = Object.assign({
update1: {
field: Object.assign(new MetadatumViewModel(), {
key: 'dc.title',
value: 'Second deleted title',
place: 1
}),
changeType: FieldChangeType.REMOVE
},
update2: {
field: Object.assign(new MetadatumViewModel(), {
key: 'dc.title',
value: 'Third changed title',
place: 2
}),
changeType: FieldChangeType.UPDATE
},
update3: {
field: Object.assign(new MetadatumViewModel(), {
key: 'dc.title',
value: 'First deleted title',
place: 0
}),
changeType: FieldChangeType.REMOVE
}
});
expected = [
{ op: 'remove', path: '/metadata/dc.title/1' },
{ op: 'replace', path: '/metadata/dc.title/1', value: { value: 'Third changed title', language: undefined } },
{ op: 'remove', path: '/metadata/dc.title/0' }
] as any[];
result = service.fieldUpdatesToPatchOperations(fieldUpdates);
});
it('should contain all the remove and replace operations with the correct indexes taking previous remove operations into account', () => {
expect(result).toEqual(expected);
});
});
});
});

View File

@@ -0,0 +1,106 @@
import { PatchOperationService } from './patch-operation.service';
import { MetadatumViewModel } from '../../../shared/metadata.models';
import { FieldUpdates } from '../object-updates.reducer';
import { Operation } from 'fast-json-patch';
import { FieldChangeType } from '../object-updates.actions';
import { InjectionToken } from '@angular/core';
import { MetadataPatchOperation } from './operations/metadata/metadata-patch-operation.model';
import { hasValue } from '../../../../shared/empty.util';
import { MetadataPatchAddOperation } from './operations/metadata/metadata-patch-add-operation.model';
import { MetadataPatchRemoveOperation } from './operations/metadata/metadata-patch-remove-operation.model';
import { MetadataPatchReplaceOperation } from './operations/metadata/metadata-patch-replace-operation.model';
/**
* Token to use for injecting this service anywhere you want
* This token can used to store in the object-updates store
*/
export const METADATA_PATCH_OPERATION_SERVICE_TOKEN = new InjectionToken<MetadataPatchOperationService>('MetadataPatchOperationService', {
providedIn: 'root',
factory: () => new MetadataPatchOperationService(),
});
/**
* Service transforming {@link FieldUpdates} into {@link Operation}s for metadata values
* This expects the fields within every {@link FieldUpdate} to be {@link MetadatumViewModel}s
*/
export class MetadataPatchOperationService implements PatchOperationService {
/**
* Transform a {@link FieldUpdates} object into an array of fast-json-patch Operations for metadata values
* This method first creates an array of {@link MetadataPatchOperation} wrapper operations, which are then
* iterated over to create the actual patch operations. While iterating, it has the ability to check for previous
* operations that would modify the operation's position and act accordingly.
* @param fieldUpdates
*/
fieldUpdatesToPatchOperations(fieldUpdates: FieldUpdates): Operation[] {
const metadataPatch = this.fieldUpdatesToMetadataPatchOperations(fieldUpdates);
// This map stores what metadata fields had a value deleted at which places
// This is used to modify the place of operations to match previous operations
const metadataRemoveMap = new Map<string, number[]>();
const patch = [];
metadataPatch.forEach((operation) => {
// If this operation is removing or editing an existing value, first check the map for previous operations
// If the map contains remove operations before this operation's place, lower the place by 1 for each
if ((operation.op === MetadataPatchRemoveOperation.operationType || operation.op === MetadataPatchReplaceOperation.operationType) && hasValue((operation as any).place)) {
if (metadataRemoveMap.has(operation.field)) {
metadataRemoveMap.get(operation.field).forEach((index) => {
if (index < (operation as any).place) {
(operation as any).place--;
}
});
}
}
// If this is a remove operation, add its (updated) place to the map, so we can adjust following operations accordingly
if (operation.op === MetadataPatchRemoveOperation.operationType && hasValue((operation as any).place)) {
if (!metadataRemoveMap.has(operation.field)) {
metadataRemoveMap.set(operation.field, []);
}
metadataRemoveMap.get(operation.field).push((operation as any).place);
}
// Transform the updated operation into a fast-json-patch Operation and add it to the patch
patch.push(operation.toOperation());
});
return patch;
}
/**
* Transform a {@link FieldUpdates} object into an array of {@link MetadataPatchOperation} wrapper objects
* These wrapper objects contain detailed information about the patch operation that needs to be creates for each update
* This information can then be modified before creating the actual patch
* @param fieldUpdates
*/
fieldUpdatesToMetadataPatchOperations(fieldUpdates: FieldUpdates): MetadataPatchOperation[] {
const metadataPatch = [];
Object.keys(fieldUpdates).forEach((uuid) => {
const update = fieldUpdates[uuid];
const metadatum = update.field as MetadatumViewModel;
const val = {
value: metadatum.value,
language: metadatum.language
}
let operation: MetadataPatchOperation;
switch (update.changeType) {
case FieldChangeType.ADD:
operation = new MetadataPatchAddOperation(metadatum.key, [ val ]);
break;
case FieldChangeType.REMOVE:
operation = new MetadataPatchRemoveOperation(metadatum.key, metadatum.place);
break;
case FieldChangeType.UPDATE:
operation = new MetadataPatchReplaceOperation(metadatum.key, metadatum.place, val);
break;
}
metadataPatch.push(operation);
});
return metadataPatch;
}
}

View File

@@ -0,0 +1,27 @@
import { MetadataPatchOperation } from './metadata-patch-operation.model';
import { Operation } from 'fast-json-patch';
/**
* Wrapper object for a metadata patch add Operation
*/
export class MetadataPatchAddOperation extends MetadataPatchOperation {
static operationType = 'add';
/**
* The metadata value(s) to add to the field
*/
value: any;
constructor(field: string, value: any) {
super(MetadataPatchAddOperation.operationType, field);
this.value = value;
}
/**
* Transform the MetadataPatchOperation into a fast-json-patch Operation by constructing its path and other properties
* using the information provided.
*/
toOperation(): Operation {
return { op: this.op as any, path: `/metadata/${this.field}/-`, value: this.value };
}
}

View File

@@ -0,0 +1,29 @@
import { Operation } from 'fast-json-patch';
/**
* Wrapper object for metadata patch Operations
* It should contain at least the operation type and metadata field. An abstract method to transform this object
* into a fast-json-patch Operation is defined in each instance extending from this.
*/
export abstract class MetadataPatchOperation {
/**
* The operation to perform
*/
op: string;
/**
* The metadata field this operation is intended for
*/
field: string;
constructor(op: string, field: string) {
this.op = op;
this.field = field;
}
/**
* Transform the MetadataPatchOperation into a fast-json-patch Operation by constructing its path and other properties
* using the information provided.
*/
abstract toOperation(): Operation;
}

View File

@@ -0,0 +1,27 @@
import { MetadataPatchOperation } from './metadata-patch-operation.model';
import { Operation } from 'fast-json-patch';
/**
* Wrapper object for a metadata patch remove Operation
*/
export class MetadataPatchRemoveOperation extends MetadataPatchOperation {
static operationType = 'remove';
/**
* The place of the metadata value to remove within its field
*/
place: number;
constructor(field: string, place: number) {
super(MetadataPatchRemoveOperation.operationType, field);
this.place = place;
}
/**
* Transform the MetadataPatchOperation into a fast-json-patch Operation by constructing its path and other properties
* using the information provided.
*/
toOperation(): Operation {
return { op: this.op as any, path: `/metadata/${this.field}/${this.place}` };
}
}

View File

@@ -0,0 +1,33 @@
import { MetadataPatchOperation } from './metadata-patch-operation.model';
import { Operation } from 'fast-json-patch';
/**
* Wrapper object for a metadata patch replace Operation
*/
export class MetadataPatchReplaceOperation extends MetadataPatchOperation {
static operationType = 'replace';
/**
* The place of the metadata value within its field to modify
*/
place: number;
/**
* The new value to replace the metadata with
*/
value: any;
constructor(field: string, place: number, value: any) {
super(MetadataPatchReplaceOperation.operationType, field);
this.place = place;
this.value = value;
}
/**
* Transform the MetadataPatchOperation into a fast-json-patch Operation by constructing its path and other properties
* using the information provided.
*/
toOperation(): Operation {
return { op: this.op as any, path: `/metadata/${this.field}/${this.place}`, value: this.value };
}
}

View File

@@ -0,0 +1,15 @@
import { FieldUpdates } from '../object-updates.reducer';
import { Operation } from 'fast-json-patch';
/**
* Interface for a service dealing with the transformations of patch operations from the object-updates store
* The implementations of this service know how to deal with the fields of a FieldUpdate and how to transform them
* into patch Operations.
*/
export interface PatchOperationService {
/**
* Transform a {@link FieldUpdates} object into an array of fast-json-patch Operations
* @param fieldUpdates
*/
fieldUpdatesToPatchOperations(fieldUpdates: FieldUpdates): Operation[];
}

View File

@@ -1,11 +1,14 @@
import { Observable } from 'rxjs/internal/Observable';
import { RemoteData } from './remote-data';
import { RestRequestMethod } from './rest-request-method';
import { Operation } from 'fast-json-patch';
import { RestResponse } from '../cache/response.models';
/**
* Represents a data service to update a given object
*/
export interface UpdateDataService<T> {
patch(dso: T, operations: Operation[]): Observable<RestResponse>;
update(object: T): Observable<RemoteData<T>>;
commitUpdates(method?: RestRequestMethod);
}

View File

@@ -5,7 +5,7 @@ import { HttpClient, HttpHeaders, HttpParams, HttpResponse } from '@angular/comm
import { DSpaceRESTV2Response } from './dspace-rest-v2-response.model';
import { RestRequestMethod } from '../data/rest-request-method';
import { hasNoValue, isNotEmpty } from '../../shared/empty.util';
import { hasNoValue, hasValue, isNotEmpty } from '../../shared/empty.util';
import { DSpaceObject } from '../shared/dspace-object.model';
export const DEFAULT_CONTENT_TYPE = 'application/json; charset=utf-8';
@@ -53,7 +53,7 @@ export class DSpaceRESTv2Service {
return observableThrowError({
statusCode: err.status,
statusText: err.statusText,
message: err.message
message: (hasValue(err.error) && isNotEmpty(err.error.message)) ? err.error.message : err.message
});
}));
}
@@ -116,7 +116,7 @@ export class DSpaceRESTv2Service {
return observableThrowError({
statusCode: err.status,
statusText: err.statusText,
message: err.message
message: (hasValue(err.error) && isNotEmpty(err.error.message)) ? err.error.message : err.message
});
}));
}

View File

@@ -3,7 +3,7 @@ import { Injectable } from '@angular/core';
import { createSelector, select, Store } from '@ngrx/store';
import { Operation } from 'fast-json-patch/lib/core';
import { Observable } from 'rxjs';
import { filter, find, map, skipWhile, switchMap, take, tap } from 'rxjs/operators';
import { filter, find, map, take } from 'rxjs/operators';
import {
EPeopleRegistryCancelEPersonAction,
EPeopleRegistryEditEPersonAction
@@ -223,8 +223,8 @@ export class EPersonDataService extends DataService<EPerson> {
* Method to delete an EPerson
* @param ePerson The EPerson to delete
*/
public deleteEPerson(ePerson: EPerson): Observable<boolean> {
return this.delete(ePerson.id).pipe(map((response: RestResponse) => response.isSuccessful));
public deleteEPerson(ePerson: EPerson): Observable<RestResponse> {
return this.delete(ePerson.id);
}
/**
@@ -299,34 +299,4 @@ export class EPersonDataService extends DataService<EPerson> {
map((request: RequestEntry) => request.response)
);
}
/**
* Make a new FindListRequest with given search method
*
* @param searchMethod The search method for the object
* @param options The [[FindListOptions]] object
* @param linksToFollow The array of [[FollowLinkConfig]]
* @return {Observable<RemoteData<PaginatedList<EPerson>>}
* Return an observable that emits response from the server
*/
searchBy(searchMethod: string, options: FindListOptions = {}, ...linksToFollow: Array<FollowLinkConfig<EPerson>>): Observable<RemoteData<PaginatedList<EPerson>>> {
const hrefObs = this.getSearchByHref(searchMethod, options, ...linksToFollow);
return hrefObs.pipe(
find((href: string) => hasValue(href)),
tap((href: string) => {
this.requestService.removeByHrefSubstring(href);
const request = new FindListRequest(this.requestService.generateRequestId(), href, options);
this.requestService.configure(request);
}
),
switchMap((href) => this.requestService.getByHref(href)),
skipWhile((requestEntry) => hasValue(requestEntry) && requestEntry.completed),
switchMap((href) =>
this.rdbService.buildList<EPerson>(hrefObs, ...linksToFollow) as Observable<RemoteData<PaginatedList<EPerson>>>
)
);
}
}

View File

@@ -0,0 +1,17 @@
import { EPerson } from './eperson.model';
/**
* This class serves as a Data Transfer Model that contains the EPerson and whether or not it's able to be deleted
*/
export class EpersonDtoModel {
/**
* The EPerson linked to this object
*/
public eperson: EPerson;
/**
* Whether or not the linked EPerson is able to be deleted
*/
public ableToDelete: boolean;
}

View File

@@ -52,10 +52,13 @@ import { UUIDService } from '../shared/uuid.service';
import { MetadataService } from './metadata.service';
import { environment } from '../../../environments/environment';
import { storeModuleConfig } from '../../app.reducer';
import { HardRedirectService } from '../services/hard-redirect.service';
import { URLCombiner } from '../url-combiner/url-combiner';
/* tslint:disable:max-classes-per-file */
@Component({
template: `<router-outlet></router-outlet>`
template: `
<router-outlet></router-outlet>`
})
class TestComponent {
constructor(private metadata: MetadataService) {
@@ -170,6 +173,7 @@ describe('MetadataService', () => {
Title,
// tslint:disable-next-line:no-empty
{ provide: ItemDataService, useValue: { findById: () => {} } },
{ provide: HardRedirectService, useValue: { rewriteDownloadURL: (a) => a, getRequestOrigin: () => environment.ui.baseUrl }},
BrowseService,
MetadataService
],
@@ -208,7 +212,7 @@ describe('MetadataService', () => {
tick();
expect(tagStore.get('citation_dissertation_name')[0].content).toEqual('Test PowerPoint Document');
expect(tagStore.get('citation_dissertation_institution')[0].content).toEqual('Mock Publisher');
expect(tagStore.get('citation_abstract_html_url')[0].content).toEqual([environment.ui.baseUrl, router.url].join(''));
expect(tagStore.get('citation_abstract_html_url')[0].content).toEqual(new URLCombiner(environment.ui.baseUrl, router.url).toString());
expect(tagStore.get('citation_pdf_url')[0].content).toEqual('https://dspace7.4science.it/dspace-spring-rest/api/core/bitstreams/99b00f3c-1cc6-4689-8158-91965bee6b28/content');
}));

View File

@@ -1,4 +1,4 @@
import { Inject, Injectable } from '@angular/core';
import { Injectable } from '@angular/core';
import { Meta, MetaDefinition, Title } from '@angular/platform-browser';
import { ActivatedRoute, NavigationEnd, Router } from '@angular/router';
@@ -20,7 +20,8 @@ import { Bitstream } from '../shared/bitstream.model';
import { DSpaceObject } from '../shared/dspace-object.model';
import { Item } from '../shared/item.model';
import { getFirstSucceededRemoteDataPayload, getFirstSucceededRemoteListPayload } from '../shared/operators';
import { environment } from '../../../environments/environment';
import { HardRedirectService } from '../services/hard-redirect.service';
import { URLCombiner } from '../url-combiner/url-combiner';
@Injectable()
export class MetadataService {
@@ -39,6 +40,7 @@ export class MetadataService {
private dsoNameService: DSONameService,
private bitstreamDataService: BitstreamDataService,
private bitstreamFormatDataService: BitstreamFormatDataService,
private redirectService: HardRedirectService
) {
// TODO: determine what open graph meta tags are needed and whether
// the differ per route. potentially add image based on DSpaceObject
@@ -254,7 +256,7 @@ export class MetadataService {
*/
private setCitationAbstractUrlTag(): void {
if (this.currentObject.value instanceof Item) {
const value = [environment.ui.baseUrl, this.router.url].join('');
const value = new URLCombiner(this.redirectService.getRequestOrigin(), this.router.url).toString();
this.addMetaTag('citation_abstract_html_url', value);
}
}
@@ -279,7 +281,8 @@ export class MetadataService {
getFirstSucceededRemoteDataPayload()
).subscribe((format: BitstreamFormat) => {
if (format.mimetype === 'application/pdf') {
this.addMetaTag('citation_pdf_url', bitstream._links.content.href);
const rewrittenURL= this.redirectService.rewriteDownloadURL(bitstream._links.content.href);
this.addMetaTag('citation_pdf_url', rewrittenURL);
}
});
}

View File

@@ -2,11 +2,12 @@ import {TestBed} from '@angular/core/testing';
import {BrowserHardRedirectService} from './browser-hard-redirect.service';
describe('BrowserHardRedirectService', () => {
const origin = 'test origin';
const mockLocation = {
href: undefined,
pathname: '/pathname',
search: '/search',
origin
} as Location;
const service: BrowserHardRedirectService = new BrowserHardRedirectService(mockLocation);
@@ -38,4 +39,12 @@ describe('BrowserHardRedirectService', () => {
expect(service.getCurrentRoute()).toEqual(mockLocation.pathname + mockLocation.search);
});
});
describe('when requesting the origin', () => {
it('should return the location origin', () => {
expect(service.getRequestOrigin()).toEqual(origin);
});
});
});

View File

@@ -11,11 +11,12 @@ export function locationProvider(): Location {
* Service for performing hard redirects within the browser app module
*/
@Injectable()
export class BrowserHardRedirectService implements HardRedirectService {
export class BrowserHardRedirectService extends HardRedirectService {
constructor(
@Inject(LocationToken) protected location: Location,
) {
super();
}
/**
@@ -32,4 +33,11 @@ export class BrowserHardRedirectService implements HardRedirectService {
getCurrentRoute() {
return this.location.pathname + this.location.search;
}
/**
* Get the hostname of the request
*/
getRequestOrigin() {
return this.location.origin;
}
}

View File

@@ -0,0 +1,57 @@
import { HardRedirectService } from './hard-redirect.service';
import { environment } from '../../../environments/environment';
import { TestBed } from '@angular/core/testing';
import { Injectable } from '@angular/core';
const requestOrigin = 'http://dspace-angular-ui.dspace.com';
describe('HardRedirectService', () => {
let service: TestHardRedirectService;
beforeEach(() => {
TestBed.configureTestingModule({ providers: [TestHardRedirectService] });
service = TestBed.get(TestHardRedirectService);
});
describe('when calling rewriteDownloadURL', () => {
let originalValue;
const relativePath = '/test/url/path';
const testURL = environment.rest.baseUrl + relativePath;
beforeEach(() => {
originalValue = environment.rewriteDownloadUrls;
});
it('it should return the same url when rewriteDownloadURL is false', () => {
environment.rewriteDownloadUrls = false;
expect(service.rewriteDownloadURL(testURL)).toEqual(testURL);
});
it('it should replace part of the url when rewriteDownloadURL is true', () => {
environment.rewriteDownloadUrls = true;
expect(service.rewriteDownloadURL(testURL)).toEqual(requestOrigin + environment.rest.nameSpace + relativePath);
});
afterEach(() => {
environment.rewriteDownloadUrls = originalValue;
})
});
});
@Injectable()
class TestHardRedirectService extends HardRedirectService {
constructor() {
super();
}
redirect(url: string) {
return undefined;
}
getCurrentRoute() {
return undefined;
}
getRequestOrigin() {
return requestOrigin;
}
}

View File

@@ -1,4 +1,6 @@
import { Injectable } from '@angular/core';
import { environment } from '../../../environments/environment';
import { URLCombiner } from '../url-combiner/url-combiner';
/**
* Service to take care of hard redirects
@@ -19,4 +21,20 @@ export abstract class HardRedirectService {
* e.g. /search?page=1&query=open%20access&f.dateIssued.min=1980&f.dateIssued.max=2020
*/
abstract getCurrentRoute();
/**
* Get the hostname of the request
*/
abstract getRequestOrigin();
public rewriteDownloadURL(originalUrl: string): string {
if (environment.rewriteDownloadUrls) {
const hostName = this.getRequestOrigin();
const namespace = environment.rest.nameSpace;
const rewrittenUrl = new URLCombiner(hostName, namespace).toString();
return originalUrl.replace(environment.rest.baseUrl, rewrittenUrl);
} else {
return originalUrl;
}
}
}

View File

@@ -7,8 +7,13 @@ describe('ServerHardRedirectService', () => {
const mockResponse = jasmine.createSpyObj(['redirect', 'end']);
const service: ServerHardRedirectService = new ServerHardRedirectService(mockRequest, mockResponse);
const origin = 'test-host';
beforeEach(() => {
mockRequest.headers = {
host: 'test-host',
};
TestBed.configureTestingModule({});
});
@@ -40,4 +45,12 @@ describe('ServerHardRedirectService', () => {
expect(service.getCurrentRoute()).toEqual(mockRequest.originalUrl);
});
});
describe('when requesting the origin', () => {
it('should return the location origin', () => {
expect(service.getRequestOrigin()).toEqual(origin);
});
});
});

View File

@@ -7,12 +7,13 @@ import { HardRedirectService } from './hard-redirect.service';
* Service for performing hard redirects within the server app module
*/
@Injectable()
export class ServerHardRedirectService implements HardRedirectService {
export class ServerHardRedirectService extends HardRedirectService {
constructor(
@Inject(REQUEST) protected req: Request,
@Inject(RESPONSE) protected res: Response,
) {
super();
}
/**
@@ -59,4 +60,11 @@ export class ServerHardRedirectService implements HardRedirectService {
getCurrentRoute() {
return this.req.originalUrl;
}
/**
* Get the hostname of the request
*/
getRequestOrigin() {
return this.req.headers.host;
}
}

View File

@@ -14,7 +14,7 @@ import { MetadataSchema } from '../metadata/metadata-schema.model';
import { BrowseDefinition } from './browse-definition.model';
import { DSpaceObject } from './dspace-object.model';
import { getUnauthorizedRoute } from '../../app-routing-paths';
import { getEndUserAgreementPath } from '../../info/info-routing.module';
import { getEndUserAgreementPath } from '../../info/info-routing-paths';
/**
* This file contains custom RxJS operators that can be used in multiple places

View File

@@ -0,0 +1,51 @@
import { autoserialize, inheritSerialization } from 'cerialize';
import { typedObject } from '../../cache/builders/build-decorators';
import { excludeFromEquals } from '../../utilities/equals.decorators';
import { ResourceType } from '../../shared/resource-type';
import { HALResource } from '../../shared/hal-resource.model';
import { USAGE_REPORT } from './usage-report.resource-type';
import { HALLink } from '../../shared/hal-link.model';
import { deserialize, autoserializeAs } from 'cerialize';
/**
* A usage report.
*/
@typedObject
@inheritSerialization(HALResource)
export class UsageReport extends HALResource {
static type = USAGE_REPORT;
/**
* The object type
*/
@excludeFromEquals
@autoserialize
type: ResourceType;
@autoserialize
id: string;
@autoserializeAs('report-type')
reportType: string;
@autoserialize
points: Point[];
@deserialize
_links: {
self: HALLink;
};
}
/**
* A statistics data point.
*/
export interface Point {
id: string;
label: string;
type: string;
values: Array<{
views: number;
}>;
}

View File

@@ -0,0 +1,9 @@
import { ResourceType } from '../../shared/resource-type';
/**
* The resource type for License
*
* Needs to be in a separate file to prevent circular
* dependencies in webpack.
*/
export const USAGE_REPORT = new ResourceType('usagereport');

View File

@@ -0,0 +1,62 @@
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Store } from '@ngrx/store';
import { NotificationsService } from '../../shared/notifications/notifications.service';
import { dataService } from '../cache/builders/build-decorators';
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
import { ObjectCacheService } from '../cache/object-cache.service';
import { CoreState } from '../core.reducers';
import { HALEndpointService } from '../shared/hal-endpoint.service';
import { DataService } from '../data/data.service';
import { RequestService } from '../data/request.service';
import { DefaultChangeAnalyzer } from '../data/default-change-analyzer.service';
import { USAGE_REPORT } from './models/usage-report.resource-type';
import { UsageReport } from './models/usage-report.model';
import { Observable } from 'rxjs';
import { getRemoteDataPayload, getSucceededRemoteData } from '../shared/operators';
import { map } from 'rxjs/operators';
/**
* A service to retrieve {@link UsageReport}s from the REST API
*/
@Injectable()
@dataService(USAGE_REPORT)
export class UsageReportService extends DataService<UsageReport> {
protected linkPath = 'statistics/usagereports';
constructor(
protected comparator: DefaultChangeAnalyzer<UsageReport>,
protected halService: HALEndpointService,
protected http: HttpClient,
protected notificationsService: NotificationsService,
protected objectCache: ObjectCacheService,
protected rdbService: RemoteDataBuildService,
protected requestService: RequestService,
protected store: Store<CoreState>,
) {
super();
}
getStatistic(scope: string, type: string): Observable<UsageReport> {
return this.findById(`${scope}_${type}`).pipe(
getSucceededRemoteData(),
getRemoteDataPayload(),
);
}
searchStatistics(uri: string, page: number, size: number): Observable<UsageReport[]> {
return this.searchBy('object', {
searchParams: [{
fieldName: `uri`,
fieldValue: uri,
}],
currentPage: page,
elementsPerPage: size,
}).pipe(
getSucceededRemoteData(),
getRemoteDataPayload(),
map((list) => list.page),
);
}
}

View File

@@ -0,0 +1,16 @@
import { getInfoModulePath } from '../app-routing-paths';
export const END_USER_AGREEMENT_PATH = 'end-user-agreement';
export const PRIVACY_PATH = 'privacy';
export function getEndUserAgreementPath() {
return getSubPath(END_USER_AGREEMENT_PATH);
}
export function getPrivacyPath() {
return getSubPath(PRIVACY_PATH);
}
function getSubPath(path: string) {
return `${getInfoModulePath()}/${path}`;
}

View File

@@ -1,24 +1,9 @@
import { NgModule } from '@angular/core';
import { RouterModule } from '@angular/router';
import { EndUserAgreementComponent } from './end-user-agreement/end-user-agreement.component';
import { getInfoModulePath } from '../app-routing-paths';
import { I18nBreadcrumbResolver } from '../core/breadcrumbs/i18n-breadcrumb.resolver';
import { PrivacyComponent } from './privacy/privacy.component';
const END_USER_AGREEMENT_PATH = 'end-user-agreement';
const PRIVACY_PATH = 'privacy';
export function getEndUserAgreementPath() {
return getSubPath(END_USER_AGREEMENT_PATH);
}
export function getPrivacyPath() {
return getSubPath(PRIVACY_PATH);
}
function getSubPath(path: string) {
return `${getInfoModulePath()}/${path}`;
}
import { PRIVACY_PATH, END_USER_AGREEMENT_PATH } from './info-routing-paths';
@NgModule({
imports: [

View File

@@ -64,19 +64,6 @@ export class NavbarComponent extends MenuComponent {
link: `/community-list`
} as LinkMenuItemModel
},
/* Statistics */
{
id: 'statistics',
active: false,
visible: true,
model: {
type: MenuItemType.LINK,
text: 'menu.section.statistics',
link: ''
} as LinkMenuItemModel,
index: 2
},
];
// Read the different Browse-By types from config and add them to the browse menu
const types = environment.browseBy.types;

View File

@@ -3,6 +3,7 @@ import { FileDownloadLinkComponent } from './file-download-link.component';
import { AuthService } from '../../core/auth/auth.service';
import { FileService } from '../../core/shared/file.service';
import { of as observableOf } from 'rxjs';
import { HardRedirectService } from '../../core/services/hard-redirect.service';
describe('FileDownloadLinkComponent', () => {
let component: FileDownloadLinkComponent;
@@ -23,13 +24,14 @@ describe('FileDownloadLinkComponent', () => {
beforeEach(async(() => {
init();
TestBed.configureTestingModule({
declarations: [ FileDownloadLinkComponent ],
declarations: [FileDownloadLinkComponent],
providers: [
{ provide: AuthService, useValue: authService },
{ provide: FileService, useValue: fileService }
{ provide: FileService, useValue: fileService },
{ provide: HardRedirectService, useValue: { rewriteDownloadURL: (a) => a } },
]
})
.compileComponents();
.compileComponents();
}));
beforeEach(() => {

View File

@@ -2,6 +2,7 @@ import { Component, Input, OnInit } from '@angular/core';
import { FileService } from '../../core/shared/file.service';
import { Observable } from 'rxjs/internal/Observable';
import { AuthService } from '../../core/auth/auth.service';
import { HardRedirectService } from '../../core/services/hard-redirect.service';
@Component({
selector: 'ds-file-download-link',
@@ -30,10 +31,13 @@ export class FileDownloadLinkComponent implements OnInit {
isAuthenticated$: Observable<boolean>;
constructor(private fileService: FileService,
private authService: AuthService) { }
private authService: AuthService,
private redirectService: HardRedirectService) {
}
ngOnInit() {
this.isAuthenticated$ = this.authService.isAuthenticated();
this.href = this.redirectService.rewriteDownloadURL(this.href);
}
/**
@@ -44,5 +48,4 @@ export class FileDownloadLinkComponent implements OnInit {
this.fileService.downloadFile(this.href);
return false;
}
}

View File

@@ -14,6 +14,7 @@ import { MenuEffects } from './menu.effects';
describe('MenuEffects', () => {
let menuEffects: MenuEffects;
let routeDataMenuSection: MenuSection;
let routeDataMenuSectionResolved: MenuSection;
let routeDataMenuChildSection: MenuSection;
let toBeRemovedMenuSection: MenuSection;
let alreadyPresentMenuSection: MenuSection;
@@ -23,13 +24,23 @@ describe('MenuEffects', () => {
function init() {
routeDataMenuSection = {
id: 'mockSection',
id: 'mockSection_:idparam',
active: false,
visible: true,
model: {
type: MenuItemType.LINK,
text: 'menu.section.mockSection',
link: ''
link: 'path/:linkparam'
} as LinkMenuItemModel
};
routeDataMenuSectionResolved = {
id: 'mockSection_id_param_resolved',
active: false,
visible: true,
model: {
type: MenuItemType.LINK,
text: 'menu.section.mockSection',
link: 'path/link_param_resolved'
} as LinkMenuItemModel
};
routeDataMenuChildSection = {
@@ -70,6 +81,10 @@ describe('MenuEffects', () => {
menu: {
[MenuID.PUBLIC]: [routeDataMenuSection, alreadyPresentMenuSection]
}
},
params: {
idparam: 'id_param_resolved',
linkparam: 'link_param_resolved',
}
},
firstChild: {
@@ -120,7 +135,7 @@ describe('MenuEffects', () => {
});
expect(menuEffects.buildRouteMenuSections$).toBeObservable(expected);
expect(menuService.addSection).toHaveBeenCalledWith(MenuID.PUBLIC, routeDataMenuSection);
expect(menuService.addSection).toHaveBeenCalledWith(MenuID.PUBLIC, routeDataMenuSectionResolved);
expect(menuService.addSection).toHaveBeenCalledWith(MenuID.PUBLIC, routeDataMenuChildSection);
expect(menuService.addSection).not.toHaveBeenCalledWith(MenuID.PUBLIC, alreadyPresentMenuSection);
expect(menuService.removeSection).toHaveBeenCalledWith(MenuID.PUBLIC, toBeRemovedMenuSection.id);

View File

@@ -19,7 +19,7 @@ export class MenuEffects {
/**
* On route change, build menu sections for every menu type depending on the current route data
*/
@Effect({ dispatch: false })
@Effect({dispatch: false})
public buildRouteMenuSections$: Observable<Action> = this.actions$
.pipe(
ofType(ROUTER_NAVIGATED),
@@ -68,17 +68,52 @@ export class MenuEffects {
*/
resolveRouteMenuSections(route: ActivatedRoute, menuID: MenuID): MenuSection[] {
const data = route.snapshot.data;
const params = route.snapshot.params;
const last: boolean = hasNoValue(route.firstChild);
if (hasValue(data) && hasValue(data.menu) && hasValue(data.menu[menuID])) {
let menuSections: MenuSection[] | MenuSection = data.menu[menuID];
menuSections = this.resolveSubstitutions(menuSections, params);
if (!last) {
return [...data.menu[menuID], ...this.resolveRouteMenuSections(route.firstChild, menuID)]
return [...menuSections, ...this.resolveRouteMenuSections(route.firstChild, menuID)]
} else {
return [...data.menu[menuID]];
return [...menuSections];
}
}
return !last ? this.resolveRouteMenuSections(route.firstChild, menuID) : [];
}
private resolveSubstitutions(object, params) {
let resolved;
if (typeof object === 'string') {
resolved = object;
let match: RegExpMatchArray;
do {
match = resolved.match(/:(\w+)/);
if (match) {
const substitute = params[match[1]];
if (hasValue(substitute)) {
resolved = resolved.replace(match[0], `${substitute}`);
}
}
} while (match);
} else if (Array.isArray(object)) {
resolved = [];
object.forEach((entry, index) => {
resolved[index] = this.resolveSubstitutions(object[index], params);
});
} else if (typeof object === 'object') {
resolved = {};
Object.keys(object).forEach((key) => {
resolved[key] = this.resolveSubstitutions(object[key], params);
});
} else {
resolved = object;
}
return resolved;
}
}

View File

@@ -0,0 +1,109 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { CollectionStatisticsPageComponent } from './collection-statistics-page.component';
import { StatisticsTableComponent } from '../statistics-table/statistics-table.component';
import { TranslateModule } from '@ngx-translate/core';
import { ActivatedRoute, Router } from '@angular/router';
import { UsageReportService } from '../../core/statistics/usage-report-data.service';
import { of as observableOf } from 'rxjs';
import { RemoteData } from '../../core/data/remote-data';
import { Collection } from '../../core/shared/collection.model';
import { DebugElement } from '@angular/core';
import { By } from '@angular/platform-browser';
import { UsageReport } from '../../core/statistics/models/usage-report.model';
import { SharedModule } from '../../shared/shared.module';
import { CommonModule } from '@angular/common';
import { DSONameService } from '../../core/breadcrumbs/dso-name.service';
import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service';
describe('CollectionStatisticsPageComponent', () => {
let component: CollectionStatisticsPageComponent;
let de: DebugElement;
let fixture: ComponentFixture<CollectionStatisticsPageComponent>;
beforeEach(async(() => {
const activatedRoute = {
data: observableOf({
scope: new RemoteData(
false,
false,
true,
undefined,
Object.assign(new Collection(), {
id: 'collection_id',
}),
)
})
};
const router = {
};
const usageReportService = {
getStatistic: (scope, type) => undefined,
};
spyOn(usageReportService, 'getStatistic').and.callFake(
(scope, type) => observableOf(
Object.assign(
new UsageReport(), {
id: `${scope}-${type}-report`,
points: [],
}
)
)
);
const nameService = {
getName: () => observableOf('test dso name'),
};
TestBed.configureTestingModule({
imports: [
TranslateModule.forRoot(),
CommonModule,
SharedModule,
],
declarations: [
CollectionStatisticsPageComponent,
StatisticsTableComponent,
],
providers: [
{ provide: ActivatedRoute, useValue: activatedRoute },
{ provide: Router, useValue: router },
{ provide: UsageReportService, useValue: usageReportService },
{ provide: DSpaceObjectDataService, useValue: {} },
{ provide: DSONameService, useValue: nameService },
],
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(CollectionStatisticsPageComponent);
component = fixture.componentInstance;
de = fixture.debugElement;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should resolve to the correct collection', () => {
expect(de.query(By.css('.header')).nativeElement.id)
.toEqual('collection_id');
});
it('should show a statistics table for each usage report', () => {
expect(de.query(By.css('ds-statistics-table.collection_id-TotalVisits-report')).nativeElement)
.toBeTruthy();
expect(de.query(By.css('ds-statistics-table.collection_id-TotalVisitsPerMonth-report')).nativeElement)
.toBeTruthy();
expect(de.query(By.css('ds-statistics-table.collection_id-TopCountries-report')).nativeElement)
.toBeTruthy();
expect(de.query(By.css('ds-statistics-table.collection_id-TopCities-report')).nativeElement)
.toBeTruthy();
});
});

View File

@@ -0,0 +1,41 @@
import { Component } from '@angular/core';
import { StatisticsPageComponent } from '../statistics-page/statistics-page.component';
import { UsageReportService } from '../../core/statistics/usage-report-data.service';
import { ActivatedRoute , Router} from '@angular/router';
import { Collection } from '../../core/shared/collection.model';
import { DSONameService } from '../../core/breadcrumbs/dso-name.service';
/**
* Component representing the statistics page for a collection.
*/
@Component({
selector: 'ds-collection-statistics-page',
templateUrl: '../statistics-page/statistics-page.component.html',
styleUrls: ['./collection-statistics-page.component.scss']
})
export class CollectionStatisticsPageComponent extends StatisticsPageComponent<Collection> {
/**
* The report types to show on this statistics page.
*/
types: string[] = [
'TotalVisits',
'TotalVisitsPerMonth',
'TopCountries',
'TopCities',
];
constructor(
protected route: ActivatedRoute,
protected router: Router,
protected usageReportService: UsageReportService,
protected nameService: DSONameService,
) {
super(
route,
router,
usageReportService,
nameService,
);
}
}

View File

@@ -0,0 +1,109 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { CommunityStatisticsPageComponent } from './community-statistics-page.component';
import { StatisticsTableComponent } from '../statistics-table/statistics-table.component';
import { TranslateModule } from '@ngx-translate/core';
import { ActivatedRoute, Router } from '@angular/router';
import { UsageReportService } from '../../core/statistics/usage-report-data.service';
import { of as observableOf } from 'rxjs';
import { RemoteData } from '../../core/data/remote-data';
import { Community } from '../../core/shared/community.model';
import { DebugElement } from '@angular/core';
import { By } from '@angular/platform-browser';
import { UsageReport } from '../../core/statistics/models/usage-report.model';
import { SharedModule } from '../../shared/shared.module';
import { CommonModule } from '@angular/common';
import { DSONameService } from '../../core/breadcrumbs/dso-name.service';
import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service';
describe('CommunityStatisticsPageComponent', () => {
let component: CommunityStatisticsPageComponent;
let de: DebugElement;
let fixture: ComponentFixture<CommunityStatisticsPageComponent>;
beforeEach(async(() => {
const activatedRoute = {
data: observableOf({
scope: new RemoteData(
false,
false,
true,
undefined,
Object.assign(new Community(), {
id: 'community_id',
}),
)
})
};
const router = {
};
const usageReportService = {
getStatistic: (scope, type) => undefined,
};
spyOn(usageReportService, 'getStatistic').and.callFake(
(scope, type) => observableOf(
Object.assign(
new UsageReport(), {
id: `${scope}-${type}-report`,
points: [],
}
)
)
);
const nameService = {
getName: () => observableOf('test dso name'),
};
TestBed.configureTestingModule({
imports: [
TranslateModule.forRoot(),
CommonModule,
SharedModule,
],
declarations: [
CommunityStatisticsPageComponent,
StatisticsTableComponent,
],
providers: [
{ provide: ActivatedRoute, useValue: activatedRoute },
{ provide: Router, useValue: router },
{ provide: UsageReportService, useValue: usageReportService },
{ provide: DSpaceObjectDataService, useValue: {} },
{ provide: DSONameService, useValue: nameService },
],
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(CommunityStatisticsPageComponent);
component = fixture.componentInstance;
de = fixture.debugElement;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should resolve to the correct community', () => {
expect(de.query(By.css('.header')).nativeElement.id)
.toEqual('community_id');
});
it('should show a statistics table for each usage report', () => {
expect(de.query(By.css('ds-statistics-table.community_id-TotalVisits-report')).nativeElement)
.toBeTruthy();
expect(de.query(By.css('ds-statistics-table.community_id-TotalVisitsPerMonth-report')).nativeElement)
.toBeTruthy();
expect(de.query(By.css('ds-statistics-table.community_id-TopCountries-report')).nativeElement)
.toBeTruthy();
expect(de.query(By.css('ds-statistics-table.community_id-TopCities-report')).nativeElement)
.toBeTruthy();
});
});

View File

@@ -0,0 +1,41 @@
import { Component } from '@angular/core';
import { StatisticsPageComponent } from '../statistics-page/statistics-page.component';
import { UsageReportService } from '../../core/statistics/usage-report-data.service';
import { ActivatedRoute, Router } from '@angular/router';
import { Community } from '../../core/shared/community.model';
import { DSONameService } from '../../core/breadcrumbs/dso-name.service';
/**
* Component representing the statistics page for a community.
*/
@Component({
selector: 'ds-community-statistics-page',
templateUrl: '../statistics-page/statistics-page.component.html',
styleUrls: ['./community-statistics-page.component.scss']
})
export class CommunityStatisticsPageComponent extends StatisticsPageComponent<Community> {
/**
* The report types to show on this statistics page.
*/
types: string[] = [
'TotalVisits',
'TotalVisitsPerMonth',
'TopCountries',
'TopCities',
];
constructor(
protected route: ActivatedRoute,
protected router: Router,
protected usageReportService: UsageReportService,
protected nameService: DSONameService,
) {
super(
route,
router,
usageReportService,
nameService,
);
}
}

View File

@@ -0,0 +1,111 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { ItemStatisticsPageComponent } from './item-statistics-page.component';
import { StatisticsTableComponent } from '../statistics-table/statistics-table.component';
import { TranslateModule } from '@ngx-translate/core';
import { ActivatedRoute, Router } from '@angular/router';
import { UsageReportService } from '../../core/statistics/usage-report-data.service';
import { of as observableOf } from 'rxjs';
import { RemoteData } from '../../core/data/remote-data';
import { Item } from '../../core/shared/item.model';
import { DebugElement } from '@angular/core';
import { By } from '@angular/platform-browser';
import { UsageReport } from '../../core/statistics/models/usage-report.model';
import { SharedModule } from '../../shared/shared.module';
import { CommonModule } from '@angular/common';
import { DSONameService } from '../../core/breadcrumbs/dso-name.service';
import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service';
describe('ItemStatisticsPageComponent', () => {
let component: ItemStatisticsPageComponent;
let de: DebugElement;
let fixture: ComponentFixture<ItemStatisticsPageComponent>;
beforeEach(async(() => {
const activatedRoute = {
data: observableOf({
scope: new RemoteData(
false,
false,
true,
undefined,
Object.assign(new Item(), {
id: 'item_id',
}),
)
})
};
const router = {
};
const usageReportService = {
getStatistic: (scope, type) => undefined,
};
spyOn(usageReportService, 'getStatistic').and.callFake(
(scope, type) => observableOf(
Object.assign(
new UsageReport(), {
id: `${scope}-${type}-report`,
points: [],
}
)
)
);
const nameService = {
getName: () => observableOf('test dso name'),
};
TestBed.configureTestingModule({
imports: [
TranslateModule.forRoot(),
CommonModule,
SharedModule,
],
declarations: [
ItemStatisticsPageComponent,
StatisticsTableComponent,
],
providers: [
{ provide: ActivatedRoute, useValue: activatedRoute },
{ provide: Router, useValue: router },
{ provide: UsageReportService, useValue: usageReportService },
{ provide: DSpaceObjectDataService, useValue: {} },
{ provide: DSONameService, useValue: nameService },
],
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(ItemStatisticsPageComponent);
component = fixture.componentInstance;
de = fixture.debugElement;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should resolve to the correct item', () => {
expect(de.query(By.css('.header')).nativeElement.id)
.toEqual('item_id');
});
it('should show a statistics table for each usage report', () => {
expect(de.query(By.css('ds-statistics-table.item_id-TotalVisits-report')).nativeElement)
.toBeTruthy();
expect(de.query(By.css('ds-statistics-table.item_id-TotalVisitsPerMonth-report')).nativeElement)
.toBeTruthy();
expect(de.query(By.css('ds-statistics-table.item_id-TotalDownloads-report')).nativeElement)
.toBeTruthy();
expect(de.query(By.css('ds-statistics-table.item_id-TopCountries-report')).nativeElement)
.toBeTruthy();
expect(de.query(By.css('ds-statistics-table.item_id-TopCities-report')).nativeElement)
.toBeTruthy();
});
});

View File

@@ -0,0 +1,42 @@
import { Component } from '@angular/core';
import { StatisticsPageComponent } from '../statistics-page/statistics-page.component';
import { UsageReportService } from '../../core/statistics/usage-report-data.service';
import { ActivatedRoute, Router } from '@angular/router';
import { Item } from '../../core/shared/item.model';
import { DSONameService } from '../../core/breadcrumbs/dso-name.service';
/**
* Component representing the statistics page for an item.
*/
@Component({
selector: 'ds-item-statistics-page',
templateUrl: '../statistics-page/statistics-page.component.html',
styleUrls: ['./item-statistics-page.component.scss']
})
export class ItemStatisticsPageComponent extends StatisticsPageComponent<Item> {
/**
* The report types to show on this statistics page.
*/
types: string[] = [
'TotalVisits',
'TotalVisitsPerMonth',
'TotalDownloads',
'TopCountries',
'TopCities',
];
constructor(
protected route: ActivatedRoute,
protected router: Router,
protected usageReportService: UsageReportService,
protected nameService: DSONameService,
) {
super(
route,
router,
usageReportService,
nameService,
);
}
}

View File

@@ -0,0 +1,100 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { SiteStatisticsPageComponent } from './site-statistics-page.component';
import { StatisticsTableComponent } from '../statistics-table/statistics-table.component';
import { TranslateModule } from '@ngx-translate/core';
import { ActivatedRoute, Router } from '@angular/router';
import { UsageReportService } from '../../core/statistics/usage-report-data.service';
import { of as observableOf } from 'rxjs';
import { Site } from '../../core/shared/site.model';
import { DebugElement } from '@angular/core';
import { By } from '@angular/platform-browser';
import { UsageReport } from '../../core/statistics/models/usage-report.model';
import { SharedModule } from '../../shared/shared.module';
import { CommonModule } from '@angular/common';
import { DSONameService } from '../../core/breadcrumbs/dso-name.service';
import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service';
import { SiteDataService } from '../../core/data/site-data.service';
describe('SiteStatisticsPageComponent', () => {
let component: SiteStatisticsPageComponent;
let de: DebugElement;
let fixture: ComponentFixture<SiteStatisticsPageComponent>;
beforeEach(async(() => {
const activatedRoute = {
};
const router = {
};
const usageReportService = {
searchStatistics: () => observableOf([
Object.assign(
new UsageReport(), {
id: `site_id-TotalVisits-report`,
points: [],
}
),
]),
};
const nameService = {
getName: () => observableOf('test dso name'),
};
const siteService = {
find: () => observableOf(Object.assign(new Site(), {
id: 'site_id',
_links: {
self: {
href: 'test_site_link',
},
},
}))
};
TestBed.configureTestingModule({
imports: [
TranslateModule.forRoot(),
CommonModule,
SharedModule,
],
declarations: [
SiteStatisticsPageComponent,
StatisticsTableComponent,
],
providers: [
{ provide: ActivatedRoute, useValue: activatedRoute },
{ provide: Router, useValue: router },
{ provide: UsageReportService, useValue: usageReportService },
{ provide: DSpaceObjectDataService, useValue: {} },
{ provide: DSONameService, useValue: nameService },
{ provide: SiteDataService, useValue: siteService },
],
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(SiteStatisticsPageComponent);
component = fixture.componentInstance;
de = fixture.debugElement;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should resolve to the correct site', () => {
expect(de.query(By.css('.header')).nativeElement.id)
.toEqual('site_id');
});
it('should show a statistics table for each usage report', () => {
expect(de.query(By.css('ds-statistics-table.site_id-TotalVisits-report')).nativeElement)
.toBeTruthy();
});
});

View File

@@ -0,0 +1,53 @@
import { Component } from '@angular/core';
import { StatisticsPageComponent } from '../statistics-page/statistics-page.component';
import { SiteDataService } from '../../core/data/site-data.service';
import { UsageReportService } from '../../core/statistics/usage-report-data.service';
import { ActivatedRoute, Router } from '@angular/router';
import { Site } from '../../core/shared/site.model';
import { DSONameService } from '../../core/breadcrumbs/dso-name.service';
import { switchMap } from 'rxjs/operators';
/**
* Component representing the site-wide statistics page.
*/
@Component({
selector: 'ds-site-statistics-page',
templateUrl: '../statistics-page/statistics-page.component.html',
styleUrls: ['./site-statistics-page.component.scss']
})
export class SiteStatisticsPageComponent extends StatisticsPageComponent<Site> {
/**
* The report types to show on this statistics page.
*/
types: string[] = [
'TotalVisits',
];
constructor(
protected route: ActivatedRoute,
protected router: Router,
protected usageReportService: UsageReportService,
protected nameService: DSONameService,
protected siteService: SiteDataService,
) {
super(
route,
router,
usageReportService,
nameService,
);
}
protected getScope$() {
return this.siteService.find();
}
protected getReports$() {
return this.scope$.pipe(
switchMap((scope) =>
this.usageReportService.searchStatistics(scope._links.self.href, 0, 10),
),
);
}
}

View File

@@ -0,0 +1,81 @@
import { NgModule } from '@angular/core';
import { RouterModule } from '@angular/router';
import { I18nBreadcrumbResolver } from '../core/breadcrumbs/i18n-breadcrumb.resolver';
import { I18nBreadcrumbsService } from '../core/breadcrumbs/i18n-breadcrumbs.service';
import { StatisticsPageModule } from './statistics-page.module';
import { SiteStatisticsPageComponent } from './site-statistics-page/site-statistics-page.component';
import { ItemPageResolver } from '../+item-page/item-page.resolver';
import { ItemStatisticsPageComponent } from './item-statistics-page/item-statistics-page.component';
import { CollectionPageResolver } from '../+collection-page/collection-page.resolver';
import { CollectionStatisticsPageComponent } from './collection-statistics-page/collection-statistics-page.component';
import { CommunityPageResolver } from '../+community-page/community-page.resolver';
import { CommunityStatisticsPageComponent } from './community-statistics-page/community-statistics-page.component';
@NgModule({
imports: [
StatisticsPageModule,
RouterModule.forChild([
{
path: '',
resolve: {
breadcrumb: I18nBreadcrumbResolver
},
data: {
title: 'statistics.title',
breadcrumbKey: 'statistics'
},
children: [
{
path: '',
component: SiteStatisticsPageComponent,
},
]
},
{
path: `items/:id`,
resolve: {
scope: ItemPageResolver,
breadcrumb: I18nBreadcrumbResolver
},
data: {
title: 'statistics.title',
breadcrumbKey: 'statistics'
},
component: ItemStatisticsPageComponent,
},
{
path: `collections/:id`,
resolve: {
scope: CollectionPageResolver,
breadcrumb: I18nBreadcrumbResolver
},
data: {
title: 'statistics.title',
breadcrumbKey: 'statistics'
},
component: CollectionStatisticsPageComponent,
},
{
path: `communities/:id`,
resolve: {
scope: CommunityPageResolver,
breadcrumb: I18nBreadcrumbResolver
},
data: {
title: 'statistics.title',
breadcrumbKey: 'statistics'
},
component: CommunityStatisticsPageComponent,
},
]
)
],
providers: [
I18nBreadcrumbResolver,
I18nBreadcrumbsService,
CollectionPageResolver,
CommunityPageResolver,
]
})
export class StatisticsPageRoutingModule {
}

View File

@@ -0,0 +1,39 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { CoreModule } from '../core/core.module';
import { SharedModule } from '../shared/shared.module';
import { StatisticsModule } from '../statistics/statistics.module';
import { UsageReportService } from '../core/statistics/usage-report-data.service';
import { SiteStatisticsPageComponent } from './site-statistics-page/site-statistics-page.component';
import { StatisticsTableComponent } from './statistics-table/statistics-table.component';
import { ItemStatisticsPageComponent } from './item-statistics-page/item-statistics-page.component';
import { CollectionStatisticsPageComponent } from './collection-statistics-page/collection-statistics-page.component';
import { CommunityStatisticsPageComponent } from './community-statistics-page/community-statistics-page.component';
const components = [
StatisticsTableComponent,
SiteStatisticsPageComponent,
ItemStatisticsPageComponent,
CollectionStatisticsPageComponent,
CommunityStatisticsPageComponent,
];
@NgModule({
imports: [
CommonModule,
SharedModule,
CoreModule.forRoot(),
StatisticsModule.forRoot()
],
declarations: components,
providers: [
UsageReportService,
],
exports: components
})
/**
* This module handles all components and pipes that are necessary for the search page
*/
export class StatisticsPageModule {
}

View File

@@ -0,0 +1,29 @@
<div class="container">
<ng-container *ngVar="(scope$ | async) as scope">
<h2 *ngIf="scope"
class="header"
id="{{ scope.id }}">
{{ 'statistics.header' | translate: { scope: getName(scope) } }}
</h2>
</ng-container>
<ng-container *ngVar="reports$ | async as reports">
<ng-container *ngIf="!reports">
<ds-loading></ds-loading>
</ng-container>
<ng-container *ngIf="reports">
<ds-statistics-table *ngFor="let report of reports"
[report]="report"
class="m-2 {{ report.id }}">
</ds-statistics-table>
<div *ngIf="!(hasData$ | async)">
{{ 'statistics.page.no-data' | translate }}
</div>
</ng-container>
</ng-container>
</div>

View File

@@ -0,0 +1,84 @@
import { OnInit } from '@angular/core';
import { combineLatest, Observable } from 'rxjs';
import { UsageReportService } from '../../core/statistics/usage-report-data.service';
import { map, switchMap } from 'rxjs/operators';
import { UsageReport } from '../../core/statistics/models/usage-report.model';
import { RemoteData } from '../../core/data/remote-data';
import { getRemoteDataPayload, getSucceededRemoteData, redirectToPageNotFoundOn404 } from '../../core/shared/operators';
import { DSpaceObject } from '../../core/shared/dspace-object.model';
import { ActivatedRoute, Router } from '@angular/router';
import { DSONameService } from '../../core/breadcrumbs/dso-name.service';
/**
* Class representing an abstract statistics page component.
*/
export abstract class StatisticsPageComponent<T extends DSpaceObject> implements OnInit {
/**
* The scope dso for this statistics page, as an Observable.
*/
scope$: Observable<DSpaceObject>;
/**
* The report types to show on this statistics page.
*/
types: string[];
/**
* The usage report types to show on this statistics page, as an Observable list.
*/
reports$: Observable<UsageReport[]>;
hasData$: Observable<boolean>;
constructor(
protected route: ActivatedRoute,
protected router: Router,
protected usageReportService: UsageReportService,
protected nameService: DSONameService,
) {
}
ngOnInit(): void {
this.scope$ = this.getScope$();
this.reports$ = this.getReports$();
this.hasData$ = this.reports$.pipe(
map((reports) => reports.some(
(report) => report.points.length > 0
)),
);
}
/**
* Get the scope dso for this statistics page, as an Observable.
*/
protected getScope$(): Observable<DSpaceObject> {
return this.route.data.pipe(
map((data) => data.scope as RemoteData<T>),
redirectToPageNotFoundOn404(this.router),
getSucceededRemoteData(),
getRemoteDataPayload(),
);
}
/**
* Get the usage reports for this statistics page, as an Observable list
*/
protected getReports$(): Observable<UsageReport[]> {
return this.scope$.pipe(
switchMap((scope) =>
combineLatest(
this.types.map((type) => this.usageReportService.getStatistic(scope.id, type))
),
),
);
}
/**
* Get the name of the scope dso.
* @param scope the scope dso to get the name for
*/
getName(scope: DSpaceObject): string {
return this.nameService.getName(scope);
}
}

View File

@@ -0,0 +1,36 @@
<div *ngIf="hasData"
class="m-1">
<h3 class="m-1">
{{ 'statistics.table.title.' + report.reportType | translate }}
</h3>
<table class="table table-striped">
<tbody>
<tr>
<th scope="col"></th>
<th scope="col"
*ngFor="let header of headers"
class="{{header}}-header">
{{ header }}
</th>
</tr>
<tr *ngFor="let point of report.points"
class="{{point.id}}-data">
<th scope="row">
{{ getLabel(point) | async }}
</th>
<td *ngFor="let header of headers"
class="{{point.id}}-{{header}}-data">
{{ point.values[header] }}
</td>
</tr>
</tbody>
</table>
</div>

View File

@@ -0,0 +1,8 @@
th, td {
padding: 0.5rem;
}
td {
width: 50px;
max-width: 50px;
}

View File

@@ -0,0 +1,98 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { StatisticsTableComponent } from './statistics-table.component';
import { UsageReport } from '../../core/statistics/models/usage-report.model';
import { DebugElement } from '@angular/core';
import { By } from '@angular/platform-browser';
import { TranslateModule } from '@ngx-translate/core';
import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service';
import { DSONameService } from '../../core/breadcrumbs/dso-name.service';
describe('StatisticsTableComponent', () => {
let component: StatisticsTableComponent;
let de: DebugElement;
let fixture: ComponentFixture<StatisticsTableComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [
TranslateModule.forRoot(),
],
declarations: [
StatisticsTableComponent,
],
providers: [
{ provide: DSpaceObjectDataService, useValue: {} },
{ provide: DSONameService, useValue: {} },
],
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(StatisticsTableComponent);
component = fixture.componentInstance;
de = fixture.debugElement;
component.report = Object.assign(new UsageReport(), {
points: [],
});
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
describe('when the storage report is empty', () => {
it ('should not display a table', () => {
expect(de.query(By.css('table'))).toBeNull();
});
});
describe('when the storage report has data', () => {
beforeEach(() => {
component.report = Object.assign(new UsageReport(), {
points: [
{
id: 'item_1',
values: {
views: 7,
downloads: 4,
},
},
{
id: 'item_2',
values: {
views: 8,
downloads: 8,
},
}
]
});
component.ngOnInit();
fixture.detectChanges();
});
it ('should display a table with the correct data', () => {
expect(de.query(By.css('table'))).toBeTruthy();
expect(de.query(By.css('th.views-header')).nativeElement.innerText)
.toEqual('views');
expect(de.query(By.css('th.downloads-header')).nativeElement.innerText)
.toEqual('downloads');
expect(de.query(By.css('td.item_1-views-data')).nativeElement.innerText)
.toEqual('7');
expect(de.query(By.css('td.item_1-downloads-data')).nativeElement.innerText)
.toEqual('4');
expect(de.query(By.css('td.item_2-views-data')).nativeElement.innerText)
.toEqual('8');
expect(de.query(By.css('td.item_2-downloads-data')).nativeElement.innerText)
.toEqual('8');
});
});
});

View File

@@ -0,0 +1,67 @@
import { Component, Input, OnInit } from '@angular/core';
import { Point, UsageReport } from '../../core/statistics/models/usage-report.model';
import { Observable, of } from 'rxjs';
import { DSONameService } from '../../core/breadcrumbs/dso-name.service';
import { map } from 'rxjs/operators';
import { getRemoteDataPayload, getSucceededRemoteData } from '../../core/shared/operators';
import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service';
/**
* Component representing a statistics table for a given usage report.
*/
@Component({
selector: 'ds-statistics-table',
templateUrl: './statistics-table.component.html',
styleUrls: ['./statistics-table.component.scss']
})
export class StatisticsTableComponent implements OnInit {
/**
* The usage report to display a statistics table for
*/
@Input()
report: UsageReport;
/**
* Boolean indicating whether the usage report has data
*/
hasData: boolean;
/**
* The table headers
*/
headers: string[];
constructor(
protected dsoService: DSpaceObjectDataService,
protected nameService: DSONameService,
) {
}
ngOnInit() {
this.hasData = this.report.points.length > 0;
if (this.hasData) {
this.headers = Object.keys(this.report.points[0].values);
}
}
/**
* Get the row label to display for a statistics point.
* @param point the statistics point to get the label for
*/
getLabel(point: Point): Observable<string> {
switch (this.report.reportType) {
case 'TotalVisits':
return this.dsoService.findById(point.id).pipe(
getSucceededRemoteData(),
getRemoteDataPayload(),
map((item) => this.nameService.getName(item)),
);
case 'TopCities':
case 'topCountries':
default:
return of(point.label);
}
}
}

View File

@@ -258,6 +258,10 @@
"admin.access-control.epeople.form.notification.edited.failure": "Failed to edit EPerson \"{{name}}\"",
"admin.access-control.epeople.form.notification.deleted.success": "Successfully deleted EPerson \"{{name}}\"",
"admin.access-control.epeople.form.notification.deleted.failure": "Failed to delete EPerson \"{{name}}\"",
"admin.access-control.epeople.form.groupsEPersonIsMemberOf": "Member of these groups:",
"admin.access-control.epeople.form.table.id": "ID",
@@ -1065,6 +1069,13 @@
"confirmation-modal.export-metadata.confirm": "Export",
"confirmation-modal.delete-eperson.header": "Delete EPerson \"{{ dsoName }}\"",
"confirmation-modal.delete-eperson.info": "Are you sure you want to delete EPerson \"{{ dsoName }}\"",
"confirmation-modal.delete-eperson.cancel": "Cancel",
"confirmation-modal.delete-eperson.confirm": "Delete",
"error.bitstream": "Error fetching bitstream",
@@ -1107,6 +1118,10 @@
"file-section.error.header": "Error obtaining files for this item",
"footer.copyright": "copyright © 2002-{{ year }}",
"footer.link.dspace": "DSpace software",
@@ -1437,6 +1452,8 @@
"item.edit.metadata.notifications.discarded.title": "Changed discarded",
"item.edit.metadata.notifications.error.title": "An error occurred",
"item.edit.metadata.notifications.invalid.content": "Your changes were not saved. Please make sure all fields are valid before you save.",
"item.edit.metadata.notifications.invalid.title": "Metadata invalid",
@@ -2846,6 +2863,30 @@
"statistics.title": "Statistics",
"statistics.header": "Statistics for {{ scope }}",
"statistics.breadcrumbs": "Statistics",
"statistics.page.no-data": "No data available",
"statistics.table.no-data": "No data available",
"statistics.table.title.TotalVisits": "Total visits",
"statistics.table.title.TotalVisitsPerMonth": "Total visits per month",
"statistics.table.title.TotalDownloads": "File Visits",
"statistics.table.title.TopCountries": "Top country views",
"statistics.table.title.TopCities": "Top city views",
"statistics.table.header.views": "Views",
"submission.edit.title": "Edit Submission",
"submission.general.cannot_submit": "You have not the privilege to make a new submission.",

View File

@@ -31,4 +31,5 @@ export interface GlobalConfig extends Config {
item: ItemPageConfig;
collection: CollectionPageConfig;
theme: Theme;
rewriteDownloadUrls: boolean;
}

View File

@@ -16,13 +16,12 @@ export const environment: GlobalConfig = {
},
// The REST API server settings.
// NOTE: these must be "synced" with the 'dspace.server.url' setting in your backend's local.cfg.
// The 'nameSpace' must always end in "/api" as that's the subpath of the REST API in the backend.
rest: {
ssl: true,
host: 'dspace7.4science.cloud',
port: 443,
// NOTE: Space is capitalized because 'namespace' is a reserved string in TypeScript
nameSpace: '/server/api',
nameSpace: '/server',
},
// Caching settings
cache: {
@@ -216,4 +215,6 @@ export const environment: GlobalConfig = {
theme: {
name: 'default',
},
// Whether the UI should rewrite file download URLs to match its domain. Only necessary to enable when running UI and REST API on separate domains
rewriteDownloadUrls: false,
};

View File

@@ -4,7 +4,7 @@ export const environment = {
* e.g.
* rest: {
* host: 'rest.api',
* nameSpace: '/rest/api',
* nameSpace: '/rest',
* }
*/
};