+ {{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/+admin/admin-workflow-page/admin-workflow-search-results/admin-workflow-search-result-grid-element/workflow-item/workflow-item-search-result-admin-workflow-grid-element.component.spec.ts b/src/app/+admin/admin-workflow-page/admin-workflow-search-results/admin-workflow-search-result-grid-element/workflow-item/workflow-item-search-result-admin-workflow-grid-element.component.spec.ts
index 2f3f88fa70..6436f2d873 100644
--- a/src/app/+admin/admin-workflow-page/admin-workflow-search-results/admin-workflow-search-result-grid-element/workflow-item/workflow-item-search-result-admin-workflow-grid-element.component.spec.ts
+++ b/src/app/+admin/admin-workflow-page/admin-workflow-search-results/admin-workflow-search-result-grid-element/workflow-item/workflow-item-search-result-admin-workflow-grid-element.component.spec.ts
@@ -18,6 +18,7 @@ import { WorkflowItemSearchResult } from '../../../../../shared/object-collectio
import { BitstreamDataService } from '../../../../../core/data/bitstream-data.service';
import { createSuccessfulRemoteDataObject$ } from '../../../../../shared/remote-data.utils';
import { getMockLinkService } from '../../../../../shared/mocks/link-service.mock';
+import { of as observableOf } from 'rxjs';
describe('WorkflowItemAdminWorkflowGridElementComponent', () => {
let component: WorkflowItemSearchResultAdminWorkflowGridElementComponent;
@@ -50,7 +51,9 @@ describe('WorkflowItemAdminWorkflowGridElementComponent', () => {
],
providers: [
{ provide: LinkService, useValue: linkService },
- { provide: TruncatableService, useValue: {} },
+ { provide: TruncatableService, useValue: {
+ isCollapsed: () => observableOf(true),
+ } },
{ provide: BitstreamDataService, useValue: {} },
],
schemas: [NO_ERRORS_SCHEMA]
diff --git a/src/app/+bitstream-page/bitstream-page.resolver.ts b/src/app/+bitstream-page/bitstream-page.resolver.ts
index 8e9f64fcc1..9c85688c39 100644
--- a/src/app/+bitstream-page/bitstream-page.resolver.ts
+++ b/src/app/+bitstream-page/bitstream-page.resolver.ts
@@ -6,6 +6,7 @@ import { find } from 'rxjs/operators';
import { hasValue } from '../shared/empty.util';
import { Bitstream } from '../core/shared/bitstream.model';
import { BitstreamDataService } from '../core/data/bitstream-data.service';
+import {followLink, FollowLinkConfig} from '../shared/utils/follow-link-config.model';
/**
* This class represents a resolver that requests a specific bitstream before the route is activated
@@ -23,9 +24,20 @@ export class BitstreamPageResolver implements Resolve> {
* or an error if something went wrong
*/
resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable> {
- return this.bitstreamService.findById(route.params.id)
+ return this.bitstreamService.findById(route.params.id, ...this.followLinks)
.pipe(
find((RD) => hasValue(RD.error) || RD.hasSucceeded),
);
}
+ /**
+ * Method that returns the follow links to already resolve
+ * The self links defined in this list are expected to be requested somewhere in the near future
+ * Requesting them as embeds will limit the number of requests
+ */
+ get followLinks(): Array> {
+ return [
+ followLink('bundle', undefined, true, followLink('item')),
+ followLink('format')
+ ];
+ }
}
diff --git a/src/app/+bitstream-page/edit-bitstream-page/edit-bitstream-page.component.spec.ts b/src/app/+bitstream-page/edit-bitstream-page/edit-bitstream-page.component.spec.ts
index c802622dc4..ce46c2a7b3 100644
--- a/src/app/+bitstream-page/edit-bitstream-page/edit-bitstream-page.component.spec.ts
+++ b/src/app/+bitstream-page/edit-bitstream-page/edit-bitstream-page.component.spec.ts
@@ -4,7 +4,7 @@ import { TranslateModule } from '@ngx-translate/core';
import { RouterTestingModule } from '@angular/router/testing';
import { RemoteData } from '../../core/data/remote-data';
import { of as observableOf } from 'rxjs/internal/observable/of';
-import { ActivatedRoute } from '@angular/router';
+import {ActivatedRoute, Router} from '@angular/router';
import { DynamicFormControlModel, DynamicFormService } from '@ng-dynamic-forms/core';
import { NotificationsService } from '../../shared/notifications/notifications.service';
import { BitstreamDataService } from '../../core/data/bitstream-data.service';
@@ -22,6 +22,11 @@ import { PageInfo } from '../../core/shared/page-info.model';
import { FileSizePipe } from '../../shared/utils/file-size-pipe';
import { RestResponse } from '../../core/cache/response.models';
import { VarDirective } from '../../shared/utils/var.directive';
+import {
+ createSuccessfulRemoteDataObject$
+} from '../../shared/remote-data.utils';
+import {RouterStub} from '../../shared/testing/router.stub';
+import { getItemEditRoute } from '../../+item-page/item-page-routing-paths';
const infoNotification: INotification = new Notification('id', NotificationType.Info, 'info');
const warningNotification: INotification = new Notification('id', NotificationType.Warning, 'warning');
@@ -34,6 +39,8 @@ let bitstreamFormatService: BitstreamFormatDataService;
let bitstream: Bitstream;
let selectedFormat: BitstreamFormat;
let allFormats: BitstreamFormat[];
+let router: Router;
+let routerStub;
describe('EditBitstreamPageComponent', () => {
let comp: EditBitstreamPageComponent;
@@ -105,7 +112,12 @@ describe('EditBitstreamPageComponent', () => {
format: observableOf(new RemoteData(false, false, true, null, selectedFormat)),
_links: {
self: 'bitstream-selflink'
- }
+ },
+ bundle: createSuccessfulRemoteDataObject$({
+ item: createSuccessfulRemoteDataObject$({
+ uuid: 'some-uuid'
+ })
+ })
});
bitstreamService = jasmine.createSpyObj('bitstreamService', {
findById: observableOf(new RemoteData(false, false, true, null, bitstream)),
@@ -118,6 +130,10 @@ describe('EditBitstreamPageComponent', () => {
findAll: observableOf(new RemoteData(false, false, true, null, new PaginatedList(new PageInfo(), allFormats)))
});
+ const itemPageUrl = `fake-url/some-uuid`;
+ routerStub = Object.assign(new RouterStub(), {
+ url: `${itemPageUrl}`
+ });
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot(), RouterTestingModule],
declarations: [EditBitstreamPageComponent, FileSizePipe, VarDirective],
@@ -127,6 +143,7 @@ describe('EditBitstreamPageComponent', () => {
{ provide: ActivatedRoute, useValue: { data: observableOf({ bitstream: new RemoteData(false, false, true, null, bitstream) }), snapshot: { queryParams: {} } } },
{ provide: BitstreamDataService, useValue: bitstreamService },
{ provide: BitstreamFormatDataService, useValue: bitstreamFormatService },
+ { provide: Router, useValue: routerStub },
ChangeDetectorRef
],
schemas: [NO_ERRORS_SCHEMA]
@@ -138,6 +155,7 @@ describe('EditBitstreamPageComponent', () => {
fixture = TestBed.createComponent(EditBitstreamPageComponent);
comp = fixture.componentInstance;
fixture.detectChanges();
+ router = (comp as any).router;
});
describe('on startup', () => {
@@ -213,4 +231,25 @@ describe('EditBitstreamPageComponent', () => {
});
});
});
+ describe('when the cancel button is clicked', () => {
+ it('should call navigateToItemEditBitstreams method', () => {
+ spyOn(comp, 'navigateToItemEditBitstreams');
+ comp.onCancel();
+ expect(comp.navigateToItemEditBitstreams).toHaveBeenCalled();
+ });
+ });
+ describe('when navigateToItemEditBitstreams is called, and the component has an itemId', () => {
+ it('should redirect to the item edit page on the bitstreams tab with the itemId from the component', () => {
+ comp.itemId = 'some-uuid1'
+ comp.navigateToItemEditBitstreams();
+ expect(routerStub.navigate).toHaveBeenCalledWith([getItemEditRoute('some-uuid1'), 'bitstreams']);
+ });
+ });
+ describe('when navigateToItemEditBitstreams is called, and the component does not have an itemId', () => {
+ it('should redirect to the item edit page on the bitstreams tab with the itemId from the bundle links ', () => {
+ comp.itemId = undefined;
+ comp.navigateToItemEditBitstreams();
+ expect(routerStub.navigate).toHaveBeenCalledWith([getItemEditRoute('some-uuid'), 'bitstreams']);
+ });
+ });
});
diff --git a/src/app/+bitstream-page/edit-bitstream-page/edit-bitstream-page.component.ts b/src/app/+bitstream-page/edit-bitstream-page/edit-bitstream-page.component.ts
index 3e8b686e48..ad64739dac 100644
--- a/src/app/+bitstream-page/edit-bitstream-page/edit-bitstream-page.component.ts
+++ b/src/app/+bitstream-page/edit-bitstream-page/edit-bitstream-page.component.ts
@@ -1,7 +1,7 @@
import { ChangeDetectionStrategy, Component, OnDestroy, OnInit } from '@angular/core';
import { Bitstream } from '../../core/shared/bitstream.model';
import { ActivatedRoute, Router } from '@angular/router';
-import { filter, map, switchMap } from 'rxjs/operators';
+import { map, mergeMap, switchMap} from 'rxjs/operators';
import { combineLatest as observableCombineLatest, of as observableOf } from 'rxjs';
import { Subscription } from 'rxjs/internal/Subscription';
import {
@@ -19,7 +19,7 @@ import { DynamicCustomSwitchModel } from '../../shared/form/builder/ds-dynamic-f
import { cloneDeep } from 'lodash';
import { BitstreamDataService } from '../../core/data/bitstream-data.service';
import {
- getAllSucceededRemoteData, getAllSucceededRemoteDataPayload,
+ getAllSucceededRemoteDataPayload,
getFirstSucceededRemoteDataPayload,
getRemoteDataPayload,
getSucceededRemoteData
@@ -35,8 +35,9 @@ import { Location } from '@angular/common';
import { Observable } from 'rxjs/internal/Observable';
import { RemoteData } from '../../core/data/remote-data';
import { PaginatedList } from '../../core/data/paginated-list';
-import { followLink } from '../../shared/utils/follow-link-config.model';
import { getItemEditRoute } from '../../+item-page/item-page-routing-paths';
+import {Bundle} from '../../core/shared/bundle.model';
+import {Item} from '../../core/shared/item.model';
@Component({
selector: 'ds-edit-bitstream-page',
@@ -299,12 +300,7 @@ export class EditBitstreamPageComponent implements OnInit, OnDestroy {
const bitstream$ = this.bitstreamRD$.pipe(
getSucceededRemoteData(),
- getRemoteDataPayload(),
- switchMap((bitstream: Bitstream) => this.bitstreamService.findById(bitstream.id, followLink('format')).pipe(
- getAllSucceededRemoteData(),
- getRemoteDataPayload(),
- filter((bs: Bitstream) => hasValue(bs)))
- )
+ getRemoteDataPayload()
);
const allFormats$ = this.bitstreamFormatsRD$.pipe(
@@ -501,14 +497,18 @@ export class EditBitstreamPageComponent implements OnInit, OnDestroy {
}
/**
- * When the item ID is present, navigate back to the item's edit bitstreams page, otherwise go back to the previous
- * page the user came from
+ * When the item ID is present, navigate back to the item's edit bitstreams page,
+ * otherwise retrieve the item ID based on the owning bundle's link
*/
navigateToItemEditBitstreams() {
if (hasValue(this.itemId)) {
this.router.navigate([getItemEditRoute(this.itemId), 'bitstreams']);
} else {
- this.location.back();
+ this.bitstream.bundle.pipe(getFirstSucceededRemoteDataPayload(),
+ mergeMap((bundle: Bundle) => bundle.item.pipe(getFirstSucceededRemoteDataPayload(), map((item: Item) => item.uuid))))
+ .subscribe((item) => {
+ this.router.navigate(([getItemEditRoute(item), 'bitstreams']));
+ });
}
}
diff --git a/src/app/+collection-page/collection-page-administrator.guard.ts b/src/app/+collection-page/collection-page-administrator.guard.ts
new file mode 100644
index 0000000000..4d2f246689
--- /dev/null
+++ b/src/app/+collection-page/collection-page-administrator.guard.ts
@@ -0,0 +1,30 @@
+import { Injectable } from '@angular/core';
+import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from '@angular/router';
+import { Collection } from '../core/shared/collection.model';
+import { CollectionPageResolver } from './collection-page.resolver';
+import { AuthorizationDataService } from '../core/data/feature-authorization/authorization-data.service';
+import { of as observableOf } from 'rxjs';
+import { DsoPageFeatureGuard } from '../core/data/feature-authorization/feature-authorization-guard/dso-page-feature.guard';
+import { Observable } from 'rxjs/internal/Observable';
+import { FeatureID } from '../core/data/feature-authorization/feature-id';
+
+@Injectable({
+ providedIn: 'root'
+})
+/**
+ * Guard for preventing unauthorized access to certain {@link Collection} pages requiring administrator rights
+ */
+export class CollectionPageAdministratorGuard extends DsoPageFeatureGuard {
+ constructor(protected resolver: CollectionPageResolver,
+ protected authorizationService: AuthorizationDataService,
+ protected router: Router) {
+ super(resolver, authorizationService, router);
+ }
+
+ /**
+ * Check administrator authorization rights
+ */
+ getFeatureID(next: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable {
+ return observableOf(FeatureID.AdministratorOf);
+ }
+}
diff --git a/src/app/+collection-page/collection-page-routing.module.ts b/src/app/+collection-page/collection-page-routing.module.ts
index 479a136140..af2612911b 100644
--- a/src/app/+collection-page/collection-page-routing.module.ts
+++ b/src/app/+collection-page/collection-page-routing.module.ts
@@ -19,6 +19,9 @@ import {
COLLECTION_EDIT_PATH,
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: [
@@ -39,7 +42,7 @@ import {
{
path: COLLECTION_EDIT_PATH,
loadChildren: './edit-collection-page/edit-collection-page.module#EditCollectionPageModule',
- canActivate: [AuthenticatedGuard]
+ canActivate: [CollectionPageAdministratorGuard]
},
{
path: 'delete',
@@ -68,7 +71,21 @@ import {
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,
+ }],
+ },
+ },
},
])
],
@@ -78,7 +95,8 @@ import {
CollectionBreadcrumbResolver,
DSOBreadcrumbsService,
LinkService,
- CreateCollectionPageGuard
+ CreateCollectionPageGuard,
+ CollectionPageAdministratorGuard
]
})
export class CollectionPageRoutingModule {
diff --git a/src/app/+collection-page/collection-page.component.ts b/src/app/+collection-page/collection-page.component.ts
index c7d287ed6a..a390bda4cb 100644
--- a/src/app/+collection-page/collection-page.component.ts
+++ b/src/app/+collection-page/collection-page.component.ts
@@ -17,7 +17,7 @@ import { DSpaceObjectType } from '../core/shared/dspace-object-type.model';
import { Item } from '../core/shared/item.model';
import {
getSucceededRemoteData,
- redirectToPageNotFoundOn404,
+ redirectOn404Or401,
toDSpaceObjectListRD
} from '../core/shared/operators';
@@ -63,7 +63,7 @@ export class CollectionPageComponent implements OnInit {
ngOnInit(): void {
this.collectionRD$ = this.route.data.pipe(
map((data) => data.dso as RemoteData),
- redirectToPageNotFoundOn404(this.router),
+ redirectOn404Or401(this.router),
take(1)
);
this.logoRD$ = this.collectionRD$.pipe(
diff --git a/src/app/+community-page/community-page-administrator.guard.ts b/src/app/+community-page/community-page-administrator.guard.ts
new file mode 100644
index 0000000000..c5e58ddb1a
--- /dev/null
+++ b/src/app/+community-page/community-page-administrator.guard.ts
@@ -0,0 +1,30 @@
+import { Injectable } from '@angular/core';
+import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from '@angular/router';
+import { Community } from '../core/shared/community.model';
+import { CommunityPageResolver } from './community-page.resolver';
+import { AuthorizationDataService } from '../core/data/feature-authorization/authorization-data.service';
+import { of as observableOf } from 'rxjs';
+import { DsoPageFeatureGuard } from '../core/data/feature-authorization/feature-authorization-guard/dso-page-feature.guard';
+import { Observable } from 'rxjs/internal/Observable';
+import { FeatureID } from '../core/data/feature-authorization/feature-id';
+
+@Injectable({
+ providedIn: 'root'
+})
+/**
+ * Guard for preventing unauthorized access to certain {@link Community} pages requiring administrator rights
+ */
+export class CommunityPageAdministratorGuard extends DsoPageFeatureGuard {
+ constructor(protected resolver: CommunityPageResolver,
+ protected authorizationService: AuthorizationDataService,
+ protected router: Router) {
+ super(resolver, authorizationService, router);
+ }
+
+ /**
+ * Check administrator authorization rights
+ */
+ getFeatureID(next: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable {
+ return observableOf(FeatureID.AdministratorOf);
+ }
+}
diff --git a/src/app/+community-page/community-page-routing.module.ts b/src/app/+community-page/community-page-routing.module.ts
index 08520ab8d4..66a5a73198 100644
--- a/src/app/+community-page/community-page-routing.module.ts
+++ b/src/app/+community-page/community-page-routing.module.ts
@@ -11,6 +11,9 @@ import { CommunityBreadcrumbResolver } from '../core/breadcrumbs/community-bread
import { DSOBreadcrumbsService } from '../core/breadcrumbs/dso-breadcrumbs.service';
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: [
@@ -31,7 +34,7 @@ import { COMMUNITY_EDIT_PATH, COMMUNITY_CREATE_PATH } from './community-page-rou
{
path: COMMUNITY_EDIT_PATH,
loadChildren: './edit-community-page/edit-community-page.module#EditCommunityPageModule',
- canActivate: [AuthenticatedGuard]
+ canActivate: [CommunityPageAdministratorGuard]
},
{
path: 'delete',
@@ -44,7 +47,21 @@ import { COMMUNITY_EDIT_PATH, COMMUNITY_CREATE_PATH } from './community-page-rou
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,
+ }],
+ },
+ },
},
])
],
@@ -53,7 +70,8 @@ import { COMMUNITY_EDIT_PATH, COMMUNITY_CREATE_PATH } from './community-page-rou
CommunityBreadcrumbResolver,
DSOBreadcrumbsService,
LinkService,
- CreateCommunityPageGuard
+ CreateCommunityPageGuard,
+ CommunityPageAdministratorGuard
]
})
export class CommunityPageRoutingModule {
diff --git a/src/app/+community-page/community-page.component.ts b/src/app/+community-page/community-page.component.ts
index 3621829927..7c93b1bf4e 100644
--- a/src/app/+community-page/community-page.component.ts
+++ b/src/app/+community-page/community-page.component.ts
@@ -13,7 +13,7 @@ import { MetadataService } from '../core/metadata/metadata.service';
import { fadeInOut } from '../shared/animations/fade';
import { hasValue } from '../shared/empty.util';
-import { redirectToPageNotFoundOn404 } from '../core/shared/operators';
+import { redirectOn404Or401 } from '../core/shared/operators';
@Component({
selector: 'ds-community-page',
@@ -47,7 +47,7 @@ export class CommunityPageComponent implements OnInit {
ngOnInit(): void {
this.communityRD$ = this.route.data.pipe(
map((data) => data.dso as RemoteData),
- redirectToPageNotFoundOn404(this.router)
+ redirectOn404Or401(this.router)
);
this.logoRD$ = this.communityRD$.pipe(
map((rd: RemoteData) => rd.payload),
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/abstract-item-update/abstract-item-update.component.ts b/src/app/+item-page/edit-item-page/abstract-item-update/abstract-item-update.component.ts
index 6f97ec3057..bde2b5a1b0 100644
--- a/src/app/+item-page/edit-item-page/abstract-item-update/abstract-item-update.component.ts
+++ b/src/app/+item-page/edit-item-page/abstract-item-update/abstract-item-update.component.ts
@@ -123,7 +123,7 @@ export class AbstractItemUpdateComponent extends AbstractTrackableComponent impl
/**
* Check if the current page is entirely valid
*/
- protected isValid() {
+ public isValid() {
return this.objectUpdatesService.isValidPage(this.url);
}
diff --git a/src/app/+item-page/edit-item-page/edit-item-page.routing.module.ts b/src/app/+item-page/edit-item-page/edit-item-page.routing.module.ts
index 7006c5dc89..3acbd77c40 100644
--- a/src/app/+item-page/edit-item-page/edit-item-page.routing.module.ts
+++ b/src/app/+item-page/edit-item-page/edit-item-page.routing.module.ts
@@ -29,6 +29,8 @@ import {
ITEM_EDIT_REINSTATE_PATH,
ITEM_EDIT_WITHDRAW_PATH
} from './edit-item-page.routing-paths';
+import { ItemPageReinstateGuard } from './item-page-reinstate.guard';
+import { ItemPageWithdrawGuard } from './item-page-withdraw.guard';
/**
* Routing module that handles the routing for the Edit Item page administrator functionality
@@ -98,10 +100,12 @@ import {
{
path: ITEM_EDIT_WITHDRAW_PATH,
component: ItemWithdrawComponent,
+ canActivate: [ItemPageWithdrawGuard]
},
{
path: ITEM_EDIT_REINSTATE_PATH,
component: ItemReinstateComponent,
+ canActivate: [ItemPageReinstateGuard]
},
{
path: ITEM_EDIT_PRIVATE_PATH,
@@ -154,7 +158,9 @@ import {
I18nBreadcrumbResolver,
I18nBreadcrumbsService,
ResourcePolicyResolver,
- ResourcePolicyTargetResolver
+ ResourcePolicyTargetResolver,
+ ItemPageReinstateGuard,
+ ItemPageWithdrawGuard
]
})
export class EditItemPageRoutingModule {
diff --git a/src/app/+item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.html b/src/app/+item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.html
index 80c78941c8..8f0776e4d3 100644
--- a/src/app/+item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.html
+++ b/src/app/+item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.html
@@ -5,14 +5,15 @@
-
-
diff --git a/src/app/+item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.spec.ts b/src/app/+item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.spec.ts
index 19765f0e09..60419f41b2 100644
--- a/src/app/+item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.spec.ts
+++ b/src/app/+item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.spec.ts
@@ -1,11 +1,12 @@
import { CUSTOM_ELEMENTS_SCHEMA, DebugElement } from '@angular/core';
-import { async, ComponentFixture, TestBed } from '@angular/core/testing';
+import { async, ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing';
import { FormsModule } from '@angular/forms';
import { By } from '@angular/platform-browser';
import { TranslateModule } from '@ngx-translate/core';
import { getTestScheduler } from 'jasmine-marbles';
import { of as observableOf } from 'rxjs';
import { TestScheduler } from 'rxjs/testing';
+import { MetadataFieldDataService } from '../../../../core/data/metadata-field-data.service';
import { FieldChangeType } from '../../../../core/data/object-updates/object-updates.actions';
import { ObjectUpdatesService } from '../../../../core/data/object-updates/object-updates.service';
import { PaginatedList } from '../../../../core/data/paginated-list';
@@ -14,9 +15,14 @@ import { MetadataSchema } from '../../../../core/metadata/metadata-schema.model'
import { RegistryService } from '../../../../core/registry/registry.service';
import { MetadatumViewModel } from '../../../../core/shared/metadata.models';
import { InputSuggestion } from '../../../../shared/input-suggestions/input-suggestions.model';
-import { SharedModule } from '../../../../shared/shared.module';
-import { createSuccessfulRemoteDataObject$ } from '../../../../shared/remote-data.utils';
+import {
+ createSuccessfulRemoteDataObject$
+} from '../../../../shared/remote-data.utils';
+import { followLink } from '../../../../shared/utils/follow-link-config.model';
import { EditInPlaceFieldComponent } from './edit-in-place-field.component';
+import { FilterInputSuggestionsComponent } from '../../../../shared/input-suggestions/filter-suggestions/filter-input-suggestions.component';
+import { MockComponent, MockDirective } from 'ng-mocks';
+import { DebounceDirective } from '../../../../shared/utils/debounce.directive';
let comp: EditInPlaceFieldComponent;
let fixture: ComponentFixture;
@@ -25,17 +31,21 @@ let el: HTMLElement;
let metadataFieldService;
let objectUpdatesService;
let paginatedMetadataFields;
-const mdSchema = Object.assign(new MetadataSchema(), { prefix: 'dc' })
+const mdSchema = Object.assign(new MetadataSchema(), { prefix: 'dc' });
+const mdSchemaRD$ = createSuccessfulRemoteDataObject$(mdSchema);
const mdField1 = Object.assign(new MetadataField(), {
- schema: mdSchema,
+ schema: mdSchemaRD$,
element: 'contributor',
qualifier: 'author'
});
-const mdField2 = Object.assign(new MetadataField(), { schema: mdSchema, element: 'title' });
+const mdField2 = Object.assign(new MetadataField(), {
+ schema: mdSchemaRD$,
+ element: 'title'
+});
const mdField3 = Object.assign(new MetadataField(), {
- schema: mdSchema,
+ schema: mdSchemaRD$,
element: 'description',
- qualifier: 'abstract'
+ qualifier: 'abstract',
});
const metadatum = Object.assign(new MetadatumViewModel(), {
@@ -74,11 +84,16 @@ describe('EditInPlaceFieldComponent', () => {
);
TestBed.configureTestingModule({
- imports: [FormsModule, SharedModule, TranslateModule.forRoot()],
- declarations: [EditInPlaceFieldComponent],
+ imports: [FormsModule, TranslateModule.forRoot()],
+ declarations: [
+ EditInPlaceFieldComponent,
+ MockDirective(DebounceDirective),
+ MockComponent(FilterInputSuggestionsComponent)
+ ],
providers: [
{ provide: RegistryService, useValue: metadataFieldService },
{ provide: ObjectUpdatesService, useValue: objectUpdatesService },
+ { provide: MetadataFieldDataService, useValue: {} }
], schemas: [
CUSTOM_ELEMENTS_SCHEMA
]
@@ -94,13 +109,12 @@ describe('EditInPlaceFieldComponent', () => {
comp.url = url;
comp.fieldUpdate = fieldUpdate;
comp.metadata = metadatum;
-
- fixture.detectChanges();
});
describe('update', () => {
beforeEach(() => {
comp.update();
+ fixture.detectChanges();
});
it('it should call saveChangeFieldUpdate on the objectUpdatesService with the correct url and metadata', () => {
@@ -112,6 +126,7 @@ describe('EditInPlaceFieldComponent', () => {
const editable = false;
beforeEach(() => {
comp.setEditable(editable);
+ fixture.detectChanges();
});
it('it should call setEditableFieldUpdate on the objectUpdatesService with the correct url and uuid and false', () => {
@@ -121,7 +136,7 @@ describe('EditInPlaceFieldComponent', () => {
describe('editable is true', () => {
beforeEach(() => {
- comp.editable = observableOf(true);
+ objectUpdatesService.isEditable.and.returnValue(observableOf(true));
fixture.detectChanges();
});
it('the div should contain input fields or textareas', () => {
@@ -133,7 +148,7 @@ describe('EditInPlaceFieldComponent', () => {
describe('editable is false', () => {
beforeEach(() => {
- comp.editable = observableOf(false);
+ objectUpdatesService.isEditable.and.returnValue(observableOf(false));
fixture.detectChanges();
});
it('the div should contain no input fields or textareas', () => {
@@ -145,7 +160,7 @@ describe('EditInPlaceFieldComponent', () => {
describe('isValid is true', () => {
beforeEach(() => {
- comp.valid = observableOf(true);
+ objectUpdatesService.isValid.and.returnValue(observableOf(true));
fixture.detectChanges();
});
it('the div should not contain an error message', () => {
@@ -157,10 +172,10 @@ describe('EditInPlaceFieldComponent', () => {
describe('isValid is false', () => {
beforeEach(() => {
- comp.valid = observableOf(false);
+ objectUpdatesService.isValid.and.returnValue(observableOf(false));
fixture.detectChanges();
});
- it('the div should contain no input fields or textareas', () => {
+ it('there should be an error message', () => {
const errorMessages = de.queryAll(By.css('small.text-danger'));
expect(errorMessages.length).toBeGreaterThan(0);
@@ -170,6 +185,7 @@ describe('EditInPlaceFieldComponent', () => {
describe('remove', () => {
beforeEach(() => {
comp.remove();
+ fixture.detectChanges();
});
it('it should call saveRemoveFieldUpdate on the objectUpdatesService with the correct url and metadata', () => {
@@ -180,6 +196,7 @@ describe('EditInPlaceFieldComponent', () => {
describe('removeChangesFromField', () => {
beforeEach(() => {
comp.removeChangesFromField();
+ fixture.detectChanges();
});
it('it should call removeChangesFromField on the objectUpdatesService with the correct url and uuid', () => {
@@ -192,19 +209,19 @@ describe('EditInPlaceFieldComponent', () => {
const metadataFieldSuggestions: InputSuggestion[] =
[
- { displayValue: mdField1.toString().split('.').join('.'), value: mdField1.toString() },
- { displayValue: mdField2.toString().split('.').join('.'), value: mdField2.toString() },
- { displayValue: mdField3.toString().split('.').join('.'), value: mdField3.toString() }
+ { displayValue: ('dc.' + mdField1.toString()).split('.').join('.'), value: ('dc.' + mdField1.toString()) },
+ { displayValue: ('dc.' + mdField2.toString()).split('.').join('.'), value: ('dc.' + mdField2.toString()) },
+ { displayValue: ('dc.' + mdField3.toString()).split('.').join('.'), value: ('dc.' + mdField3.toString()) }
];
- beforeEach(() => {
+ beforeEach(fakeAsync(() => {
comp.findMetadataFieldSuggestions(query);
-
- });
+ tick();
+ fixture.detectChanges();
+ }));
it('it should call queryMetadataFields on the metadataFieldService with the correct query', () => {
-
- expect(metadataFieldService.queryMetadataFields).toHaveBeenCalledWith(query);
+ expect(metadataFieldService.queryMetadataFields).toHaveBeenCalledWith(query, null, followLink('schema'));
});
it('it should set metadataFieldSuggestions to the right value', () => {
@@ -216,7 +233,8 @@ describe('EditInPlaceFieldComponent', () => {
describe('canSetEditable', () => {
describe('when editable is currently true', () => {
beforeEach(() => {
- comp.editable = observableOf(true);
+ objectUpdatesService.isEditable.and.returnValue(observableOf(true));
+ fixture.detectChanges();
});
it('canSetEditable should return an observable emitting false', () => {
@@ -227,12 +245,14 @@ describe('EditInPlaceFieldComponent', () => {
describe('when editable is currently false', () => {
beforeEach(() => {
- comp.editable = observableOf(false);
+ objectUpdatesService.isEditable.and.returnValue(observableOf(false));
+ fixture.detectChanges();
});
describe('when the fieldUpdate\'s changeType is currently not REMOVE', () => {
beforeEach(() => {
comp.fieldUpdate.changeType = FieldChangeType.ADD;
+ fixture.detectChanges();
});
it('canSetEditable should return an observable emitting true', () => {
const expected = '(a|)';
@@ -243,6 +263,7 @@ describe('EditInPlaceFieldComponent', () => {
describe('when the fieldUpdate\'s changeType is currently REMOVE', () => {
beforeEach(() => {
comp.fieldUpdate.changeType = FieldChangeType.REMOVE;
+ fixture.detectChanges();
});
it('canSetEditable should return an observable emitting false', () => {
const expected = '(a|)';
@@ -255,7 +276,8 @@ describe('EditInPlaceFieldComponent', () => {
describe('canSetUneditable', () => {
describe('when editable is currently true', () => {
beforeEach(() => {
- comp.editable = observableOf(true);
+ objectUpdatesService.isEditable.and.returnValue(observableOf(true));
+ fixture.detectChanges();
});
it('canSetUneditable should return an observable emitting true', () => {
@@ -266,7 +288,8 @@ describe('EditInPlaceFieldComponent', () => {
describe('when editable is currently false', () => {
beforeEach(() => {
- comp.editable = observableOf(false);
+ objectUpdatesService.isEditable.and.returnValue(observableOf(false));
+ fixture.detectChanges();
});
it('canSetUneditable should return an observable emitting false', () => {
@@ -278,7 +301,7 @@ describe('EditInPlaceFieldComponent', () => {
describe('when canSetEditable emits true', () => {
beforeEach(() => {
- comp.editable = observableOf(false);
+ objectUpdatesService.isEditable.and.returnValue(observableOf(false));
spyOn(comp, 'canSetEditable').and.returnValue(observableOf(true));
fixture.detectChanges();
});
@@ -290,7 +313,7 @@ describe('EditInPlaceFieldComponent', () => {
describe('when canSetEditable emits false', () => {
beforeEach(() => {
- comp.editable = observableOf(false);
+ objectUpdatesService.isEditable.and.returnValue(observableOf(false));
spyOn(comp, 'canSetEditable').and.returnValue(observableOf(false));
fixture.detectChanges();
});
@@ -302,7 +325,7 @@ describe('EditInPlaceFieldComponent', () => {
describe('when canSetUneditable emits true', () => {
beforeEach(() => {
- comp.editable = observableOf(true);
+ objectUpdatesService.isEditable.and.returnValue(observableOf(true));
spyOn(comp, 'canSetUneditable').and.returnValue(observableOf(true));
fixture.detectChanges();
});
@@ -314,7 +337,7 @@ describe('EditInPlaceFieldComponent', () => {
describe('when canSetUneditable emits false', () => {
beforeEach(() => {
- comp.editable = observableOf(true);
+ objectUpdatesService.isEditable.and.returnValue(observableOf(true));
spyOn(comp, 'canSetUneditable').and.returnValue(observableOf(false));
fixture.detectChanges();
});
@@ -372,6 +395,7 @@ describe('EditInPlaceFieldComponent', () => {
describe('when the fieldUpdate\'s changeType is currently not REMOVE or ADD', () => {
beforeEach(() => {
comp.fieldUpdate.changeType = FieldChangeType.UPDATE;
+ fixture.detectChanges();
});
it('canRemove should return an observable emitting true', () => {
const expected = '(a|)';
@@ -382,6 +406,7 @@ describe('EditInPlaceFieldComponent', () => {
describe('when the fieldUpdate\'s changeType is currently ADD', () => {
beforeEach(() => {
comp.fieldUpdate.changeType = FieldChangeType.ADD;
+ fixture.detectChanges();
});
it('canRemove should return an observable emitting false', () => {
const expected = '(a|)';
@@ -394,7 +419,7 @@ describe('EditInPlaceFieldComponent', () => {
describe('when editable is currently true', () => {
beforeEach(() => {
- comp.editable = observableOf(true);
+ objectUpdatesService.isEditable.and.returnValue(observableOf(true));
comp.fieldUpdate.changeType = undefined;
fixture.detectChanges();
});
@@ -408,6 +433,7 @@ describe('EditInPlaceFieldComponent', () => {
describe('when the fieldUpdate\'s changeType is currently ADD, UPDATE or REMOVE', () => {
beforeEach(() => {
comp.fieldUpdate.changeType = FieldChangeType.ADD;
+ fixture.detectChanges();
});
it('canUndo should return an observable emitting true', () => {
@@ -419,6 +445,7 @@ describe('EditInPlaceFieldComponent', () => {
describe('when the fieldUpdate\'s changeType is currently undefined', () => {
beforeEach(() => {
comp.fieldUpdate.changeType = undefined;
+ fixture.detectChanges();
});
it('canUndo should return an observable emitting false', () => {
diff --git a/src/app/+item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.ts b/src/app/+item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.ts
index 5a905fc7ea..8543bbef42 100644
--- a/src/app/+item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.ts
+++ b/src/app/+item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.ts
@@ -1,4 +1,5 @@
import { Component, Input, OnChanges, OnInit } from '@angular/core';
+import { metadataFieldsToString } from '../../../../core/shared/operators';
import { hasValue, isNotEmpty } from '../../../../shared/empty.util';
import { RegistryService } from '../../../../core/registry/registry.service';
import { cloneDeep } from 'lodash';
@@ -9,8 +10,8 @@ import { FieldUpdate } from '../../../../core/data/object-updates/object-updates
import { ObjectUpdatesService } from '../../../../core/data/object-updates/object-updates.service';
import { NgModel } from '@angular/forms';
import { MetadatumViewModel } from '../../../../core/shared/metadata.models';
-import { MetadataField } from '../../../../core/metadata/metadata-field.model';
import { InputSuggestion } from '../../../../shared/input-suggestions/input-suggestions.model';
+import { followLink } from '../../../../shared/utils/follow-link-config.model';
@Component({
// tslint:disable-next-line:component-selector
@@ -32,15 +33,10 @@ export class EditInPlaceFieldComponent implements OnInit, OnChanges {
*/
@Input() url: string;
- /**
- * List of strings with all metadata field keys available
- */
- @Input() metadataFields: string[];
-
/**
* The metadatum of this field
*/
- metadata: MetadatumViewModel;
+ @Input() metadata: MetadatumViewModel;
/**
* Emits whether or not this field is currently editable
@@ -126,27 +122,34 @@ export class EditInPlaceFieldComponent implements OnInit, OnChanges {
* Ignores fields from metadata schemas "relation" and "relationship"
* @param query The query to look for
*/
- findMetadataFieldSuggestions(query: string): void {
+ findMetadataFieldSuggestions(query: string) {
if (isNotEmpty(query)) {
- this.registryService.queryMetadataFields(query).pipe(
- // getSucceededRemoteData(),
- take(1),
- map((data) => data.payload.page)
- ).subscribe(
- (fields: MetadataField[]) => this.metadataFieldSuggestions.next(
- fields.map((field: MetadataField) => {
- return {
- displayValue: field.toString().split('.').join('.'),
- value: field.toString()
- };
- })
- )
- );
+ return this.registryService.queryMetadataFields(query, null, followLink('schema')).pipe(
+ metadataFieldsToString(),
+ take(1))
+ .subscribe((fieldNames: string[]) => {
+ this.setInputSuggestions(fieldNames);
+ })
} else {
this.metadataFieldSuggestions.next([]);
}
}
+ /**
+ * Set the list of input suggestion with the given Metadata fields, which all require a resolved MetadataSchema
+ * @param fields list of Metadata fields, which all require a resolved MetadataSchema
+ */
+ setInputSuggestions(fields: string[]) {
+ this.metadataFieldSuggestions.next(
+ fields.map((fieldName: string) => {
+ return {
+ displayValue: fieldName.split('.').join('.'),
+ value: fieldName
+ };
+ })
+ );
+ }
+
/**
* Check if a user should be allowed to edit this field
* @return an observable that emits true when the user should be able to edit this field and false when they should not
diff --git a/src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.html b/src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.html
index 366f6fffe2..b743ef2745 100644
--- a/src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.html
+++ b/src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.html
@@ -16,7 +16,7 @@
class="fas fa-undo-alt">
{{"item.edit.metadata.reinstate-button" | translate}}
-
{{"item.edit.metadata.save-button" | translate}}
@@ -33,7 +33,6 @@
;
@@ -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 52402f8b32..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,21 +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, map, switchMap, take, tap } from 'rxjs/operators';
+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 { RegistryService } from '../../../core/registry/registry.service';
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 { MetadataField } from '../../../core/metadata/metadata-field.model';
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',
@@ -42,11 +40,6 @@ export class ItemMetadataComponent extends AbstractItemUpdateComponent {
*/
@Input() updateService: UpdateDataService- ;
- /**
- * Observable with a list of strings with all existing metadata field keys
- */
- metadataFields$: Observable
;
-
constructor(
public itemService: ItemDataService,
public objectUpdatesService: ObjectUpdatesService,
@@ -54,7 +47,6 @@ export class ItemMetadataComponent extends AbstractItemUpdateComponent {
public notificationsService: NotificationsService,
public translateService: TranslateService,
public route: ActivatedRoute,
- public metadataFieldService: RegistryService,
) {
super(itemService, objectUpdatesService, router, notificationsService, translateService, route);
}
@@ -64,7 +56,6 @@ export class ItemMetadataComponent extends AbstractItemUpdateComponent {
*/
ngOnInit(): void {
super.ngOnInit();
- this.metadataFields$ = this.findMetadataFields();
if (hasNoValue(this.updateService)) {
this.updateService = this.itemService;
}
@@ -96,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);
}
/**
@@ -106,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;
@@ -130,16 +129,6 @@ export class ItemMetadataComponent extends AbstractItemUpdateComponent {
});
}
- /**
- * Method to request all metadata fields and convert them to a list of strings
- */
- findMetadataFields(): Observable
{
- return this.metadataFieldService.getAllMetadataFields().pipe(
- getSucceededRemoteData(),
- take(1),
- map((remoteData$) => remoteData$.payload.page.map((field: MetadataField) => field.toString())));
- }
-
/**
* Check for empty metadata UUIDs and fix them (empty UUIDs would break the object-update service)
*/
diff --git a/src/app/+item-page/edit-item-page/item-page-reinstate.guard.ts b/src/app/+item-page/edit-item-page/item-page-reinstate.guard.ts
new file mode 100644
index 0000000000..061705619a
--- /dev/null
+++ b/src/app/+item-page/edit-item-page/item-page-reinstate.guard.ts
@@ -0,0 +1,30 @@
+import { Injectable } from '@angular/core';
+import { DsoPageFeatureGuard } from '../../core/data/feature-authorization/feature-authorization-guard/dso-page-feature.guard';
+import { Item } from '../../core/shared/item.model';
+import { ItemPageResolver } from '../item-page.resolver';
+import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service';
+import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from '@angular/router';
+import { Observable } from 'rxjs/internal/Observable';
+import { FeatureID } from '../../core/data/feature-authorization/feature-id';
+import { of as observableOf } from 'rxjs';
+
+@Injectable({
+ providedIn: 'root'
+})
+/**
+ * Guard for preventing unauthorized access to certain {@link Item} pages requiring reinstate rights
+ */
+export class ItemPageReinstateGuard extends DsoPageFeatureGuard- {
+ constructor(protected resolver: ItemPageResolver,
+ protected authorizationService: AuthorizationDataService,
+ protected router: Router) {
+ super(resolver, authorizationService, router);
+ }
+
+ /**
+ * Check reinstate authorization rights
+ */
+ getFeatureID(next: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable
{
+ return observableOf(FeatureID.ReinstateItem);
+ }
+}
diff --git a/src/app/+item-page/edit-item-page/item-page-withdraw.guard.ts b/src/app/+item-page/edit-item-page/item-page-withdraw.guard.ts
new file mode 100644
index 0000000000..60576bcdb8
--- /dev/null
+++ b/src/app/+item-page/edit-item-page/item-page-withdraw.guard.ts
@@ -0,0 +1,30 @@
+import { DsoPageFeatureGuard } from '../../core/data/feature-authorization/feature-authorization-guard/dso-page-feature.guard';
+import { Item } from '../../core/shared/item.model';
+import { Injectable } from '@angular/core';
+import { ItemPageResolver } from '../item-page.resolver';
+import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service';
+import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from '@angular/router';
+import { Observable } from 'rxjs/internal/Observable';
+import { FeatureID } from '../../core/data/feature-authorization/feature-id';
+import { of as observableOf } from 'rxjs';
+
+@Injectable({
+ providedIn: 'root'
+})
+/**
+ * Guard for preventing unauthorized access to certain {@link Item} pages requiring withdraw rights
+ */
+export class ItemPageWithdrawGuard extends DsoPageFeatureGuard- {
+ constructor(protected resolver: ItemPageResolver,
+ protected authorizationService: AuthorizationDataService,
+ protected router: Router) {
+ super(resolver, authorizationService, router);
+ }
+
+ /**
+ * Check withdraw authorization rights
+ */
+ getFeatureID(next: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable
{
+ return observableOf(FeatureID.WithdrawItem);
+ }
+}
diff --git a/src/app/+item-page/edit-item-page/item-status/item-status.component.html b/src/app/+item-page/edit-item-page/item-status/item-status.component.html
index 83662c9d7c..3fcf10a2f5 100644
--- a/src/app/+item-page/edit-item-page/item-status/item-status.component.html
+++ b/src/app/+item-page/edit-item-page/item-status/item-status.component.html
@@ -15,7 +15,7 @@
{{getItemPage((itemRD$ | async)?.payload)}}
-
diff --git a/src/app/+item-page/edit-item-page/item-status/item-status.component.spec.ts b/src/app/+item-page/edit-item-page/item-status/item-status.component.spec.ts
index abb2839551..9c28f097a4 100644
--- a/src/app/+item-page/edit-item-page/item-status/item-status.component.spec.ts
+++ b/src/app/+item-page/edit-item-page/item-status/item-status.component.spec.ts
@@ -12,6 +12,7 @@ import { By } from '@angular/platform-browser';
import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import { of as observableOf } from 'rxjs';
import { createSuccessfulRemoteDataObject } from '../../../shared/remote-data.utils';
+import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service';
describe('ItemStatusComponent', () => {
let comp: ItemStatusComponent;
@@ -20,7 +21,10 @@ describe('ItemStatusComponent', () => {
const mockItem = Object.assign(new Item(), {
id: 'fake-id',
handle: 'fake/handle',
- lastModified: '2018'
+ lastModified: '2018',
+ _links: {
+ self: { href: 'test-item-selflink' }
+ }
});
const itemPageUrl = `items/${mockItem.id}`;
@@ -31,13 +35,20 @@ describe('ItemStatusComponent', () => {
}
};
+ let authorizationService: AuthorizationDataService;
+
beforeEach(async(() => {
+ authorizationService = jasmine.createSpyObj('authorizationService', {
+ isAuthorized: observableOf(true)
+ });
+
TestBed.configureTestingModule({
imports: [CommonModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule],
declarations: [ItemStatusComponent],
providers: [
{ provide: ActivatedRoute, useValue: routeStub },
- { provide: HostWindowService, useValue: new HostWindowServiceStub(0) }
+ { provide: HostWindowService, useValue: new HostWindowServiceStub(0) },
+ { provide: AuthorizationDataService, useValue: authorizationService },
], schemas: [CUSTOM_ELEMENTS_SCHEMA]
}).compileComponents();
}));
diff --git a/src/app/+item-page/edit-item-page/item-status/item-status.component.ts b/src/app/+item-page/edit-item-page/item-status/item-status.component.ts
index 2696c90353..dd043330d6 100644
--- a/src/app/+item-page/edit-item-page/item-status/item-status.component.ts
+++ b/src/app/+item-page/edit-item-page/item-status/item-status.component.ts
@@ -3,15 +3,19 @@ import { fadeIn, fadeInOut } from '../../../shared/animations/fade';
import { Item } from '../../../core/shared/item.model';
import { ActivatedRoute } from '@angular/router';
import { ItemOperation } from '../item-operation/itemOperation.model';
-import { first, map } from 'rxjs/operators';
+import { distinctUntilChanged, first, map } from 'rxjs/operators';
import { Observable } from 'rxjs';
import { RemoteData } from '../../../core/data/remote-data';
import { getItemEditRoute, getItemPageRoute } from '../../item-page-routing-paths';
+import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service';
+import { FeatureID } from '../../../core/data/feature-authorization/feature-id';
+import { hasValue } from '../../../shared/empty.util';
+import { BehaviorSubject } from 'rxjs/internal/BehaviorSubject';
@Component({
selector: 'ds-item-status',
templateUrl: './item-status.component.html',
- changeDetection: ChangeDetectionStrategy.OnPush,
+ changeDetection: ChangeDetectionStrategy.Default,
animations: [
fadeIn,
fadeInOut
@@ -40,14 +44,15 @@ export class ItemStatusComponent implements OnInit {
* The possible actions that can be performed on the item
* key: id value: url to action's component
*/
- operations: ItemOperation[];
+ operations$: BehaviorSubject = new BehaviorSubject([]);
/**
* The keys of the actions (to loop over)
*/
actionsKeys;
- constructor(private route: ActivatedRoute) {
+ constructor(private route: ActivatedRoute,
+ private authorizationService: AuthorizationDataService) {
}
ngOnInit(): void {
@@ -67,21 +72,43 @@ export class ItemStatusComponent implements OnInit {
i18n example: 'item.edit.tabs.status.buttons..label'
The value is supposed to be a href for the button
*/
- this.operations = [];
- this.operations.push(new ItemOperation('authorizations', this.getCurrentUrl(item) + '/authorizations'));
- this.operations.push(new ItemOperation('mappedCollections', this.getCurrentUrl(item) + '/mapper'));
- if (item.isWithdrawn) {
- this.operations.push(new ItemOperation('reinstate', this.getCurrentUrl(item) + '/reinstate'));
- } else {
- this.operations.push(new ItemOperation('withdraw', this.getCurrentUrl(item) + '/withdraw'));
- }
+ const operations = [];
+ operations.push(new ItemOperation('authorizations', this.getCurrentUrl(item) + '/authorizations'));
+ operations.push(new ItemOperation('mappedCollections', this.getCurrentUrl(item) + '/mapper'));
+ operations.push(undefined);
+ // Store the index of the "withdraw" or "reinstate" operation, because it's added asynchronously
+ const indexOfWithdrawReinstate = operations.length - 1;
if (item.isDiscoverable) {
- this.operations.push(new ItemOperation('private', this.getCurrentUrl(item) + '/private'));
+ operations.push(new ItemOperation('private', this.getCurrentUrl(item) + '/private'));
} else {
- this.operations.push(new ItemOperation('public', this.getCurrentUrl(item) + '/public'));
+ operations.push(new ItemOperation('public', this.getCurrentUrl(item) + '/public'));
+ }
+ operations.push(new ItemOperation('delete', this.getCurrentUrl(item) + '/delete'));
+ operations.push(new ItemOperation('move', this.getCurrentUrl(item) + '/move'));
+
+ this.operations$.next(operations);
+
+ if (item.isWithdrawn) {
+ this.authorizationService.isAuthorized(FeatureID.ReinstateItem, item.self).pipe(distinctUntilChanged()).subscribe((authorized) => {
+ const newOperations = [...this.operations$.value];
+ if (authorized) {
+ newOperations[indexOfWithdrawReinstate] = new ItemOperation('reinstate', this.getCurrentUrl(item) + '/reinstate');
+ } else {
+ newOperations[indexOfWithdrawReinstate] = undefined;
+ }
+ this.operations$.next(newOperations);
+ });
+ } else {
+ this.authorizationService.isAuthorized(FeatureID.WithdrawItem, item.self).pipe(distinctUntilChanged()).subscribe((authorized) => {
+ const newOperations = [...this.operations$.value];
+ if (authorized) {
+ newOperations[indexOfWithdrawReinstate] = new ItemOperation('withdraw', this.getCurrentUrl(item) + '/withdraw');
+ } else {
+ newOperations[indexOfWithdrawReinstate] = undefined;
+ }
+ this.operations$.next(newOperations);
+ });
}
- this.operations.push(new ItemOperation('delete', this.getCurrentUrl(item) + '/delete'));
- this.operations.push(new ItemOperation('move', this.getCurrentUrl(item) + '/move'));
});
}
@@ -102,4 +129,8 @@ export class ItemStatusComponent implements OnInit {
return getItemEditRoute(item.id);
}
+ trackOperation(index: number, operation: ItemOperation) {
+ return hasValue(operation) ? operation.operationKey : undefined;
+ }
+
}
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-administrator.guard.ts b/src/app/+item-page/item-page-administrator.guard.ts
new file mode 100644
index 0000000000..eae76348ad
--- /dev/null
+++ b/src/app/+item-page/item-page-administrator.guard.ts
@@ -0,0 +1,30 @@
+import { Injectable } from '@angular/core';
+import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from '@angular/router';
+import { AuthorizationDataService } from '../core/data/feature-authorization/authorization-data.service';
+import { ItemPageResolver } from './item-page.resolver';
+import { Item } from '../core/shared/item.model';
+import { DsoPageFeatureGuard } from '../core/data/feature-authorization/feature-authorization-guard/dso-page-feature.guard';
+import { Observable } from 'rxjs/internal/Observable';
+import { FeatureID } from '../core/data/feature-authorization/feature-id';
+import { of as observableOf } from 'rxjs';
+
+@Injectable({
+ providedIn: 'root'
+})
+/**
+ * Guard for preventing unauthorized access to certain {@link Item} pages requiring administrator rights
+ */
+export class ItemPageAdministratorGuard extends DsoPageFeatureGuard- {
+ constructor(protected resolver: ItemPageResolver,
+ protected authorizationService: AuthorizationDataService,
+ protected router: Router) {
+ super(resolver, authorizationService, router);
+ }
+
+ /**
+ * Check administrator authorization rights
+ */
+ getFeatureID(next: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable
{
+ return observableOf(FeatureID.AdministratorOf);
+ }
+}
diff --git a/src/app/+item-page/item-page-routing.module.ts b/src/app/+item-page/item-page-routing.module.ts
index 088aab326d..e4f17326a4 100644
--- a/src/app/+item-page/item-page-routing.module.ts
+++ b/src/app/+item-page/item-page-routing.module.ts
@@ -10,6 +10,9 @@ import { DSOBreadcrumbsService } from '../core/breadcrumbs/dso-breadcrumbs.servi
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: [
@@ -34,7 +37,7 @@ import { UPLOAD_BITSTREAM_PATH, ITEM_EDIT_PATH } from './item-page-routing-paths
{
path: ITEM_EDIT_PATH,
loadChildren: './edit-item-page/edit-item-page.module#EditItemPageModule',
- canActivate: [AuthenticatedGuard]
+ canActivate: [ItemPageAdministratorGuard]
},
{
path: UPLOAD_BITSTREAM_PATH,
@@ -42,6 +45,20 @@ import { UPLOAD_BITSTREAM_PATH, ITEM_EDIT_PATH } from './item-page-routing-paths
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,
+ }],
+ },
+ },
}
])
],
@@ -49,7 +66,8 @@ import { UPLOAD_BITSTREAM_PATH, ITEM_EDIT_PATH } from './item-page-routing-paths
ItemPageResolver,
ItemBreadcrumbResolver,
DSOBreadcrumbsService,
- LinkService
+ LinkService,
+ ItemPageAdministratorGuard
]
})
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/+item-page/simple/item-page.component.ts b/src/app/+item-page/simple/item-page.component.ts
index 10deef23e4..87d2294ff9 100644
--- a/src/app/+item-page/simple/item-page.component.ts
+++ b/src/app/+item-page/simple/item-page.component.ts
@@ -11,7 +11,7 @@ import { Item } from '../../core/shared/item.model';
import { MetadataService } from '../../core/metadata/metadata.service';
import { fadeInOut } from '../../shared/animations/fade';
-import { redirectToPageNotFoundOn404 } from '../../core/shared/operators';
+import { redirectOn404Or401 } from '../../core/shared/operators';
import { ViewMode } from '../../core/shared/view-mode.model';
/**
@@ -56,7 +56,7 @@ export class ItemPageComponent implements OnInit {
ngOnInit(): void {
this.itemRD$ = this.route.data.pipe(
map((data) => data.item as RemoteData- ),
- redirectToPageNotFoundOn404(this.router)
+ redirectOn404Or401(this.router)
);
this.metadataService.processRemoteData(this.itemRD$);
}
diff --git a/src/app/app-routing-paths.ts b/src/app/app-routing-paths.ts
index 5d529edeb7..2b57a1957c 100644
--- a/src/app/app-routing-paths.ts
+++ b/src/app/app-routing-paths.ts
@@ -55,8 +55,19 @@ export function getDSORoute(dso: DSpaceObject): string {
}
}
-export const UNAUTHORIZED_PATH = 'unauthorized';
+export const UNAUTHORIZED_PATH = '401';
export function getUnauthorizedRoute() {
return `/${UNAUTHORIZED_PATH}`;
}
+
+export const PAGE_NOT_FOUND_PATH = '404';
+
+export function getPageNotFoundRoute() {
+ return `/${PAGE_NOT_FOUND_PATH}`;
+}
+
+export const INFO_MODULE_PATH = 'info';
+export function getInfoModulePath() {
+ return `/${INFO_MODULE_PATH}`;
+}
diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts
index b0317f68ea..ecb27efbb3 100644
--- a/src/app/app-routing.module.ts
+++ b/src/app/app-routing.module.ts
@@ -1,5 +1,6 @@
import { NgModule } from '@angular/core';
import { RouterModule } from '@angular/router';
+import { AuthBlockingGuard } from './core/auth/auth-blocking.guard';
import { PageNotFoundComponent } from './pagenotfound/pagenotfound.component';
import { AuthenticatedGuard } from './core/auth/authenticated.guard';
@@ -12,55 +13,69 @@ import {
REGISTER_PATH,
PROFILE_MODULE_PATH,
ADMIN_MODULE_PATH,
- BITSTREAM_MODULE_PATH
+ BITSTREAM_MODULE_PATH,
+ INFO_MODULE_PATH
} from './app-routing-paths';
import { COLLECTION_MODULE_PATH } from './+collection-page/collection-page-routing-paths';
import { COMMUNITY_MODULE_PATH } from './+community-page/community-page-routing-paths';
import { ITEM_MODULE_PATH } from './+item-page/item-page-routing-paths';
+import { ReloadGuard } from './core/reload/reload.guard';
+import { EndUserAgreementCurrentUserGuard } from './core/end-user-agreement/end-user-agreement-current-user.guard';
+import { SiteRegisterGuard } from './core/data/feature-authorization/feature-authorization-guard/site-register.guard';
@NgModule({
imports: [
RouterModule.forRoot([
- { path: '', redirectTo: '/home', pathMatch: 'full' },
- { path: 'reload/:rnd', redirectTo: '/home', pathMatch: 'full' },
- { path: 'home', loadChildren: './+home-page/home-page.module#HomePageModule', data: { showBreadcrumbs: false } },
- { path: 'community-list', loadChildren: './community-list-page/community-list-page.module#CommunityListPageModule' },
- { path: 'id', loadChildren: './+lookup-by-id/lookup-by-id.module#LookupIdModule' },
- { path: 'handle', loadChildren: './+lookup-by-id/lookup-by-id.module#LookupIdModule' },
- { path: REGISTER_PATH, loadChildren: './register-page/register-page.module#RegisterPageModule' },
- { path: FORGOT_PASSWORD_PATH, loadChildren: './forgot-password/forgot-password.module#ForgotPasswordModule' },
- { path: COMMUNITY_MODULE_PATH, loadChildren: './+community-page/community-page.module#CommunityPageModule' },
- { path: COLLECTION_MODULE_PATH, loadChildren: './+collection-page/collection-page.module#CollectionPageModule' },
- { path: ITEM_MODULE_PATH, loadChildren: './+item-page/item-page.module#ItemPageModule' },
- { path: BITSTREAM_MODULE_PATH, loadChildren: './+bitstream-page/bitstream-page.module#BitstreamPageModule' },
- {
- path: 'mydspace',
- loadChildren: './+my-dspace-page/my-dspace-page.module#MyDSpacePageModule',
- canActivate: [AuthenticatedGuard]
- },
- { path: 'search', loadChildren: './+search-page/search-page-routing.module#SearchPageRoutingModule' },
- { path: 'browse', loadChildren: './+browse-by/browse-by.module#BrowseByModule'},
- { path: ADMIN_MODULE_PATH, loadChildren: './+admin/admin.module#AdminModule', canActivate: [SiteAdministratorGuard] },
- { path: 'login', loadChildren: './+login-page/login-page.module#LoginPageModule' },
- { path: 'logout', loadChildren: './+logout-page/logout-page.module#LogoutPageModule' },
- { path: 'submit', loadChildren: './+submit-page/submit-page.module#SubmitPageModule' },
- { path: 'import-external', loadChildren: './+import-external-page/import-external-page.module#ImportExternalPageModule' },
- {
- path: 'workspaceitems',
- loadChildren: './+workspaceitems-edit-page/workspaceitems-edit-page.module#WorkspaceitemsEditPageModule'
- },
- {
- path: WORKFLOW_ITEM_MODULE_PATH,
- loadChildren: './+workflowitems-edit-page/workflowitems-edit-page.module#WorkflowItemsEditPageModule'
- },
- {
- path: PROFILE_MODULE_PATH,
- loadChildren: './profile-page/profile-page.module#ProfilePageModule', canActivate: [AuthenticatedGuard]
- },
- { path: 'processes', loadChildren: './process-page/process-page.module#ProcessPageModule', canActivate: [AuthenticatedGuard] },
- { path: UNAUTHORIZED_PATH, component: UnauthorizedComponent },
- { path: '**', pathMatch: 'full', component: PageNotFoundComponent },
- ],
+ { path: '', canActivate: [AuthBlockingGuard],
+ children: [
+ { path: '', redirectTo: '/home', pathMatch: 'full' },
+ { path: 'reload/:rnd', component: PageNotFoundComponent, pathMatch: 'full', canActivate: [ReloadGuard] },
+ { path: 'home', loadChildren: './+home-page/home-page.module#HomePageModule', data: { showBreadcrumbs: false }, canActivate: [EndUserAgreementCurrentUserGuard] },
+ { path: 'community-list', loadChildren: './community-list-page/community-list-page.module#CommunityListPageModule', canActivate: [EndUserAgreementCurrentUserGuard] },
+ { path: 'id', loadChildren: './+lookup-by-id/lookup-by-id.module#LookupIdModule', canActivate: [EndUserAgreementCurrentUserGuard] },
+ { path: 'handle', loadChildren: './+lookup-by-id/lookup-by-id.module#LookupIdModule', canActivate: [EndUserAgreementCurrentUserGuard] },
+ { path: REGISTER_PATH, loadChildren: './register-page/register-page.module#RegisterPageModule', canActivate: [SiteRegisterGuard] },
+ { path: FORGOT_PASSWORD_PATH, loadChildren: './forgot-password/forgot-password.module#ForgotPasswordModule', canActivate: [EndUserAgreementCurrentUserGuard] },
+ { path: COMMUNITY_MODULE_PATH, loadChildren: './+community-page/community-page.module#CommunityPageModule', canActivate: [EndUserAgreementCurrentUserGuard] },
+ { path: COLLECTION_MODULE_PATH, loadChildren: './+collection-page/collection-page.module#CollectionPageModule', canActivate: [EndUserAgreementCurrentUserGuard] },
+ { path: ITEM_MODULE_PATH, loadChildren: './+item-page/item-page.module#ItemPageModule', canActivate: [EndUserAgreementCurrentUserGuard] },
+ { path: BITSTREAM_MODULE_PATH, loadChildren: './+bitstream-page/bitstream-page.module#BitstreamPageModule', canActivate: [EndUserAgreementCurrentUserGuard] },
+ {
+ path: 'mydspace',
+ loadChildren: './+my-dspace-page/my-dspace-page.module#MyDSpacePageModule',
+ canActivate: [AuthenticatedGuard, EndUserAgreementCurrentUserGuard]
+ },
+ { path: 'search', loadChildren: './+search-page/search-page-routing.module#SearchPageRoutingModule', canActivate: [EndUserAgreementCurrentUserGuard] },
+ { path: 'browse', loadChildren: './+browse-by/browse-by.module#BrowseByModule', canActivate: [EndUserAgreementCurrentUserGuard] },
+ { path: ADMIN_MODULE_PATH, loadChildren: './+admin/admin.module#AdminModule', canActivate: [SiteAdministratorGuard, EndUserAgreementCurrentUserGuard] },
+ { path: 'login', loadChildren: './+login-page/login-page.module#LoginPageModule' },
+ { path: 'logout', loadChildren: './+logout-page/logout-page.module#LogoutPageModule' },
+ { path: 'submit', loadChildren: './+submit-page/submit-page.module#SubmitPageModule', canActivate: [EndUserAgreementCurrentUserGuard] },
+ { path: 'import-external', loadChildren: './+import-external-page/import-external-page.module#ImportExternalPageModule', canActivate: [EndUserAgreementCurrentUserGuard] },
+ {
+ path: 'workspaceitems',
+ loadChildren: './+workspaceitems-edit-page/workspaceitems-edit-page.module#WorkspaceitemsEditPageModule',
+ canActivate: [EndUserAgreementCurrentUserGuard]
+ },
+ {
+ path: WORKFLOW_ITEM_MODULE_PATH,
+ loadChildren: './+workflowitems-edit-page/workflowitems-edit-page.module#WorkflowItemsEditPageModule',
+ canActivate: [EndUserAgreementCurrentUserGuard]
+ },
+ {
+ path: PROFILE_MODULE_PATH,
+ loadChildren: './profile-page/profile-page.module#ProfilePageModule', canActivate: [AuthenticatedGuard, EndUserAgreementCurrentUserGuard]
+ },
+ { 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 },
+ ]}
+ ],
{
onSameUrlNavigation: 'reload',
})
diff --git a/src/app/app.component.html b/src/app/app.component.html
index 8656970f31..fa534855e7 100644
--- a/src/app/app.component.html
+++ b/src/app/app.component.html
@@ -1,4 +1,4 @@
-
+
+
+
+
+
+
diff --git a/src/app/app.component.scss b/src/app/app.component.scss
index 7793b7529c..b18e7e1402 100644
--- a/src/app/app.component.scss
+++ b/src/app/app.component.scss
@@ -47,3 +47,7 @@ ds-admin-sidebar {
position: fixed;
z-index: $sidebar-z-index;
}
+
+.ds-full-screen-loader {
+ height: 100vh;
+}
diff --git a/src/app/app.component.spec.ts b/src/app/app.component.spec.ts
index da3cf9537b..31507831be 100644
--- a/src/app/app.component.spec.ts
+++ b/src/app/app.component.spec.ts
@@ -1,9 +1,8 @@
+import * as ngrx from '@ngrx/store';
import { async, ComponentFixture, inject, TestBed } from '@angular/core/testing';
-import { CUSTOM_ELEMENTS_SCHEMA, DebugElement } from '@angular/core';
+import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import { CommonModule } from '@angular/common';
-import { By } from '@angular/platform-browser';
import { ActivatedRoute, Router } from '@angular/router';
-
import { TranslateLoader, TranslateModule } from '@ngx-translate/core';
import { Store, StoreModule } from '@ngrx/store';
import { Angulartics2GoogleAnalytics } from 'angulartics2/ga';
@@ -32,11 +31,11 @@ import { RouterMock } from './shared/mocks/router.mock';
import { Angulartics2DSpace } from './statistics/angulartics/dspace-provider';
import { storeModuleConfig } from './app.reducer';
import { LocaleService } from './core/locale/locale.service';
+import { authReducer } from './core/auth/auth.reducer';
+import { cold } from 'jasmine-marbles';
let comp: AppComponent;
let fixture: ComponentFixture
;
-let de: DebugElement;
-let el: HTMLElement;
const menuService = new MenuServiceStub();
describe('App component', () => {
@@ -52,7 +51,7 @@ describe('App component', () => {
return TestBed.configureTestingModule({
imports: [
CommonModule,
- StoreModule.forRoot({}, storeModuleConfig),
+ StoreModule.forRoot(authReducer, storeModuleConfig),
TranslateModule.forRoot({
loader: {
provide: TranslateLoader,
@@ -82,12 +81,19 @@ describe('App component', () => {
// synchronous beforeEach
beforeEach(() => {
- fixture = TestBed.createComponent(AppComponent);
+ spyOnProperty(ngrx, 'select').and.callFake(() => {
+ return () => {
+ return () => cold('a', {
+ a: {
+ core: { auth: { loading: false } }
+ }
+ })
+ };
+ });
+ fixture = TestBed.createComponent(AppComponent);
comp = fixture.componentInstance; // component test instance
- // query for the by CSS element selector
- de = fixture.debugElement.query(By.css('div.outer-wrapper'));
- el = de.nativeElement;
+ fixture.detectChanges();
});
it('should create component', inject([AppComponent], (app: AppComponent) => {
diff --git a/src/app/app.component.ts b/src/app/app.component.ts
index 10f81a9adc..43ae0534ad 100644
--- a/src/app/app.component.ts
+++ b/src/app/app.component.ts
@@ -1,11 +1,11 @@
-import { delay, filter, map, take } from 'rxjs/operators';
+import { delay, map, distinctUntilChanged, filter, take } from 'rxjs/operators';
import {
AfterViewInit,
ChangeDetectionStrategy,
Component,
HostListener,
Inject,
- OnInit,
+ OnInit, Optional,
ViewEncapsulation
} from '@angular/core';
import { NavigationCancel, NavigationEnd, NavigationStart, Router } from '@angular/router';
@@ -19,7 +19,7 @@ import { MetadataService } from './core/metadata/metadata.service';
import { HostWindowResizeAction } from './shared/host-window.actions';
import { HostWindowState } from './shared/search/host-window.reducer';
import { NativeWindowRef, NativeWindowService } from './core/services/window.service';
-import { isAuthenticated } from './core/auth/selectors';
+import { isAuthenticationBlocking } from './core/auth/selectors';
import { AuthService } from './core/auth/auth.service';
import { CSSVariableService } from './shared/sass-helper/sass-helper.service';
import { MenuService } from './shared/menu/menu.service';
@@ -31,8 +31,8 @@ import { Angulartics2DSpace } from './statistics/angulartics/dspace-provider';
import { environment } from '../environments/environment';
import { models } from './core/core.module';
import { LocaleService } from './core/locale/locale.service';
-
-export const LANG_COOKIE = 'language_cookie';
+import { hasValue } from './shared/empty.util';
+import { KlaroService } from './shared/cookies/klaro.service';
@Component({
selector: 'ds-app',
@@ -52,6 +52,11 @@ export class AppComponent implements OnInit, AfterViewInit {
notificationOptions = environment.notifications;
models;
+ /**
+ * Whether or not the authentication is currently blocking the UI
+ */
+ isNotAuthBlocking$: Observable
;
+
constructor(
@Inject(NativeWindowService) private _window: NativeWindowRef,
private translate: TranslateService,
@@ -64,8 +69,10 @@ export class AppComponent implements OnInit, AfterViewInit {
private cssService: CSSVariableService,
private menuService: MenuService,
private windowService: HostWindowService,
- private localeService: LocaleService
+ private localeService: LocaleService,
+ @Optional() private cookiesService: KlaroService
) {
+
/* Use models object so all decorators are actually called */
this.models = models;
// Load all the languages that are defined as active from the config file
@@ -86,19 +93,25 @@ export class AppComponent implements OnInit, AfterViewInit {
console.info(environment);
}
this.storeCSSVariables();
+
}
ngOnInit() {
+ this.isNotAuthBlocking$ = this.store.pipe(select(isAuthenticationBlocking)).pipe(
+ map((isBlocking: boolean) => isBlocking === false),
+ distinctUntilChanged()
+ );
+ this.isNotAuthBlocking$
+ .pipe(
+ filter((notBlocking: boolean) => notBlocking),
+ take(1)
+ ).subscribe(() => this.initializeKlaro());
+
const env: string = environment.production ? 'Production' : 'Development';
const color: string = environment.production ? 'red' : 'green';
console.info(`Environment: %c${env}`, `color: ${color}; font-weight: bold;`);
this.dispatchWindowSize(this._window.nativeWindow.innerWidth, this._window.nativeWindow.innerHeight);
- // Whether is not authenticathed try to retrieve a possible stored auth token
- this.store.pipe(select(isAuthenticated),
- take(1),
- filter((authenticated) => !authenticated)
- ).subscribe((authenticated) => this.authService.checkAuthenticationToken());
this.sidebarVisible = this.menuService.isMenuVisible(MenuID.ADMIN);
this.collapsedSidebarWidth = this.cssService.getVariable('collapsedSidebarWidth');
@@ -154,4 +167,9 @@ export class AppComponent implements OnInit, AfterViewInit {
);
}
+ private initializeKlaro() {
+ if (hasValue(this.cookiesService)) {
+ this.cookiesService.initialize()
+ }
+ }
}
diff --git a/src/app/app.module.ts b/src/app/app.module.ts
index 33454ed6c5..f1cdd5f2e5 100755
--- a/src/app/app.module.ts
+++ b/src/app/app.module.ts
@@ -1,11 +1,11 @@
import { APP_BASE_HREF, CommonModule } from '@angular/common';
import { HttpClientModule } from '@angular/common/http';
-import { NgModule } from '@angular/core';
+import { APP_INITIALIZER, NgModule } from '@angular/core';
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
import { EffectsModule } from '@ngrx/effects';
import { RouterStateSerializer, StoreRouterConnectingModule } from '@ngrx/router-store';
-import { MetaReducer, StoreModule, USER_PROVIDED_META_REDUCERS } from '@ngrx/store';
+import { MetaReducer, Store, StoreModule, USER_PROVIDED_META_REDUCERS } from '@ngrx/store';
import { StoreDevtoolsModule } from '@ngrx/store-devtools';
import { DYNAMIC_MATCHER_PROVIDERS } from '@ng-dynamic-forms/core';
import { TranslateModule } from '@ngx-translate/core';
@@ -21,6 +21,7 @@ import { AppComponent } from './app.component';
import { appEffects } from './app.effects';
import { appMetaReducers, debugMetaReducers } from './app.metareducers';
import { appReducers, AppState, storeModuleConfig } from './app.reducer';
+import { CheckAuthenticationTokenAction } from './core/auth/auth.actions';
import { CoreModule } from './core/core.module';
import { ClientCookieService } from './core/services/client-cookie.service';
@@ -91,6 +92,15 @@ const PROVIDERS = [
useClass: DSpaceRouterStateSerializer
},
ClientCookieService,
+ // Check the authentication token when the app initializes
+ {
+ provide: APP_INITIALIZER,
+ useFactory: (store: Store,) => {
+ return () => store.dispatch(new CheckAuthenticationTokenAction());
+ },
+ deps: [ Store ],
+ multi: true
+ },
...DYNAMIC_MATCHER_PROVIDERS,
];
diff --git a/src/app/community-list-page/community-list-page.module.ts b/src/app/community-list-page/community-list-page.module.ts
index 2e3914fe03..57b016bc6e 100644
--- a/src/app/community-list-page/community-list-page.module.ts
+++ b/src/app/community-list-page/community-list-page.module.ts
@@ -4,7 +4,6 @@ import { SharedModule } from '../shared/shared.module';
import { CommunityListPageComponent } from './community-list-page.component';
import { CommunityListPageRoutingModule } from './community-list-page.routing.module';
import { CommunityListComponent } from './community-list/community-list.component';
-import { CdkTreeModule } from '@angular/cdk/tree';
/**
* The page which houses a title and the community list, as described in community-list.component
@@ -13,8 +12,7 @@ import { CdkTreeModule } from '@angular/cdk/tree';
imports: [
CommonModule,
SharedModule,
- CommunityListPageRoutingModule,
- CdkTreeModule,
+ CommunityListPageRoutingModule
],
declarations: [
CommunityListPageComponent,
diff --git a/src/app/core/auth/auth-blocking.guard.spec.ts b/src/app/core/auth/auth-blocking.guard.spec.ts
new file mode 100644
index 0000000000..2a89b01a85
--- /dev/null
+++ b/src/app/core/auth/auth-blocking.guard.spec.ts
@@ -0,0 +1,62 @@
+import { Store } from '@ngrx/store';
+import * as ngrx from '@ngrx/store';
+import { cold, getTestScheduler, initTestScheduler, resetTestScheduler } from 'jasmine-marbles/es6';
+import { of as observableOf } from 'rxjs';
+import { AppState } from '../../app.reducer';
+import { AuthBlockingGuard } from './auth-blocking.guard';
+
+describe('AuthBlockingGuard', () => {
+ let guard: AuthBlockingGuard;
+ beforeEach(() => {
+ guard = new AuthBlockingGuard(new Store(undefined, undefined, undefined));
+ initTestScheduler();
+ });
+
+ afterEach(() => {
+ getTestScheduler().flush();
+ resetTestScheduler();
+ });
+
+ describe(`canActivate`, () => {
+
+ describe(`when authState.loading is undefined`, () => {
+ beforeEach(() => {
+ spyOnProperty(ngrx, 'select').and.callFake(() => {
+ return () => {
+ return () => observableOf(undefined);
+ };
+ })
+ });
+ it(`should not emit anything`, () => {
+ expect(guard.canActivate()).toBeObservable(cold('|'));
+ });
+ });
+
+ describe(`when authState.loading is true`, () => {
+ beforeEach(() => {
+ spyOnProperty(ngrx, 'select').and.callFake(() => {
+ return () => {
+ return () => observableOf(true);
+ };
+ })
+ });
+ it(`should not emit anything`, () => {
+ expect(guard.canActivate()).toBeObservable(cold('|'));
+ });
+ });
+
+ describe(`when authState.loading is false`, () => {
+ beforeEach(() => {
+ spyOnProperty(ngrx, 'select').and.callFake(() => {
+ return () => {
+ return () => observableOf(false);
+ };
+ })
+ });
+ it(`should succeed`, () => {
+ expect(guard.canActivate()).toBeObservable(cold('(a|)', { a: true }));
+ });
+ });
+ });
+
+});
diff --git a/src/app/core/auth/auth-blocking.guard.ts b/src/app/core/auth/auth-blocking.guard.ts
new file mode 100644
index 0000000000..9054f66f8b
--- /dev/null
+++ b/src/app/core/auth/auth-blocking.guard.ts
@@ -0,0 +1,34 @@
+import { Injectable } from '@angular/core';
+import { CanActivate } from '@angular/router';
+import { select, Store } from '@ngrx/store';
+import { Observable } from 'rxjs';
+import { distinctUntilChanged, filter, map, take } from 'rxjs/operators';
+import { AppState } from '../../app.reducer';
+import { isAuthenticationBlocking } from './selectors';
+
+/**
+ * A guard that blocks the loading of any
+ * route until the authentication status has loaded.
+ * To ensure all rest requests get the correct auth header.
+ */
+@Injectable({
+ providedIn: 'root'
+})
+export class AuthBlockingGuard implements CanActivate {
+
+ constructor(private store: Store) {
+ }
+
+ /**
+ * True when the authentication isn't blocking everything
+ */
+ canActivate(): Observable {
+ return this.store.pipe(select(isAuthenticationBlocking)).pipe(
+ map((isBlocking: boolean) => isBlocking === false),
+ distinctUntilChanged(),
+ filter((finished: boolean) => finished === true),
+ take(1),
+ );
+ }
+
+}
diff --git a/src/app/core/auth/auth.actions.ts b/src/app/core/auth/auth.actions.ts
index be4bdf2a26..f80be89034 100644
--- a/src/app/core/auth/auth.actions.ts
+++ b/src/app/core/auth/auth.actions.ts
@@ -34,6 +34,7 @@ export const AuthActionTypes = {
RETRIEVE_AUTHENTICATED_EPERSON: type('dspace/auth/RETRIEVE_AUTHENTICATED_EPERSON'),
RETRIEVE_AUTHENTICATED_EPERSON_SUCCESS: type('dspace/auth/RETRIEVE_AUTHENTICATED_EPERSON_SUCCESS'),
RETRIEVE_AUTHENTICATED_EPERSON_ERROR: type('dspace/auth/RETRIEVE_AUTHENTICATED_EPERSON_ERROR'),
+ REDIRECT_AFTER_LOGIN_SUCCESS: type('dspace/auth/REDIRECT_AFTER_LOGIN_SUCCESS')
};
/* tslint:disable:max-classes-per-file */
@@ -335,6 +336,20 @@ export class SetRedirectUrlAction implements Action {
}
}
+/**
+ * Start loading for a hard redirect
+ * @class StartHardRedirectLoadingAction
+ * @implements {Action}
+ */
+export class RedirectAfterLoginSuccessAction implements Action {
+ public type: string = AuthActionTypes.REDIRECT_AFTER_LOGIN_SUCCESS;
+ payload: string;
+
+ constructor(url: string) {
+ this.payload = url;
+ }
+}
+
/**
* Retrieve the authenticated eperson.
* @class RetrieveAuthenticatedEpersonAction
@@ -402,8 +417,8 @@ export type AuthActions
| RetrieveAuthMethodsSuccessAction
| RetrieveAuthMethodsErrorAction
| RetrieveTokenAction
- | ResetAuthenticationMessagesAction
| RetrieveAuthenticatedEpersonAction
| RetrieveAuthenticatedEpersonErrorAction
| RetrieveAuthenticatedEpersonSuccessAction
- | SetRedirectUrlAction;
+ | SetRedirectUrlAction
+ | RedirectAfterLoginSuccessAction;
diff --git a/src/app/core/auth/auth.effects.ts b/src/app/core/auth/auth.effects.ts
index 37ef3b79bc..ab18dcb508 100644
--- a/src/app/core/auth/auth.effects.ts
+++ b/src/app/core/auth/auth.effects.ts
@@ -27,6 +27,7 @@ import {
CheckAuthenticationTokenCookieAction,
LogOutErrorAction,
LogOutSuccessAction,
+ RedirectAfterLoginSuccessAction,
RefreshTokenAction,
RefreshTokenErrorAction,
RefreshTokenSuccessAction,
@@ -79,7 +80,26 @@ export class AuthEffects {
public authenticatedSuccess$: Observable = this.actions$.pipe(
ofType(AuthActionTypes.AUTHENTICATED_SUCCESS),
tap((action: AuthenticatedSuccessAction) => this.authService.storeToken(action.payload.authToken)),
- map((action: AuthenticatedSuccessAction) => new RetrieveAuthenticatedEpersonAction(action.payload.userHref))
+ switchMap((action: AuthenticatedSuccessAction) => this.authService.getRedirectUrl().pipe(
+ take(1),
+ map((redirectUrl: string) => [action, redirectUrl])
+ )),
+ map(([action, redirectUrl]: [AuthenticatedSuccessAction, string]) => {
+ if (hasValue(redirectUrl)) {
+ return new RedirectAfterLoginSuccessAction(redirectUrl);
+ } else {
+ return new RetrieveAuthenticatedEpersonAction(action.payload.userHref);
+ }
+ })
+ );
+
+ @Effect({ dispatch: false })
+ public redirectAfterLoginSuccess$: Observable = this.actions$.pipe(
+ ofType(AuthActionTypes.REDIRECT_AFTER_LOGIN_SUCCESS),
+ tap((action: RedirectAfterLoginSuccessAction) => {
+ this.authService.clearRedirectUrl();
+ this.authService.navigateToRedirectUrl(action.payload);
+ })
);
// It means "reacts to this action but don't send another"
@@ -201,13 +221,6 @@ export class AuthEffects {
tap(() => this.authService.refreshAfterLogout())
);
- @Effect({ dispatch: false })
- public redirectToLogin$: Observable = this.actions$
- .pipe(ofType(AuthActionTypes.REDIRECT_AUTHENTICATION_REQUIRED),
- tap(() => this.authService.removeToken()),
- tap(() => this.authService.redirectToLogin())
- );
-
@Effect({ dispatch: false })
public redirectToLoginTokenExpired$: Observable = this.actions$
.pipe(
diff --git a/src/app/core/auth/auth.interceptor.ts b/src/app/core/auth/auth.interceptor.ts
index f4e7aa2fd3..3366cdb3d8 100644
--- a/src/app/core/auth/auth.interceptor.ts
+++ b/src/app/core/auth/auth.interceptor.ts
@@ -251,7 +251,6 @@ export class AuthInterceptor implements HttpInterceptor {
// Pass on the new request instead of the original request.
return next.handle(newReq).pipe(
- // tap((response) => console.log('next.handle: ', response)),
map((response) => {
// Intercept a Login/Logout response
if (response instanceof HttpResponse && this.isSuccess(response) && this.isAuthRequest(response)) {
diff --git a/src/app/core/auth/auth.reducer.spec.ts b/src/app/core/auth/auth.reducer.spec.ts
index cf934a7f47..4c6f1e2a25 100644
--- a/src/app/core/auth/auth.reducer.spec.ts
+++ b/src/app/core/auth/auth.reducer.spec.ts
@@ -42,6 +42,7 @@ describe('authReducer', () => {
initialState = {
authenticated: false,
loaded: false,
+ blocking: true,
loading: false,
};
const action = new AuthenticateAction('user', 'password');
@@ -49,6 +50,7 @@ describe('authReducer', () => {
state = {
authenticated: false,
loaded: false,
+ blocking: true,
error: undefined,
loading: true,
info: undefined
@@ -62,6 +64,7 @@ describe('authReducer', () => {
authenticated: false,
loaded: false,
error: undefined,
+ blocking: true,
loading: true,
info: undefined
};
@@ -76,6 +79,7 @@ describe('authReducer', () => {
authenticated: false,
loaded: false,
error: undefined,
+ blocking: true,
loading: true,
info: undefined
};
@@ -84,6 +88,7 @@ describe('authReducer', () => {
state = {
authenticated: false,
loaded: false,
+ blocking: false,
loading: false,
info: undefined,
authToken: undefined,
@@ -96,6 +101,7 @@ describe('authReducer', () => {
it('should properly set the state, in response to a AUTHENTICATED action', () => {
initialState = {
authenticated: false,
+ blocking: false,
loaded: false,
error: undefined,
loading: true,
@@ -103,8 +109,15 @@ describe('authReducer', () => {
};
const action = new AuthenticatedAction(mockTokenInfo);
const newState = authReducer(initialState, action);
-
- expect(newState).toEqual(initialState);
+ state = {
+ authenticated: false,
+ blocking: true,
+ loaded: false,
+ error: undefined,
+ loading: true,
+ info: undefined
+ };
+ expect(newState).toEqual(state);
});
it('should properly set the state, in response to a AUTHENTICATED_SUCCESS action', () => {
@@ -112,6 +125,7 @@ describe('authReducer', () => {
authenticated: false,
loaded: false,
error: undefined,
+ blocking: true,
loading: true,
info: undefined
};
@@ -122,6 +136,7 @@ describe('authReducer', () => {
authToken: mockTokenInfo,
loaded: false,
error: undefined,
+ blocking: true,
loading: true,
info: undefined
};
@@ -133,6 +148,7 @@ describe('authReducer', () => {
authenticated: false,
loaded: false,
error: undefined,
+ blocking: true,
loading: true,
info: undefined
};
@@ -143,6 +159,7 @@ describe('authReducer', () => {
authToken: undefined,
error: 'Test error message',
loaded: true,
+ blocking: false,
loading: false,
info: undefined
};
@@ -153,6 +170,7 @@ describe('authReducer', () => {
initialState = {
authenticated: false,
loaded: false,
+ blocking: false,
loading: false,
};
const action = new CheckAuthenticationTokenAction();
@@ -160,6 +178,7 @@ describe('authReducer', () => {
state = {
authenticated: false,
loaded: false,
+ blocking: true,
loading: true,
};
expect(newState).toEqual(state);
@@ -169,6 +188,7 @@ describe('authReducer', () => {
initialState = {
authenticated: false,
loaded: false,
+ blocking: false,
loading: true,
};
const action = new CheckAuthenticationTokenCookieAction();
@@ -176,6 +196,7 @@ describe('authReducer', () => {
state = {
authenticated: false,
loaded: false,
+ blocking: true,
loading: true,
};
expect(newState).toEqual(state);
@@ -187,6 +208,7 @@ describe('authReducer', () => {
authToken: mockTokenInfo,
loaded: true,
error: undefined,
+ blocking: false,
loading: false,
info: undefined,
userId: EPersonMock.id
@@ -204,6 +226,7 @@ describe('authReducer', () => {
authToken: mockTokenInfo,
loaded: true,
error: undefined,
+ blocking: false,
loading: false,
info: undefined,
userId: EPersonMock.id
@@ -216,7 +239,8 @@ describe('authReducer', () => {
authToken: undefined,
error: undefined,
loaded: false,
- loading: false,
+ blocking: true,
+ loading: true,
info: undefined,
refreshing: false,
userId: undefined
@@ -230,6 +254,7 @@ describe('authReducer', () => {
authToken: mockTokenInfo,
loaded: true,
error: undefined,
+ blocking: false,
loading: false,
info: undefined,
userId: EPersonMock.id
@@ -242,6 +267,7 @@ describe('authReducer', () => {
authToken: mockTokenInfo,
loaded: true,
error: 'Test error message',
+ blocking: false,
loading: false,
info: undefined,
userId: EPersonMock.id
@@ -255,6 +281,7 @@ describe('authReducer', () => {
authToken: mockTokenInfo,
loaded: false,
error: undefined,
+ blocking: true,
loading: true,
info: undefined
};
@@ -265,6 +292,7 @@ describe('authReducer', () => {
authToken: mockTokenInfo,
loaded: true,
error: undefined,
+ blocking: false,
loading: false,
info: undefined,
userId: EPersonMock.id
@@ -277,6 +305,7 @@ describe('authReducer', () => {
authenticated: false,
loaded: false,
error: undefined,
+ blocking: true,
loading: true,
info: undefined
};
@@ -287,6 +316,7 @@ describe('authReducer', () => {
authToken: undefined,
error: 'Test error message',
loaded: true,
+ blocking: false,
loading: false,
info: undefined
};
@@ -299,6 +329,7 @@ describe('authReducer', () => {
authToken: mockTokenInfo,
loaded: true,
error: undefined,
+ blocking: false,
loading: false,
info: undefined,
userId: EPersonMock.id
@@ -311,6 +342,7 @@ describe('authReducer', () => {
authToken: mockTokenInfo,
loaded: true,
error: undefined,
+ blocking: false,
loading: false,
info: undefined,
userId: EPersonMock.id,
@@ -325,6 +357,7 @@ describe('authReducer', () => {
authToken: mockTokenInfo,
loaded: true,
error: undefined,
+ blocking: false,
loading: false,
info: undefined,
userId: EPersonMock.id,
@@ -338,6 +371,7 @@ describe('authReducer', () => {
authToken: newTokenInfo,
loaded: true,
error: undefined,
+ blocking: false,
loading: false,
info: undefined,
userId: EPersonMock.id,
@@ -352,6 +386,7 @@ describe('authReducer', () => {
authToken: mockTokenInfo,
loaded: true,
error: undefined,
+ blocking: false,
loading: false,
info: undefined,
userId: EPersonMock.id,
@@ -364,6 +399,7 @@ describe('authReducer', () => {
authToken: undefined,
error: undefined,
loaded: false,
+ blocking: false,
loading: false,
info: undefined,
refreshing: false,
@@ -378,6 +414,7 @@ describe('authReducer', () => {
authToken: mockTokenInfo,
loaded: true,
error: undefined,
+ blocking: false,
loading: false,
info: undefined,
userId: EPersonMock.id
@@ -387,6 +424,7 @@ describe('authReducer', () => {
authenticated: false,
authToken: undefined,
loaded: false,
+ blocking: false,
loading: false,
error: undefined,
info: 'Message',
@@ -410,6 +448,7 @@ describe('authReducer', () => {
initialState = {
authenticated: false,
loaded: false,
+ blocking: false,
loading: false,
};
const action = new AddAuthenticationMessageAction('Message');
@@ -417,6 +456,7 @@ describe('authReducer', () => {
state = {
authenticated: false,
loaded: false,
+ blocking: false,
loading: false,
info: 'Message'
};
@@ -427,6 +467,7 @@ describe('authReducer', () => {
initialState = {
authenticated: false,
loaded: false,
+ blocking: false,
loading: false,
error: 'Error',
info: 'Message'
@@ -436,6 +477,7 @@ describe('authReducer', () => {
state = {
authenticated: false,
loaded: false,
+ blocking: false,
loading: false,
error: undefined,
info: undefined
@@ -447,6 +489,7 @@ describe('authReducer', () => {
initialState = {
authenticated: false,
loaded: false,
+ blocking: false,
loading: false
};
const action = new SetRedirectUrlAction('redirect.url');
@@ -454,6 +497,7 @@ describe('authReducer', () => {
state = {
authenticated: false,
loaded: false,
+ blocking: false,
loading: false,
redirectUrl: 'redirect.url'
};
@@ -464,6 +508,7 @@ describe('authReducer', () => {
initialState = {
authenticated: false,
loaded: false,
+ blocking: false,
loading: false,
authMethods: []
};
@@ -472,6 +517,7 @@ describe('authReducer', () => {
state = {
authenticated: false,
loaded: false,
+ blocking: true,
loading: true,
authMethods: []
};
@@ -482,6 +528,7 @@ describe('authReducer', () => {
initialState = {
authenticated: false,
loaded: false,
+ blocking: true,
loading: true,
authMethods: []
};
@@ -494,6 +541,7 @@ describe('authReducer', () => {
state = {
authenticated: false,
loaded: false,
+ blocking: false,
loading: false,
authMethods: authMethods
};
@@ -504,6 +552,7 @@ describe('authReducer', () => {
initialState = {
authenticated: false,
loaded: false,
+ blocking: true,
loading: true,
authMethods: []
};
@@ -513,6 +562,7 @@ describe('authReducer', () => {
state = {
authenticated: false,
loaded: false,
+ blocking: false,
loading: false,
authMethods: [new AuthMethod(AuthMethodType.Password)]
};
diff --git a/src/app/core/auth/auth.reducer.ts b/src/app/core/auth/auth.reducer.ts
index 34c8fe2b41..6d5635f263 100644
--- a/src/app/core/auth/auth.reducer.ts
+++ b/src/app/core/auth/auth.reducer.ts
@@ -39,6 +39,10 @@ export interface AuthState {
// true when loading
loading: boolean;
+ // true when everything else should wait for authorization
+ // to complete
+ blocking: boolean;
+
// info message
info?: string;
@@ -62,6 +66,7 @@ export interface AuthState {
const initialState: AuthState = {
authenticated: false,
loaded: false,
+ blocking: true,
loading: false,
authMethods: []
};
@@ -86,7 +91,8 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut
case AuthActionTypes.CHECK_AUTHENTICATION_TOKEN:
case AuthActionTypes.CHECK_AUTHENTICATION_TOKEN_COOKIE:
return Object.assign({}, state, {
- loading: true
+ loading: true,
+ blocking: true
});
case AuthActionTypes.AUTHENTICATED_ERROR:
@@ -96,6 +102,7 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut
authToken: undefined,
error: (action as AuthenticationErrorAction).payload.message,
loaded: true,
+ blocking: false,
loading: false
});
@@ -110,6 +117,7 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut
loaded: true,
error: undefined,
loading: false,
+ blocking: false,
info: undefined,
userId: (action as RetrieveAuthenticatedEpersonSuccessAction).payload
});
@@ -119,6 +127,7 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut
authenticated: false,
authToken: undefined,
error: (action as AuthenticationErrorAction).payload.message,
+ blocking: false,
loading: false
});
@@ -132,25 +141,39 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut
error: (action as LogOutErrorAction).payload.message
});
- case AuthActionTypes.LOG_OUT_SUCCESS:
case AuthActionTypes.REFRESH_TOKEN_ERROR:
return Object.assign({}, state, {
authenticated: false,
authToken: undefined,
error: undefined,
loaded: false,
+ blocking: false,
loading: false,
info: undefined,
refreshing: false,
userId: undefined
});
+ case AuthActionTypes.LOG_OUT_SUCCESS:
+ return Object.assign({}, state, {
+ authenticated: false,
+ authToken: undefined,
+ error: undefined,
+ loaded: false,
+ blocking: true,
+ loading: true,
+ info: undefined,
+ refreshing: false,
+ userId: undefined
+ });
+
case AuthActionTypes.REDIRECT_AUTHENTICATION_REQUIRED:
case AuthActionTypes.REDIRECT_TOKEN_EXPIRED:
return Object.assign({}, state, {
authenticated: false,
authToken: undefined,
loaded: false,
+ blocking: false,
loading: false,
info: (action as RedirectWhenTokenExpiredAction as RedirectWhenAuthenticationIsRequiredAction).payload,
userId: undefined
@@ -181,18 +204,21 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut
// next three cases are used by dynamic rendering of login methods
case AuthActionTypes.RETRIEVE_AUTH_METHODS:
return Object.assign({}, state, {
- loading: true
+ loading: true,
+ blocking: true
});
case AuthActionTypes.RETRIEVE_AUTH_METHODS_SUCCESS:
return Object.assign({}, state, {
loading: false,
+ blocking: false,
authMethods: (action as RetrieveAuthMethodsSuccessAction).payload
});
case AuthActionTypes.RETRIEVE_AUTH_METHODS_ERROR:
return Object.assign({}, state, {
loading: false,
+ blocking: false,
authMethods: [new AuthMethod(AuthMethodType.Password)]
});
@@ -201,6 +227,12 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut
redirectUrl: (action as SetRedirectUrlAction).payload,
});
+ case AuthActionTypes.REDIRECT_AFTER_LOGIN_SUCCESS:
+ return Object.assign({}, state, {
+ loading: true,
+ blocking: true,
+ });
+
default:
return state;
}
diff --git a/src/app/core/auth/auth.service.spec.ts b/src/app/core/auth/auth.service.spec.ts
index 7f2c1e29cc..d3c2b6c44d 100644
--- a/src/app/core/auth/auth.service.spec.ts
+++ b/src/app/core/auth/auth.service.spec.ts
@@ -27,6 +27,7 @@ import { EPersonDataService } from '../eperson/eperson-data.service';
import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils';
import { authMethodsMock } from '../../shared/testing/auth-service.stub';
import { AuthMethod } from './models/auth.method';
+import { HardRedirectService } from '../services/hard-redirect.service';
describe('AuthService test', () => {
@@ -48,6 +49,7 @@ describe('AuthService test', () => {
let authenticatedState;
let unAuthenticatedState;
let linkService;
+ let hardRedirectService;
function init() {
mockStore = jasmine.createSpyObj('store', {
@@ -77,6 +79,7 @@ describe('AuthService test', () => {
linkService = {
resolveLinks: {}
};
+ hardRedirectService = jasmine.createSpyObj('hardRedirectService', ['redirect']);
spyOn(linkService, 'resolveLinks').and.returnValue({ authenticated: true, eperson: observableOf({ payload: {} }) });
}
@@ -104,6 +107,7 @@ describe('AuthService test', () => {
{ provide: ActivatedRoute, useValue: routeStub },
{ provide: Store, useValue: mockStore },
{ provide: EPersonDataService, useValue: mockEpersonDataService },
+ { provide: HardRedirectService, useValue: hardRedirectService },
CookieService,
AuthService
],
@@ -210,7 +214,7 @@ describe('AuthService test', () => {
(state as any).core = Object.create({});
(state as any).core.auth = authenticatedState;
});
- authService = new AuthService({}, window, undefined, authReqService, mockEpersonDataService, router, routeService, cookieService, store);
+ authService = new AuthService({}, window, undefined, authReqService, mockEpersonDataService, router, routeService, cookieService, store, hardRedirectService);
}));
it('should return true when user is logged in', () => {
@@ -289,7 +293,7 @@ describe('AuthService test', () => {
(state as any).core = Object.create({});
(state as any).core.auth = authenticatedState;
});
- authService = new AuthService({}, window, undefined, authReqService, mockEpersonDataService, router, routeService, cookieService, store);
+ authService = new AuthService({}, window, undefined, authReqService, mockEpersonDataService, router, routeService, cookieService, store, hardRedirectService);
storage = (authService as any).storage;
routeServiceMock = TestBed.get(RouteService);
routerStub = TestBed.get(Router);
@@ -318,36 +322,28 @@ describe('AuthService test', () => {
expect(storage.remove).toHaveBeenCalled();
});
- it('should set redirect url to previous page', () => {
- spyOn(routeServiceMock, 'getHistory').and.callThrough();
- spyOn(routerStub, 'navigateByUrl');
- authService.redirectAfterLoginSuccess(true);
- expect(routeServiceMock.getHistory).toHaveBeenCalled();
- expect(routerStub.navigateByUrl).toHaveBeenCalledWith('/collection/123');
+ it('should redirect to reload with redirect url', () => {
+ authService.navigateToRedirectUrl('/collection/123');
+ // Reload with redirect URL set to /collection/123
+ expect(hardRedirectService.redirect).toHaveBeenCalledWith(jasmine.stringMatching(new RegExp('/reload/[0-9]*\\?redirect=' + encodeURIComponent('/collection/123'))));
});
- it('should set redirect url to current page', () => {
- spyOn(routeServiceMock, 'getHistory').and.callThrough();
- spyOn(routerStub, 'navigateByUrl');
- authService.redirectAfterLoginSuccess(false);
- expect(routeServiceMock.getHistory).toHaveBeenCalled();
- expect(routerStub.navigateByUrl).toHaveBeenCalledWith('/home');
+ it('should redirect to reload with /home', () => {
+ authService.navigateToRedirectUrl('/home');
+ // Reload with redirect URL set to /home
+ expect(hardRedirectService.redirect).toHaveBeenCalledWith(jasmine.stringMatching(new RegExp('/reload/[0-9]*\\?redirect=' + encodeURIComponent('/home'))));
});
- it('should redirect to / and not to /login', () => {
- spyOn(routeServiceMock, 'getHistory').and.returnValue(observableOf(['/login', '/login']));
- spyOn(routerStub, 'navigateByUrl');
- authService.redirectAfterLoginSuccess(true);
- expect(routeServiceMock.getHistory).toHaveBeenCalled();
- expect(routerStub.navigateByUrl).toHaveBeenCalledWith('/');
+ it('should redirect to regular reload and not to /login', () => {
+ authService.navigateToRedirectUrl('/login');
+ // Reload without a redirect URL
+ expect(hardRedirectService.redirect).toHaveBeenCalledWith(jasmine.stringMatching(new RegExp('/reload/[0-9]*(?!\\?)$')));
});
- it('should redirect to / when no redirect url is found', () => {
- spyOn(routeServiceMock, 'getHistory').and.returnValue(observableOf(['']));
- spyOn(routerStub, 'navigateByUrl');
- authService.redirectAfterLoginSuccess(true);
- expect(routeServiceMock.getHistory).toHaveBeenCalled();
- expect(routerStub.navigateByUrl).toHaveBeenCalledWith('/');
+ it('should redirect to regular reload when no redirect url is found', () => {
+ authService.navigateToRedirectUrl(undefined);
+ // Reload without a redirect URL
+ expect(hardRedirectService.redirect).toHaveBeenCalledWith(jasmine.stringMatching(new RegExp('/reload/[0-9]*(?!\\?)$')));
});
describe('impersonate', () => {
@@ -464,6 +460,14 @@ describe('AuthService test', () => {
});
});
});
+
+ describe('refreshAfterLogout', () => {
+ it('should call navigateToRedirectUrl with no url', () => {
+ spyOn(authService as any, 'navigateToRedirectUrl').and.stub();
+ authService.refreshAfterLogout();
+ expect((authService as any).navigateToRedirectUrl).toHaveBeenCalled();
+ });
+ });
});
describe('when user is not logged in', () => {
@@ -496,7 +500,7 @@ describe('AuthService test', () => {
(state as any).core = Object.create({});
(state as any).core.auth = unAuthenticatedState;
});
- authService = new AuthService({}, window, undefined, authReqService, mockEpersonDataService, router, routeService, cookieService, store);
+ authService = new AuthService({}, window, undefined, authReqService, mockEpersonDataService, router, routeService, cookieService, store, hardRedirectService);
}));
it('should return null for the shortlived token', () => {
diff --git a/src/app/core/auth/auth.service.ts b/src/app/core/auth/auth.service.ts
index 7d854d9d4d..06906346ed 100644
--- a/src/app/core/auth/auth.service.ts
+++ b/src/app/core/auth/auth.service.ts
@@ -1,11 +1,10 @@
import { Inject, Injectable, Optional } from '@angular/core';
-import { PRIMARY_OUTLET, Router, UrlSegmentGroup, UrlTree } from '@angular/router';
+import { Router } from '@angular/router';
import { HttpHeaders } from '@angular/common/http';
import { REQUEST, RESPONSE } from '@nguniversal/express-engine/tokens';
import { Observable, of as observableOf } from 'rxjs';
-import { distinctUntilChanged, filter, map, startWith, switchMap, take, withLatestFrom } from 'rxjs/operators';
-import { RouterReducerState } from '@ngrx/router-store';
+import { map, startWith, switchMap, take } from 'rxjs/operators';
import { select, Store } from '@ngrx/store';
import { CookieAttributes } from 'js-cookie';
@@ -14,7 +13,15 @@ import { AuthRequestService } from './auth-request.service';
import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service';
import { AuthStatus } from './models/auth-status.model';
import { AuthTokenInfo, TOKENITEM } from './models/auth-token-info.model';
-import { hasValue, hasValueOperator, isEmpty, isNotEmpty, isNotNull, isNotUndefined } from '../../shared/empty.util';
+import {
+ hasValue,
+ hasValueOperator,
+ isEmpty,
+ isNotEmpty,
+ isNotNull,
+ isNotUndefined,
+ hasNoValue
+} from '../../shared/empty.util';
import { CookieService } from '../services/cookie.service';
import {
getAuthenticatedUserId,
@@ -24,7 +31,7 @@ import {
isTokenRefreshing,
isAuthenticatedLoaded
} from './selectors';
-import { AppState, routerStateSelector } from '../../app.reducer';
+import { AppState } from '../../app.reducer';
import {
CheckAuthenticationTokenAction,
ResetAuthenticationMessagesAction,
@@ -36,6 +43,7 @@ import { RouteService } from '../services/route.service';
import { EPersonDataService } from '../eperson/eperson-data.service';
import { getAllSucceededRemoteDataPayload } from '../shared/operators';
import { AuthMethod } from './models/auth.method';
+import { HardRedirectService } from '../services/hard-redirect.service';
export const LOGIN_ROUTE = '/login';
export const LOGOUT_ROUTE = '/logout';
@@ -62,43 +70,13 @@ export class AuthService {
protected router: Router,
protected routeService: RouteService,
protected storage: CookieService,
- protected store: Store
+ protected store: Store,
+ protected hardRedirectService: HardRedirectService
) {
this.store.pipe(
select(isAuthenticated),
startWith(false)
).subscribe((authenticated: boolean) => this._authenticated = authenticated);
-
- // If current route is different from the one setted in authentication guard
- // and is not the login route, clear redirect url and messages
- const routeUrl$ = this.store.pipe(
- select(routerStateSelector),
- filter((routerState: RouterReducerState) => isNotUndefined(routerState)
- && isNotUndefined(routerState.state) && isNotEmpty(routerState.state.url)),
- filter((routerState: RouterReducerState) => !this.isLoginRoute(routerState.state.url)),
- map((routerState: RouterReducerState) => routerState.state.url)
- );
- const redirectUrl$ = this.store.pipe(select(getRedirectUrl), distinctUntilChanged());
- routeUrl$.pipe(
- withLatestFrom(redirectUrl$),
- map(([routeUrl, redirectUrl]) => [routeUrl, redirectUrl])
- ).pipe(filter(([routeUrl, redirectUrl]) => isNotEmpty(redirectUrl) && (routeUrl !== redirectUrl)))
- .subscribe(() => {
- this.clearRedirectUrl();
- });
- }
-
- /**
- * Check if is a login page route
- *
- * @param {string} url
- * @returns {Boolean}.
- */
- protected isLoginRoute(url: string) {
- const urlTree: UrlTree = this.router.parseUrl(url);
- const g: UrlSegmentGroup = urlTree.root.children[PRIMARY_OUTLET];
- const segment = '/' + g.toString();
- return segment === LOGIN_ROUTE;
}
/**
@@ -409,69 +387,38 @@ export class AuthService {
}
/**
- * Redirect to the route navigated before the login
+ * Perform a hard redirect to the URL
+ * @param redirectUrl
*/
- public redirectAfterLoginSuccess(isStandalonePage: boolean) {
- this.getRedirectUrl().pipe(
- take(1))
- .subscribe((redirectUrl) => {
-
- if (isNotEmpty(redirectUrl)) {
- this.clearRedirectUrl();
- this.router.onSameUrlNavigation = 'reload';
- this.navigateToRedirectUrl(redirectUrl);
- } else {
- // If redirectUrl is empty use history.
- this.routeService.getHistory().pipe(
- take(1)
- ).subscribe((history) => {
- let redirUrl;
- if (isStandalonePage) {
- // For standalone login pages, use the previous route.
- redirUrl = history[history.length - 2] || '';
- } else {
- redirUrl = history[history.length - 1] || '';
- }
- this.navigateToRedirectUrl(redirUrl);
- });
- }
- });
-
- }
-
- protected navigateToRedirectUrl(redirectUrl: string) {
- const url = decodeURIComponent(redirectUrl);
- // in case the user navigates directly to /login (via bookmark, etc), or the route history is not found.
- if (isEmpty(url) || url.startsWith(LOGIN_ROUTE)) {
- this.router.navigateByUrl('/');
- /* TODO Reenable hard redirect when REST API can handle x-forwarded-for, see https://github.com/DSpace/DSpace/pull/2207 */
- // this._window.nativeWindow.location.href = '/';
- } else {
- /* TODO Reenable hard redirect when REST API can handle x-forwarded-for, see https://github.com/DSpace/DSpace/pull/2207 */
- // this._window.nativeWindow.location.href = url;
- this.router.navigateByUrl(url);
+ public navigateToRedirectUrl(redirectUrl: string) {
+ let url = `/reload/${new Date().getTime()}`;
+ if (isNotEmpty(redirectUrl) && !redirectUrl.startsWith(LOGIN_ROUTE)) {
+ url += `?redirect=${encodeURIComponent(redirectUrl)}`;
}
+ this.hardRedirectService.redirect(url);
}
/**
* Refresh route navigated
*/
public refreshAfterLogout() {
- // Hard redirect to the reload page with a unique number behind it
- // so that all state is definitely lost
- this._window.nativeWindow.location.href = `/reload/${new Date().getTime()}`;
+ this.navigateToRedirectUrl(undefined);
}
/**
* Get redirect url
*/
getRedirectUrl(): Observable {
- const redirectUrl = this.storage.get(REDIRECT_COOKIE);
- if (isNotEmpty(redirectUrl)) {
- return observableOf(redirectUrl);
- } else {
- return this.store.pipe(select(getRedirectUrl));
- }
+ return this.store.pipe(
+ select(getRedirectUrl),
+ map((urlFromStore: string) => {
+ if (hasValue(urlFromStore)) {
+ return urlFromStore;
+ } else {
+ return this.storage.get(REDIRECT_COOKIE);
+ }
+ })
+ );
}
/**
@@ -488,6 +435,20 @@ export class AuthService {
this.store.dispatch(new SetRedirectUrlAction(isNotUndefined(url) ? url : ''));
}
+ /**
+ * Set the redirect url if the current one has not been set yet
+ * @param newRedirectUrl
+ */
+ setRedirectUrlIfNotSet(newRedirectUrl: string) {
+ this.getRedirectUrl().pipe(
+ take(1))
+ .subscribe((currentRedirectUrl) => {
+ if (hasNoValue(currentRedirectUrl)) {
+ this.setRedirectUrl(newRedirectUrl);
+ }
+ })
+ }
+
/**
* Clear redirect url
*/
diff --git a/src/app/core/auth/authenticated.guard.ts b/src/app/core/auth/authenticated.guard.ts
index 7a2f39854c..0b9eeec509 100644
--- a/src/app/core/auth/authenticated.guard.ts
+++ b/src/app/core/auth/authenticated.guard.ts
@@ -1,21 +1,26 @@
import { Injectable } from '@angular/core';
-import { ActivatedRouteSnapshot, CanActivate, CanLoad, Route, Router, RouterStateSnapshot } from '@angular/router';
+import {
+ ActivatedRouteSnapshot,
+ CanActivate,
+ Router,
+ RouterStateSnapshot,
+ UrlTree
+} from '@angular/router';
import { Observable } from 'rxjs';
-import { take } from 'rxjs/operators';
+import { map, find, switchMap } from 'rxjs/operators';
import { select, Store } from '@ngrx/store';
import { CoreState } from '../core.reducers';
-import { isAuthenticated } from './selectors';
-import { AuthService } from './auth.service';
-import { RedirectWhenAuthenticationIsRequiredAction } from './auth.actions';
+import { isAuthenticated, isAuthenticationLoading } from './selectors';
+import { AuthService, LOGIN_ROUTE } from './auth.service';
/**
* Prevent unauthorized activating and loading of routes
* @class AuthenticatedGuard
*/
@Injectable()
-export class AuthenticatedGuard implements CanActivate, CanLoad {
+export class AuthenticatedGuard implements CanActivate {
/**
* @constructor
@@ -24,46 +29,37 @@ export class AuthenticatedGuard implements CanActivate, CanLoad {
/**
* True when user is authenticated
+ * UrlTree with redirect to login page when user isn't authenticated
* @method canActivate
*/
- canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable {
+ canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable {
const url = state.url;
return this.handleAuth(url);
}
/**
* True when user is authenticated
+ * UrlTree with redirect to login page when user isn't authenticated
* @method canActivateChild
*/
- canActivateChild(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable {
+ canActivateChild(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable {
return this.canActivate(route, state);
}
- /**
- * True when user is authenticated
- * @method canLoad
- */
- canLoad(route: Route): Observable {
- const url = `/${route.path}`;
-
- return this.handleAuth(url);
- }
-
- private handleAuth(url: string): Observable {
- // get observable
- const observable = this.store.pipe(select(isAuthenticated));
-
+ private handleAuth(url: string): Observable {
// redirect to sign in page if user is not authenticated
- observable.pipe(
- // .filter(() => isEmpty(this.router.routerState.snapshot.url) || this.router.routerState.snapshot.url === url)
- take(1))
- .subscribe((authenticated) => {
- if (!authenticated) {
+ return this.store.pipe(select(isAuthenticationLoading)).pipe(
+ find((isLoading: boolean) => isLoading === false),
+ switchMap(() => this.store.pipe(select(isAuthenticated))),
+ map((authenticated) => {
+ if (authenticated) {
+ return authenticated;
+ } else {
this.authService.setRedirectUrl(url);
- this.store.dispatch(new RedirectWhenAuthenticationIsRequiredAction('Login required'));
+ this.authService.removeToken();
+ return this.router.createUrlTree([LOGIN_ROUTE]);
}
- });
-
- return observable;
+ })
+ );
}
}
diff --git a/src/app/core/auth/selectors.ts b/src/app/core/auth/selectors.ts
index 173f82e810..c4e95a0fb3 100644
--- a/src/app/core/auth/selectors.ts
+++ b/src/app/core/auth/selectors.ts
@@ -65,6 +65,14 @@ const _getAuthenticationInfo = (state: AuthState) => state.info;
*/
const _isLoading = (state: AuthState) => state.loading;
+/**
+ * Returns true if everything else should wait for authentication.
+ * @function _isBlocking
+ * @param {State} state
+ * @returns {boolean}
+ */
+const _isBlocking = (state: AuthState) => state.blocking;
+
/**
* Returns true if a refresh token request is in progress.
* @function _isRefreshing
@@ -170,6 +178,16 @@ export const isAuthenticatedLoaded = createSelector(getAuthState, _isAuthenticat
*/
export const isAuthenticationLoading = createSelector(getAuthState, _isLoading);
+/**
+ * Returns true if the authentication should block everything else
+ *
+ * @function isAuthenticationBlocking
+ * @param {AuthState} state
+ * @param {any} props
+ * @return {boolean}
+ */
+export const isAuthenticationBlocking = createSelector(getAuthState, _isBlocking);
+
/**
* Returns true if the refresh token request is loading.
* @function isTokenRefreshing
diff --git a/src/app/core/auth/server-auth.service.ts b/src/app/core/auth/server-auth.service.ts
index 7b78255001..88a4ac406e 100644
--- a/src/app/core/auth/server-auth.service.ts
+++ b/src/app/core/auth/server-auth.service.ts
@@ -2,7 +2,7 @@ import { Injectable } from '@angular/core';
import { HttpHeaders } from '@angular/common/http';
import { Observable } from 'rxjs';
-import { filter, map, take } from 'rxjs/operators';
+import { map } from 'rxjs/operators';
import { isNotEmpty } from '../../shared/empty.util';
import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service';
@@ -58,32 +58,4 @@ export class ServerAuthService extends AuthService {
map((status: AuthStatus) => Object.assign(new AuthStatus(), status))
);
}
-
- /**
- * Redirect to the route navigated before the login
- */
- public redirectAfterLoginSuccess(isStandalonePage: boolean) {
- this.getRedirectUrl().pipe(
- take(1))
- .subscribe((redirectUrl) => {
- if (isNotEmpty(redirectUrl)) {
- // override the route reuse strategy
- this.router.routeReuseStrategy.shouldReuseRoute = () => {
- return false;
- };
- this.router.navigated = false;
- const url = decodeURIComponent(redirectUrl);
- this.router.navigateByUrl(url);
- } else {
- // If redirectUrl is empty use history. For ssr the history array should contain the requested url.
- this.routeService.getHistory().pipe(
- filter((history) => history.length > 0),
- take(1)
- ).subscribe((history) => {
- this.navigateToRedirectUrl(history[history.length - 1] || '');
- });
- }
- })
- }
-
}
diff --git a/src/app/core/breadcrumbs/dso-breadcrumb.resolver.ts b/src/app/core/breadcrumbs/dso-breadcrumb.resolver.ts
index 09292fec21..03d4db3f5d 100644
--- a/src/app/core/breadcrumbs/dso-breadcrumb.resolver.ts
+++ b/src/app/core/breadcrumbs/dso-breadcrumb.resolver.ts
@@ -3,12 +3,13 @@ import { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot, Resolve, RouterStateSnapshot } from '@angular/router';
import { DSOBreadcrumbsService } from './dso-breadcrumbs.service';
import { DataService } from '../data/data.service';
-import { getRemoteDataPayload, getSucceededRemoteData } from '../shared/operators';
-import { map } from 'rxjs/operators';
+import { getRemoteDataPayload } from '../shared/operators';
+import { filter, map, take } from 'rxjs/operators';
import { Observable } from 'rxjs';
import { DSpaceObject } from '../shared/dspace-object.model';
import { ChildHALResource } from '../shared/child-hal-resource.model';
import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model';
+import { hasValue } from '../../shared/empty.util';
/**
* The class that resolves the BreadcrumbConfig object for a DSpaceObject
@@ -29,12 +30,17 @@ export abstract class DSOBreadcrumbResolver> {
const uuid = route.params.id;
return this.dataService.findById(uuid, ...this.followLinks).pipe(
- getSucceededRemoteData(),
+ filter((rd) => hasValue(rd.error) || hasValue(rd.payload)),
+ take(1),
getRemoteDataPayload(),
map((object: T) => {
- const fullPath = state.url;
- const url = fullPath.substr(0, fullPath.indexOf(uuid)) + uuid;
- return { provider: this.breadcrumbService, key: object, url: url };
+ if (hasValue(object)) {
+ const fullPath = state.url;
+ const url = fullPath.substr(0, fullPath.indexOf(uuid)) + uuid;
+ return {provider: this.breadcrumbService, key: object, url: url};
+ } else {
+ return undefined;
+ }
})
);
}
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/cache/response.models.ts b/src/app/core/cache/response.models.ts
index 5f19185d1c..b33080b641 100644
--- a/src/app/core/cache/response.models.ts
+++ b/src/app/core/cache/response.models.ts
@@ -5,7 +5,6 @@ import { PageInfo } from '../shared/page-info.model';
import { ConfigObject } from '../config/models/config.model';
import { FacetValue } from '../../shared/search/facet-value.model';
import { SearchFilterConfig } from '../../shared/search/search-filter-config.model';
-import { IntegrationModel } from '../integration/models/integration.model';
import { PaginatedList } from '../data/paginated-list';
import { SubmissionObject } from '../submission/models/submission-object.model';
import { DSpaceObject } from '../shared/dspace-object.model';
@@ -181,17 +180,6 @@ export class TokenResponse extends RestResponse {
}
}
-export class IntegrationSuccessResponse extends RestResponse {
- constructor(
- public dataDefinition: PaginatedList,
- public statusCode: number,
- public statusText: string,
- public pageInfo?: PageInfo
- ) {
- super(true, statusCode, statusText);
- }
-}
-
export class PostPatchSuccessResponse extends RestResponse {
constructor(
public dataDefinition: any,
diff --git a/src/app/core/core.module.ts b/src/app/core/core.module.ts
index d262bfd0d6..95cd89e87d 100644
--- a/src/app/core/core.module.ts
+++ b/src/app/core/core.module.ts
@@ -1,6 +1,7 @@
import { CommonModule } from '@angular/common';
import { HTTP_INTERCEPTORS, HttpClient } from '@angular/common/http';
import { ModuleWithProviders, NgModule, Optional, SkipSelf } from '@angular/core';
+
import { DynamicFormLayoutService, DynamicFormService, DynamicFormValidationService } from '@ng-dynamic-forms/core';
import { EffectsModule } from '@ngrx/effects';
@@ -16,8 +17,8 @@ import { MenuService } from '../shared/menu/menu.service';
import { EndpointMockingRestService } from '../shared/mocks/dspace-rest-v2/endpoint-mocking-rest.service';
import {
MOCK_RESPONSE_MAP,
- ResponseMapMock,
- mockResponseMap
+ mockResponseMap,
+ ResponseMapMock
} from '../shared/mocks/dspace-rest-v2/mocks/response-map.mock';
import { NotificationsService } from '../shared/notifications/notifications.service';
import { SelectableListService } from '../shared/object-list/selectable-list/selectable-list.service';
@@ -82,9 +83,6 @@ import { EPersonDataService } from './eperson/eperson-data.service';
import { EpersonResponseParsingService } from './eperson/eperson-response-parsing.service';
import { EPerson } from './eperson/models/eperson.model';
import { Group } from './eperson/models/group.model';
-import { AuthorityService } from './integration/authority.service';
-import { IntegrationResponseParsingService } from './integration/integration-response-parsing.service';
-import { AuthorityValue } from './integration/models/authority.value';
import { JsonPatchOperationsBuilder } from './json-patch/builder/json-patch-operations-builder';
import { MetadataField } from './metadata/metadata-field.model';
import { MetadataSchema } from './metadata/metadata-schema.model';
@@ -162,8 +160,20 @@ import { SubmissionCcLicenseDataService } from './submission/submission-cc-licen
import { SubmissionCcLicence } from './submission/models/submission-cc-license.model';
import { SubmissionCcLicenceUrl } from './submission/models/submission-cc-license-url.model';
import { SubmissionCcLicenseUrlDataService } from './submission/submission-cc-license-url-data.service';
+import { VocabularyEntry } from './submission/vocabularies/models/vocabulary-entry.model';
+import { Vocabulary } from './submission/vocabularies/models/vocabulary.model';
+import { VocabularyEntriesResponseParsingService } from './submission/vocabularies/vocabulary-entries-response-parsing.service';
+import { VocabularyEntryDetail } from './submission/vocabularies/models/vocabulary-entry-detail.model';
+import { VocabularyService } from './submission/vocabularies/vocabulary.service';
+import { VocabularyTreeviewService } from '../shared/vocabulary-treeview/vocabulary-treeview.service';
import { ConfigurationDataService } from './data/configuration-data.service';
import { ConfigurationProperty } from './shared/configuration-property.model';
+import { ReloadGuard } from './reload/reload.guard';
+import { EndUserAgreementCurrentUserGuard } from './end-user-agreement/end-user-agreement-current-user.guard';
+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
@@ -197,7 +207,7 @@ const PROVIDERS = [
SiteDataService,
DSOResponseParsingService,
{ provide: MOCK_RESPONSE_MAP, useValue: mockResponseMap },
- { provide: DSpaceRESTv2Service, useFactory: restServiceFactory, deps: [MOCK_RESPONSE_MAP, HttpClient]},
+ { provide: DSpaceRESTv2Service, useFactory: restServiceFactory, deps: [MOCK_RESPONSE_MAP, HttpClient] },
DynamicFormLayoutService,
DynamicFormService,
DynamicFormValidationService,
@@ -239,8 +249,6 @@ const PROVIDERS = [
SubmissionResponseParsingService,
SubmissionJsonPatchOperationsService,
JsonPatchOperationsBuilder,
- AuthorityService,
- IntegrationResponseParsingService,
UploaderService,
UUIDService,
NotificationsService,
@@ -289,9 +297,14 @@ const PROVIDERS = [
FeatureDataService,
AuthorizationDataService,
SiteAdministratorGuard,
+ SiteRegisterGuard,
MetadataSchemaDataService,
MetadataFieldDataService,
TokenResponseParsingService,
+ ReloadGuard,
+ EndUserAgreementCurrentUserGuard,
+ EndUserAgreementCookieGuard,
+ EndUserAgreementService,
// register AuthInterceptor as HttpInterceptor
{
provide: HTTP_INTERCEPTORS,
@@ -306,7 +319,10 @@ const PROVIDERS = [
},
NotificationsService,
FilteredDiscoveryPageResponseParsingService,
- { provide: NativeWindowService, useFactory: NativeWindowFactory }
+ { provide: NativeWindowService, useFactory: NativeWindowFactory },
+ VocabularyService,
+ VocabularyEntriesResponseParsingService,
+ VocabularyTreeviewService
];
/**
@@ -337,7 +353,6 @@ export const models =
SubmissionSectionModel,
SubmissionUploadsModel,
AuthStatus,
- AuthorityValue,
BrowseEntry,
BrowseDefinition,
ClaimedTask,
@@ -358,7 +373,11 @@ export const models =
Feature,
Authorization,
Registration,
- ConfigurationProperty
+ Vocabulary,
+ VocabularyEntry,
+ VocabularyEntryDetail,
+ 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/browse-entries-response-parsing.service.ts b/src/app/core/data/browse-entries-response-parsing.service.ts
index 98385f0237..1009a07bca 100644
--- a/src/app/core/data/browse-entries-response-parsing.service.ts
+++ b/src/app/core/data/browse-entries-response-parsing.service.ts
@@ -1,40 +1,22 @@
-import { Inject, Injectable } from '@angular/core';
-import { isNotEmpty } from '../../shared/empty.util';
+import { Injectable } from '@angular/core';
import { ObjectCacheService } from '../cache/object-cache.service';
-import { ErrorResponse, GenericSuccessResponse, RestResponse } from '../cache/response.models';
-import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model';
-import { DSpaceSerializer } from '../dspace-rest-v2/dspace.serializer';
import { BrowseEntry } from '../shared/browse-entry.model';
-import { BaseResponseParsingService } from './base-response-parsing.service';
-import { ResponseParsingService } from './parsing.service';
-import { RestRequest } from './request.models';
+import { EntriesResponseParsingService } from './entries-response-parsing.service';
+import { GenericConstructor } from '../shared/generic-constructor';
@Injectable()
-export class BrowseEntriesResponseParsingService extends BaseResponseParsingService implements ResponseParsingService {
+export class BrowseEntriesResponseParsingService extends EntriesResponseParsingService {
protected toCache = false;
constructor(
protected objectCache: ObjectCacheService,
- ) { super();
+ ) {
+ super(objectCache);
}
- parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse {
- if (isNotEmpty(data.payload)) {
- let browseEntries = [];
- if (isNotEmpty(data.payload._embedded) && Array.isArray(data.payload._embedded[Object.keys(data.payload._embedded)[0]])) {
- const serializer = new DSpaceSerializer(BrowseEntry);
- browseEntries = serializer.deserializeArray(data.payload._embedded[Object.keys(data.payload._embedded)[0]]);
- }
- return new GenericSuccessResponse(browseEntries, data.statusCode, data.statusText, this.processPageInfo(data.payload));
- } else {
- return new ErrorResponse(
- Object.assign(
- new Error('Unexpected response from browse endpoint'),
- { statusCode: data.statusCode, statusText: data.statusText }
- )
- );
- }
+ getSerializerModel(): GenericConstructor {
+ return BrowseEntry;
}
}
diff --git a/src/app/core/data/bundle-data.service.spec.ts b/src/app/core/data/bundle-data.service.spec.ts
index 1e1bf0eb9c..6c63ca8978 100644
--- a/src/app/core/data/bundle-data.service.spec.ts
+++ b/src/app/core/data/bundle-data.service.spec.ts
@@ -1,21 +1,11 @@
import { HttpClient } from '@angular/common/http';
import { Store } from '@ngrx/store';
import { compare, Operation } from 'fast-json-patch';
-import { Observable, of as observableOf } from 'rxjs';
import { NotificationsService } from '../../shared/notifications/notifications.service';
-import { followLink } from '../../shared/utils/follow-link-config.model';
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
-import { SortDirection, SortOptions } from '../cache/models/sort-options.model';
-import { ObjectCacheService } from '../cache/object-cache.service';
import { CoreState } from '../core.reducers';
-import { DSpaceObject } from '../shared/dspace-object.model';
-import { HALEndpointService } from '../shared/hal-endpoint.service';
import { Item } from '../shared/item.model';
-import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils';
import { ChangeAnalyzer } from './change-analyzer';
-import { DataService } from './data.service';
-import { FindListOptions, PatchRequest } from './request.models';
-import { RequestService } from './request.service';
import { getMockRequestService } from '../../shared/mocks/request.service.mock';
import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service.stub';
import { BundleDataService } from './bundle-data.service';
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/collection-data.service.spec.ts b/src/app/core/data/collection-data.service.spec.ts
index 76aad4ad56..4b0dee7df7 100644
--- a/src/app/core/data/collection-data.service.spec.ts
+++ b/src/app/core/data/collection-data.service.spec.ts
@@ -6,18 +6,18 @@ import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-servic
import { NotificationsServiceStub } from '../../shared/testing/notifications-service.stub';
import { getMockTranslateService } from '../../shared/mocks/translate.service.mock';
import { fakeAsync, tick } from '@angular/core/testing';
-import { ContentSourceRequest, GetRequest, RequestError, UpdateContentSourceRequest } from './request.models';
+import { ContentSourceRequest, GetRequest, UpdateContentSourceRequest } from './request.models';
import { ContentSource } from '../shared/content-source.model';
import { of as observableOf } from 'rxjs/internal/observable/of';
import { RequestEntry } from './request.reducer';
-import { ErrorResponse, RestResponse } from '../cache/response.models';
+import { ErrorResponse } from '../cache/response.models';
import { ObjectCacheService } from '../cache/object-cache.service';
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
import { Collection } from '../shared/collection.model';
import { PageInfo } from '../shared/page-info.model';
import { PaginatedList } from './paginated-list';
import { createSuccessfulRemoteDataObject } from 'src/app/shared/remote-data.utils';
-import { hot, getTestScheduler, cold } from 'jasmine-marbles';
+import { cold, getTestScheduler, hot } from 'jasmine-marbles';
import { TestScheduler } from 'rxjs/testing';
const url = 'fake-url';
diff --git a/src/app/core/data/community-data.service.ts b/src/app/core/data/community-data.service.ts
index 123c3eccd1..474bdef44a 100644
--- a/src/app/core/data/community-data.service.ts
+++ b/src/app/core/data/community-data.service.ts
@@ -24,7 +24,7 @@ import { RequestService } from './request.service';
@dataService(COMMUNITY)
export class CommunityDataService extends ComColDataService {
protected linkPath = 'communities';
- protected topLinkPath = 'communities/search/top';
+ protected topLinkPath = 'search/top';
protected cds = this;
constructor(
diff --git a/src/app/core/data/data.service.spec.ts b/src/app/core/data/data.service.spec.ts
index a99fc54269..31013c5132 100644
--- a/src/app/core/data/data.service.spec.ts
+++ b/src/app/core/data/data.service.spec.ts
@@ -18,6 +18,7 @@ import { FindListOptions, PatchRequest } from './request.models';
import { RequestService } from './request.service';
import { getMockRequestService } from '../../shared/mocks/request.service.mock';
import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service.stub';
+import { RequestParam } from '../cache/models/request-param.model';
const endpoint = 'https://rest.api/core';
@@ -150,7 +151,8 @@ describe('DataService', () => {
currentPage: 6,
elementsPerPage: 10,
sort: sortOptions,
- startsWith: 'ab'
+ startsWith: 'ab',
+
};
const expected = `${endpoint}?page=${options.currentPage - 1}&size=${options.elementsPerPage}` +
`&sort=${sortOptions.field},${sortOptions.direction}&startsWith=${options.startsWith}`;
@@ -160,6 +162,26 @@ describe('DataService', () => {
});
});
+ it('should include all searchParams in href if any provided in options', () => {
+ options = { searchParams: [
+ new RequestParam('param1', 'test'),
+ new RequestParam('param2', 'test2'),
+ ] };
+ const expected = `${endpoint}?param1=test¶m2=test2`;
+
+ (service as any).getFindAllHref(options).subscribe((value) => {
+ expect(value).toBe(expected);
+ });
+ });
+
+ it('should include linkPath in href if any provided', () => {
+ const expected = `${endpoint}/test/entries`;
+
+ (service as any).getFindAllHref({}, 'test/entries').subscribe((value) => {
+ expect(value).toBe(expected);
+ });
+ });
+
it('should include single linksToFollow as embed', () => {
const expected = `${endpoint}?embed=bundles`;
diff --git a/src/app/core/data/data.service.ts b/src/app/core/data/data.service.ts
index 0d818f2030..e3f367c8bf 100644
--- a/src/app/core/data/data.service.ts
+++ b/src/app/core/data/data.service.ts
@@ -3,7 +3,7 @@ import { Store } from '@ngrx/store';
import { Operation } from 'fast-json-patch';
import { Observable } from 'rxjs';
import { distinctUntilChanged, filter, find, first, map, mergeMap, switchMap, take } from 'rxjs/operators';
-import { hasValue, isNotEmpty, isNotEmptyOperator } from '../../shared/empty.util';
+import { hasValue, hasValueOperator, isNotEmpty, isNotEmptyOperator } from '../../shared/empty.util';
import { NotificationOptions } from '../../shared/notifications/models/notification-options.model';
import { NotificationsService } from '../../shared/notifications/notifications.service';
import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model';
@@ -71,13 +71,17 @@ export abstract class DataService implements UpdateDa
* Return an observable that emits created HREF
* @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved
*/
- protected getFindAllHref(options: FindListOptions = {}, linkPath?: string, ...linksToFollow: Array>): Observable {
- let result$: Observable;
+ public getFindAllHref(options: FindListOptions = {}, linkPath?: string, ...linksToFollow: Array>): Observable {
+ let endpoint$: Observable;
const args = [];
- result$ = this.getBrowseEndpoint(options, linkPath).pipe(distinctUntilChanged());
+ endpoint$ = this.getBrowseEndpoint(options).pipe(
+ filter((href: string) => isNotEmpty(href)),
+ map((href: string) => isNotEmpty(linkPath) ? `${href}/${linkPath}` : href),
+ distinctUntilChanged()
+ );
- return result$.pipe(map((result: string) => this.buildHrefFromFindOptions(result, options, args, ...linksToFollow)));
+ return endpoint$.pipe(map((result: string) => this.buildHrefFromFindOptions(result, options, args, ...linksToFollow)));
}
/**
@@ -89,18 +93,12 @@ export abstract class DataService implements UpdateDa
* Return an observable that emits created HREF
* @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved
*/
- protected getSearchByHref(searchMethod: string, options: FindListOptions = {}, ...linksToFollow: Array>): Observable {
+ public getSearchByHref(searchMethod: string, options: FindListOptions = {}, ...linksToFollow: Array>): Observable {
let result$: Observable;
const args = [];
result$ = this.getSearchEndpoint(searchMethod);
- if (hasValue(options.searchParams)) {
- options.searchParams.forEach((param: RequestParam) => {
- args.push(`${param.fieldName}=${param.fieldValue}`);
- })
- }
-
return result$.pipe(map((result: string) => this.buildHrefFromFindOptions(result, options, args, ...linksToFollow)));
}
@@ -114,7 +112,7 @@ export abstract class DataService implements UpdateDa
* Return an observable that emits created HREF
* @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved
*/
- protected buildHrefFromFindOptions(href: string, options: FindListOptions, extraArgs: string[] = [], ...linksToFollow: Array>): string {
+ public buildHrefFromFindOptions(href: string, options: FindListOptions, extraArgs: string[] = [], ...linksToFollow: Array>): string {
let args = [...extraArgs];
if (hasValue(options.currentPage) && typeof options.currentPage === 'number') {
@@ -130,6 +128,11 @@ export abstract class DataService implements UpdateDa
if (hasValue(options.startsWith)) {
args = [...args, `startsWith=${options.startsWith}`];
}
+ if (hasValue(options.searchParams)) {
+ options.searchParams.forEach((param: RequestParam) => {
+ args = [...args, `${param.fieldName}=${param.fieldValue}`];
+ })
+ }
args = this.addEmbedParams(args, ...linksToFollow);
if (isNotEmpty(args)) {
return new URLCombiner(href, `?${args.join('&')}`).toString();
@@ -373,11 +376,20 @@ export abstract class DataService implements UpdateDa
).subscribe();
return this.requestService.getByUUID(requestId).pipe(
+ hasValueOperator(),
find((request: RequestEntry) => request.completed),
map((request: RequestEntry) => request.response)
);
}
+ createPatchFromCache(object: T): Observable {
+ const oldVersion$ = this.findByHref(object._links.self.href);
+ return oldVersion$.pipe(
+ getSucceededRemoteData(),
+ getRemoteDataPayload(),
+ map((oldVersion: T) => this.comparator.diff(oldVersion, object)));
+ }
+
/**
* Send a PUT request for the specified object
*
@@ -406,18 +418,16 @@ export abstract class DataService implements UpdateDa
* @param {DSpaceObject} object The given object
*/
update(object: T): Observable> {
- const oldVersion$ = this.findByHref(object._links.self.href);
- return oldVersion$.pipe(
- getSucceededRemoteData(),
- getRemoteDataPayload(),
- mergeMap((oldVersion: T) => {
- const operations = this.comparator.diff(oldVersion, object);
- if (isNotEmpty(operations)) {
- this.objectCache.addPatch(object._links.self.href, operations);
+ return this.createPatchFromCache(object)
+ .pipe(
+ mergeMap((operations: Operation[]) => {
+ if (isNotEmpty(operations)) {
+ this.objectCache.addPatch(object._links.self.href, operations);
+ }
+ return this.findByHref(object._links.self.href);
}
- return this.findByHref(object._links.self.href);
- }
- ));
+ )
+ );
}
/**
diff --git a/src/app/core/data/entries-response-parsing.service.ts b/src/app/core/data/entries-response-parsing.service.ts
new file mode 100644
index 0000000000..09ae8ae1c5
--- /dev/null
+++ b/src/app/core/data/entries-response-parsing.service.ts
@@ -0,0 +1,54 @@
+import { isNotEmpty } from '../../shared/empty.util';
+import { ObjectCacheService } from '../cache/object-cache.service';
+import { ErrorResponse, GenericSuccessResponse, RestResponse } from '../cache/response.models';
+import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model';
+import { DSpaceSerializer } from '../dspace-rest-v2/dspace.serializer';
+import { BaseResponseParsingService } from './base-response-parsing.service';
+import { ResponseParsingService } from './parsing.service';
+import { RestRequest } from './request.models';
+import { CacheableObject } from '../cache/object-cache.reducer';
+import { GenericConstructor } from '../shared/generic-constructor';
+
+/**
+ * An abstract class to extend, responsible for parsing data for an entries response
+ */
+export abstract class EntriesResponseParsingService extends BaseResponseParsingService implements ResponseParsingService {
+
+ protected toCache = false;
+
+ constructor(
+ protected objectCache: ObjectCacheService,
+ ) {
+ super();
+ }
+
+ /**
+ * Abstract method to implement that must return the dspace serializer Constructor to use during parse
+ */
+ abstract getSerializerModel(): GenericConstructor;
+
+ /**
+ * Parse response
+ *
+ * @param request
+ * @param data
+ */
+ parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse {
+ if (isNotEmpty(data.payload)) {
+ let entries = [];
+ if (isNotEmpty(data.payload._embedded) && Array.isArray(data.payload._embedded[Object.keys(data.payload._embedded)[0]])) {
+ const serializer = new DSpaceSerializer(this.getSerializerModel());
+ entries = serializer.deserializeArray(data.payload._embedded[Object.keys(data.payload._embedded)[0]]);
+ }
+ return new GenericSuccessResponse(entries, data.statusCode, data.statusText, this.processPageInfo(data.payload));
+ } else {
+ return new ErrorResponse(
+ Object.assign(
+ new Error('Unexpected response from browse endpoint'),
+ { statusCode: data.statusCode, statusText: data.statusText }
+ )
+ );
+ }
+ }
+
+}
diff --git a/src/app/core/data/feature-authorization/authorization-data.service.spec.ts b/src/app/core/data/feature-authorization/authorization-data.service.spec.ts
index 29db1a086b..7db7c27c29 100644
--- a/src/app/core/data/feature-authorization/authorization-data.service.spec.ts
+++ b/src/app/core/data/feature-authorization/authorization-data.service.spec.ts
@@ -63,33 +63,33 @@ describe('AuthorizationDataService', () => {
return Object.assign(new FindListOptions(), { searchParams });
}
- describe('when no arguments are provided and a user is authenticated', () => {
+ describe('when no arguments are provided', () => {
beforeEach(() => {
service.searchByObject().subscribe();
});
- it('should call searchBy with the site\'s url and authenticated user\'s uuid', () => {
- expect(service.searchBy).toHaveBeenCalledWith('object', createExpected(site.self, ePerson.uuid));
+ it('should call searchBy with the site\'s url', () => {
+ expect(service.searchBy).toHaveBeenCalledWith('object', createExpected(site.self));
});
});
- describe('when no arguments except for a feature are provided and a user is authenticated', () => {
+ describe('when no arguments except for a feature are provided', () => {
beforeEach(() => {
service.searchByObject(FeatureID.LoginOnBehalfOf).subscribe();
});
- it('should call searchBy with the site\'s url, authenticated user\'s uuid and the feature', () => {
- expect(service.searchBy).toHaveBeenCalledWith('object', createExpected(site.self, ePerson.uuid, FeatureID.LoginOnBehalfOf));
+ it('should call searchBy with the site\'s url and the feature', () => {
+ expect(service.searchBy).toHaveBeenCalledWith('object', createExpected(site.self, null, FeatureID.LoginOnBehalfOf));
});
});
- describe('when a feature and object url are provided, but no user uuid and a user is authenticated', () => {
+ describe('when a feature and object url are provided', () => {
beforeEach(() => {
service.searchByObject(FeatureID.LoginOnBehalfOf, objectUrl).subscribe();
});
- it('should call searchBy with the object\'s url, authenticated user\'s uuid and the feature', () => {
- expect(service.searchBy).toHaveBeenCalledWith('object', createExpected(objectUrl, ePerson.uuid, FeatureID.LoginOnBehalfOf));
+ it('should call searchBy with the object\'s url and the feature', () => {
+ expect(service.searchBy).toHaveBeenCalledWith('object', createExpected(objectUrl, null, FeatureID.LoginOnBehalfOf));
});
});
@@ -102,17 +102,6 @@ describe('AuthorizationDataService', () => {
expect(service.searchBy).toHaveBeenCalledWith('object', createExpected(objectUrl, ePersonUuid, FeatureID.LoginOnBehalfOf));
});
});
-
- describe('when no arguments are provided and no user is authenticated', () => {
- beforeEach(() => {
- spyOn(authService, 'isAuthenticated').and.returnValue(observableOf(false));
- service.searchByObject().subscribe();
- });
-
- it('should call searchBy with the site\'s url', () => {
- expect(service.searchBy).toHaveBeenCalledWith('object', createExpected(site.self));
- });
- });
});
describe('isAuthorized', () => {
diff --git a/src/app/core/data/feature-authorization/authorization-data.service.ts b/src/app/core/data/feature-authorization/authorization-data.service.ts
index 2d32b26efa..4dfa89cde6 100644
--- a/src/app/core/data/feature-authorization/authorization-data.service.ts
+++ b/src/app/core/data/feature-authorization/authorization-data.service.ts
@@ -25,7 +25,6 @@ import { hasValue, isNotEmpty } from '../../../shared/empty.util';
import { RequestParam } from '../../cache/models/request-param.model';
import { AuthorizationSearchParams } from './authorization-search-params';
import {
- addAuthenticatedUserUuidIfEmpty,
addSiteObjectUrlIfEmpty,
oneAuthorizationMatchesFeature
} from './authorization-utils';
@@ -90,7 +89,6 @@ export class AuthorizationDataService extends DataService {
searchByObject(featureId?: FeatureID, objectUrl?: string, ePersonUuid?: string, options: FindListOptions = {}, ...linksToFollow: Array>): Observable>> {
return observableOf(new AuthorizationSearchParams(objectUrl, ePersonUuid, featureId)).pipe(
addSiteObjectUrlIfEmpty(this.siteService),
- addAuthenticatedUserUuidIfEmpty(this.authService),
switchMap((params: AuthorizationSearchParams) => {
return this.searchBy(this.searchByObjectPath, this.createSearchOptions(params.objectUrl, options, params.ePersonUuid, params.featureId), ...linksToFollow);
})
diff --git a/src/app/core/data/feature-authorization/feature-authorization-guard/dso-page-feature.guard.spec.ts b/src/app/core/data/feature-authorization/feature-authorization-guard/dso-page-feature.guard.spec.ts
new file mode 100644
index 0000000000..1f5efd1329
--- /dev/null
+++ b/src/app/core/data/feature-authorization/feature-authorization-guard/dso-page-feature.guard.spec.ts
@@ -0,0 +1,63 @@
+import { AuthorizationDataService } from '../authorization-data.service';
+import { ActivatedRouteSnapshot, Resolve, Router, RouterStateSnapshot } from '@angular/router';
+import { RemoteData } from '../../remote-data';
+import { of as observableOf } from 'rxjs';
+import { createSuccessfulRemoteDataObject$ } from '../../../../shared/remote-data.utils';
+import { DSpaceObject } from '../../../shared/dspace-object.model';
+import { DsoPageFeatureGuard } from './dso-page-feature.guard';
+import { FeatureID } from '../feature-id';
+import { Observable } from 'rxjs/internal/Observable';
+
+/**
+ * Test implementation of abstract class DsoPageAdministratorGuard
+ */
+class DsoPageFeatureGuardImpl extends DsoPageFeatureGuard {
+ constructor(protected resolver: Resolve>,
+ protected authorizationService: AuthorizationDataService,
+ protected router: Router,
+ protected featureID: FeatureID) {
+ super(resolver, authorizationService, router);
+ }
+
+ getFeatureID(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable {
+ return observableOf(this.featureID);
+ }
+}
+
+describe('DsoPageAdministratorGuard', () => {
+ let guard: DsoPageFeatureGuard;
+ let authorizationService: AuthorizationDataService;
+ let router: Router;
+ let resolver: Resolve>;
+ let object: DSpaceObject;
+
+ function init() {
+ object = {
+ self: 'test-selflink'
+ } as DSpaceObject;
+
+ authorizationService = jasmine.createSpyObj('authorizationService', {
+ isAuthorized: observableOf(true)
+ });
+ router = jasmine.createSpyObj('router', {
+ parseUrl: {}
+ });
+ resolver = jasmine.createSpyObj('resolver', {
+ resolve: createSuccessfulRemoteDataObject$(object)
+ });
+ guard = new DsoPageFeatureGuardImpl(resolver, authorizationService, router, undefined);
+ }
+
+ beforeEach(() => {
+ init();
+ });
+
+ describe('getObjectUrl', () => {
+ it('should return the resolved object\'s selflink', (done) => {
+ guard.getObjectUrl(undefined, undefined).subscribe((selflink) => {
+ expect(selflink).toEqual(object.self);
+ done();
+ });
+ });
+ });
+});
diff --git a/src/app/core/data/feature-authorization/feature-authorization-guard/dso-page-feature.guard.ts b/src/app/core/data/feature-authorization/feature-authorization-guard/dso-page-feature.guard.ts
new file mode 100644
index 0000000000..ed2590b521
--- /dev/null
+++ b/src/app/core/data/feature-authorization/feature-authorization-guard/dso-page-feature.guard.ts
@@ -0,0 +1,30 @@
+import { ActivatedRouteSnapshot, Resolve, Router, RouterStateSnapshot } from '@angular/router';
+import { RemoteData } from '../../remote-data';
+import { AuthorizationDataService } from '../authorization-data.service';
+import { Observable } from 'rxjs/internal/Observable';
+import { getAllSucceededRemoteDataPayload } from '../../../shared/operators';
+import { map } from 'rxjs/operators';
+import { DSpaceObject } from '../../../shared/dspace-object.model';
+import { FeatureAuthorizationGuard } from './feature-authorization.guard';
+
+/**
+ * Abstract Guard for preventing unauthorized access to {@link DSpaceObject} pages that require rights for a specific feature
+ * This guard utilizes a resolver to retrieve the relevant object to check authorizations for
+ */
+export abstract class DsoPageFeatureGuard extends FeatureAuthorizationGuard {
+ constructor(protected resolver: Resolve>,
+ protected authorizationService: AuthorizationDataService,
+ protected router: Router) {
+ super(authorizationService, router);
+ }
+
+ /**
+ * Check authorization rights for the object resolved using the provided resolver
+ */
+ getObjectUrl(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable {
+ return (this.resolver.resolve(route, state) as Observable>).pipe(
+ getAllSucceededRemoteDataPayload(),
+ map((dso) => dso.self)
+ );
+ }
+}
diff --git a/src/app/core/data/feature-authorization/feature-authorization-guard/feature-authorization.guard.spec.ts b/src/app/core/data/feature-authorization/feature-authorization-guard/feature-authorization.guard.spec.ts
index bfd161bad2..829a246dcc 100644
--- a/src/app/core/data/feature-authorization/feature-authorization-guard/feature-authorization.guard.spec.ts
+++ b/src/app/core/data/feature-authorization/feature-authorization-guard/feature-authorization.guard.spec.ts
@@ -2,7 +2,8 @@ import { FeatureAuthorizationGuard } from './feature-authorization.guard';
import { AuthorizationDataService } from '../authorization-data.service';
import { FeatureID } from '../feature-id';
import { of as observableOf } from 'rxjs';
-import { Router } from '@angular/router';
+import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from '@angular/router';
+import { Observable } from 'rxjs/internal/Observable';
/**
* Test implementation of abstract class FeatureAuthorizationGuard
@@ -17,16 +18,16 @@ class FeatureAuthorizationGuardImpl extends FeatureAuthorizationGuard {
super(authorizationService, router);
}
- getFeatureID(): FeatureID {
- return this.featureId;
+ getFeatureID(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable {
+ return observableOf(this.featureId);
}
- getObjectUrl(): string {
- return this.objectUrl;
+ getObjectUrl(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable {
+ return observableOf(this.objectUrl);
}
- getEPersonUuid(): string {
- return this.ePersonUuid;
+ getEPersonUuid(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable {
+ return observableOf(this.ePersonUuid);
}
}
diff --git a/src/app/core/data/feature-authorization/feature-authorization-guard/feature-authorization.guard.ts b/src/app/core/data/feature-authorization/feature-authorization-guard/feature-authorization.guard.ts
index 7806d87b0c..d53e71e289 100644
--- a/src/app/core/data/feature-authorization/feature-authorization-guard/feature-authorization.guard.ts
+++ b/src/app/core/data/feature-authorization/feature-authorization-guard/feature-authorization.guard.ts
@@ -9,6 +9,8 @@ import { AuthorizationDataService } from '../authorization-data.service';
import { FeatureID } from '../feature-id';
import { Observable } from 'rxjs/internal/Observable';
import { returnUnauthorizedUrlTreeOnFalse } from '../../../shared/operators';
+import { combineLatest as observableCombineLatest, of as observableOf } from 'rxjs';
+import { switchMap } from 'rxjs/operators';
/**
* Abstract Guard for preventing unauthorized activating and loading of routes when a user
@@ -24,29 +26,32 @@ export abstract class FeatureAuthorizationGuard implements CanActivate {
* True when user has authorization rights for the feature and object provided
* Redirect the user to the unauthorized page when he/she's not authorized for the given feature
*/
- canActivate(next: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable {
- return this.authorizationService.isAuthorized(this.getFeatureID(), this.getObjectUrl(), this.getEPersonUuid()).pipe(returnUnauthorizedUrlTreeOnFalse(this.router));
+ canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable {
+ return observableCombineLatest(this.getFeatureID(route, state), this.getObjectUrl(route, state), this.getEPersonUuid(route, state)).pipe(
+ switchMap(([featureID, objectUrl, ePersonUuid]) => this.authorizationService.isAuthorized(featureID, objectUrl, ePersonUuid)),
+ returnUnauthorizedUrlTreeOnFalse(this.router)
+ );
}
/**
* The type of feature to check authorization for
* Override this method to define a feature
*/
- abstract getFeatureID(): FeatureID;
+ abstract getFeatureID(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable;
/**
* The URL of the object to check if the user has authorized rights for
* Override this method to define an object URL. If not provided, the {@link Site}'s URL will be used
*/
- getObjectUrl(): string {
- return undefined;
+ getObjectUrl(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable {
+ return observableOf(undefined);
}
/**
* The UUID of the user to check authorization rights for
* Override this method to define an {@link EPerson} UUID. If not provided, the authenticated user's UUID will be used.
*/
- getEPersonUuid(): string {
- return undefined;
+ getEPersonUuid(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable {
+ return observableOf(undefined);
}
}
diff --git a/src/app/core/data/feature-authorization/feature-authorization-guard/site-administrator.guard.ts b/src/app/core/data/feature-authorization/feature-authorization-guard/site-administrator.guard.ts
index a64e40468d..a45049645a 100644
--- a/src/app/core/data/feature-authorization/feature-authorization-guard/site-administrator.guard.ts
+++ b/src/app/core/data/feature-authorization/feature-authorization-guard/site-administrator.guard.ts
@@ -2,7 +2,9 @@ import { Injectable } from '@angular/core';
import { FeatureAuthorizationGuard } from './feature-authorization.guard';
import { FeatureID } from '../feature-id';
import { AuthorizationDataService } from '../authorization-data.service';
-import { Router } from '@angular/router';
+import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from '@angular/router';
+import { of as observableOf } from 'rxjs';
+import { Observable } from 'rxjs/internal/Observable';
/**
* Prevent unauthorized activating and loading of routes when the current authenticated user doesn't have administrator
@@ -19,7 +21,7 @@ export class SiteAdministratorGuard extends FeatureAuthorizationGuard {
/**
* Check administrator authorization rights
*/
- getFeatureID(): FeatureID {
- return FeatureID.AdministratorOf;
+ getFeatureID(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable {
+ return observableOf(FeatureID.AdministratorOf);
}
}
diff --git a/src/app/core/data/feature-authorization/feature-authorization-guard/site-register.guard.ts b/src/app/core/data/feature-authorization/feature-authorization-guard/site-register.guard.ts
new file mode 100644
index 0000000000..18397cf71e
--- /dev/null
+++ b/src/app/core/data/feature-authorization/feature-authorization-guard/site-register.guard.ts
@@ -0,0 +1,27 @@
+import { FeatureAuthorizationGuard } from './feature-authorization.guard';
+import { Injectable } from '@angular/core';
+import { AuthorizationDataService } from '../authorization-data.service';
+import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from '@angular/router';
+import { Observable } from 'rxjs/internal/Observable';
+import { FeatureID } from '../feature-id';
+import { of as observableOf } from 'rxjs';
+
+/**
+ * Prevent unauthorized activating and loading of routes when the current authenticated user doesn't have registration
+ * rights to the {@link Site}
+ */
+@Injectable({
+ providedIn: 'root'
+})
+export class SiteRegisterGuard extends FeatureAuthorizationGuard {
+ constructor(protected authorizationService: AuthorizationDataService, protected router: Router) {
+ super(authorizationService, router);
+ }
+
+ /**
+ * Check registration authorization rights
+ */
+ getFeatureID(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable {
+ return observableOf(FeatureID.EPersonRegistration);
+ }
+}
diff --git a/src/app/core/data/feature-authorization/feature-id.ts b/src/app/core/data/feature-authorization/feature-id.ts
index 4731e92d6c..450d5057aa 100644
--- a/src/app/core/data/feature-authorization/feature-id.ts
+++ b/src/app/core/data/feature-authorization/feature-id.ts
@@ -3,5 +3,9 @@
*/
export enum FeatureID {
LoginOnBehalfOf = 'loginOnBehalfOf',
- AdministratorOf = 'administratorOf'
+ 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/metadata-field-data.service.ts b/src/app/core/data/metadata-field-data.service.ts
index 1f7a4b9089..af2ab7c45c 100644
--- a/src/app/core/data/metadata-field-data.service.ts
+++ b/src/app/core/data/metadata-field-data.service.ts
@@ -1,6 +1,9 @@
import { Injectable } from '@angular/core';
+import { hasValue } from '../../shared/empty.util';
import { dataService } from '../cache/builders/build-decorators';
import { DataService } from './data.service';
+import { PaginatedList } from './paginated-list';
+import { RemoteData } from './remote-data';
import { RequestService } from './request.service';
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
import { Store } from '@ngrx/store';
@@ -27,6 +30,7 @@ import { RequestParam } from '../cache/models/request-param.model';
export class MetadataFieldDataService extends DataService {
protected linkPath = 'metadatafields';
protected searchBySchemaLinkPath = 'bySchema';
+ protected searchByFieldNameLinkPath = 'byFieldName';
constructor(
protected requestService: RequestService,
@@ -53,6 +57,43 @@ export class MetadataFieldDataService extends DataService {
return this.searchBy(this.searchBySchemaLinkPath, optionsWithSchema, ...linksToFollow);
}
+ /**
+ * Find metadata fields with either the partial metadata field name (e.g. "dc.ti") as query or an exact match to
+ * at least the schema, element or qualifier
+ * @param schema optional; an exact match of the prefix of the metadata schema (e.g. "dc", "dcterms", "eperson")
+ * @param element optional; an exact match of the field's element (e.g. "contributor", "title")
+ * @param qualifier optional; an exact match of the field's qualifier (e.g. "author", "alternative")
+ * @param query optional (if any of schema, element or qualifier used) - part of the fully qualified field,
+ * should start with the start of the schema, element or qualifier (e.g. “dc.ti”, “contributor”, “auth”, “contributor.ot”)
+ * @param exactName optional; the exact fully qualified field, should use the syntax schema.element.qualifier or
+ * schema.element if no qualifier exists (e.g. "dc.title", "dc.contributor.author"). It will only return one value
+ * if there's an exact match
+ * @param options The options info used to retrieve the fields
+ * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved
+ */
+ searchByFieldNameParams(schema: string, element: string, qualifier: string, query: string, exactName: string, options: FindListOptions = {}, ...linksToFollow: Array>): Observable>> {
+ const optionParams = Object.assign(new FindListOptions(), options, {
+ searchParams: [
+ new RequestParam('schema', hasValue(schema) ? schema : ''),
+ new RequestParam('element', hasValue(element) ? element : ''),
+ new RequestParam('qualifier', hasValue(qualifier) ? qualifier : ''),
+ new RequestParam('query', hasValue(query) ? query : ''),
+ new RequestParam('exactName', hasValue(exactName) ? exactName : '')
+ ]
+ });
+ return this.searchBy(this.searchByFieldNameLinkPath, optionParams, ...linksToFollow);
+ }
+
+ /**
+ * Finds a specific metadata field by name.
+ * @param exactFieldName The exact fully qualified field, should use the syntax schema.element.qualifier or
+ * schema.element if no qualifier exists (e.g. "dc.title", "dc.contributor.author"). It will only return one value
+ * if there's an exact match, empty list if there is no exact match.
+ */
+ findByExactFieldName(exactFieldName: string): Observable>> {
+ return this.searchByFieldNameParams(null, null, null, null, exactFieldName, null);
+ }
+
/**
* Clear all metadata field requests
* Used for refreshing lists after adding/updating/removing a metadata field from a metadata schema
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/request.models.ts b/src/app/core/data/request.models.ts
index bd497d4ddb..6730487660 100644
--- a/src/app/core/data/request.models.ts
+++ b/src/app/core/data/request.models.ts
@@ -9,7 +9,6 @@ import { ConfigResponseParsingService } from '../config/config-response-parsing.
import { AuthResponseParsingService } from '../auth/auth-response-parsing.service';
import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service';
import { SubmissionResponseParsingService } from '../submission/submission-response-parsing.service';
-import { IntegrationResponseParsingService } from '../integration/integration-response-parsing.service';
import { RestRequestMethod } from './rest-request-method';
import { RequestParam } from '../cache/models/request-param.model';
import { EpersonResponseParsingService } from '../eperson/eperson-response-parsing.service';
@@ -20,12 +19,13 @@ import { ContentSourceResponseParsingService } from './content-source-response-p
import { MappedCollectionsReponseParsingService } from './mapped-collections-reponse-parsing.service';
import { ProcessFilesResponseParsingService } from './process-files-response-parsing.service';
import { TokenResponseParsingService } from '../auth/token-response-parsing.service';
+import { VocabularyEntriesResponseParsingService } from '../submission/vocabularies/vocabulary-entries-response-parsing.service';
/* tslint:disable:max-classes-per-file */
// uuid and handle requests have separate endpoints
export enum IdentifierType {
- UUID ='uuid',
+ UUID = 'uuid',
HANDLE = 'handle'
}
@@ -60,7 +60,7 @@ export class GetRequest extends RestRequest {
public href: string,
public body?: any,
public options?: HttpOptions
- ) {
+ ) {
super(uuid, href, RestRequestMethod.GET, body, options)
}
}
@@ -71,7 +71,7 @@ export class PostRequest extends RestRequest {
public href: string,
public body?: any,
public options?: HttpOptions
- ) {
+ ) {
super(uuid, href, RestRequestMethod.POST, body)
}
}
@@ -97,7 +97,7 @@ export class PutRequest extends RestRequest {
public href: string,
public body?: any,
public options?: HttpOptions
- ) {
+ ) {
super(uuid, href, RestRequestMethod.PUT, body)
}
}
@@ -108,7 +108,7 @@ export class DeleteRequest extends RestRequest {
public href: string,
public body?: any,
public options?: HttpOptions
- ) {
+ ) {
super(uuid, href, RestRequestMethod.DELETE, body)
}
}
@@ -119,7 +119,7 @@ export class OptionsRequest extends RestRequest {
public href: string,
public body?: any,
public options?: HttpOptions
- ) {
+ ) {
super(uuid, href, RestRequestMethod.OPTIONS, body)
}
}
@@ -130,7 +130,7 @@ export class HeadRequest extends RestRequest {
public href: string,
public body?: any,
public options?: HttpOptions
- ) {
+ ) {
super(uuid, href, RestRequestMethod.HEAD, body)
}
}
@@ -143,7 +143,7 @@ export class PatchRequest extends RestRequest {
public href: string,
public body?: any,
public options?: HttpOptions
- ) {
+ ) {
super(uuid, href, RestRequestMethod.PATCH, body)
}
}
@@ -276,16 +276,6 @@ export class TokenPostRequest extends PostRequest {
}
}
-export class IntegrationRequest extends GetRequest {
- constructor(uuid: string, href: string) {
- super(uuid, href);
- }
-
- getResponseParser(): GenericConstructor {
- return IntegrationResponseParsingService;
- }
-}
-
/**
* Class representing a submission HTTP GET request object
*/
@@ -425,6 +415,15 @@ export class MyDSpaceRequest extends GetRequest {
public responseMsToLive = 10 * 1000;
}
+/**
+ * Request to get vocabulary entries
+ */
+export class VocabularyEntriesRequest extends FindListRequest {
+ getResponseParser(): GenericConstructor {
+ return VocabularyEntriesResponseParsingService;
+ }
+}
+
export class RequestError extends Error {
statusCode: number;
statusText: string;
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/end-user-agreement/abstract-end-user-agreement.guard.ts b/src/app/core/end-user-agreement/abstract-end-user-agreement.guard.ts
new file mode 100644
index 0000000000..ee07da004b
--- /dev/null
+++ b/src/app/core/end-user-agreement/abstract-end-user-agreement.guard.ts
@@ -0,0 +1,32 @@
+import { ActivatedRouteSnapshot, CanActivate, Router, RouterStateSnapshot, UrlTree } from '@angular/router';
+import { Observable } from 'rxjs/internal/Observable';
+import { returnEndUserAgreementUrlTreeOnFalse } from '../shared/operators';
+
+/**
+ * An abstract guard for redirecting users to the user agreement page if a certain condition is met
+ * That condition is defined by abstract method hasAccepted
+ */
+export abstract class AbstractEndUserAgreementGuard implements CanActivate {
+
+ constructor(protected router: Router) {
+ }
+
+ /**
+ * True when the user agreement has been accepted
+ * The user will be redirected to the End User Agreement page if they haven't accepted it before
+ * A redirect URL will be provided with the navigation so the component can redirect the user back to the blocked route
+ * when they're finished accepting the agreement
+ */
+ canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable {
+ return this.hasAccepted().pipe(
+ returnEndUserAgreementUrlTreeOnFalse(this.router, state.url)
+ );
+ }
+
+ /**
+ * This abstract method determines how the User Agreement has to be accepted before the user is allowed to visit
+ * the desired route
+ */
+ abstract hasAccepted(): Observable;
+
+}
diff --git a/src/app/core/end-user-agreement/end-user-agreement-cookie.guard.spec.ts b/src/app/core/end-user-agreement/end-user-agreement-cookie.guard.spec.ts
new file mode 100644
index 0000000000..805c765832
--- /dev/null
+++ b/src/app/core/end-user-agreement/end-user-agreement-cookie.guard.spec.ts
@@ -0,0 +1,47 @@
+import { EndUserAgreementService } from './end-user-agreement.service';
+import { Router, UrlTree } from '@angular/router';
+import { EndUserAgreementCookieGuard } from './end-user-agreement-cookie.guard';
+
+describe('EndUserAgreementCookieGuard', () => {
+ let guard: EndUserAgreementCookieGuard;
+
+ let endUserAgreementService: EndUserAgreementService;
+ let router: Router;
+
+ beforeEach(() => {
+ endUserAgreementService = jasmine.createSpyObj('endUserAgreementService', {
+ isCookieAccepted: true
+ });
+ router = jasmine.createSpyObj('router', {
+ navigateByUrl: {},
+ parseUrl: new UrlTree(),
+ createUrlTree: new UrlTree()
+ });
+
+ guard = new EndUserAgreementCookieGuard(endUserAgreementService, router);
+ });
+
+ describe('canActivate', () => {
+ describe('when the cookie has been accepted', () => {
+ it('should return true', (done) => {
+ guard.canActivate(undefined, { url: Object.assign({ url: 'redirect' }) } as any).subscribe((result) => {
+ expect(result).toEqual(true);
+ done();
+ });
+ });
+ });
+
+ describe('when the cookie hasn\'t been accepted', () => {
+ beforeEach(() => {
+ (endUserAgreementService.isCookieAccepted as jasmine.Spy).and.returnValue(false);
+ });
+
+ it('should return a UrlTree', (done) => {
+ guard.canActivate(undefined, Object.assign({ url: 'redirect' })).subscribe((result) => {
+ expect(result).toEqual(jasmine.any(UrlTree));
+ done();
+ });
+ });
+ });
+ });
+});
diff --git a/src/app/core/end-user-agreement/end-user-agreement-cookie.guard.ts b/src/app/core/end-user-agreement/end-user-agreement-cookie.guard.ts
new file mode 100644
index 0000000000..e6461859f3
--- /dev/null
+++ b/src/app/core/end-user-agreement/end-user-agreement-cookie.guard.ts
@@ -0,0 +1,26 @@
+import { Injectable } from '@angular/core';
+import { AbstractEndUserAgreementGuard } from './abstract-end-user-agreement.guard';
+import { Observable } from 'rxjs/internal/Observable';
+import { of as observableOf } from 'rxjs';
+import { EndUserAgreementService } from './end-user-agreement.service';
+import { Router } from '@angular/router';
+
+/**
+ * A guard redirecting users to the end agreement page when the user agreement cookie hasn't been accepted
+ */
+@Injectable()
+export class EndUserAgreementCookieGuard extends AbstractEndUserAgreementGuard {
+
+ constructor(protected endUserAgreementService: EndUserAgreementService,
+ protected router: Router) {
+ super(router);
+ }
+
+ /**
+ * True when the user agreement cookie has been accepted
+ */
+ hasAccepted(): Observable {
+ return observableOf(this.endUserAgreementService.isCookieAccepted());
+ }
+
+}
diff --git a/src/app/core/end-user-agreement/end-user-agreement-current-user.guard.spec.ts b/src/app/core/end-user-agreement/end-user-agreement-current-user.guard.spec.ts
new file mode 100644
index 0000000000..1892509aef
--- /dev/null
+++ b/src/app/core/end-user-agreement/end-user-agreement-current-user.guard.spec.ts
@@ -0,0 +1,49 @@
+import { EndUserAgreementCurrentUserGuard } from './end-user-agreement-current-user.guard';
+import { EndUserAgreementService } from './end-user-agreement.service';
+import { Router, UrlTree } from '@angular/router';
+import { of as observableOf } from 'rxjs';
+import { AuthService } from '../auth/auth.service';
+
+describe('EndUserAgreementGuard', () => {
+ let guard: EndUserAgreementCurrentUserGuard;
+
+ let endUserAgreementService: EndUserAgreementService;
+ let router: Router;
+
+ beforeEach(() => {
+ endUserAgreementService = jasmine.createSpyObj('endUserAgreementService', {
+ hasCurrentUserAcceptedAgreement: observableOf(true)
+ });
+ router = jasmine.createSpyObj('router', {
+ navigateByUrl: {},
+ parseUrl: new UrlTree(),
+ createUrlTree: new UrlTree()
+ });
+
+ guard = new EndUserAgreementCurrentUserGuard(endUserAgreementService, router);
+ });
+
+ describe('canActivate', () => {
+ describe('when the user has accepted the agreement', () => {
+ it('should return true', (done) => {
+ guard.canActivate(undefined, Object.assign({ url: 'redirect' })).subscribe((result) => {
+ expect(result).toEqual(true);
+ done();
+ });
+ });
+ });
+
+ describe('when the user hasn\'t accepted the agreement', () => {
+ beforeEach(() => {
+ (endUserAgreementService.hasCurrentUserAcceptedAgreement as jasmine.Spy).and.returnValue(observableOf(false));
+ });
+
+ it('should return a UrlTree', (done) => {
+ guard.canActivate(undefined, Object.assign({ url: 'redirect' })).subscribe((result) => {
+ expect(result).toEqual(jasmine.any(UrlTree));
+ done();
+ });
+ });
+ });
+ });
+});
diff --git a/src/app/core/end-user-agreement/end-user-agreement-current-user.guard.ts b/src/app/core/end-user-agreement/end-user-agreement-current-user.guard.ts
new file mode 100644
index 0000000000..348a3285cc
--- /dev/null
+++ b/src/app/core/end-user-agreement/end-user-agreement-current-user.guard.ts
@@ -0,0 +1,25 @@
+import { Injectable } from '@angular/core';
+import { Observable } from 'rxjs/internal/Observable';
+import { AbstractEndUserAgreementGuard } from './abstract-end-user-agreement.guard';
+import { EndUserAgreementService } from './end-user-agreement.service';
+import { Router } from '@angular/router';
+
+/**
+ * A guard redirecting logged in users to the end agreement page when they haven't accepted the latest user agreement
+ */
+@Injectable()
+export class EndUserAgreementCurrentUserGuard extends AbstractEndUserAgreementGuard {
+
+ constructor(protected endUserAgreementService: EndUserAgreementService,
+ protected router: Router) {
+ super(router);
+ }
+
+ /**
+ * True when the currently logged in user has accepted the agreements or when the user is not currently authenticated
+ */
+ hasAccepted(): Observable {
+ return this.endUserAgreementService.hasCurrentUserAcceptedAgreement(true);
+ }
+
+}
diff --git a/src/app/core/end-user-agreement/end-user-agreement.service.spec.ts b/src/app/core/end-user-agreement/end-user-agreement.service.spec.ts
new file mode 100644
index 0000000000..d50c730d28
--- /dev/null
+++ b/src/app/core/end-user-agreement/end-user-agreement.service.spec.ts
@@ -0,0 +1,138 @@
+import {
+ END_USER_AGREEMENT_COOKIE,
+ END_USER_AGREEMENT_METADATA_FIELD,
+ EndUserAgreementService
+} from './end-user-agreement.service';
+import { CookieServiceMock } from '../../shared/mocks/cookie.service.mock';
+import { of as observableOf } from 'rxjs';
+import { EPerson } from '../eperson/models/eperson.model';
+import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils';
+import { RestResponse } from '../cache/response.models';
+
+describe('EndUserAgreementService', () => {
+ let service: EndUserAgreementService;
+
+ let userWithMetadata: EPerson;
+ let userWithoutMetadata: EPerson;
+
+ let cookie;
+ let authService;
+ let ePersonService;
+
+ beforeEach(() => {
+ userWithMetadata = Object.assign(new EPerson(), {
+ metadata: {
+ [END_USER_AGREEMENT_METADATA_FIELD]: [
+ {
+ value: 'true'
+ }
+ ]
+ }
+ });
+ userWithoutMetadata = Object.assign(new EPerson());
+
+ cookie = new CookieServiceMock();
+ authService = jasmine.createSpyObj('authService', {
+ isAuthenticated: observableOf(true),
+ getAuthenticatedUserFromStore: observableOf(userWithMetadata)
+ });
+ ePersonService = jasmine.createSpyObj('ePersonService', {
+ update: createSuccessfulRemoteDataObject$(userWithMetadata),
+ patch: observableOf(new RestResponse(true, 200, 'OK'))
+ });
+
+ service = new EndUserAgreementService(cookie, authService, ePersonService);
+ });
+
+ describe('when the cookie is set to true', () => {
+ beforeEach(() => {
+ cookie.set(END_USER_AGREEMENT_COOKIE, true);
+ });
+
+ it('hasCurrentUserOrCookieAcceptedAgreement should return true', (done) => {
+ service.hasCurrentUserOrCookieAcceptedAgreement(false).subscribe((result) => {
+ expect(result).toEqual(true);
+ done();
+ });
+ });
+
+ it('isCookieAccepted should return true', () => {
+ expect(service.isCookieAccepted()).toEqual(true);
+ });
+
+ it('removeCookieAccepted should remove the cookie', () => {
+ service.removeCookieAccepted();
+ expect(cookie.get(END_USER_AGREEMENT_COOKIE)).toBeUndefined();
+ });
+ });
+
+ describe('when the cookie isn\'t set', () => {
+ describe('and the user is authenticated', () => {
+ beforeEach(() => {
+ (authService.isAuthenticated as jasmine.Spy).and.returnValue(observableOf(true));
+ });
+
+ describe('and the user contains agreement metadata', () => {
+ beforeEach(() => {
+ (authService.getAuthenticatedUserFromStore as jasmine.Spy).and.returnValue(observableOf(userWithMetadata));
+ });
+
+ it('hasCurrentUserOrCookieAcceptedAgreement should return true', (done) => {
+ service.hasCurrentUserOrCookieAcceptedAgreement(false).subscribe((result) => {
+ expect(result).toEqual(true);
+ done();
+ });
+ });
+ });
+
+ describe('and the user doesn\'t contain agreement metadata', () => {
+ beforeEach(() => {
+ (authService.getAuthenticatedUserFromStore as jasmine.Spy).and.returnValue(observableOf(userWithoutMetadata));
+ });
+
+ it('hasCurrentUserOrCookieAcceptedAgreement should return false', (done) => {
+ service.hasCurrentUserOrCookieAcceptedAgreement(false).subscribe((result) => {
+ expect(result).toEqual(false);
+ done();
+ });
+ });
+ });
+
+ it('setUserAcceptedAgreement should update the user with new metadata', (done) => {
+ service.setUserAcceptedAgreement(true).subscribe(() => {
+ expect(ePersonService.patch).toHaveBeenCalled();
+ done();
+ });
+ });
+ });
+
+ describe('and the user is not authenticated', () => {
+ beforeEach(() => {
+ (authService.isAuthenticated as jasmine.Spy).and.returnValue(observableOf(false));
+ });
+
+ it('hasCurrentUserOrCookieAcceptedAgreement should return false', (done) => {
+ service.hasCurrentUserOrCookieAcceptedAgreement(false).subscribe((result) => {
+ expect(result).toEqual(false);
+ done();
+ });
+ });
+
+ it('setUserAcceptedAgreement should set the cookie to true', (done) => {
+ service.setUserAcceptedAgreement(true).subscribe(() => {
+ expect(cookie.get(END_USER_AGREEMENT_COOKIE)).toEqual(true);
+ done();
+ });
+ });
+ });
+
+ it('isCookieAccepted should return false', () => {
+ expect(service.isCookieAccepted()).toEqual(false);
+ });
+
+ it('setCookieAccepted should set the cookie', () => {
+ service.setCookieAccepted(true);
+ expect(cookie.get(END_USER_AGREEMENT_COOKIE)).toEqual(true);
+ });
+ });
+});
diff --git a/src/app/core/end-user-agreement/end-user-agreement.service.ts b/src/app/core/end-user-agreement/end-user-agreement.service.ts
new file mode 100644
index 0000000000..23bda89169
--- /dev/null
+++ b/src/app/core/end-user-agreement/end-user-agreement.service.ts
@@ -0,0 +1,111 @@
+import { Injectable } from '@angular/core';
+import { AuthService } from '../auth/auth.service';
+import { CookieService } from '../services/cookie.service';
+import { Observable } from 'rxjs/internal/Observable';
+import { of as observableOf } from 'rxjs';
+import { map, switchMap, take } from 'rxjs/operators';
+import { hasValue } from '../../shared/empty.util';
+import { EPersonDataService } from '../eperson/eperson-data.service';
+
+export const END_USER_AGREEMENT_COOKIE = 'hasAgreedEndUser';
+export const END_USER_AGREEMENT_METADATA_FIELD = 'dspace.agreements.end-user';
+
+/**
+ * Service for checking and managing the status of the current end user agreement
+ */
+@Injectable()
+export class EndUserAgreementService {
+
+ constructor(protected cookie: CookieService,
+ protected authService: AuthService,
+ protected ePersonService: EPersonDataService) {
+ }
+
+ /**
+ * Whether or not either the cookie was accepted or the current user has accepted the End User Agreement
+ * @param acceptedWhenAnonymous Whether or not the user agreement should be considered accepted if the user is
+ * currently not authenticated (anonymous)
+ */
+ hasCurrentUserOrCookieAcceptedAgreement(acceptedWhenAnonymous: boolean): Observable {
+ if (this.isCookieAccepted()) {
+ return observableOf(true);
+ } else {
+ return this.hasCurrentUserAcceptedAgreement(acceptedWhenAnonymous);
+ }
+ }
+
+ /**
+ * Whether or not the current user has accepted the End User Agreement
+ * @param acceptedWhenAnonymous Whether or not the user agreement should be considered accepted if the user is
+ * currently not authenticated (anonymous)
+ */
+ hasCurrentUserAcceptedAgreement(acceptedWhenAnonymous: boolean): Observable {
+ return this.authService.isAuthenticated().pipe(
+ switchMap((authenticated) => {
+ if (authenticated) {
+ return this.authService.getAuthenticatedUserFromStore().pipe(
+ map((user) => hasValue(user) && user.hasMetadata(END_USER_AGREEMENT_METADATA_FIELD) && user.firstMetadata(END_USER_AGREEMENT_METADATA_FIELD).value === 'true')
+ );
+ } else {
+ return observableOf(acceptedWhenAnonymous);
+ }
+ })
+ );
+ }
+
+ /**
+ * Set the current user's accepted agreement status
+ * When a user is authenticated, set his/her metadata to the provided value
+ * When no user is authenticated, set the cookie to the provided value
+ * @param accepted
+ */
+ setUserAcceptedAgreement(accepted: boolean): Observable {
+ return this.authService.isAuthenticated().pipe(
+ switchMap((authenticated) => {
+ if (authenticated) {
+ return this.authService.getAuthenticatedUserFromStore().pipe(
+ take(1),
+ switchMap((user) => {
+ const newValue = { value: String(accepted) };
+ let operation;
+ if (user.hasMetadata(END_USER_AGREEMENT_METADATA_FIELD)) {
+ operation = { op: 'replace', path: `/metadata/${END_USER_AGREEMENT_METADATA_FIELD}/0`, value: newValue };
+ } else {
+ operation = { op: 'add', path: `/metadata/${END_USER_AGREEMENT_METADATA_FIELD}`, value: [ newValue ] };
+ }
+ return this.ePersonService.patch(user, [operation]);
+ }),
+ map((response) => response.isSuccessful)
+ );
+ } else {
+ this.setCookieAccepted(accepted);
+ return observableOf(true);
+ }
+ }),
+ take(1)
+ );
+ }
+
+ /**
+ * Is the End User Agreement accepted in the cookie?
+ */
+ isCookieAccepted(): boolean {
+ return this.cookie.get(END_USER_AGREEMENT_COOKIE) === true;
+ }
+
+ /**
+ * Set the cookie's End User Agreement accepted state
+ * @param accepted
+ */
+ setCookieAccepted(accepted: boolean) {
+ this.cookie.set(END_USER_AGREEMENT_COOKIE, accepted);
+ }
+
+ /**
+ * Remove the End User Agreement cookie
+ */
+ removeCookieAccepted() {
+ this.cookie.remove(END_USER_AGREEMENT_COOKIE);
+ }
+
+}
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/group-data.service.ts b/src/app/core/eperson/group-data.service.ts
index c186bc8dcd..d42ba392f3 100644
--- a/src/app/core/eperson/group-data.service.ts
+++ b/src/app/core/eperson/group-data.service.ts
@@ -3,7 +3,7 @@ import { Injectable } from '@angular/core';
import { createSelector, select, Store } from '@ngrx/store';
import { Observable } from 'rxjs';
-import { distinctUntilChanged, filter, map, switchMap, take, tap } from 'rxjs/operators';
+import { filter, map, take, tap } from 'rxjs/operators';
import {
GroupRegistryCancelGroupAction,
GroupRegistryEditGroupAction
@@ -21,18 +21,12 @@ import { DataService } from '../data/data.service';
import { DSOChangeAnalyzer } from '../data/dso-change-analyzer.service';
import { PaginatedList } from '../data/paginated-list';
import { RemoteData } from '../data/remote-data';
-import {
- CreateRequest,
- DeleteRequest,
- FindListOptions,
- FindListRequest,
- PostRequest
-} from '../data/request.models';
+import { CreateRequest, DeleteRequest, FindListOptions, FindListRequest, PostRequest } from '../data/request.models';
import { RequestService } from '../data/request.service';
import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service';
import { HALEndpointService } from '../shared/hal-endpoint.service';
-import { configureRequest, getResponseFromEntry} from '../shared/operators';
+import { getResponseFromEntry } from '../shared/operators';
import { EPerson } from './models/eperson.model';
import { Group } from './models/group.model';
import { dataService } from '../cache/builders/build-decorators';
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/integration/authority.service.ts b/src/app/core/integration/authority.service.ts
deleted file mode 100644
index f0a1759be6..0000000000
--- a/src/app/core/integration/authority.service.ts
+++ /dev/null
@@ -1,21 +0,0 @@
-import { Injectable } from '@angular/core';
-
-import { RequestService } from '../data/request.service';
-import { IntegrationService } from './integration.service';
-import { HALEndpointService } from '../shared/hal-endpoint.service';
-import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
-
-@Injectable()
-export class AuthorityService extends IntegrationService {
- protected linkPath = 'authorities';
- protected entriesEndpoint = 'entries';
- protected entryValueEndpoint = 'entryValues';
-
- constructor(
- protected requestService: RequestService,
- protected rdbService: RemoteDataBuildService,
- protected halService: HALEndpointService) {
- super();
- }
-
-}
diff --git a/src/app/core/integration/integration-data.ts b/src/app/core/integration/integration-data.ts
deleted file mode 100644
index b93ce36dad..0000000000
--- a/src/app/core/integration/integration-data.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-import { PageInfo } from '../shared/page-info.model';
-import { IntegrationModel } from './models/integration.model';
-
-/**
- * A class to represent the data retrieved by an Integration service
- */
-export class IntegrationData {
- constructor(
- public pageInfo: PageInfo,
- public payload: IntegrationModel[]
- ) { }
-}
diff --git a/src/app/core/integration/integration-response-parsing.service.spec.ts b/src/app/core/integration/integration-response-parsing.service.spec.ts
deleted file mode 100644
index b5cb8c4dc4..0000000000
--- a/src/app/core/integration/integration-response-parsing.service.spec.ts
+++ /dev/null
@@ -1,221 +0,0 @@
-import { Store } from '@ngrx/store';
-
-import { ObjectCacheService } from '../cache/object-cache.service';
-import { ErrorResponse, IntegrationSuccessResponse } from '../cache/response.models';
-import { CoreState } from '../core.reducers';
-import { PaginatedList } from '../data/paginated-list';
-import { IntegrationRequest } from '../data/request.models';
-import { PageInfo } from '../shared/page-info.model';
-import { IntegrationResponseParsingService } from './integration-response-parsing.service';
-import { AuthorityValue } from './models/authority.value';
-
-describe('IntegrationResponseParsingService', () => {
- let service: IntegrationResponseParsingService;
-
- const store = {} as Store;
- const objectCacheService = new ObjectCacheService(store, undefined);
- const name = 'type';
- const metadata = 'dc.type';
- const query = '';
- const uuid = 'd9d30c0c-69b7-4369-8397-ca67c888974d';
- const integrationEndpoint = 'https://rest.api/integration/authorities';
- const entriesEndpoint = `${integrationEndpoint}/${name}/entries?query=${query}&metadata=${metadata}&uuid=${uuid}`;
- let validRequest;
-
- let validResponse;
-
- let invalidResponse1;
- let invalidResponse2;
- let pageInfo;
- let definitions;
-
- function initVars() {
- pageInfo = Object.assign(new PageInfo(), {
- elementsPerPage: 5,
- totalElements: 5,
- totalPages: 1,
- currentPage: 1,
- _links: {
- self: { href: 'https://rest.api/integration/authorities/type/entries' }
- }
- });
- definitions = new PaginatedList(pageInfo, [
- Object.assign(new AuthorityValue(), {
- type: 'authority',
- display: 'One',
- id: 'One',
- otherInformation: undefined,
- value: 'One'
- }),
- Object.assign(new AuthorityValue(), {
- type: 'authority',
- display: 'Two',
- id: 'Two',
- otherInformation: undefined,
- value: 'Two'
- }),
- Object.assign(new AuthorityValue(), {
- type: 'authority',
- display: 'Three',
- id: 'Three',
- otherInformation: undefined,
- value: 'Three'
- }),
- Object.assign(new AuthorityValue(), {
- type: 'authority',
- display: 'Four',
- id: 'Four',
- otherInformation: undefined,
- value: 'Four'
- }),
- Object.assign(new AuthorityValue(), {
- type: 'authority',
- display: 'Five',
- id: 'Five',
- otherInformation: undefined,
- value: 'Five'
- })
- ]);
- validRequest = new IntegrationRequest('69f375b5-19f4-4453-8c7a-7dc5c55aafbb', entriesEndpoint);
-
- validResponse = {
- payload: {
- page: {
- number: 0,
- size: 5,
- totalElements: 5,
- totalPages: 1
- },
- _embedded: {
- authorityEntries: [
- {
- display: 'One',
- id: 'One',
- otherInformation: {},
- type: 'authority',
- value: 'One'
- },
- {
- display: 'Two',
- id: 'Two',
- otherInformation: {},
- type: 'authority',
- value: 'Two'
- },
- {
- display: 'Three',
- id: 'Three',
- otherInformation: {},
- type: 'authority',
- value: 'Three'
- },
- {
- display: 'Four',
- id: 'Four',
- otherInformation: {},
- type: 'authority',
- value: 'Four'
- },
- {
- display: 'Five',
- id: 'Five',
- otherInformation: {},
- type: 'authority',
- value: 'Five'
- },
- ],
-
- },
- _links: {
- self: { href: 'https://rest.api/integration/authorities/type/entries' }
- }
- },
- statusCode: 200,
- statusText: 'OK'
- };
-
- invalidResponse1 = {
- payload: {},
- statusCode: 400,
- statusText: 'Bad Request'
- };
-
- invalidResponse2 = {
- payload: {
- page: {
- number: 0,
- size: 5,
- totalElements: 5,
- totalPages: 1
- },
- _embedded: {
- authorityEntries: [
- {
- display: 'One',
- id: 'One',
- otherInformation: {},
- type: 'authority',
- value: 'One'
- },
- {
- display: 'Two',
- id: 'Two',
- otherInformation: {},
- type: 'authority',
- value: 'Two'
- },
- {
- display: 'Three',
- id: 'Three',
- otherInformation: {},
- type: 'authority',
- value: 'Three'
- },
- {
- display: 'Four',
- id: 'Four',
- otherInformation: {},
- type: 'authority',
- value: 'Four'
- },
- {
- display: 'Five',
- id: 'Five',
- otherInformation: {},
- type: 'authority',
- value: 'Five'
- },
- ],
-
- },
- _links: {}
- },
- statusCode: 500,
- statusText: 'Internal Server Error'
- };
- }
- beforeEach(() => {
- initVars();
- service = new IntegrationResponseParsingService(objectCacheService);
- });
-
- describe('parse', () => {
- it('should return a IntegrationSuccessResponse if data contains a valid endpoint response', () => {
- const response = service.parse(validRequest, validResponse);
- expect(response.constructor).toBe(IntegrationSuccessResponse);
- });
-
- it('should return an ErrorResponse if data contains an invalid config endpoint response', () => {
- const response1 = service.parse(validRequest, invalidResponse1);
- const response2 = service.parse(validRequest, invalidResponse2);
- expect(response1.constructor).toBe(ErrorResponse);
- expect(response2.constructor).toBe(ErrorResponse);
- });
-
- it('should return a IntegrationSuccessResponse with data definition', () => {
- const response = service.parse(validRequest, validResponse);
- expect((response as any).dataDefinition).toEqual(definitions);
- });
-
- });
-});
diff --git a/src/app/core/integration/integration-response-parsing.service.ts b/src/app/core/integration/integration-response-parsing.service.ts
deleted file mode 100644
index 2719669bae..0000000000
--- a/src/app/core/integration/integration-response-parsing.service.ts
+++ /dev/null
@@ -1,50 +0,0 @@
-import { Inject, Injectable } from '@angular/core';
-import { RestRequest } from '../data/request.models';
-import { ResponseParsingService } from '../data/parsing.service';
-import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model';
-import { ErrorResponse, IntegrationSuccessResponse, RestResponse } from '../cache/response.models';
-import { isNotEmpty } from '../../shared/empty.util';
-
-import { BaseResponseParsingService } from '../data/base-response-parsing.service';
-import { ObjectCacheService } from '../cache/object-cache.service';
-import { IntegrationModel } from './models/integration.model';
-import { AuthorityValue } from './models/authority.value';
-import { PaginatedList } from '../data/paginated-list';
-
-@Injectable()
-export class IntegrationResponseParsingService extends BaseResponseParsingService implements ResponseParsingService {
-
- protected toCache = true;
-
- constructor(
- protected objectCache: ObjectCacheService,
- ) {
- super();
- }
-
- parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse {
- if (isNotEmpty(data.payload) && isNotEmpty(data.payload._links)) {
- const dataDefinition = this.process(data.payload, request);
- return new IntegrationSuccessResponse(this.processResponse(dataDefinition), data.statusCode, data.statusText, this.processPageInfo(data.payload));
- } else {
- return new ErrorResponse(
- Object.assign(
- new Error('Unexpected response from Integration endpoint'),
- {statusCode: data.statusCode, statusText: data.statusText}
- )
- );
- }
- }
-
- protected processResponse(data: PaginatedList): any {
- const returnList = Array.of();
- data.page.forEach((item, index) => {
- if (item.type === AuthorityValue.type.value) {
- data.page[index] = Object.assign(new AuthorityValue(), item);
- }
- });
-
- return data;
- }
-
-}
diff --git a/src/app/core/integration/integration.service.spec.ts b/src/app/core/integration/integration.service.spec.ts
deleted file mode 100644
index 148a5df7b8..0000000000
--- a/src/app/core/integration/integration.service.spec.ts
+++ /dev/null
@@ -1,96 +0,0 @@
-import { cold, getTestScheduler } from 'jasmine-marbles';
-import { TestScheduler } from 'rxjs/testing';
-import { getMockRequestService } from '../../shared/mocks/request.service.mock';
-
-import { RequestService } from '../data/request.service';
-import { IntegrationRequest } from '../data/request.models';
-import { HALEndpointService } from '../shared/hal-endpoint.service';
-import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service.stub';
-import { IntegrationService } from './integration.service';
-import { IntegrationSearchOptions } from './models/integration-options.model';
-import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
-import { getMockRemoteDataBuildService } from '../../shared/mocks/remote-data-build.service.mock';
-
-const LINK_NAME = 'authorities';
-const ENTRIES = 'entries';
-const ENTRY_VALUE = 'entryValue';
-
-class TestService extends IntegrationService {
- protected linkPath = LINK_NAME;
- protected entriesEndpoint = ENTRIES;
- protected entryValueEndpoint = ENTRY_VALUE;
-
- constructor(
- protected requestService: RequestService,
- protected rdbService: RemoteDataBuildService,
- protected halService: HALEndpointService) {
- super();
- }
-}
-
-describe('IntegrationService', () => {
- let scheduler: TestScheduler;
- let service: TestService;
- let requestService: RequestService;
- let rdbService: RemoteDataBuildService;
- let halService: any;
- let findOptions: IntegrationSearchOptions;
-
- const name = 'type';
- const metadata = 'dc.type';
- const query = '';
- const value = 'test';
- const uuid = 'd9d30c0c-69b7-4369-8397-ca67c888974d';
- const integrationEndpoint = 'https://rest.api/integration';
- const serviceEndpoint = `${integrationEndpoint}/${LINK_NAME}`;
- const entriesEndpoint = `${serviceEndpoint}/${name}/entries?query=${query}&metadata=${metadata}&uuid=${uuid}`;
- const entryValueEndpoint = `${serviceEndpoint}/${name}/entryValue/${value}?metadata=${metadata}`;
-
- findOptions = new IntegrationSearchOptions(uuid, name, metadata);
-
- function initTestService(): TestService {
- return new TestService(
- requestService,
- rdbService,
- halService
- );
- }
-
- beforeEach(() => {
- requestService = getMockRequestService();
- rdbService = getMockRemoteDataBuildService();
- scheduler = getTestScheduler();
- halService = new HALEndpointServiceStub(integrationEndpoint);
- findOptions = new IntegrationSearchOptions(uuid, name, metadata, query);
- service = initTestService();
-
- });
-
- describe('getEntriesByName', () => {
-
- it('should configure a new IntegrationRequest', () => {
- const expected = new IntegrationRequest(requestService.generateRequestId(), entriesEndpoint);
- scheduler.schedule(() => service.getEntriesByName(findOptions).subscribe());
- scheduler.flush();
-
- expect(requestService.configure).toHaveBeenCalledWith(expected);
- });
- });
-
- describe('getEntryByValue', () => {
-
- it('should configure a new IntegrationRequest', () => {
- findOptions = new IntegrationSearchOptions(
- null,
- name,
- metadata,
- value);
-
- const expected = new IntegrationRequest(requestService.generateRequestId(), entryValueEndpoint);
- scheduler.schedule(() => service.getEntryByValue(findOptions).subscribe());
- scheduler.flush();
-
- expect(requestService.configure).toHaveBeenCalledWith(expected);
- });
- });
-});
diff --git a/src/app/core/integration/integration.service.ts b/src/app/core/integration/integration.service.ts
deleted file mode 100644
index 5826f4646d..0000000000
--- a/src/app/core/integration/integration.service.ts
+++ /dev/null
@@ -1,121 +0,0 @@
-import { Observable, of as observableOf, throwError as observableThrowError } from 'rxjs';
-import { distinctUntilChanged, filter, map, mergeMap, tap } from 'rxjs/operators';
-import { RequestService } from '../data/request.service';
-import { IntegrationSuccessResponse } from '../cache/response.models';
-import { GetRequest, IntegrationRequest } from '../data/request.models';
-import { hasValue, isNotEmpty } from '../../shared/empty.util';
-import { HALEndpointService } from '../shared/hal-endpoint.service';
-import { IntegrationData } from './integration-data';
-import { IntegrationSearchOptions } from './models/integration-options.model';
-import { getResponseFromEntry } from '../shared/operators';
-
-export abstract class IntegrationService {
- protected request: IntegrationRequest;
- protected abstract requestService: RequestService;
- protected abstract linkPath: string;
- protected abstract entriesEndpoint: string;
- protected abstract entryValueEndpoint: string;
- protected abstract halService: HALEndpointService;
-
- protected getData(request: GetRequest): Observable {
- return this.requestService.getByHref(request.href).pipe(
- getResponseFromEntry(),
- mergeMap((response: IntegrationSuccessResponse) => {
- if (response.isSuccessful && isNotEmpty(response)) {
- return observableOf(new IntegrationData(
- response.pageInfo,
- (response.dataDefinition) ? response.dataDefinition.page : []
- ));
- } else if (!response.isSuccessful) {
- return observableThrowError(new Error(`Couldn't retrieve the integration data`));
- }
- }),
- distinctUntilChanged()
- );
- }
-
- protected getEntriesHref(endpoint, options: IntegrationSearchOptions = new IntegrationSearchOptions()): string {
- let result;
- const args = [];
-
- if (hasValue(options.name)) {
- result = `${endpoint}/${options.name}/${this.entriesEndpoint}`;
- } else {
- result = endpoint;
- }
-
- if (hasValue(options.query)) {
- args.push(`query=${options.query}`);
- }
-
- if (hasValue(options.metadata)) {
- args.push(`metadata=${options.metadata}`);
- }
-
- if (hasValue(options.uuid)) {
- args.push(`uuid=${options.uuid}`);
- }
-
- if (hasValue(options.currentPage) && typeof options.currentPage === 'number') {
- /* TODO: this is a temporary fix for the pagination start index (0 or 1) discrepancy between the rest and the frontend respectively */
- args.push(`page=${options.currentPage - 1}`);
- }
-
- if (hasValue(options.elementsPerPage)) {
- args.push(`size=${options.elementsPerPage}`);
- }
-
- if (hasValue(options.sort)) {
- args.push(`sort=${options.sort.field},${options.sort.direction}`);
- }
-
- if (isNotEmpty(args)) {
- result = `${result}?${args.join('&')}`;
- }
- return result;
- }
-
- protected getEntryValueHref(endpoint, options: IntegrationSearchOptions = new IntegrationSearchOptions()): string {
- let result;
- const args = [];
-
- if (hasValue(options.name) && hasValue(options.query)) {
- result = `${endpoint}/${options.name}/${this.entryValueEndpoint}/${options.query}`;
- } else {
- result = endpoint;
- }
-
- if (hasValue(options.metadata)) {
- args.push(`metadata=${options.metadata}`);
- }
-
- if (isNotEmpty(args)) {
- result = `${result}?${args.join('&')}`;
- }
-
- return result;
- }
-
- public getEntriesByName(options: IntegrationSearchOptions): Observable {
- return this.halService.getEndpoint(this.linkPath).pipe(
- map((endpoint: string) => this.getEntriesHref(endpoint, options)),
- filter((href: string) => isNotEmpty(href)),
- distinctUntilChanged(),
- map((endpointURL: string) => new IntegrationRequest(this.requestService.generateRequestId(), endpointURL)),
- tap((request: GetRequest) => this.requestService.configure(request)),
- mergeMap((request: GetRequest) => this.getData(request)),
- distinctUntilChanged());
- }
-
- public getEntryByValue(options: IntegrationSearchOptions): Observable {
- return this.halService.getEndpoint(this.linkPath).pipe(
- map((endpoint: string) => this.getEntryValueHref(endpoint, options)),
- filter((href: string) => isNotEmpty(href)),
- distinctUntilChanged(),
- map((endpointURL: string) => new IntegrationRequest(this.requestService.generateRequestId(), endpointURL)),
- tap((request: GetRequest) => this.requestService.configure(request)),
- mergeMap((request: GetRequest) => this.getData(request)),
- distinctUntilChanged());
- }
-
-}
diff --git a/src/app/core/integration/models/authority-options.model.ts b/src/app/core/integration/models/authority-options.model.ts
deleted file mode 100644
index 0b826f7f9c..0000000000
--- a/src/app/core/integration/models/authority-options.model.ts
+++ /dev/null
@@ -1,16 +0,0 @@
-export class AuthorityOptions {
- name: string;
- metadata: string;
- scope: string;
- closed: boolean;
-
- constructor(name: string,
- metadata: string,
- scope: string,
- closed: boolean = false) {
- this.name = name;
- this.metadata = metadata;
- this.scope = scope;
- this.closed = closed;
- }
-}
diff --git a/src/app/core/integration/models/authority.value.ts b/src/app/core/integration/models/authority.value.ts
deleted file mode 100644
index 4af10034b2..0000000000
--- a/src/app/core/integration/models/authority.value.ts
+++ /dev/null
@@ -1,92 +0,0 @@
-import { autoserialize, deserialize, inheritSerialization } from 'cerialize';
-import { isNotEmpty } from '../../../shared/empty.util';
-import { OtherInformation } from '../../../shared/form/builder/models/form-field-metadata-value.model';
-import { typedObject } from '../../cache/builders/build-decorators';
-import { HALLink } from '../../shared/hal-link.model';
-import { MetadataValueInterface } from '../../shared/metadata.models';
-import { AUTHORITY_VALUE } from './authority.resource-type';
-import { IntegrationModel } from './integration.model';
-import { PLACEHOLDER_PARENT_METADATA } from '../../../shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-constants';
-
-/**
- * Class representing an authority object
- */
-@typedObject
-@inheritSerialization(IntegrationModel)
-export class AuthorityValue extends IntegrationModel implements MetadataValueInterface {
- static type = AUTHORITY_VALUE;
-
- /**
- * The identifier of this authority
- */
- @autoserialize
- id: string;
-
- /**
- * The display value of this authority
- */
- @autoserialize
- display: string;
-
- /**
- * The value of this authority
- */
- @autoserialize
- value: string;
-
- /**
- * An object containing additional information related to this authority
- */
- @autoserialize
- otherInformation: OtherInformation;
-
- /**
- * The language code of this authority value
- */
- @autoserialize
- language: string;
-
- /**
- * The {@link HALLink}s for this AuthorityValue
- */
- @deserialize
- _links: {
- self: HALLink,
- };
-
- /**
- * This method checks if authority has an identifier value
- *
- * @return boolean
- */
- hasAuthority(): boolean {
- return isNotEmpty(this.id);
- }
-
- /**
- * This method checks if authority has a value
- *
- * @return boolean
- */
- hasValue(): boolean {
- return isNotEmpty(this.value);
- }
-
- /**
- * This method checks if authority has related information object
- *
- * @return boolean
- */
- hasOtherInformation(): boolean {
- return isNotEmpty(this.otherInformation);
- }
-
- /**
- * This method checks if authority has a placeholder as value
- *
- * @return boolean
- */
- hasPlaceholder(): boolean {
- return this.hasValue() && this.value === PLACEHOLDER_PARENT_METADATA;
- }
-}
diff --git a/src/app/core/integration/models/integration-options.model.ts b/src/app/core/integration/models/integration-options.model.ts
deleted file mode 100644
index 5f158bd47c..0000000000
--- a/src/app/core/integration/models/integration-options.model.ts
+++ /dev/null
@@ -1,14 +0,0 @@
-import { SortOptions } from '../../cache/models/sort-options.model';
-
-export class IntegrationSearchOptions {
-
- constructor(public uuid: string = '',
- public name: string = '',
- public metadata: string = '',
- public query: string = '',
- public elementsPerPage?: number,
- public currentPage?: number,
- public sort?: SortOptions) {
-
- }
-}
diff --git a/src/app/core/integration/models/integration.model.ts b/src/app/core/integration/models/integration.model.ts
deleted file mode 100644
index d2f21a70c0..0000000000
--- a/src/app/core/integration/models/integration.model.ts
+++ /dev/null
@@ -1,22 +0,0 @@
-import { autoserialize, deserialize } from 'cerialize';
-import { CacheableObject } from '../../cache/object-cache.reducer';
-import { HALLink } from '../../shared/hal-link.model';
-
-export abstract class IntegrationModel implements CacheableObject {
-
- @autoserialize
- self: string;
-
- @autoserialize
- uuid: string;
-
- @autoserialize
- public type: any;
-
- @deserialize
- public _links: {
- self: HALLink,
- [name: string]: HALLink
- }
-
-}
diff --git a/src/app/core/json-patch/builder/json-patch-operations-builder.ts b/src/app/core/json-patch/builder/json-patch-operations-builder.ts
index eb54265318..ced3750834 100644
--- a/src/app/core/json-patch/builder/json-patch-operations-builder.ts
+++ b/src/app/core/json-patch/builder/json-patch-operations-builder.ts
@@ -1,11 +1,16 @@
import { Store } from '@ngrx/store';
import { CoreState } from '../../core.reducers';
-import { NewPatchAddOperationAction, NewPatchMoveOperationAction, NewPatchRemoveOperationAction, NewPatchReplaceOperationAction } from '../json-patch-operations.actions';
+import {
+ NewPatchAddOperationAction,
+ NewPatchMoveOperationAction,
+ NewPatchRemoveOperationAction,
+ NewPatchReplaceOperationAction
+} from '../json-patch-operations.actions';
import { JsonPatchOperationPathObject } from './json-patch-operation-path-combiner';
import { Injectable } from '@angular/core';
-import { hasNoValue, isEmpty, isNotEmpty } from '../../../shared/empty.util';
+import { hasNoValue, hasValue, isEmpty, isNotEmpty } from '../../../shared/empty.util';
import { dateToISOFormat } from '../../../shared/date.util';
-import { AuthorityValue } from '../../integration/models/authority.value';
+import { VocabularyEntry } from '../../submission/vocabularies/models/vocabulary-entry.model';
import { FormFieldMetadataValueObject } from '../../../shared/form/builder/models/form-field-metadata-value.model';
import { FormFieldLanguageValueObject } from '../../../shared/form/builder/models/form-field-language-value.model';
@@ -96,7 +101,7 @@ export class JsonPatchOperationsBuilder {
protected prepareValue(value: any, plain: boolean, first: boolean) {
let operationValue: any = null;
- if (isNotEmpty(value)) {
+ if (hasValue(value)) {
if (plain) {
operationValue = value;
} else {
@@ -125,10 +130,12 @@ export class JsonPatchOperationsBuilder {
operationValue = value;
} else if (value instanceof Date) {
operationValue = new FormFieldMetadataValueObject(dateToISOFormat(value));
- } else if (value instanceof AuthorityValue) {
+ } else if (value instanceof VocabularyEntry) {
operationValue = this.prepareAuthorityValue(value);
} else if (value instanceof FormFieldLanguageValueObject) {
operationValue = new FormFieldMetadataValueObject(value.value, value.language);
+ } else if (value.hasOwnProperty('authority')) {
+ operationValue = new FormFieldMetadataValueObject(value.value, value.language, value.authority);
} else if (value.hasOwnProperty('value')) {
operationValue = new FormFieldMetadataValueObject(value.value);
} else {
@@ -144,10 +151,10 @@ export class JsonPatchOperationsBuilder {
return operationValue;
}
- protected prepareAuthorityValue(value: any) {
- let operationValue: any = null;
- if (isNotEmpty(value.id)) {
- operationValue = new FormFieldMetadataValueObject(value.value, value.language, value.id);
+ protected prepareAuthorityValue(value: any): FormFieldMetadataValueObject {
+ let operationValue: FormFieldMetadataValueObject;
+ if (isNotEmpty(value.authority)) {
+ operationValue = new FormFieldMetadataValueObject(value.value, value.language, value.authority);
} else {
operationValue = new FormFieldMetadataValueObject(value.value, value.language);
}
diff --git a/src/app/core/json-patch/json-patch-operations.service.spec.ts b/src/app/core/json-patch/json-patch-operations.service.spec.ts
index fb9e641441..4ada78172e 100644
--- a/src/app/core/json-patch/json-patch-operations.service.spec.ts
+++ b/src/app/core/json-patch/json-patch-operations.service.spec.ts
@@ -1,9 +1,8 @@
-import { async, TestBed } from '@angular/core/testing';
-
import { getTestScheduler } from 'jasmine-marbles';
import { TestScheduler } from 'rxjs/testing';
import { of as observableOf } from 'rxjs';
-import { Store, StoreModule } from '@ngrx/store';
+import { catchError } from 'rxjs/operators';
+import { Store } from '@ngrx/store';
import { getMockRequestService } from '../../shared/mocks/request.service.mock';
import { RequestService } from '../data/request.service';
@@ -22,7 +21,6 @@ import {
StartTransactionPatchOperationsAction
} from './json-patch-operations.actions';
import { RequestEntry } from '../data/request.reducer';
-import { catchError } from 'rxjs/operators';
class TestService extends JsonPatchOperationsService {
protected linkPath = '';
diff --git a/src/app/core/locale/locale.service.ts b/src/app/core/locale/locale.service.ts
index ade5c1f864..315fc02833 100644
--- a/src/app/core/locale/locale.service.ts
+++ b/src/app/core/locale/locale.service.ts
@@ -10,7 +10,7 @@ import { Observable, of as observableOf, combineLatest } from 'rxjs';
import { map, take, flatMap } from 'rxjs/operators';
import { NativeWindowService, NativeWindowRef } from '../services/window.service';
-export const LANG_COOKIE = 'language_cookie';
+export const LANG_COOKIE = 'dsLanguage';
/**
* This enum defines the possible origin of the languages
diff --git a/src/app/core/metadata/metadata-field.model.ts b/src/app/core/metadata/metadata-field.model.ts
index 66171141c5..18840278c4 100644
--- a/src/app/core/metadata/metadata-field.model.ts
+++ b/src/app/core/metadata/metadata-field.model.ts
@@ -68,8 +68,8 @@ export class MetadataField extends ListableObject implements HALResource {
schema?: Observable>;
/**
- * Method to print this metadata field as a string
- * @param separator The separator between the schema, element and qualifier in the string
+ * Method to print this metadata field as a string without the schema
+ * @param separator The separator between element and qualifier in the string
*/
toString(separator: string = '.'): string {
let key = this.element;
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/registry/registry.service.ts b/src/app/core/registry/registry.service.ts
index 72de6ec793..f349cf428c 100644
--- a/src/app/core/registry/registry.service.ts
+++ b/src/app/core/registry/registry.service.ts
@@ -30,7 +30,6 @@ import { MetadataSchemaDataService } from '../data/metadata-schema-data.service'
import { MetadataFieldDataService } from '../data/metadata-field-data.service';
import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model';
import { RequestParam } from '../cache/models/request-param.model';
-import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils';
const metadataRegistryStateSelector = (state: AppState) => state.metadataRegistry;
const editMetadataSchemaSelector = createSelector(metadataRegistryStateSelector, (metadataState: MetadataRegistryState) => metadataState.editSchema);
@@ -90,20 +89,6 @@ export class RegistryService {
return this.metadataFieldService.findBySchema(schema, options, ...linksToFollow);
}
- /**
- * Retrieve all existing metadata fields as a paginated list
- * @param options Options to determine which page of metadata fields should be requested
- * When no options are provided, all metadata fields are requested in one large page
- * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved
- * @returns an observable that emits a remote data object with a page of metadata fields
- */
- // TODO this is temporarily disabled. The performance is too bad.
- // It is used down the line for validation. That validation will have to be rewritten against a new rest endpoint.
- // Not by downloading the list of all fields.
- public getAllMetadataFields(options?: FindListOptions, ...linksToFollow: Array>): Observable>> {
- return createSuccessfulRemoteDataObject$(new PaginatedList(null, []));
- }
-
public editMetadataSchema(schema: MetadataSchema) {
this.store.dispatch(new MetadataRegistryEditSchemaAction(schema));
}
@@ -151,6 +136,7 @@ export class RegistryService {
public getSelectedMetadataSchemas(): Observable {
return this.store.pipe(select(selectedMetadataSchemasSelector));
}
+
/**
* Method to start editing a metadata field, dispatches an edit field action
* @param field The field that's being edited
@@ -165,12 +151,14 @@ export class RegistryService {
public cancelEditMetadataField() {
this.store.dispatch(new MetadataRegistryCancelFieldAction());
}
+
/**
* Method to retrieve the metadata field that are currently being edited
*/
public getActiveMetadataField(): Observable {
return this.store.pipe(select(editMetadataFieldSelector));
}
+
/**
* Method to select a metadata field, dispatches a select field action
* @param field The field that's being selected
@@ -178,6 +166,7 @@ export class RegistryService {
public selectMetadataField(field: MetadataField) {
this.store.dispatch(new MetadataRegistrySelectFieldAction(field));
}
+
/**
* Method to deselect a metadata field, dispatches a deselect field action
* @param field The field that's it being deselected
@@ -185,6 +174,7 @@ export class RegistryService {
public deselectMetadataField(field: MetadataField) {
this.store.dispatch(new MetadataRegistryDeselectFieldAction(field));
}
+
/**
* Method to deselect all currently selected metadata fields, dispatches a deselect all field action
*/
@@ -213,7 +203,7 @@ export class RegistryService {
getFirstSucceededRemoteDataPayload(),
hasValueOperator(),
tap(() => {
- this.showNotifications(true, isUpdate, false, {prefix: schema.prefix});
+ this.showNotifications(true, isUpdate, false, { prefix: schema.prefix });
})
);
}
@@ -244,7 +234,7 @@ export class RegistryService {
getFirstSucceededRemoteDataPayload(),
hasValueOperator(),
tap(() => {
- this.showNotifications(true, false, true, {field: field.toString()});
+ this.showNotifications(true, false, true, { field: field.toString() });
})
);
}
@@ -259,7 +249,7 @@ export class RegistryService {
getFirstSucceededRemoteDataPayload(),
hasValueOperator(),
tap(() => {
- this.showNotifications(true, true, true, {field: field.toString()});
+ this.showNotifications(true, true, true, { field: field.toString() });
})
);
}
@@ -271,6 +261,7 @@ export class RegistryService {
public deleteMetadataField(id: number): Observable {
return this.metadataFieldService.delete(`${id}`);
}
+
/**
* Method that clears a cached metadata field request and returns its REST url
*/
@@ -297,13 +288,11 @@ export class RegistryService {
/**
* Retrieve a filtered paginated list of metadata fields
- * @param query {string} The query to filter the field names by
+ * @param query {string} The query to use for the metadata field name, can be part of the fully qualified field,
+ * should start with the start of the schema, element or qualifier (e.g. “dc.ti”, “contributor”, “auth”, “contributor.ot”)
* @returns an observable that emits a remote data object with a page of metadata fields that match the query
*/
- // TODO this is temporarily disabled. The performance is too bad.
- // Querying metadatafields will need to be implemented as a search endpoint on the rest api,
- // not by downloading everything and preforming the query client side.
- queryMetadataFields(query: string): Observable>> {
- return createSuccessfulRemoteDataObject$(new PaginatedList(null, []));
+ queryMetadataFields(query: string, options: FindListOptions = {}, ...linksToFollow: Array>): Observable>> {
+ return this.metadataFieldService.searchByFieldNameParams(null, null, null, query, null, options, ...linksToFollow);
}
}
diff --git a/src/app/core/reload/reload.guard.spec.ts b/src/app/core/reload/reload.guard.spec.ts
new file mode 100644
index 0000000000..317245bafa
--- /dev/null
+++ b/src/app/core/reload/reload.guard.spec.ts
@@ -0,0 +1,47 @@
+import { ReloadGuard } from './reload.guard';
+import { Router } from '@angular/router';
+
+describe('ReloadGuard', () => {
+ let guard: ReloadGuard;
+ let router: Router;
+
+ beforeEach(() => {
+ router = jasmine.createSpyObj('router', ['parseUrl', 'createUrlTree']);
+ guard = new ReloadGuard(router);
+ });
+
+ describe('canActivate', () => {
+ let route;
+
+ describe('when the route\'s query params contain a redirect url', () => {
+ let redirectUrl;
+
+ beforeEach(() => {
+ redirectUrl = '/redirect/url?param=extra';
+ route = {
+ queryParams: {
+ redirect: redirectUrl
+ }
+ };
+ });
+
+ it('should create a UrlTree with the redirect URL', () => {
+ guard.canActivate(route, undefined);
+ expect(router.parseUrl).toHaveBeenCalledWith(redirectUrl);
+ });
+ });
+
+ describe('when the route\'s query params doesn\'t contain a redirect url', () => {
+ beforeEach(() => {
+ route = {
+ queryParams: {}
+ };
+ });
+
+ it('should create a UrlTree to home', () => {
+ guard.canActivate(route, undefined);
+ expect(router.createUrlTree).toHaveBeenCalledWith(['home']);
+ });
+ });
+ });
+});
diff --git a/src/app/core/reload/reload.guard.ts b/src/app/core/reload/reload.guard.ts
new file mode 100644
index 0000000000..78f9dcf642
--- /dev/null
+++ b/src/app/core/reload/reload.guard.ts
@@ -0,0 +1,26 @@
+import { ActivatedRouteSnapshot, CanActivate, Router, RouterStateSnapshot, UrlTree } from '@angular/router';
+import { Injectable } from '@angular/core';
+import { isNotEmpty } from '../../shared/empty.util';
+
+/**
+ * A guard redirecting the user to the URL provided in the route's query params
+ * When no redirect url is found, the user is redirected to the homepage
+ */
+@Injectable()
+export class ReloadGuard implements CanActivate {
+ constructor(private router: Router) {
+ }
+
+ /**
+ * Get the UrlTree of the URL to redirect to
+ * @param route
+ * @param state
+ */
+ canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): UrlTree {
+ if (isNotEmpty(route.queryParams.redirect)) {
+ return this.router.parseUrl(route.queryParams.redirect);
+ } else {
+ return this.router.createUrlTree(['home']);
+ }
+ }
+}
diff --git a/src/app/core/services/browser-hard-redirect.service.spec.ts b/src/app/core/services/browser-hard-redirect.service.spec.ts
new file mode 100644
index 0000000000..64518579aa
--- /dev/null
+++ b/src/app/core/services/browser-hard-redirect.service.spec.ts
@@ -0,0 +1,50 @@
+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);
+
+ beforeEach(() => {
+ TestBed.configureTestingModule({});
+ });
+
+ it('should be created', () => {
+ expect(service).toBeTruthy();
+ });
+
+ describe('when performing a redirect', () => {
+
+ const redirect = 'test redirect';
+
+ beforeEach(() => {
+ service.redirect(redirect);
+ });
+
+ it('should update the location', () => {
+ expect(mockLocation.href).toEqual(redirect);
+ })
+ });
+
+ describe('when requesting the current route', () => {
+
+ it('should return the location origin', () => {
+ 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
new file mode 100644
index 0000000000..4b7424bee2
--- /dev/null
+++ b/src/app/core/services/browser-hard-redirect.service.ts
@@ -0,0 +1,43 @@
+import { Inject, Injectable, InjectionToken } from '@angular/core';
+import { HardRedirectService } from './hard-redirect.service';
+
+export const LocationToken = new InjectionToken('Location');
+
+export function locationProvider(): Location {
+ return window.location;
+}
+
+/**
+ * Service for performing hard redirects within the browser app module
+ */
+@Injectable()
+export class BrowserHardRedirectService extends HardRedirectService {
+
+ constructor(
+ @Inject(LocationToken) protected location: Location,
+ ) {
+ super();
+ }
+
+ /**
+ * Perform a hard redirect to URL
+ * @param url
+ */
+ redirect(url: string) {
+ this.location.href = url;
+ }
+
+ /**
+ * Get the origin of a request
+ */
+ 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
new file mode 100644
index 0000000000..a09521dae5
--- /dev/null
+++ b/src/app/core/services/hard-redirect.service.ts
@@ -0,0 +1,40 @@
+import { Injectable } from '@angular/core';
+import { environment } from '../../../environments/environment';
+import { URLCombiner } from '../url-combiner/url-combiner';
+
+/**
+ * Service to take care of hard redirects
+ */
+@Injectable()
+export abstract class HardRedirectService {
+
+ /**
+ * Perform a hard redirect to a given location.
+ *
+ * @param url
+ * the page to redirect to
+ */
+ abstract redirect(url: string);
+
+ /**
+ * Get the current route, with query params included
+ * 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
new file mode 100644
index 0000000000..dc89517468
--- /dev/null
+++ b/src/app/core/services/server-hard-redirect.service.spec.ts
@@ -0,0 +1,56 @@
+import { TestBed } from '@angular/core/testing';
+import { ServerHardRedirectService } from './server-hard-redirect.service';
+
+describe('ServerHardRedirectService', () => {
+
+ const mockRequest = jasmine.createSpyObj(['get']);
+ 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({});
+ });
+
+ it('should be created', () => {
+ expect(service).toBeTruthy();
+ });
+
+ describe('when performing a redirect', () => {
+
+ const redirect = 'test redirect';
+
+ beforeEach(() => {
+ service.redirect(redirect);
+ });
+
+ it('should update the response object', () => {
+ expect(mockResponse.redirect).toHaveBeenCalledWith(302, redirect);
+ expect(mockResponse.end).toHaveBeenCalled();
+ })
+ });
+
+ describe('when requesting the current route', () => {
+
+ beforeEach(() => {
+ mockRequest.originalUrl = 'original/url';
+ });
+
+ it('should return the location origin', () => {
+ 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
new file mode 100644
index 0000000000..65b404ca6c
--- /dev/null
+++ b/src/app/core/services/server-hard-redirect.service.ts
@@ -0,0 +1,70 @@
+import { Inject, Injectable } from '@angular/core';
+import { Request, Response } from 'express';
+import { REQUEST, RESPONSE } from '@nguniversal/express-engine/tokens';
+import { HardRedirectService } from './hard-redirect.service';
+
+/**
+ * Service for performing hard redirects within the server app module
+ */
+@Injectable()
+export class ServerHardRedirectService extends HardRedirectService {
+
+ constructor(
+ @Inject(REQUEST) protected req: Request,
+ @Inject(RESPONSE) protected res: Response,
+ ) {
+ super();
+ }
+
+ /**
+ * Perform a hard redirect to URL
+ * @param url
+ */
+ redirect(url: string) {
+
+ if (url === this.req.url) {
+ return;
+ }
+
+ if (this.res.finished) {
+ const req: any = this.req;
+ req._r_count = (req._r_count || 0) + 1;
+
+ console.warn('Attempted to redirect on a finished response. From',
+ this.req.url, 'to', url);
+
+ if (req._r_count > 10) {
+ console.error('Detected a redirection loop. killing the nodejs process');
+ process.exit(1);
+ }
+ } else {
+ // attempt to use the already set status
+ let status = this.res.statusCode || 0;
+ if (status < 300 || status >= 400) {
+ // temporary redirect
+ status = 302;
+ }
+
+ console.log(`Redirecting from ${this.req.url} to ${url} with ${status}`);
+ this.res.redirect(status, url);
+ this.res.end();
+ // I haven't found a way to correctly stop Angular rendering.
+ // So we just let it end its work, though we have already closed
+ // the response.
+ }
+ }
+
+ /**
+ * Get the origin of a request
+ */
+ getCurrentRoute() {
+ return this.req.originalUrl;
+ }
+
+ /**
+ * Get the hostname of the request
+ */
+ getRequestOrigin() {
+ return this.req.headers.host;
+ }
+}
diff --git a/src/app/core/shared/bitstream.model.ts b/src/app/core/shared/bitstream.model.ts
index ab9d1548b7..314818b482 100644
--- a/src/app/core/shared/bitstream.model.ts
+++ b/src/app/core/shared/bitstream.model.ts
@@ -8,6 +8,8 @@ import { BITSTREAM } from './bitstream.resource-type';
import { DSpaceObject } from './dspace-object.model';
import { HALLink } from './hal-link.model';
import { HALResource } from './hal-resource.model';
+import {BUNDLE} from './bundle.resource-type';
+import {Bundle} from './bundle.model';
@typedObject
@inheritSerialization(DSpaceObject)
@@ -57,4 +59,10 @@ export class Bitstream extends DSpaceObject implements HALResource {
@link(BITSTREAM_FORMAT, false, 'format')
format?: Observable>;
+ /**
+ * The owning bundle for this Bitstream
+ * Will be undefined unless the bundle{@link HALLink} has been resolved.
+ */
+ @link(BUNDLE)
+ bundle?: Observable>;
}
diff --git a/src/app/core/shared/bundle.model.ts b/src/app/core/shared/bundle.model.ts
index 1e5c14d486..c84b1f691f 100644
--- a/src/app/core/shared/bundle.model.ts
+++ b/src/app/core/shared/bundle.model.ts
@@ -10,6 +10,8 @@ import { RemoteData } from '../data/remote-data';
import { PaginatedList } from '../data/paginated-list';
import { BITSTREAM } from './bitstream.resource-type';
import { Bitstream } from './bitstream.model';
+import {ITEM} from './item.resource-type';
+import {Item} from './item.model';
@typedObject
@inheritSerialization(DSpaceObject)
@@ -24,6 +26,7 @@ export class Bundle extends DSpaceObject {
self: HALLink;
primaryBitstream: HALLink;
bitstreams: HALLink;
+ item: HALLink;
};
/**
@@ -39,4 +42,11 @@ export class Bundle extends DSpaceObject {
*/
@link(BITSTREAM, true)
bitstreams?: Observable>>;
+
+ /**
+ * The owning item for this Bundle
+ * Will be undefined unless the Item{@link HALLink} has been resolved.
+ */
+ @link(ITEM)
+ item?: Observable>;
}
diff --git a/src/app/core/integration/models/confidence-type.ts b/src/app/core/shared/confidence-type.ts
similarity index 100%
rename from src/app/core/integration/models/confidence-type.ts
rename to src/app/core/shared/confidence-type.ts
diff --git a/src/app/core/shared/dspace-object.model.ts b/src/app/core/shared/dspace-object.model.ts
index a9256fbb7f..3abb9bceed 100644
--- a/src/app/core/shared/dspace-object.model.ts
+++ b/src/app/core/shared/dspace-object.model.ts
@@ -184,4 +184,25 @@ export class DSpaceObject extends ListableObject implements CacheableObject {
getRenderTypes(): Array> {
return [this.constructor as GenericConstructor];
}
+
+ setMetadata(key: string, language?: string, ...values: string[]) {
+ const mdValues: MetadataValue[] = values.map((value: string, index: number) => {
+ const md = new MetadataValue();
+ md.value = value;
+ md.authority = null;
+ md.confidence = -1;
+ md.language = language || null;
+ md.place = index;
+ return md;
+ });
+ if (hasNoValue(this.metadata)) {
+ this.metadata = Object.create({});
+ }
+ this.metadata[key] = mdValues;
+ }
+
+ removeMetadata(key: string) {
+ delete this.metadata[key];
+ }
+
}
diff --git a/src/app/core/shared/operators.spec.ts b/src/app/core/shared/operators.spec.ts
index a19419259d..8acf5ea860 100644
--- a/src/app/core/shared/operators.spec.ts
+++ b/src/app/core/shared/operators.spec.ts
@@ -13,7 +13,8 @@ import {
getRequestFromRequestUUID,
getResourceLinksFromResponse,
getResponseFromEntry,
- getSucceededRemoteData, redirectToPageNotFoundOn404
+ getSucceededRemoteData,
+ redirectOn404Or401
} from './operators';
import { RemoteData } from '../data/remote-data';
import { RemoteDataError } from '../data/remote-data-error';
@@ -199,7 +200,7 @@ describe('Core Module - RxJS Operators', () => {
});
});
- describe('redirectToPageNotFoundOn404', () => {
+ describe('redirectOn404Or401', () => {
let router;
beforeEach(() => {
router = jasmine.createSpyObj('router', ['navigateByUrl']);
@@ -208,21 +209,28 @@ describe('Core Module - RxJS Operators', () => {
it('should call navigateByUrl to a 404 page, when the remote data contains a 404 error', () => {
const testRD = createFailedRemoteDataObject(undefined, new RemoteDataError(404, 'Not Found', 'Object was not found'));
- observableOf(testRD).pipe(redirectToPageNotFoundOn404(router)).subscribe();
+ observableOf(testRD).pipe(redirectOn404Or401(router)).subscribe();
expect(router.navigateByUrl).toHaveBeenCalledWith('/404', { skipLocationChange: true });
});
- it('should not call navigateByUrl to a 404 page, when the remote data contains another error than a 404', () => {
+ it('should call navigateByUrl to a 401 page, when the remote data contains a 401 error', () => {
+ const testRD = createFailedRemoteDataObject(undefined, new RemoteDataError(401, 'Unauthorized', 'The current user is unauthorized'));
+
+ observableOf(testRD).pipe(redirectOn404Or401(router)).subscribe();
+ expect(router.navigateByUrl).toHaveBeenCalledWith('/401', { skipLocationChange: true });
+ });
+
+ it('should not call navigateByUrl to a 404 or 401 page, when the remote data contains another error than a 404 or 401', () => {
const testRD = createFailedRemoteDataObject(undefined, new RemoteDataError(500, 'Server Error', 'Something went wrong'));
- observableOf(testRD).pipe(redirectToPageNotFoundOn404(router)).subscribe();
+ observableOf(testRD).pipe(redirectOn404Or401(router)).subscribe();
expect(router.navigateByUrl).not.toHaveBeenCalled();
});
- it('should not call navigateByUrl to a 404 page, when the remote data contains no error', () => {
+ it('should not call navigateByUrl to a 404 or 401 page, when the remote data contains no error', () => {
const testRD = createSuccessfulRemoteDataObject(undefined);
- observableOf(testRD).pipe(redirectToPageNotFoundOn404(router)).subscribe();
+ observableOf(testRD).pipe(redirectOn404Or401(router)).subscribe();
expect(router.navigateByUrl).not.toHaveBeenCalled();
});
});
diff --git a/src/app/core/shared/operators.ts b/src/app/core/shared/operators.ts
index b8120d4765..29e41907e1 100644
--- a/src/app/core/shared/operators.ts
+++ b/src/app/core/shared/operators.ts
@@ -1,6 +1,6 @@
import { Router, UrlTree } from '@angular/router';
-import { Observable } from 'rxjs';
-import { filter, find, flatMap, map, take, tap } from 'rxjs/operators';
+import { Observable, combineLatest as observableCombineLatest } from 'rxjs';
+import { filter, find, flatMap, map, switchMap, take, tap } from 'rxjs/operators';
import { hasValue, hasValueOperator, isNotEmpty } from '../../shared/empty.util';
import { SearchResult } from '../../shared/search/search-result.model';
import { DSOSuccessResponse, RestResponse } from '../cache/response.models';
@@ -9,9 +9,12 @@ import { RemoteData } from '../data/remote-data';
import { RestRequest } from '../data/request.models';
import { RequestEntry } from '../data/request.reducer';
import { RequestService } from '../data/request.service';
+import { MetadataField } from '../metadata/metadata-field.model';
+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 { getPageNotFoundRoute, getUnauthorizedRoute } from '../../app-routing-paths';
+import { getEndUserAgreementPath } from '../../info/info-routing-paths';
/**
* This file contains custom RxJS operators that can be used in multiple places
@@ -168,16 +171,20 @@ export const getAllSucceededRemoteListPayload = () =>
);
/**
- * Operator that checks if a remote data object contains a page not found error
- * When it does contain such an error, it will redirect the user to a page not found, without altering the current URL
+ * Operator that checks if a remote data object returned a 401 or 404 error
+ * When it does contain such an error, it will redirect the user to the related error page, without altering the current URL
* @param router The router used to navigate to a new page
*/
-export const redirectToPageNotFoundOn404 = (router: Router) =>
+export const redirectOn404Or401 = (router: Router) =>
(source: Observable>): Observable> =>
source.pipe(
tap((rd: RemoteData) => {
- if (rd.hasFailed && rd.error.statusCode === 404) {
- router.navigateByUrl('/404', { skipLocationChange: true });
+ if (rd.hasFailed) {
+ if (rd.error.statusCode === 404) {
+ router.navigateByUrl(getPageNotFoundRoute(), {skipLocationChange: true});
+ } else if (rd.error.statusCode === 401) {
+ router.navigateByUrl(getUnauthorizedRoute(), {skipLocationChange: true});
+ }
}
}));
@@ -192,6 +199,20 @@ export const returnUnauthorizedUrlTreeOnFalse = (router: Router) =>
return authorized ? authorized : router.parseUrl(getUnauthorizedRoute())
}));
+/**
+ * Operator that returns a UrlTree to the unauthorized page when the boolean received is false
+ * @param router Router
+ * @param redirect Redirect URL to add to the UrlTree. This is used to redirect back to the original route after the
+ * user accepts the agreement.
+ */
+export const returnEndUserAgreementUrlTreeOnFalse = (router: Router, redirect: string) =>
+ (source: Observable): Observable =>
+ source.pipe(
+ map((hasAgreed: boolean) => {
+ const queryParams = { redirect: encodeURIComponent(redirect) };
+ return hasAgreed ? hasAgreed : router.createUrlTree([getEndUserAgreementPath()], { queryParams });
+ }));
+
export const getFinishedRemoteData = () =>
(source: Observable>): Observable> =>
source.pipe(find((rd: RemoteData) => !rd.isLoading));
@@ -250,3 +271,27 @@ export const paginatedListToArray = () =>
hasValueOperator(),
map((objectRD: RemoteData>) => objectRD.payload.page.filter((object: T) => hasValue(object)))
);
+
+/**
+ * Operator for turning a list of metadata fields into an array of string representing their schema.element.qualifier string
+ */
+export const metadataFieldsToString = () =>
+ (source: Observable>>): Observable =>
+ source.pipe(
+ hasValueOperator(),
+ map((fieldRD: RemoteData>) => {
+ return fieldRD.payload.page.filter((object: MetadataField) => hasValue(object))
+ }),
+ switchMap((fields: MetadataField[]) => {
+ const fieldSchemaArray = fields.map((field: MetadataField) => {
+ return field.schema.pipe(
+ getFirstSucceededRemoteDataPayload(),
+ map((schema: MetadataSchema) => ({ field, schema }))
+ );
+ });
+ return observableCombineLatest(fieldSchemaArray);
+ }),
+ map((fieldSchemaArray: Array<{ field: MetadataField, schema: MetadataSchema }>): string[] => {
+ return fieldSchemaArray.map((fieldSchema: { field: MetadataField, schema: MetadataSchema }) => fieldSchema.schema.prefix + '.' + fieldSchema.field.toString())
+ })
+ );
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/integration/models/authority.resource-type.ts b/src/app/core/statistics/models/usage-report.resource-type.ts
similarity index 59%
rename from src/app/core/integration/models/authority.resource-type.ts
rename to src/app/core/statistics/models/usage-report.resource-type.ts
index ec87ddc85f..650a51b3c3 100644
--- a/src/app/core/integration/models/authority.resource-type.ts
+++ b/src/app/core/statistics/models/usage-report.resource-type.ts
@@ -1,10 +1,9 @@
import { ResourceType } from '../../shared/resource-type';
/**
- * The resource type for AuthorityValue
+ * The resource type for License
*
* Needs to be in a separate file to prevent circular
* dependencies in webpack.
*/
-
-export const AUTHORITY_VALUE = new ResourceType('authority');
+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/core/submission/submission-object-data.service.spec.ts b/src/app/core/submission/submission-object-data.service.spec.ts
index 931a7ae7d5..781036e950 100644
--- a/src/app/core/submission/submission-object-data.service.spec.ts
+++ b/src/app/core/submission/submission-object-data.service.spec.ts
@@ -1,8 +1,6 @@
-import { Observable } from 'rxjs';
import { SubmissionService } from '../../submission/submission.service';
import { RemoteData } from '../data/remote-data';
import { SubmissionObject } from './models/submission-object.model';
-import { WorkspaceItem } from './models/workspaceitem.model';
import { SubmissionObjectDataService } from './submission-object-data.service';
import { SubmissionScopeType } from './submission-scope-type';
import { WorkflowItemDataService } from './workflowitem-data.service';
diff --git a/src/app/core/submission/submission-response-parsing.service.ts b/src/app/core/submission/submission-response-parsing.service.ts
index 4bbd93b18d..b588c919a1 100644
--- a/src/app/core/submission/submission-response-parsing.service.ts
+++ b/src/app/core/submission/submission-response-parsing.service.ts
@@ -1,4 +1,4 @@
-import { Inject, Injectable } from '@angular/core';
+import { Injectable } from '@angular/core';
import { deepClone } from 'fast-json-patch';
import { DSOResponseParsingService } from '../data/dso-response-parsing.service';
@@ -113,7 +113,7 @@ export class SubmissionResponseParsingService extends BaseResponseParsingService
return new ErrorResponse(
Object.assign(
new Error('Unexpected response from server'),
- {statusCode: data.statusCode, statusText: data.statusText}
+ { statusCode: data.statusCode, statusText: data.statusText }
)
);
}
@@ -133,7 +133,7 @@ export class SubmissionResponseParsingService extends BaseResponseParsingService
processedList.forEach((item) => {
- item = Object.assign({}, item);
+ // item = Object.assign({}, item);
// In case data is an Instance of WorkspaceItem normalize field value of all the section of type form
if (item instanceof WorkspaceItem
|| item instanceof WorkflowItem) {
diff --git a/src/app/core/submission/vocabularies/models/vocabularies.resource-type.ts b/src/app/core/submission/vocabularies/models/vocabularies.resource-type.ts
new file mode 100644
index 0000000000..5902fe4e17
--- /dev/null
+++ b/src/app/core/submission/vocabularies/models/vocabularies.resource-type.ts
@@ -0,0 +1,12 @@
+import { ResourceType } from '../../../shared/resource-type';
+
+/**
+ * The resource type for vocabulary models
+ *
+ * Needs to be in a separate file to prevent circular
+ * dependencies in webpack.
+ */
+
+export const VOCABULARY = new ResourceType('vocabulary');
+export const VOCABULARY_ENTRY = new ResourceType('vocabularyEntry');
+export const VOCABULARY_ENTRY_DETAIL = new ResourceType('vocabularyEntryDetail');
diff --git a/src/app/core/submission/vocabularies/models/vocabulary-entry-detail.model.ts b/src/app/core/submission/vocabularies/models/vocabulary-entry-detail.model.ts
new file mode 100644
index 0000000000..2e066bae95
--- /dev/null
+++ b/src/app/core/submission/vocabularies/models/vocabulary-entry-detail.model.ts
@@ -0,0 +1,39 @@
+import { autoserialize, deserialize, inheritSerialization } from 'cerialize';
+
+import { HALLink } from '../../../shared/hal-link.model';
+import { VOCABULARY_ENTRY_DETAIL } from './vocabularies.resource-type';
+import { typedObject } from '../../../cache/builders/build-decorators';
+import { VocabularyEntry } from './vocabulary-entry.model';
+
+/**
+ * Model class for a VocabularyEntryDetail
+ */
+@typedObject
+@inheritSerialization(VocabularyEntry)
+export class VocabularyEntryDetail extends VocabularyEntry {
+ static type = VOCABULARY_ENTRY_DETAIL;
+
+ /**
+ * The unique id of the entry
+ */
+ @autoserialize
+ id: string;
+
+ /**
+ * In an hierarchical vocabulary representing if entry is selectable as value
+ */
+ @autoserialize
+ selectable: boolean;
+
+ /**
+ * The {@link HALLink}s for this ExternalSourceEntry
+ */
+ @deserialize
+ _links: {
+ self: HALLink;
+ vocabulary: HALLink;
+ parent: HALLink;
+ children
+ };
+
+}
diff --git a/src/app/core/submission/vocabularies/models/vocabulary-entry.model.ts b/src/app/core/submission/vocabularies/models/vocabulary-entry.model.ts
new file mode 100644
index 0000000000..ca26c1b41e
--- /dev/null
+++ b/src/app/core/submission/vocabularies/models/vocabulary-entry.model.ts
@@ -0,0 +1,103 @@
+import { autoserialize, deserialize } from 'cerialize';
+
+import { HALLink } from '../../../shared/hal-link.model';
+import { VOCABULARY_ENTRY } from './vocabularies.resource-type';
+import { typedObject } from '../../../cache/builders/build-decorators';
+import { excludeFromEquals } from '../../../utilities/equals.decorators';
+import { PLACEHOLDER_PARENT_METADATA } from '../../../../shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-constants';
+import { OtherInformation } from '../../../../shared/form/builder/models/form-field-metadata-value.model';
+import { isNotEmpty } from '../../../../shared/empty.util';
+import { ListableObject } from '../../../../shared/object-collection/shared/listable-object.model';
+import { GenericConstructor } from '../../../shared/generic-constructor';
+
+/**
+ * Model class for a VocabularyEntry
+ */
+@typedObject
+export class VocabularyEntry extends ListableObject {
+ static type = VOCABULARY_ENTRY;
+
+ /**
+ * The identifier of this vocabulary entry
+ */
+ @autoserialize
+ authority: string;
+
+ /**
+ * The display value of this vocabulary entry
+ */
+ @autoserialize
+ display: string;
+
+ /**
+ * The value of this vocabulary entry
+ */
+ @autoserialize
+ value: string;
+
+ /**
+ * An object containing additional information related to this vocabulary entry
+ */
+ @autoserialize
+ otherInformation: OtherInformation;
+
+ /**
+ * A string representing the kind of vocabulary entry
+ */
+ @excludeFromEquals
+ @autoserialize
+ public type: any;
+
+ /**
+ * The {@link HALLink}s for this ExternalSourceEntry
+ */
+ @deserialize
+ _links: {
+ self: HALLink;
+ vocabularyEntryDetail?: HALLink;
+ };
+
+ /**
+ * This method checks if entry has an authority value
+ *
+ * @return boolean
+ */
+ hasAuthority(): boolean {
+ return isNotEmpty(this.authority);
+ }
+
+ /**
+ * This method checks if entry has a value
+ *
+ * @return boolean
+ */
+ hasValue(): boolean {
+ return isNotEmpty(this.value);
+ }
+
+ /**
+ * This method checks if entry has related information object
+ *
+ * @return boolean
+ */
+ hasOtherInformation(): boolean {
+ return isNotEmpty(this.otherInformation);
+ }
+
+ /**
+ * This method checks if entry has a placeholder as value
+ *
+ * @return boolean
+ */
+ hasPlaceholder(): boolean {
+ return this.hasValue() && this.value === PLACEHOLDER_PARENT_METADATA;
+ }
+
+ /**
+ * Method that returns as which type of object this object should be rendered
+ */
+ getRenderTypes(): Array> {
+ return [this.constructor as GenericConstructor];
+ }
+
+}
diff --git a/src/app/core/submission/vocabularies/models/vocabulary-find-options.model.ts b/src/app/core/submission/vocabularies/models/vocabulary-find-options.model.ts
new file mode 100644
index 0000000000..bd9bd55b95
--- /dev/null
+++ b/src/app/core/submission/vocabularies/models/vocabulary-find-options.model.ts
@@ -0,0 +1,37 @@
+import { SortOptions } from '../../../cache/models/sort-options.model';
+import { FindListOptions } from '../../../data/request.models';
+import { RequestParam } from '../../../cache/models/request-param.model';
+import { isNotEmpty } from '../../../../shared/empty.util';
+
+/**
+ * Representing properties used to build a vocabulary find request
+ */
+export class VocabularyFindOptions extends FindListOptions {
+
+ constructor(public query: string = '',
+ public filter?: string,
+ public exact?: boolean,
+ public entryID?: string,
+ public elementsPerPage?: number,
+ public currentPage?: number,
+ public sort?: SortOptions
+ ) {
+ super();
+
+ const searchParams = [];
+
+ if (isNotEmpty(query)) {
+ searchParams.push(new RequestParam('query', query))
+ }
+ if (isNotEmpty(filter)) {
+ searchParams.push(new RequestParam('filter', filter))
+ }
+ if (isNotEmpty(exact)) {
+ searchParams.push(new RequestParam('exact', exact.toString()))
+ }
+ if (isNotEmpty(entryID)) {
+ searchParams.push(new RequestParam('entryID', entryID))
+ }
+ this.searchParams = searchParams;
+ }
+}
diff --git a/src/app/core/submission/vocabularies/models/vocabulary-options.model.ts b/src/app/core/submission/vocabularies/models/vocabulary-options.model.ts
new file mode 100644
index 0000000000..fd103718e1
--- /dev/null
+++ b/src/app/core/submission/vocabularies/models/vocabulary-options.model.ts
@@ -0,0 +1,21 @@
+/**
+ * Representing vocabulary properties
+ */
+export class VocabularyOptions {
+
+ /**
+ * The name of the vocabulary
+ */
+ name: string;
+
+ /**
+ * A boolean representing if value is closely related to a vocabulary entry or not
+ */
+ closed: boolean;
+
+ constructor(name: string,
+ closed: boolean = false) {
+ this.name = name;
+ this.closed = closed;
+ }
+}
diff --git a/src/app/core/submission/vocabularies/models/vocabulary.model.ts b/src/app/core/submission/vocabularies/models/vocabulary.model.ts
new file mode 100644
index 0000000000..8672d1c6ed
--- /dev/null
+++ b/src/app/core/submission/vocabularies/models/vocabulary.model.ts
@@ -0,0 +1,61 @@
+import { autoserialize, deserialize } from 'cerialize';
+
+import { HALLink } from '../../../shared/hal-link.model';
+import { VOCABULARY } from './vocabularies.resource-type';
+import { CacheableObject } from '../../../cache/object-cache.reducer';
+import { typedObject } from '../../../cache/builders/build-decorators';
+import { excludeFromEquals } from '../../../utilities/equals.decorators';
+
+/**
+ * Model class for a Vocabulary
+ */
+@typedObject
+export class Vocabulary implements CacheableObject {
+ static type = VOCABULARY;
+ /**
+ * The identifier of this Vocabulary
+ */
+ @autoserialize
+ id: string;
+
+ /**
+ * The name of this Vocabulary
+ */
+ @autoserialize
+ name: string;
+
+ /**
+ * True if it is possible to scroll all the entries in the vocabulary without providing a filter parameter
+ */
+ @autoserialize
+ scrollable: boolean;
+
+ /**
+ * True if the vocabulary exposes a tree structure where some entries are parent of others
+ */
+ @autoserialize
+ hierarchical: boolean;
+
+ /**
+ * For hierarchical vocabularies express the preference to preload the tree at a specific
+ * level of depth (0 only the top nodes are shown, 1 also their children are preloaded and so on)
+ */
+ @autoserialize
+ preloadLevel: any;
+
+ /**
+ * A string representing the kind of Vocabulary model
+ */
+ @excludeFromEquals
+ @autoserialize
+ public type: any;
+
+ /**
+ * The {@link HALLink}s for this Vocabulary
+ */
+ @deserialize
+ _links: {
+ self: HALLink,
+ entries: HALLink
+ };
+}
diff --git a/src/app/core/submission/vocabularies/vocabulary-entries-response-parsing.service.spec.ts b/src/app/core/submission/vocabularies/vocabulary-entries-response-parsing.service.spec.ts
new file mode 100644
index 0000000000..8e3b63df74
--- /dev/null
+++ b/src/app/core/submission/vocabularies/vocabulary-entries-response-parsing.service.spec.ts
@@ -0,0 +1,111 @@
+import { getMockObjectCacheService } from '../../../shared/mocks/object-cache.service.mock';
+import { ErrorResponse, GenericSuccessResponse } from '../../cache/response.models';
+import { DSpaceRESTV2Response } from '../../dspace-rest-v2/dspace-rest-v2-response.model';
+import { VocabularyEntriesResponseParsingService } from './vocabulary-entries-response-parsing.service';
+import { VocabularyEntriesRequest } from '../../data/request.models';
+
+describe('VocabularyEntriesResponseParsingService', () => {
+ let service: VocabularyEntriesResponseParsingService;
+ const metadata = 'dc.type';
+ const collectionUUID = '8b39g7ya-5a4b-438b-851f-be1d5b4a1c5a';
+ const entriesRequestURL = `https://rest.api/rest/api/submission/vocabularies/types/entries?metadata=${metadata}&collection=${collectionUUID}`
+
+ beforeEach(() => {
+ service = new VocabularyEntriesResponseParsingService(getMockObjectCacheService());
+ });
+
+ describe('parse', () => {
+ const request = new VocabularyEntriesRequest('client/f5b4ccb8-fbb0-4548-b558-f234d9fdfad6', entriesRequestURL);
+
+ const validResponse = {
+ payload: {
+ _embedded: {
+ entries: [
+ {
+ display: 'testValue1',
+ value: 'testValue1',
+ otherInformation: {},
+ type: 'vocabularyEntry'
+ },
+ {
+ display: 'testValue2',
+ value: 'testValue2',
+ otherInformation: {},
+ type: 'vocabularyEntry'
+ },
+ {
+ display: 'testValue3',
+ value: 'testValue3',
+ otherInformation: {},
+ type: 'vocabularyEntry'
+ },
+ {
+ authority: 'authorityId1',
+ display: 'testValue1',
+ value: 'testValue1',
+ otherInformation: {
+ id: 'VR131402',
+ parent: 'Research Subject Categories::SOCIAL SCIENCES::Social sciences::Social work',
+ hasChildren: 'false',
+ note: 'Familjeforskning'
+ },
+ type: 'vocabularyEntry',
+ _links: {
+ vocabularyEntryDetail: {
+ href: 'https://rest.api/rest/api/submission/vocabularyEntryDetails/srsc:VR131402'
+ }
+ }
+ }
+ ]
+ },
+ _links: {
+ first: {
+ href: 'https://rest.api/rest/api/submission/vocabularies/types/entries/first?page=0&size=5'
+ },
+ self: {
+ href: 'https://rest.api/rest/api/submission/vocabularies/types/entries'
+ },
+ next: {
+ href: 'https://rest.api/rest/api/submission/vocabularies/types/entries/next?page=1&size=5'
+ },
+ last: {
+ href: 'https://rest.api/rest/api/submission/vocabularies/types/entries/last?page=9&size=5'
+ }
+ },
+ page: {
+ size: 5,
+ totalElements: 50,
+ totalPages: 10,
+ number: 0
+ }
+ },
+ statusCode: 200,
+ statusText: 'OK'
+ } as DSpaceRESTV2Response;
+
+ const invalidResponseNotAList = {
+ statusCode: 200,
+ statusText: 'OK'
+ } as DSpaceRESTV2Response;
+
+ const invalidResponseStatusCode = {
+ payload: {}, statusCode: 500, statusText: 'Internal Server Error'
+ } as DSpaceRESTV2Response;
+
+ it('should return a GenericSuccessResponse if data contains a valid browse entries response', () => {
+ const response = service.parse(request, validResponse);
+ expect(response.constructor).toBe(GenericSuccessResponse);
+ });
+
+ it('should return an ErrorResponse if data contains an invalid browse entries response', () => {
+ const response = service.parse(request, invalidResponseNotAList);
+ expect(response.constructor).toBe(ErrorResponse);
+ });
+
+ it('should return an ErrorResponse if data contains a statuscode other than 200', () => {
+ const response = service.parse(request, invalidResponseStatusCode);
+ expect(response.constructor).toBe(ErrorResponse);
+ });
+
+ });
+});
diff --git a/src/app/core/submission/vocabularies/vocabulary-entries-response-parsing.service.ts b/src/app/core/submission/vocabularies/vocabulary-entries-response-parsing.service.ts
new file mode 100644
index 0000000000..f0c20fe7c5
--- /dev/null
+++ b/src/app/core/submission/vocabularies/vocabulary-entries-response-parsing.service.ts
@@ -0,0 +1,26 @@
+import { Injectable } from '@angular/core';
+
+import { ObjectCacheService } from '../../cache/object-cache.service';
+import { VocabularyEntry } from './models/vocabulary-entry.model';
+import { EntriesResponseParsingService } from '../../data/entries-response-parsing.service';
+import { GenericConstructor } from '../../shared/generic-constructor';
+
+/**
+ * A service responsible for parsing data for a vocabulary entries response
+ */
+@Injectable()
+export class VocabularyEntriesResponseParsingService extends EntriesResponseParsingService {
+
+ protected toCache = false;
+
+ constructor(
+ protected objectCache: ObjectCacheService,
+ ) {
+ super(objectCache);
+ }
+
+ getSerializerModel(): GenericConstructor {
+ return VocabularyEntry;
+ }
+
+}
diff --git a/src/app/core/submission/vocabularies/vocabulary.service.spec.ts b/src/app/core/submission/vocabularies/vocabulary.service.spec.ts
new file mode 100644
index 0000000000..1119d4f6e6
--- /dev/null
+++ b/src/app/core/submission/vocabularies/vocabulary.service.spec.ts
@@ -0,0 +1,569 @@
+import { HttpClient } from '@angular/common/http';
+
+import { cold, getTestScheduler, hot } from 'jasmine-marbles';
+import { of as observableOf } from 'rxjs';
+import { TestScheduler } from 'rxjs/testing';
+
+import { NotificationsService } from '../../../shared/notifications/notifications.service';
+import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service';
+import { ObjectCacheService } from '../../cache/object-cache.service';
+import { HALEndpointService } from '../../shared/hal-endpoint.service';
+import { RequestService } from '../../data/request.service';
+import { VocabularyEntriesRequest } from '../../data/request.models';
+import { RequestParam } from '../../cache/models/request-param.model';
+import { PageInfo } from '../../shared/page-info.model';
+import { PaginatedList } from '../../data/paginated-list';
+import { createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils';
+import { RequestEntry } from '../../data/request.reducer';
+import { RestResponse } from '../../cache/response.models';
+import { VocabularyService } from './vocabulary.service';
+import { getMockRequestService } from '../../../shared/mocks/request.service.mock';
+import { getMockRemoteDataBuildService } from '../../../shared/mocks/remote-data-build.service.mock';
+import { VocabularyOptions } from './models/vocabulary-options.model';
+import { VocabularyFindOptions } from './models/vocabulary-find-options.model';
+
+describe('VocabularyService', () => {
+ let scheduler: TestScheduler;
+ let service: VocabularyService;
+ let requestService: RequestService;
+ let rdbService: RemoteDataBuildService;
+ let objectCache: ObjectCacheService;
+ let halService: HALEndpointService;
+ let responseCacheEntry: RequestEntry;
+
+ const vocabulary: any = {
+ id: 'types',
+ name: 'types',
+ scrollable: true,
+ hierarchical: false,
+ preloadLevel: 1,
+ type: 'vocabulary',
+ uuid: 'vocabulary-types',
+ _links: {
+ self: {
+ href: 'https://rest.api/rest/api/submission/vocabularies/types'
+ },
+ entries: {
+ href: 'https://rest.api/rest/api/submission/vocabularies/types/entries'
+ },
+ }
+ };
+
+ const hierarchicalVocabulary: any = {
+ id: 'srsc',
+ name: 'srsc',
+ scrollable: false,
+ hierarchical: true,
+ preloadLevel: 2,
+ type: 'vocabulary',
+ uuid: 'vocabulary-srsc',
+ _links: {
+ self: {
+ href: 'https://rest.api/rest/api/submission/vocabularies/types'
+ },
+ entries: {
+ href: 'https://rest.api/rest/api/submission/vocabularies/types/entries'
+ },
+ }
+ };
+
+ const vocabularyEntry: any = {
+ display: 'testValue1',
+ value: 'testValue1',
+ otherInformation: {},
+ type: 'vocabularyEntry'
+ };
+
+ const vocabularyEntry2: any = {
+ display: 'testValue2',
+ value: 'testValue2',
+ otherInformation: {},
+ type: 'vocabularyEntry'
+ };
+
+ const vocabularyEntry3: any = {
+ display: 'testValue3',
+ value: 'testValue3',
+ otherInformation: {},
+ type: 'vocabularyEntry'
+ };
+
+ const vocabularyEntryParentDetail: any = {
+ authority: 'authorityId2',
+ display: 'testParent',
+ value: 'testParent',
+ otherInformation: {
+ id: 'authorityId2',
+ hasChildren: 'true',
+ note: 'Familjeforskning'
+ },
+ type: 'vocabularyEntryDetail',
+ _links: {
+ self: {
+ href: 'https://rest.api/rest/api/submission/vocabularyEntryDetails/srsc:VR131402'
+ },
+ parent: {
+ href: 'https://rest.api/rest/api/submission/vocabularyEntryDetails/srsc:parent'
+ },
+ children: {
+ href: 'https://rest.api/rest/api/submission/vocabularyEntryDetails/srsc:children'
+ }
+ }
+ };
+
+ const vocabularyEntryChildDetail: any = {
+ authority: 'authoritytestChild1',
+ display: 'testChild1',
+ value: 'testChild1',
+ otherInformation: {
+ id: 'authoritytestChild1',
+ hasChildren: 'true',
+ note: 'Familjeforskning'
+ },
+ type: 'vocabularyEntryDetail',
+ _links: {
+ self: {
+ href: 'https://rest.api/rest/api/submission/vocabularyEntryDetails/srsc:authoritytestChild1'
+ },
+ parent: {
+ href: 'https://rest.api/rest/api/submission/vocabularyEntryDetails/srsc:parent'
+ },
+ children: {
+ href: 'https://rest.api/rest/api/submission/vocabularyEntryDetails/srsc:children'
+ }
+ }
+ };
+
+ const vocabularyEntryChild2Detail: any = {
+ authority: 'authoritytestChild2',
+ display: 'testChild2',
+ value: 'testChild2',
+ otherInformation: {
+ id: 'authoritytestChild2',
+ hasChildren: 'true',
+ note: 'Familjeforskning'
+ },
+ type: 'vocabularyEntryDetail',
+ _links: {
+ self: {
+ href: 'https://rest.api/rest/api/submission/vocabularyEntryDetails/srsc:authoritytestChild2'
+ },
+ parent: {
+ href: 'https://rest.api/rest/api/submission/vocabularyEntryDetails/srsc:parent'
+ },
+ children: {
+ href: 'https://rest.api/rest/api/submission/vocabularyEntryDetails/srsc:children'
+ }
+ }
+ };
+
+ const endpointURL = `https://rest.api/rest/api/submission/vocabularies`;
+ const requestURL = `https://rest.api/rest/api/submission/vocabularies/${vocabulary.id}`;
+ const entryDetailEndpointURL = `https://rest.api/rest/api/submission/vocabularyEntryDetails`;
+ const entryDetailRequestURL = `https://rest.api/rest/api/submission/vocabularyEntryDetails/${hierarchicalVocabulary.id}:testValue`;
+ const entryDetailParentRequestURL = `https://rest.api/rest/api/submission/vocabularyEntryDetails/${hierarchicalVocabulary.id}:testValue/parent`;
+ const entryDetailChildrenRequestURL = `https://rest.api/rest/api/submission/vocabularyEntryDetails/${hierarchicalVocabulary.id}:testValue/children`;
+ const requestUUID = '8b3c613a-5a4b-438b-9686-be1d5b4a1c5a';
+ const vocabularyId = 'types';
+ const metadata = 'dc.type';
+ const collectionUUID = '8b39g7ya-5a4b-438b-851f-be1d5b4a1c5a';
+ const entryID = 'dsfsfsdf-5a4b-438b-851f-be1d5b4a1c5a';
+ const searchRequestURL = `https://rest.api/rest/api/submission/vocabularies/search/byMetadataAndCollection?metadata=${metadata}&collection=${collectionUUID}`;
+ const entriesRequestURL = `https://rest.api/rest/api/submission/vocabularies/${vocabulary.id}/entries`;
+ const entriesByValueRequestURL = `https://rest.api/rest/api/submission/vocabularies/${vocabulary.id}/entries?filter=test&exact=false`;
+ const entryByValueRequestURL = `https://rest.api/rest/api/submission/vocabularies/${vocabulary.id}/entries?filter=test&exact=true`;
+ const entryByIDRequestURL = `https://rest.api/rest/api/submission/vocabularies/${vocabulary.id}/entries?entryID=${entryID}`;
+ const vocabularyOptions: VocabularyOptions = {
+ name: vocabularyId,
+ closed: false
+ }
+ const pageInfo = new PageInfo();
+ const array = [vocabulary, hierarchicalVocabulary];
+ const arrayEntries = [vocabularyEntry, vocabularyEntry2, vocabularyEntry3];
+ const childrenEntries = [vocabularyEntryChildDetail, vocabularyEntryChild2Detail];
+ const paginatedList = new PaginatedList(pageInfo, array);
+ const paginatedListEntries = new PaginatedList(pageInfo, arrayEntries);
+ const childrenPaginatedList = new PaginatedList(pageInfo, childrenEntries);
+ const vocabularyRD = createSuccessfulRemoteDataObject(vocabulary);
+ const vocabularyRD$ = createSuccessfulRemoteDataObject$(vocabulary);
+ const vocabularyEntriesRD = createSuccessfulRemoteDataObject$(paginatedListEntries);
+ const vocabularyEntryDetailParentRD = createSuccessfulRemoteDataObject(vocabularyEntryParentDetail);
+ const vocabularyEntryChildrenRD = createSuccessfulRemoteDataObject(childrenPaginatedList);
+ const paginatedListRD = createSuccessfulRemoteDataObject(paginatedList);
+ const getRequestEntries$ = (successful: boolean) => {
+ return observableOf({
+ response: { isSuccessful: successful, payload: arrayEntries } as any
+ } as RequestEntry)
+ };
+ objectCache = {} as ObjectCacheService;
+ const notificationsService = {} as NotificationsService;
+ const http = {} as HttpClient;
+ const comparator = {} as any;
+ const comparatorEntry = {} as any;
+
+ function initTestService() {
+ return new VocabularyService(
+ requestService,
+ rdbService,
+ objectCache,
+ halService,
+ notificationsService,
+ http,
+ comparator,
+ comparatorEntry
+ );
+ }
+
+ describe('vocabularies endpoint', () => {
+ beforeEach(() => {
+ scheduler = getTestScheduler();
+
+ halService = jasmine.createSpyObj('halService', {
+ getEndpoint: cold('a', { a: endpointURL })
+ });
+ });
+
+ afterEach(() => {
+ service = null;
+ });
+
+ describe('', () => {
+ beforeEach(() => {
+ responseCacheEntry = new RequestEntry();
+ responseCacheEntry.request = { href: 'https://rest.api/' } as any;
+ responseCacheEntry.completed = true;
+ responseCacheEntry.response = new RestResponse(true, 200, 'Success');
+
+ requestService = jasmine.createSpyObj('requestService', {
+ generateRequestId: requestUUID,
+ configure: true,
+ removeByHrefSubstring: {},
+ getByHref: observableOf(responseCacheEntry),
+ getByUUID: observableOf(responseCacheEntry),
+ });
+ rdbService = jasmine.createSpyObj('rdbService', {
+ buildSingle: hot('a|', {
+ a: vocabularyRD
+ }),
+ buildList: hot('a|', {
+ a: paginatedListRD
+ }),
+ });
+
+ service = initTestService();
+
+ spyOn((service as any).vocabularyDataService, 'findById').and.callThrough();
+ spyOn((service as any).vocabularyDataService, 'findAll').and.callThrough();
+ spyOn((service as any).vocabularyDataService, 'findByHref').and.callThrough();
+ spyOn((service as any).vocabularyDataService, 'searchBy').and.callThrough();
+ spyOn((service as any).vocabularyDataService, 'getSearchByHref').and.returnValue(observableOf(searchRequestURL));
+ spyOn((service as any).vocabularyDataService, 'getFindAllHref').and.returnValue(observableOf(entriesRequestURL));
+ });
+
+ afterEach(() => {
+ service = null;
+ });
+
+ describe('findVocabularyById', () => {
+ it('should proxy the call to vocabularyDataService.findVocabularyById', () => {
+ scheduler.schedule(() => service.findVocabularyById(vocabularyId));
+ scheduler.flush();
+
+ expect((service as any).vocabularyDataService.findById).toHaveBeenCalledWith(vocabularyId);
+ });
+
+ it('should return a RemoteData for the object with the given id', () => {
+ const result = service.findVocabularyById(vocabularyId);
+ const expected = cold('a|', {
+ a: vocabularyRD
+ });
+ expect(result).toBeObservable(expected);
+ });
+ });
+
+ describe('findVocabularyByHref', () => {
+ it('should proxy the call to vocabularyDataService.findVocabularyByHref', () => {
+ scheduler.schedule(() => service.findVocabularyByHref(requestURL));
+ scheduler.flush();
+
+ expect((service as any).vocabularyDataService.findByHref).toHaveBeenCalledWith(requestURL);
+ });
+
+ it('should return a RemoteData for the object with the given URL', () => {
+ const result = service.findVocabularyByHref(requestURL);
+ const expected = cold('a|', {
+ a: vocabularyRD
+ });
+ expect(result).toBeObservable(expected);
+ });
+ });
+
+ describe('findAllVocabularies', () => {
+ it('should proxy the call to vocabularyDataService.findAllVocabularies', () => {
+ scheduler.schedule(() => service.findAllVocabularies());
+ scheduler.flush();
+
+ expect((service as any).vocabularyDataService.findAll).toHaveBeenCalled();
+ });
+
+ it('should return a RemoteData>', () => {
+ const result = service.findAllVocabularies();
+ const expected = cold('a|', {
+ a: paginatedListRD
+ });
+ expect(result).toBeObservable(expected);
+ });
+ });
+ });
+
+ describe('', () => {
+
+ beforeEach(() => {
+ requestService = getMockRequestService(getRequestEntries$(true));
+ rdbService = getMockRemoteDataBuildService(undefined, vocabularyEntriesRD);
+ spyOn(rdbService, 'toRemoteDataObservable').and.callThrough();
+ service = initTestService();
+ spyOn(service, 'findVocabularyById').and.returnValue(vocabularyRD$);
+ });
+
+ describe('getVocabularyEntries', () => {
+
+ it('should configure a new VocabularyEntriesRequest', () => {
+ const expected = new VocabularyEntriesRequest(requestService.generateRequestId(), entriesRequestURL);
+
+ scheduler.schedule(() => service.getVocabularyEntries(vocabularyOptions, pageInfo).subscribe());
+ scheduler.flush();
+
+ expect(requestService.configure).toHaveBeenCalledWith(expected);
+ });
+
+ it('should call RemoteDataBuildService to create the RemoteData Observable', () => {
+ scheduler.schedule(() => service.getVocabularyEntries(vocabularyOptions, pageInfo));
+ scheduler.flush();
+
+ expect(rdbService.toRemoteDataObservable).toHaveBeenCalled();
+ });
+ });
+
+ describe('getVocabularyEntriesByValue', () => {
+
+ it('should configure a new VocabularyEntriesRequest', () => {
+ const expected = new VocabularyEntriesRequest(requestService.generateRequestId(), entriesByValueRequestURL);
+
+ scheduler.schedule(() => service.getVocabularyEntriesByValue('test', false, vocabularyOptions, pageInfo).subscribe());
+ scheduler.flush();
+
+ expect(requestService.configure).toHaveBeenCalledWith(expected);
+ });
+
+ it('should call RemoteDataBuildService to create the RemoteData Observable', () => {
+ scheduler.schedule(() => service.getVocabularyEntriesByValue('test', false, vocabularyOptions, pageInfo));
+ scheduler.flush();
+
+ expect(rdbService.toRemoteDataObservable).toHaveBeenCalled();
+
+ });
+ });
+
+ describe('getVocabularyEntryByValue', () => {
+
+ it('should configure a new VocabularyEntriesRequest', () => {
+ const expected = new VocabularyEntriesRequest(requestService.generateRequestId(), entryByValueRequestURL);
+
+ scheduler.schedule(() => service.getVocabularyEntryByValue('test', vocabularyOptions).subscribe());
+ scheduler.flush();
+
+ expect(requestService.configure).toHaveBeenCalledWith(expected);
+ });
+
+ it('should call RemoteDataBuildService to create the RemoteData Observable', () => {
+ scheduler.schedule(() => service.getVocabularyEntryByValue('test', vocabularyOptions));
+ scheduler.flush();
+
+ expect(rdbService.toRemoteDataObservable).toHaveBeenCalled();
+
+ });
+ });
+
+ describe('getVocabularyEntryByID', () => {
+ it('should configure a new VocabularyEntriesRequest', () => {
+ const expected = new VocabularyEntriesRequest(requestService.generateRequestId(), entryByIDRequestURL);
+
+ scheduler.schedule(() => service.getVocabularyEntryByID(entryID, vocabularyOptions).subscribe());
+ scheduler.flush();
+
+ expect(requestService.configure).toHaveBeenCalledWith(expected);
+ });
+
+ it('should call RemoteDataBuildService to create the RemoteData Observable', () => {
+ scheduler.schedule(() => service.getVocabularyEntryByID('test', vocabularyOptions));
+ scheduler.flush();
+
+ expect(rdbService.toRemoteDataObservable).toHaveBeenCalled();
+
+ });
+ });
+
+ });
+
+ });
+
+ describe('vocabularyEntryDetails endpoint', () => {
+ beforeEach(() => {
+ scheduler = getTestScheduler();
+
+ halService = jasmine.createSpyObj('halService', {
+ getEndpoint: cold('a', { a: entryDetailEndpointURL })
+ });
+
+ responseCacheEntry = new RequestEntry();
+ responseCacheEntry.request = { href: 'https://rest.api/' } as any;
+ responseCacheEntry.completed = true;
+ responseCacheEntry.response = new RestResponse(true, 200, 'Success');
+
+ requestService = jasmine.createSpyObj('requestService', {
+ generateRequestId: requestUUID,
+ configure: true,
+ removeByHrefSubstring: {},
+ getByHref: observableOf(responseCacheEntry),
+ getByUUID: observableOf(responseCacheEntry),
+ });
+ rdbService = jasmine.createSpyObj('rdbService', {
+ buildSingle: hot('a|', {
+ a: vocabularyEntryDetailParentRD
+ }),
+ buildList: hot('a|', {
+ a: vocabularyEntryChildrenRD
+ }),
+ });
+
+ service = initTestService();
+
+ spyOn((service as any).vocabularyEntryDetailDataService, 'findById').and.callThrough();
+ spyOn((service as any).vocabularyEntryDetailDataService, 'findAll').and.callThrough();
+ spyOn((service as any).vocabularyEntryDetailDataService, 'findByHref').and.callThrough();
+ spyOn((service as any).vocabularyEntryDetailDataService, 'findAllByHref').and.callThrough();
+ spyOn((service as any).vocabularyEntryDetailDataService, 'searchBy').and.callThrough();
+ spyOn((service as any).vocabularyEntryDetailDataService, 'getSearchByHref').and.returnValue(observableOf(searchRequestURL));
+ spyOn((service as any).vocabularyEntryDetailDataService, 'getFindAllHref').and.returnValue(observableOf(entryDetailChildrenRequestURL));
+ spyOn((service as any).vocabularyEntryDetailDataService, 'getBrowseEndpoint').and.returnValue(observableOf(entryDetailEndpointURL));
+ });
+
+ afterEach(() => {
+ service = null;
+ });
+
+ describe('findEntryDetailByHref', () => {
+ it('should proxy the call to vocabularyDataService.findEntryDetailByHref', () => {
+ scheduler.schedule(() => service.findEntryDetailByHref(entryDetailRequestURL));
+ scheduler.flush();
+
+ expect((service as any).vocabularyEntryDetailDataService.findByHref).toHaveBeenCalledWith(entryDetailRequestURL);
+ });
+
+ it('should return a RemoteData for the object with the given URL', () => {
+ const result = service.findEntryDetailByHref(entryDetailRequestURL);
+ const expected = cold('a|', {
+ a: vocabularyEntryDetailParentRD
+ });
+ expect(result).toBeObservable(expected);
+ });
+ });
+
+ describe('findEntryDetailById', () => {
+ it('should proxy the call to vocabularyDataService.findVocabularyById', () => {
+ scheduler.schedule(() => service.findEntryDetailById('testValue', hierarchicalVocabulary.id));
+ scheduler.flush();
+ const expectedId = `${hierarchicalVocabulary.id}:testValue`
+ expect((service as any).vocabularyEntryDetailDataService.findById).toHaveBeenCalledWith(expectedId);
+ });
+
+ it('should return a RemoteData for the object with the given id', () => {
+ const result = service.findEntryDetailById('testValue', hierarchicalVocabulary.id);
+ const expected = cold('a|', {
+ a: vocabularyEntryDetailParentRD
+ });
+ expect(result).toBeObservable(expected);
+ });
+ });
+
+ describe('getEntryDetailParent', () => {
+ it('should proxy the call to vocabularyDataService.getEntryDetailParent', () => {
+ scheduler.schedule(() => service.getEntryDetailParent('testValue', hierarchicalVocabulary.id).subscribe());
+ scheduler.flush();
+
+ expect((service as any).vocabularyEntryDetailDataService.findByHref).toHaveBeenCalledWith(entryDetailParentRequestURL);
+ });
+
+ it('should return a RemoteData for the object with the given URL', () => {
+ const result = service.getEntryDetailParent('testValue', hierarchicalVocabulary.id);
+ const expected = cold('a|', {
+ a: vocabularyEntryDetailParentRD
+ });
+ expect(result).toBeObservable(expected);
+ });
+ });
+
+ describe('getEntryDetailChildren', () => {
+ it('should proxy the call to vocabularyDataService.getEntryDetailChildren', () => {
+ const options: VocabularyFindOptions = new VocabularyFindOptions(
+ null,
+ null,
+ null,
+ null,
+ pageInfo.elementsPerPage,
+ pageInfo.currentPage
+ );
+ scheduler.schedule(() => service.getEntryDetailChildren('testValue', hierarchicalVocabulary.id, pageInfo).subscribe());
+ scheduler.flush();
+
+ expect((service as any).vocabularyEntryDetailDataService.findAllByHref).toHaveBeenCalledWith(entryDetailChildrenRequestURL, options);
+ });
+
+ it('should return a RemoteData> for the object with the given URL', () => {
+ const result = service.getEntryDetailChildren('testValue', hierarchicalVocabulary.id, new PageInfo());
+ const expected = cold('a|', {
+ a: vocabularyEntryChildrenRD
+ });
+ expect(result).toBeObservable(expected);
+ });
+ });
+
+ describe('searchByTop', () => {
+ it('should proxy the call to vocabularyEntryDetailDataService.searchBy', () => {
+ const options: VocabularyFindOptions = new VocabularyFindOptions(
+ null,
+ null,
+ null,
+ null,
+ pageInfo.elementsPerPage,
+ pageInfo.currentPage
+ );
+ options.searchParams = [new RequestParam('vocabulary', 'srsc')];
+ scheduler.schedule(() => service.searchTopEntries('srsc', pageInfo));
+ scheduler.flush();
+
+ expect((service as any).vocabularyEntryDetailDataService.searchBy).toHaveBeenCalledWith((service as any).searchTopMethod, options);
+ });
+
+ it('should return a RemoteData> for the search', () => {
+ const result = service.searchTopEntries('srsc', pageInfo);
+ const expected = cold('a|', {
+ a: vocabularyEntryChildrenRD
+ });
+ expect(result).toBeObservable(expected);
+ });
+
+ });
+
+ describe('clearSearchTopRequests', () => {
+ it('should remove requests on the data service\'s endpoint', (done) => {
+ service.clearSearchTopRequests();
+
+ expect(requestService.removeByHrefSubstring).toHaveBeenCalledWith(`search/${(service as any).searchTopMethod}`);
+ done();
+ });
+ });
+
+ });
+});
diff --git a/src/app/core/submission/vocabularies/vocabulary.service.ts b/src/app/core/submission/vocabularies/vocabulary.service.ts
new file mode 100644
index 0000000000..595edfc861
--- /dev/null
+++ b/src/app/core/submission/vocabularies/vocabulary.service.ts
@@ -0,0 +1,389 @@
+import { Injectable } from '@angular/core';
+import { HttpClient } from '@angular/common/http';
+
+import { Store } from '@ngrx/store';
+import { Observable } from 'rxjs';
+import { distinctUntilChanged, first, flatMap, map } from 'rxjs/operators';
+
+import { FollowLinkConfig } from '../../../shared/utils/follow-link-config.model';
+import { dataService } from '../../cache/builders/build-decorators';
+import { DataService } from '../../data/data.service';
+import { RequestService } from '../../data/request.service';
+import { FindListOptions, RestRequest, VocabularyEntriesRequest } from '../../data/request.models';
+import { HALEndpointService } from '../../shared/hal-endpoint.service';
+import { RemoteData } from '../../data/remote-data';
+import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service';
+import { CoreState } from '../../core.reducers';
+import { ObjectCacheService } from '../../cache/object-cache.service';
+import { NotificationsService } from '../../../shared/notifications/notifications.service';
+import { ChangeAnalyzer } from '../../data/change-analyzer';
+import { DefaultChangeAnalyzer } from '../../data/default-change-analyzer.service';
+import { PaginatedList } from '../../data/paginated-list';
+import { Vocabulary } from './models/vocabulary.model';
+import { VOCABULARY } from './models/vocabularies.resource-type';
+import { VocabularyEntry } from './models/vocabulary-entry.model';
+import { hasValue, isNotEmpty, isNotEmptyOperator } from '../../../shared/empty.util';
+import {
+ configureRequest,
+ filterSuccessfulResponses,
+ getFirstSucceededRemoteDataPayload,
+ getFirstSucceededRemoteListPayload,
+ getRequestFromRequestHref
+} from '../../shared/operators';
+import { GenericSuccessResponse } from '../../cache/response.models';
+import { VocabularyFindOptions } from './models/vocabulary-find-options.model';
+import { VocabularyEntryDetail } from './models/vocabulary-entry-detail.model';
+import { RequestParam } from '../../cache/models/request-param.model';
+import { VocabularyOptions } from './models/vocabulary-options.model';
+import { PageInfo } from '../../shared/page-info.model';
+
+/* tslint:disable:max-classes-per-file */
+
+/**
+ * A private DataService implementation to delegate specific methods to.
+ */
+class VocabularyDataServiceImpl extends DataService {
+ protected linkPath = 'vocabularies';
+
+ constructor(
+ protected requestService: RequestService,
+ protected rdbService: RemoteDataBuildService,
+ protected store: Store,
+ protected objectCache: ObjectCacheService,
+ protected halService: HALEndpointService,
+ protected notificationsService: NotificationsService,
+ protected http: HttpClient,
+ protected comparator: ChangeAnalyzer) {
+ super();
+ }
+
+}
+
+/**
+ * A private DataService implementation to delegate specific methods to.
+ */
+class VocabularyEntryDetailDataServiceImpl extends DataService {
+ protected linkPath = 'vocabularyEntryDetails';
+
+ constructor(
+ protected requestService: RequestService,
+ protected rdbService: RemoteDataBuildService,
+ protected store: Store,
+ protected objectCache: ObjectCacheService,
+ protected halService: HALEndpointService,
+ protected notificationsService: NotificationsService,
+ protected http: HttpClient,
+ protected comparator: ChangeAnalyzer) {
+ super();
+ }
+
+}
+
+/**
+ * A service responsible for fetching/sending data from/to the REST API on the vocabularies endpoint
+ */
+@Injectable()
+@dataService(VOCABULARY)
+export class VocabularyService {
+ protected searchByMetadataAndCollectionMethod = 'byMetadataAndCollection';
+ protected searchTopMethod = 'top';
+ private vocabularyDataService: VocabularyDataServiceImpl;
+ private vocabularyEntryDetailDataService: VocabularyEntryDetailDataServiceImpl;
+
+ constructor(
+ protected requestService: RequestService,
+ protected rdbService: RemoteDataBuildService,
+ protected objectCache: ObjectCacheService,
+ protected halService: HALEndpointService,
+ protected notificationsService: NotificationsService,
+ protected http: HttpClient,
+ protected comparatorVocabulary: DefaultChangeAnalyzer,
+ protected comparatorEntry: DefaultChangeAnalyzer) {
+ this.vocabularyDataService = new VocabularyDataServiceImpl(requestService, rdbService, null, objectCache, halService, notificationsService, http, comparatorVocabulary);
+ this.vocabularyEntryDetailDataService = new VocabularyEntryDetailDataServiceImpl(requestService, rdbService, null, objectCache, halService, notificationsService, http, comparatorEntry);
+ }
+
+ /**
+ * Returns an observable of {@link RemoteData} of a {@link Vocabulary}, based on an href, with a list of {@link FollowLinkConfig},
+ * to automatically resolve {@link HALLink}s of the {@link Vocabulary}
+ * @param href The url of {@link Vocabulary} we want to retrieve
+ * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved
+ * @return {Observable>}
+ * Return an observable that emits vocabulary object
+ */
+ findVocabularyByHref(href: string, ...linksToFollow: Array>): Observable> {
+ return this.vocabularyDataService.findByHref(href, ...linksToFollow);
+ }
+
+ /**
+ * Returns an observable of {@link RemoteData} of a {@link Vocabulary}, based on its ID, with a list of {@link FollowLinkConfig},
+ * to automatically resolve {@link HALLink}s of the object
+ * @param name The name of {@link Vocabulary} we want to retrieve
+ * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved
+ * @return {Observable>}
+ * Return an observable that emits vocabulary object
+ */
+ findVocabularyById(name: string, ...linksToFollow: Array>): Observable> {
+ return this.vocabularyDataService.findById(name, ...linksToFollow);
+ }
+
+ /**
+ * Returns {@link RemoteData} of all object with a list of {@link FollowLinkConfig}, to indicate which embedded
+ * info should be added to the objects
+ *
+ * @param options Find list options object
+ * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved
+ * @return {Observable>>}
+ * Return an observable that emits object list
+ */
+ findAllVocabularies(options: FindListOptions = {}, ...linksToFollow: Array>): Observable>> {
+ return this.vocabularyDataService.findAll(options, ...linksToFollow);
+ }
+
+ /**
+ * Return the {@link VocabularyEntry} list for a given {@link Vocabulary}
+ *
+ * @param vocabularyOptions The {@link VocabularyOptions} for the request to which the entries belong
+ * @param pageInfo The {@link PageInfo} for the request
+ * @return {Observable>>}
+ * Return an observable that emits object list
+ */
+ getVocabularyEntries(vocabularyOptions: VocabularyOptions, pageInfo: PageInfo): Observable