+ {{epersonDto.eperson.id}}
+ {{epersonDto.eperson.name}}
+ {{epersonDto.eperson.email}}
-
+ title="{{labelPrefix + 'table.edit.buttons.edit' | translate: {name: epersonDto.eperson.name} }}">
-
+ title="{{labelPrefix + 'table.edit.buttons.remove' | translate: {name: epersonDto.eperson.name} }}">
@@ -85,7 +85,7 @@
-
+
{{labelPrefix + 'no-items' | translate}}
diff --git a/src/app/+admin/admin-access-control/epeople-registry/epeople-registry.component.spec.ts b/src/app/+admin/admin-access-control/epeople-registry/epeople-registry.component.spec.ts
index 4cc68a5540..0ff07d688c 100644
--- a/src/app/+admin/admin-access-control/epeople-registry/epeople-registry.component.spec.ts
+++ b/src/app/+admin/admin-access-control/epeople-registry/epeople-registry.component.spec.ts
@@ -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);
+ });
+
+ })
+ })
});
diff --git a/src/app/+admin/admin-access-control/epeople-registry/epeople-registry.component.ts b/src/app/+admin/admin-access-control/epeople-registry/epeople-registry.component.ts
index e88ba84418..2f989490a7 100644
--- a/src/app/+admin/admin-access-control/epeople-registry/epeople-registry.component.ts
+++ b/src/app/+admin/admin-access-control/epeople-registry/epeople-registry.component.ts
@@ -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>>;
+ ePeople$: BehaviorSubject>> = new BehaviorSubject>>({} 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> = new BehaviorSubject>({} as any);
+
+ /**
+ * An observable for the pageInfo, needed to pass to the pagination component
+ */
+ pageInfoState$: BehaviorSubject = new BehaviorSubject(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();
+ });
+ }
}
diff --git a/src/app/+admin/admin-access-control/epeople-registry/eperson-form/eperson-form.component.html b/src/app/+admin/admin-access-control/epeople-registry/eperson-form/eperson-form.component.html
index 34fdef89bf..3f744240e5 100644
--- a/src/app/+admin/admin-access-control/epeople-registry/eperson-form/eperson-form.component.html
+++ b/src/app/+admin/admin-access-control/epeople-registry/eperson-form/eperson-form.component.html
@@ -17,7 +17,7 @@
{{'admin.access-control.epeople.actions.reset' | translate}}
-
+
{{'admin.access-control.epeople.actions.delete' | translate}}
diff --git a/src/app/+admin/admin-access-control/epeople-registry/eperson-form/eperson-form.component.spec.ts b/src/app/+admin/admin-access-control/epeople-registry/eperson-form/eperson-form.component.spec.ts
index 7d8be4fad3..1119107a85 100644
--- a/src/app/+admin/admin-access-control/epeople-registry/eperson-form/eperson-form.component.spec.ts
+++ b/src/app/+admin/admin-access-control/epeople-registry/eperson-form/eperson-form.component.spec.ts
@@ -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;
- 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);
+ });
+ })
});
diff --git a/src/app/+admin/admin-access-control/epeople-registry/eperson-form/eperson-form.component.ts b/src/app/+admin/admin-access-control/epeople-registry/eperson-form/eperson-form.component.ts
index 0031271313..61087034b6 100644
--- a/src/app/+admin/admin-access-control/epeople-registry/eperson-form/eperson-form.component.ts
+++ b/src/app/+admin/admin-access-control/epeople-registry/eperson-form/eperson-form.component.ts
@@ -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 = of(false);
+ canDelete$: Observable;
/**
* 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();
+ }
}
diff --git a/src/app/+collection-page/collection-page-routing.module.ts b/src/app/+collection-page/collection-page-routing.module.ts
index a03f2d0b5f..af2612911b 100644
--- a/src/app/+collection-page/collection-page-routing.module.ts
+++ b/src/app/+collection-page/collection-page-routing.module.ts
@@ -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,
+ }],
+ },
+ },
},
])
],
diff --git a/src/app/+community-page/community-page-routing.module.ts b/src/app/+community-page/community-page-routing.module.ts
index f266bd7df9..66a5a73198 100644
--- a/src/app/+community-page/community-page-routing.module.ts
+++ b/src/app/+community-page/community-page-routing.module.ts
@@ -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,
+ }],
+ },
+ },
},
])
],
diff --git a/src/app/+home-page/home-page-routing.module.ts b/src/app/+home-page/home-page-routing.module.ts
index 78da529906..c94df56643 100644
--- a/src/app/+home-page/home-page-routing.module.ts
+++ b/src/app/+home-page/home-page-routing.module.ts
@@ -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
}
diff --git a/src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.spec.ts b/src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.spec.ts
index 2b50a974ee..ed9ab4a891 100644
--- a/src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.spec.ts
+++ b/src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.spec.ts
@@ -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;
@@ -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);
});
});
diff --git a/src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.ts b/src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.ts
index 026e687d55..5ad6254459 100644
--- a/src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.ts
+++ b/src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.ts
@@ -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 = this.objectUpdatesService.getUpdatedFields(this.url, this.item.metadataAsList) as Observable;
- 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- ) => {
this.item = rd.payload;
diff --git a/src/app/+item-page/full/field-components/file-section/full-file-section.component.html b/src/app/+item-page/full/field-components/file-section/full-file-section.component.html
index c6f9f8e944..00218b66d1 100644
--- a/src/app/+item-page/full/field-components/file-section/full-file-section.component.html
+++ b/src/app/+item-page/full/field-components/file-section/full-file-section.component.html
@@ -1,87 +1,87 @@
-
-
0"
- [hideGear]="true"
- [hidePagerWhenSinglePage]="true"
- [paginationOptions]="originalOptions"
- [pageInfoState]="originals"
- [collectionSize]="originals?.totalElements"
- [disableRouteParameterUpdate]="true"
- (pageChange)="switchOriginalPage($event)">
+
+
+
0"
+ [hideGear]="true"
+ [hidePagerWhenSinglePage]="true"
+ [paginationOptions]="originalOptions"
+ [pageInfoState]="originals"
+ [collectionSize]="originals?.totalElements"
+ [disableRouteParameterUpdate]="true"
+ (pageChange)="switchOriginalPage($event)">
+
+
+
+
+
+
+ {{"item.page.filesection.name" | translate}}
+ {{file.name}}
-
-
-
+
{{"item.page.filesection.size" | translate}}
+ {{(file.sizeBytes) | dsFileSize }}
+
+
+ {{"item.page.filesection.format" | translate}}
+ {{(file.format | async)?.payload?.description}}
+
+
+ {{"item.page.filesection.description" | translate}}
+ {{file.firstMetadataValue("dc.description")}}
+
+
+
+
+ {{"item.page.filesection.download" | translate}}
+
+
-
-
- {{"item.page.filesection.name" | translate}}
- {{file.name}}
-
- {{"item.page.filesection.size" | translate}}
- {{(file.sizeBytes) | dsFileSize }}
-
-
- {{"item.page.filesection.format" | translate}}
- {{(file.format | async)?.payload?.description}}
-
-
- {{"item.page.filesection.description" | translate}}
- {{file.firstMetadataValue("dc.description")}}
-
-
-
-
- {{"item.page.filesection.download" | translate}}
-
-
-
-
+
+
-
-
-
0"
- [hideGear]="true"
- [hidePagerWhenSinglePage]="true"
- [paginationOptions]="licenseOptions"
- [pageInfoState]="licenses"
- [collectionSize]="licenses?.totalElements"
- [disableRouteParameterUpdate]="true"
- (pageChange)="switchLicensePage($event)">
+
+
+
0"
+ [hideGear]="true"
+ [hidePagerWhenSinglePage]="true"
+ [paginationOptions]="licenseOptions"
+ [pageInfoState]="licenses"
+ [collectionSize]="licenses?.totalElements"
+ [disableRouteParameterUpdate]="true"
+ (pageChange)="switchLicensePage($event)">
+
+
+
+
+
+
+ {{"item.page.filesection.name" | translate}}
+ {{file.name}}
-
-
-
+
{{"item.page.filesection.size" | translate}}
+ {{(file.sizeBytes) | dsFileSize }}
+
+ {{"item.page.filesection.format" | translate}}
+ {{(file.format | async)?.payload?.description}}
+
+
+ {{"item.page.filesection.description" | translate}}
+ {{file.firstMetadataValue("dc.description")}}
+
+
+
+
+ {{"item.page.filesection.download" | translate}}
+
+
-
-
- {{"item.page.filesection.name" | translate}}
- {{file.name}}
-
- {{"item.page.filesection.size" | translate}}
- {{(file.sizeBytes) | dsFileSize }}
-
-
- {{"item.page.filesection.format" | translate}}
- {{(file.format | async)?.payload?.description}}
-
-
- {{"item.page.filesection.description" | translate}}
- {{file.firstMetadataValue("dc.description")}}
-
-
-
-
- {{"item.page.filesection.download" | translate}}
-
-
-
-
+
+
diff --git a/src/app/+item-page/full/field-components/file-section/full-file-section.component.spec.ts b/src/app/+item-page/full/field-components/file-section/full-file-section.component.spec.ts
index 970420f252..4d4b713648 100644
--- a/src/app/+item-page/full/field-components/file-section/full-file-section.component.spec.ts
+++ b/src/app/+item-page/full/field-components/file-section/full-file-section.component.spec.ts
@@ -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]
diff --git a/src/app/+item-page/full/field-components/file-section/full-file-section.component.ts b/src/app/+item-page/full/field-components/file-section/full-file-section.component.ts
index fdbe662ed9..bd3b2f7063 100644
--- a/src/app/+item-page/full/field-components/file-section/full-file-section.component.ts
+++ b/src/app/+item-page/full/field-components/file-section/full-file-section.component.ts
@@ -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>>;
pageSize = 5;
- originalOptions = Object.assign(new PaginationComponentOptions(),{
+ originalOptions = Object.assign(new PaginationComponentOptions(), {
id: 'original-bitstreams-options',
currentPage: 1,
pageSize: this.pageSize
});
originalCurrentPage$ = new BehaviorSubject(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(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>) => {
+ 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>) => {
+ 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) {
+ return hasValue(bundle) && !isEmpty(bundle.page);
+ }
}
diff --git a/src/app/+item-page/item-page-routing.module.ts b/src/app/+item-page/item-page-routing.module.ts
index 66dbcbb10d..e4f17326a4 100644
--- a/src/app/+item-page/item-page-routing.module.ts
+++ b/src/app/+item-page/item-page-routing.module.ts
@@ -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,
+ }],
+ },
+ },
}
])
],
diff --git a/src/app/+item-page/simple/field-components/file-section/file-section.component.spec.ts b/src/app/+item-page/simple/field-components/file-section/file-section.component.spec.ts
index 1b7fa75ce5..330aaadfe0 100644
--- a/src/app/+item-page/simple/field-components/file-section/file-section.component.spec.ts
+++ b/src/app/+item-page/simple/field-components/file-section/file-section.component.spec.ts
@@ -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]
diff --git a/src/app/+item-page/simple/field-components/file-section/file-section.component.ts b/src/app/+item-page/simple/field-components/file-section/file-section.component.ts
index 25b214e200..4b60691e09 100644
--- a/src/app/+item-page/simple/field-components/file-section/file-section.component.ts
+++ b/src/app/+item-page/simple/field-components/file-section/file-section.component.ts
@@ -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>) => hasValue(bitstreamsRD)),
- takeWhile((bitstreamsRD: RemoteData>) => hasNoValue(bitstreamsRD.payload) && hasNoValue(bitstreamsRD.error), true)
+ this.bitstreamDataService.findAllByItemAndBundleName(this.item, 'ORIGINAL', {
+ currentPage: this.currentPage,
+ elementsPerPage: this.pageSize
+ }).pipe(
+ filter((bitstreamsRD: RemoteData>) => hasValue(bitstreamsRD) && (hasValue(bitstreamsRD.error) || hasValue(bitstreamsRD.payload))),
+ take(1),
).subscribe((bitstreamsRD: RemoteData>) => {
- 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;
+ }
});
}
}
diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts
index 50e2f6b532..ecb27efbb3 100644
--- a/src/app/app-routing.module.ts
+++ b/src/app/app-routing.module.ts
@@ -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 },
]}
],
diff --git a/src/app/core/cache/object-cache.service.ts b/src/app/core/cache/object-cache.service.ts
index d82a1f31fe..745133373d 100644
--- a/src/app/core/cache/object-cache.service.ts
+++ b/src/app/core/cache/object-cache.service.ts
@@ -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));
diff --git a/src/app/core/core.module.ts b/src/app/core/core.module.ts
index 63fd8119b4..2203377603 100644
--- a/src/app/core/core.module.ts
+++ b/src/app/core/core.module.ts
@@ -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({
diff --git a/src/app/core/data/bitstream-data.service.ts b/src/app/core/data/bitstream-data.service.ts
index 4c24f5d78b..ca0338116f 100644
--- a/src/app/core/data/bitstream-data.service.ts
+++ b/src/app/core/data/bitstream-data.service.ts
@@ -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 {
public findAllByItemAndBundleName(item: Item, bundleName: string, options?: FindListOptions, ...linksToFollow: Array>): Observable>> {
return this.bundleService.findByItemAndName(item, bundleName).pipe(
switchMap((bundleRD: RemoteData) => {
- 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];
}
diff --git a/src/app/core/data/bundle-data.service.ts b/src/app/core/data/bundle-data.service.ts
index de0e8a4337..e651ed354f 100644
--- a/src/app/core/data/bundle-data.service.ts
+++ b/src/app/core/data/bundle-data.service.ts
@@ -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 {
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;
}
diff --git a/src/app/core/data/feature-authorization/feature-id.ts b/src/app/core/data/feature-authorization/feature-id.ts
index 27d6618e44..450d5057aa 100644
--- a/src/app/core/data/feature-authorization/feature-id.ts
+++ b/src/app/core/data/feature-authorization/feature-id.ts
@@ -4,6 +4,7 @@
export enum FeatureID {
LoginOnBehalfOf = 'loginOnBehalfOf',
AdministratorOf = 'administratorOf',
+ CanDelete = 'canDelete',
WithdrawItem = 'withdrawItem',
ReinstateItem = 'reinstateItem',
EPersonRegistration = 'epersonRegistration',
diff --git a/src/app/core/data/item-template-data.service.ts b/src/app/core/data/item-template-data.service.ts
index a44d48e9bd..4f26f47eee 100644
--- a/src/app/core/data/item-template-data.service.ts
+++ b/src/app/core/data/item-template-data.service.ts
@@ -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- {
return this.dataService.update(object);
}
+ patch(dso: Item, operations: Operation[]): Observable
{
+ return this.dataService.patch(dso, operations);
+ }
+
/**
* Find an item template by collection ID
* @param collectionID
diff --git a/src/app/core/data/object-updates/object-updates.actions.ts b/src/app/core/data/object-updates/object-updates.actions.ts
index f26be768b1..ff0babdd14 100644
--- a/src/app/core/data/object-updates/object-updates.actions.ts
+++ b/src/app/core/data/object-updates/object-updates.actions.ts
@@ -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
};
/**
@@ -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
) {
- this.payload = { url, fields, lastModified };
+ this.payload = { url, fields, lastModified, patchOperationServiceToken };
}
}
diff --git a/src/app/core/data/object-updates/object-updates.reducer.spec.ts b/src/app/core/data/object-updates/object-updates.reducer.spec.ts
index cb7f44039c..4a14e2e874 100644
--- a/src/app/core/data/object-updates/object-updates.reducer.spec.ts
+++ b/src/app/core/data/object-updates/object-updates.reducer.spec.ts
@@ -231,7 +231,8 @@ describe('objectUpdatesReducer', () => {
},
fieldUpdates: {},
virtualMetadataSources: {},
- lastModified: modDate
+ lastModified: modDate,
+ patchOperationServiceToken: undefined
}
};
const newState = objectUpdatesReducer(testState, action);
diff --git a/src/app/core/data/object-updates/object-updates.reducer.ts b/src/app/core/data/object-updates/object-updates.reducer.ts
index b1626a5ff5..94bb845aa8 100644
--- a/src/app/core/data/object-updates/object-updates.reducer.ts
+++ b/src/app/core/data/object-updates/object-updates.reducer.ts
@@ -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;
}
/**
@@ -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 = 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 });
}
diff --git a/src/app/core/data/object-updates/object-updates.service.spec.ts b/src/app/core/data/object-updates/object-updates.service.spec.ts
index 04018b8de2..ae73dc851f 100644
--- a/src/app/core/data/object-updates/object-updates.service.spec.ts
+++ b/src/app/core/data/object-updates/object-updates.service.spec.ts
@@ -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(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();
+ });
+ });
+ });
+
});
diff --git a/src/app/core/data/object-updates/object-updates.service.ts b/src/app/core/data/object-updates/object-updates.service.ts
index 84f0f06035..8bd32e54e2 100644
--- a/src/app/core/data/object-updates/object-updates.service.ts
+++ b/src/app/core/data/object-updates/object-updates.service.ts
@@ -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 {
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) {
+ constructor(private store: Store,
+ 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): void {
+ this.store.dispatch(new InitializeFieldsAction(url, fields, lastModified, patchOperationServiceToken));
}
/**
@@ -339,4 +343,22 @@ export class ObjectUpdatesService {
getLastModified(url: string): Observable {
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 {
+ return this.getObjectEntry(url).pipe(
+ map((entry) => {
+ let patch = [];
+ if (hasValue(entry.patchOperationServiceToken)) {
+ patch = this.injector.get(entry.patchOperationServiceToken).fieldUpdatesToPatchOperations(entry.fieldUpdates);
+ }
+ return patch;
+ })
+ );
+ }
}
diff --git a/src/app/core/data/object-updates/patch-operation-service/metadata-patch-operation.service.spec.ts b/src/app/core/data/object-updates/patch-operation-service/metadata-patch-operation.service.spec.ts
new file mode 100644
index 0000000000..f3578b1bde
--- /dev/null
+++ b/src/app/core/data/object-updates/patch-operation-service/metadata-patch-operation.service.spec.ts
@@ -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);
+ });
+ });
+ });
+});
diff --git a/src/app/core/data/object-updates/patch-operation-service/metadata-patch-operation.service.ts b/src/app/core/data/object-updates/patch-operation-service/metadata-patch-operation.service.ts
new file mode 100644
index 0000000000..3b590cf58c
--- /dev/null
+++ b/src/app/core/data/object-updates/patch-operation-service/metadata-patch-operation.service.ts
@@ -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', {
+ 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();
+ 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;
+ }
+
+}
diff --git a/src/app/core/data/object-updates/patch-operation-service/operations/metadata/metadata-patch-add-operation.model.ts b/src/app/core/data/object-updates/patch-operation-service/operations/metadata/metadata-patch-add-operation.model.ts
new file mode 100644
index 0000000000..7f9b1d772f
--- /dev/null
+++ b/src/app/core/data/object-updates/patch-operation-service/operations/metadata/metadata-patch-add-operation.model.ts
@@ -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 };
+ }
+}
diff --git a/src/app/core/data/object-updates/patch-operation-service/operations/metadata/metadata-patch-operation.model.ts b/src/app/core/data/object-updates/patch-operation-service/operations/metadata/metadata-patch-operation.model.ts
new file mode 100644
index 0000000000..fb7c826fc9
--- /dev/null
+++ b/src/app/core/data/object-updates/patch-operation-service/operations/metadata/metadata-patch-operation.model.ts
@@ -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;
+}
diff --git a/src/app/core/data/object-updates/patch-operation-service/operations/metadata/metadata-patch-remove-operation.model.ts b/src/app/core/data/object-updates/patch-operation-service/operations/metadata/metadata-patch-remove-operation.model.ts
new file mode 100644
index 0000000000..61fbae1980
--- /dev/null
+++ b/src/app/core/data/object-updates/patch-operation-service/operations/metadata/metadata-patch-remove-operation.model.ts
@@ -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}` };
+ }
+}
diff --git a/src/app/core/data/object-updates/patch-operation-service/operations/metadata/metadata-patch-replace-operation.model.ts b/src/app/core/data/object-updates/patch-operation-service/operations/metadata/metadata-patch-replace-operation.model.ts
new file mode 100644
index 0000000000..e889bede0b
--- /dev/null
+++ b/src/app/core/data/object-updates/patch-operation-service/operations/metadata/metadata-patch-replace-operation.model.ts
@@ -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 };
+ }
+}
diff --git a/src/app/core/data/object-updates/patch-operation-service/patch-operation.service.ts b/src/app/core/data/object-updates/patch-operation-service/patch-operation.service.ts
new file mode 100644
index 0000000000..7c67f9a2e5
--- /dev/null
+++ b/src/app/core/data/object-updates/patch-operation-service/patch-operation.service.ts
@@ -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[];
+}
diff --git a/src/app/core/data/update-data.service.ts b/src/app/core/data/update-data.service.ts
index 34835e14c1..f572c75982 100644
--- a/src/app/core/data/update-data.service.ts
+++ b/src/app/core/data/update-data.service.ts
@@ -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 {
+ patch(dso: T, operations: Operation[]): Observable;
update(object: T): Observable>;
commitUpdates(method?: RestRequestMethod);
}
diff --git a/src/app/core/dspace-rest-v2/dspace-rest-v2.service.ts b/src/app/core/dspace-rest-v2/dspace-rest-v2.service.ts
index 8cb730c358..5394b6d83f 100644
--- a/src/app/core/dspace-rest-v2/dspace-rest-v2.service.ts
+++ b/src/app/core/dspace-rest-v2/dspace-rest-v2.service.ts
@@ -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
});
}));
}
diff --git a/src/app/core/eperson/eperson-data.service.ts b/src/app/core/eperson/eperson-data.service.ts
index 415977a46f..a1428aee73 100644
--- a/src/app/core/eperson/eperson-data.service.ts
+++ b/src/app/core/eperson/eperson-data.service.ts
@@ -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 {
* Method to delete an EPerson
* @param ePerson The EPerson to delete
*/
- public deleteEPerson(ePerson: EPerson): Observable {
- return this.delete(ePerson.id).pipe(map((response: RestResponse) => response.isSuccessful));
+ public deleteEPerson(ePerson: EPerson): Observable {
+ return this.delete(ePerson.id);
}
/**
@@ -299,34 +299,4 @@ export class EPersonDataService extends DataService {
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>}
- * Return an observable that emits response from the server
- */
- searchBy(searchMethod: string, options: FindListOptions = {}, ...linksToFollow: Array>): Observable>> {
- 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(hrefObs, ...linksToFollow) as Observable>>
- )
- );
- }
-
}
diff --git a/src/app/core/eperson/models/eperson-dto.model.ts b/src/app/core/eperson/models/eperson-dto.model.ts
new file mode 100644
index 0000000000..f491f6f8be
--- /dev/null
+++ b/src/app/core/eperson/models/eperson-dto.model.ts
@@ -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;
+
+}
diff --git a/src/app/core/metadata/metadata.service.spec.ts b/src/app/core/metadata/metadata.service.spec.ts
index 74c4522bbd..28fe8e1acc 100644
--- a/src/app/core/metadata/metadata.service.spec.ts
+++ b/src/app/core/metadata/metadata.service.spec.ts
@@ -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: ` `
+ template: `
+ `
})
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');
}));
diff --git a/src/app/core/metadata/metadata.service.ts b/src/app/core/metadata/metadata.service.ts
index 02d2b0c86b..90171bac10 100644
--- a/src/app/core/metadata/metadata.service.ts
+++ b/src/app/core/metadata/metadata.service.ts
@@ -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);
}
});
}
diff --git a/src/app/core/services/browser-hard-redirect.service.spec.ts b/src/app/core/services/browser-hard-redirect.service.spec.ts
index 9d4c5df9a2..64518579aa 100644
--- a/src/app/core/services/browser-hard-redirect.service.spec.ts
+++ b/src/app/core/services/browser-hard-redirect.service.spec.ts
@@ -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);
+ });
+ });
+
});
diff --git a/src/app/core/services/browser-hard-redirect.service.ts b/src/app/core/services/browser-hard-redirect.service.ts
index 0d14b6b834..4b7424bee2 100644
--- a/src/app/core/services/browser-hard-redirect.service.ts
+++ b/src/app/core/services/browser-hard-redirect.service.ts
@@ -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;
+ }
}
diff --git a/src/app/core/services/hard-redirect.service.spec.ts b/src/app/core/services/hard-redirect.service.spec.ts
new file mode 100644
index 0000000000..f7e2ba8955
--- /dev/null
+++ b/src/app/core/services/hard-redirect.service.spec.ts
@@ -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;
+ }
+}
diff --git a/src/app/core/services/hard-redirect.service.ts b/src/app/core/services/hard-redirect.service.ts
index 09757a1250..a09521dae5 100644
--- a/src/app/core/services/hard-redirect.service.ts
+++ b/src/app/core/services/hard-redirect.service.ts
@@ -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;
+ }
+ }
}
diff --git a/src/app/core/services/server-hard-redirect.service.spec.ts b/src/app/core/services/server-hard-redirect.service.spec.ts
index 2d09c21eb9..dc89517468 100644
--- a/src/app/core/services/server-hard-redirect.service.spec.ts
+++ b/src/app/core/services/server-hard-redirect.service.spec.ts
@@ -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);
+ });
+ });
+
});
diff --git a/src/app/core/services/server-hard-redirect.service.ts b/src/app/core/services/server-hard-redirect.service.ts
index 79755d2dc9..65b404ca6c 100644
--- a/src/app/core/services/server-hard-redirect.service.ts
+++ b/src/app/core/services/server-hard-redirect.service.ts
@@ -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;
+ }
}
diff --git a/src/app/core/shared/operators.ts b/src/app/core/shared/operators.ts
index 17823c0447..ad2588f2b9 100644
--- a/src/app/core/shared/operators.ts
+++ b/src/app/core/shared/operators.ts
@@ -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
diff --git a/src/app/core/statistics/models/usage-report.model.ts b/src/app/core/statistics/models/usage-report.model.ts
new file mode 100644
index 0000000000..4350bfd7d8
--- /dev/null
+++ b/src/app/core/statistics/models/usage-report.model.ts
@@ -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;
+ }>;
+}
diff --git a/src/app/core/statistics/models/usage-report.resource-type.ts b/src/app/core/statistics/models/usage-report.resource-type.ts
new file mode 100644
index 0000000000..650a51b3c3
--- /dev/null
+++ b/src/app/core/statistics/models/usage-report.resource-type.ts
@@ -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');
diff --git a/src/app/core/statistics/usage-report-data.service.ts b/src/app/core/statistics/usage-report-data.service.ts
new file mode 100644
index 0000000000..08dd111384
--- /dev/null
+++ b/src/app/core/statistics/usage-report-data.service.ts
@@ -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 {
+
+ protected linkPath = 'statistics/usagereports';
+
+ constructor(
+ protected comparator: DefaultChangeAnalyzer,
+ protected halService: HALEndpointService,
+ protected http: HttpClient,
+ protected notificationsService: NotificationsService,
+ protected objectCache: ObjectCacheService,
+ protected rdbService: RemoteDataBuildService,
+ protected requestService: RequestService,
+ protected store: Store,
+ ) {
+ super();
+ }
+
+ getStatistic(scope: string, type: string): Observable {
+ return this.findById(`${scope}_${type}`).pipe(
+ getSucceededRemoteData(),
+ getRemoteDataPayload(),
+ );
+ }
+
+ searchStatistics(uri: string, page: number, size: number): Observable {
+ return this.searchBy('object', {
+ searchParams: [{
+ fieldName: `uri`,
+ fieldValue: uri,
+ }],
+ currentPage: page,
+ elementsPerPage: size,
+ }).pipe(
+ getSucceededRemoteData(),
+ getRemoteDataPayload(),
+ map((list) => list.page),
+ );
+ }
+}
diff --git a/src/app/info/info-routing-paths.ts b/src/app/info/info-routing-paths.ts
new file mode 100644
index 0000000000..8ec6dbcb8d
--- /dev/null
+++ b/src/app/info/info-routing-paths.ts
@@ -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}`;
+}
diff --git a/src/app/info/info-routing.module.ts b/src/app/info/info-routing.module.ts
index 86ff7fb334..799572f9b9 100644
--- a/src/app/info/info-routing.module.ts
+++ b/src/app/info/info-routing.module.ts
@@ -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: [
diff --git a/src/app/navbar/navbar.component.ts b/src/app/navbar/navbar.component.ts
index ae9000352a..454b68a81c 100644
--- a/src/app/navbar/navbar.component.ts
+++ b/src/app/navbar/navbar.component.ts
@@ -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;
diff --git a/src/app/shared/file-download-link/file-download-link.component.spec.ts b/src/app/shared/file-download-link/file-download-link.component.spec.ts
index ac1751d43d..56702787d3 100644
--- a/src/app/shared/file-download-link/file-download-link.component.spec.ts
+++ b/src/app/shared/file-download-link/file-download-link.component.spec.ts
@@ -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(() => {
diff --git a/src/app/shared/file-download-link/file-download-link.component.ts b/src/app/shared/file-download-link/file-download-link.component.ts
index 9df7c191ff..71283642c9 100644
--- a/src/app/shared/file-download-link/file-download-link.component.ts
+++ b/src/app/shared/file-download-link/file-download-link.component.ts
@@ -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;
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;
}
-
}
diff --git a/src/app/shared/menu/menu.effects.spec.ts b/src/app/shared/menu/menu.effects.spec.ts
index 11b468eded..911dbe1de6 100644
--- a/src/app/shared/menu/menu.effects.spec.ts
+++ b/src/app/shared/menu/menu.effects.spec.ts
@@ -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);
diff --git a/src/app/shared/menu/menu.effects.ts b/src/app/shared/menu/menu.effects.ts
index be314cfd49..a9aa07daad 100644
--- a/src/app/shared/menu/menu.effects.ts
+++ b/src/app/shared/menu/menu.effects.ts
@@ -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 = 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;
+ }
}
diff --git a/src/app/statistics-page/collection-statistics-page/collection-statistics-page.component.scss b/src/app/statistics-page/collection-statistics-page/collection-statistics-page.component.scss
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/src/app/statistics-page/collection-statistics-page/collection-statistics-page.component.spec.ts b/src/app/statistics-page/collection-statistics-page/collection-statistics-page.component.spec.ts
new file mode 100644
index 0000000000..110757670c
--- /dev/null
+++ b/src/app/statistics-page/collection-statistics-page/collection-statistics-page.component.spec.ts
@@ -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;
+
+ 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();
+ });
+});
diff --git a/src/app/statistics-page/collection-statistics-page/collection-statistics-page.component.ts b/src/app/statistics-page/collection-statistics-page/collection-statistics-page.component.ts
new file mode 100644
index 0000000000..05f4641d81
--- /dev/null
+++ b/src/app/statistics-page/collection-statistics-page/collection-statistics-page.component.ts
@@ -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 {
+
+ /**
+ * 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,
+ );
+ }
+}
diff --git a/src/app/statistics-page/community-statistics-page/community-statistics-page.component.scss b/src/app/statistics-page/community-statistics-page/community-statistics-page.component.scss
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/src/app/statistics-page/community-statistics-page/community-statistics-page.component.spec.ts b/src/app/statistics-page/community-statistics-page/community-statistics-page.component.spec.ts
new file mode 100644
index 0000000000..a5771dfb38
--- /dev/null
+++ b/src/app/statistics-page/community-statistics-page/community-statistics-page.component.spec.ts
@@ -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;
+
+ 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();
+ });
+});
diff --git a/src/app/statistics-page/community-statistics-page/community-statistics-page.component.ts b/src/app/statistics-page/community-statistics-page/community-statistics-page.component.ts
new file mode 100644
index 0000000000..65d5fe88e5
--- /dev/null
+++ b/src/app/statistics-page/community-statistics-page/community-statistics-page.component.ts
@@ -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 {
+
+ /**
+ * 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,
+ );
+ }
+}
diff --git a/src/app/statistics-page/item-statistics-page/item-statistics-page.component.scss b/src/app/statistics-page/item-statistics-page/item-statistics-page.component.scss
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/src/app/statistics-page/item-statistics-page/item-statistics-page.component.spec.ts b/src/app/statistics-page/item-statistics-page/item-statistics-page.component.spec.ts
new file mode 100644
index 0000000000..c0bf98ef19
--- /dev/null
+++ b/src/app/statistics-page/item-statistics-page/item-statistics-page.component.spec.ts
@@ -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;
+
+ 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();
+ });
+});
diff --git a/src/app/statistics-page/item-statistics-page/item-statistics-page.component.ts b/src/app/statistics-page/item-statistics-page/item-statistics-page.component.ts
new file mode 100644
index 0000000000..fb9ced4520
--- /dev/null
+++ b/src/app/statistics-page/item-statistics-page/item-statistics-page.component.ts
@@ -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- {
+
+ /**
+ * 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,
+ );
+ }
+}
diff --git a/src/app/statistics-page/site-statistics-page/site-statistics-page.component.scss b/src/app/statistics-page/site-statistics-page/site-statistics-page.component.scss
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/src/app/statistics-page/site-statistics-page/site-statistics-page.component.spec.ts b/src/app/statistics-page/site-statistics-page/site-statistics-page.component.spec.ts
new file mode 100644
index 0000000000..6f2247b433
--- /dev/null
+++ b/src/app/statistics-page/site-statistics-page/site-statistics-page.component.spec.ts
@@ -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
;
+
+ 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();
+ });
+});
diff --git a/src/app/statistics-page/site-statistics-page/site-statistics-page.component.ts b/src/app/statistics-page/site-statistics-page/site-statistics-page.component.ts
new file mode 100644
index 0000000000..fd1319723c
--- /dev/null
+++ b/src/app/statistics-page/site-statistics-page/site-statistics-page.component.ts
@@ -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 {
+
+ /**
+ * 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),
+ ),
+ );
+ }
+}
diff --git a/src/app/statistics-page/statistics-page-routing.module.ts b/src/app/statistics-page/statistics-page-routing.module.ts
new file mode 100644
index 0000000000..44943b94d2
--- /dev/null
+++ b/src/app/statistics-page/statistics-page-routing.module.ts
@@ -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 {
+}
diff --git a/src/app/statistics-page/statistics-page.module.ts b/src/app/statistics-page/statistics-page.module.ts
new file mode 100644
index 0000000000..068ded63aa
--- /dev/null
+++ b/src/app/statistics-page/statistics-page.module.ts
@@ -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 {
+}
diff --git a/src/app/statistics-page/statistics-page/statistics-page.component.html b/src/app/statistics-page/statistics-page/statistics-page.component.html
new file mode 100644
index 0000000000..5cf1e9c8b5
--- /dev/null
+++ b/src/app/statistics-page/statistics-page/statistics-page.component.html
@@ -0,0 +1,29 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ 'statistics.page.no-data' | translate }}
+
+
+
+
+
+
diff --git a/src/app/statistics-page/statistics-page/statistics-page.component.scss b/src/app/statistics-page/statistics-page/statistics-page.component.scss
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/src/app/statistics-page/statistics-page/statistics-page.component.ts b/src/app/statistics-page/statistics-page/statistics-page.component.ts
new file mode 100644
index 0000000000..e034a35dca
--- /dev/null
+++ b/src/app/statistics-page/statistics-page/statistics-page.component.ts
@@ -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 implements OnInit {
+
+ /**
+ * The scope dso for this statistics page, as an Observable.
+ */
+ scope$: Observable;
+
+ /**
+ * 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;
+
+ hasData$: Observable;
+
+ 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 {
+ return this.route.data.pipe(
+ map((data) => data.scope as RemoteData),
+ redirectToPageNotFoundOn404(this.router),
+ getSucceededRemoteData(),
+ getRemoteDataPayload(),
+ );
+ }
+
+ /**
+ * Get the usage reports for this statistics page, as an Observable list
+ */
+ protected getReports$(): Observable {
+ 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);
+ }
+}
diff --git a/src/app/statistics-page/statistics-table/statistics-table.component.html b/src/app/statistics-page/statistics-table/statistics-table.component.html
new file mode 100644
index 0000000000..3ecd256812
--- /dev/null
+++ b/src/app/statistics-page/statistics-table/statistics-table.component.html
@@ -0,0 +1,36 @@
+
+
+
+ {{ 'statistics.table.title.' + report.reportType | translate }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ getLabel(point) | async }}
+
+
+
+
+
+
+
+
+
diff --git a/src/app/statistics-page/statistics-table/statistics-table.component.scss b/src/app/statistics-page/statistics-table/statistics-table.component.scss
new file mode 100644
index 0000000000..4e173c040a
--- /dev/null
+++ b/src/app/statistics-page/statistics-table/statistics-table.component.scss
@@ -0,0 +1,8 @@
+th, td {
+ padding: 0.5rem;
+}
+
+td {
+ width: 50px;
+ max-width: 50px;
+}
diff --git a/src/app/statistics-page/statistics-table/statistics-table.component.spec.ts b/src/app/statistics-page/statistics-table/statistics-table.component.spec.ts
new file mode 100644
index 0000000000..f22adea37d
--- /dev/null
+++ b/src/app/statistics-page/statistics-table/statistics-table.component.spec.ts
@@ -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;
+
+ 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');
+ });
+ });
+});
diff --git a/src/app/statistics-page/statistics-table/statistics-table.component.ts b/src/app/statistics-page/statistics-table/statistics-table.component.ts
new file mode 100644
index 0000000000..8924fb8a7c
--- /dev/null
+++ b/src/app/statistics-page/statistics-table/statistics-table.component.ts
@@ -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 {
+ 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);
+ }
+ }
+}
diff --git a/src/assets/i18n/en.json5 b/src/assets/i18n/en.json5
index 600ce0aed1..84d874388c 100644
--- a/src/assets/i18n/en.json5
+++ b/src/assets/i18n/en.json5
@@ -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.",
diff --git a/src/config/global-config.interface.ts b/src/config/global-config.interface.ts
index acef3404eb..07ee4ca444 100644
--- a/src/config/global-config.interface.ts
+++ b/src/config/global-config.interface.ts
@@ -31,4 +31,5 @@ export interface GlobalConfig extends Config {
item: ItemPageConfig;
collection: CollectionPageConfig;
theme: Theme;
+ rewriteDownloadUrls: boolean;
}
diff --git a/src/environments/environment.common.ts b/src/environments/environment.common.ts
index 32ae2f54b0..4f73339690 100644
--- a/src/environments/environment.common.ts
+++ b/src/environments/environment.common.ts
@@ -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,
};
diff --git a/src/environments/environment.template.ts b/src/environments/environment.template.ts
index d0282ad607..6f6510e1ab 100644
--- a/src/environments/environment.template.ts
+++ b/src/environments/environment.template.ts
@@ -4,7 +4,7 @@ export const environment = {
* e.g.
* rest: {
* host: 'rest.api',
- * nameSpace: '/rest/api',
+ * nameSpace: '/rest',
* }
*/
};