mirror of
https://github.com/DSpace/dspace-angular.git
synced 2025-10-15 05:53:03 +00:00
Merge branch 'main' into fix_com_col_refresh
This commit is contained in:
@@ -4,7 +4,7 @@
|
||||
|
||||
<h2 id="header" class="border-bottom pb-2">{{labelPrefix + 'head' | translate}}</h2>
|
||||
|
||||
<ds-eperson-form *ngIf="isEPersonFormShown" (submitForm)="forceUpdateEPeople()"
|
||||
<ds-eperson-form *ngIf="isEPersonFormShown" (submitForm)="reset()"
|
||||
(cancelForm)="isEPersonFormShown = false"></ds-eperson-form>
|
||||
|
||||
<div *ngIf="!isEPersonFormShown">
|
||||
@@ -40,10 +40,10 @@
|
||||
</form>
|
||||
|
||||
<ds-pagination
|
||||
*ngIf="(ePeople | async)?.payload?.totalElements > 0"
|
||||
*ngIf="(ePeopleDto$ | async)?.totalElements > 0"
|
||||
[paginationOptions]="config"
|
||||
[pageInfoState]="(ePeople | async)?.payload"
|
||||
[collectionSize]="(ePeople | async)?.payload?.totalElements"
|
||||
[pageInfoState]="pageInfoState$"
|
||||
[collectionSize]="(pageInfoState$ | async)?.totalElements"
|
||||
[hideGear]="true"
|
||||
[hidePagerWhenSinglePage]="true"
|
||||
(pageChange)="onPageChange($event)">
|
||||
@@ -59,21 +59,21 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr *ngFor="let eperson of (ePeople | async)?.payload?.page"
|
||||
[ngClass]="{'table-primary' : isActive(eperson) | async}">
|
||||
<td>{{eperson.id}}</td>
|
||||
<td>{{eperson.name}}</td>
|
||||
<td>{{eperson.email}}</td>
|
||||
<tr *ngFor="let epersonDto of (ePeopleDto$ | async)?.page"
|
||||
[ngClass]="{'table-primary' : isActive(epersonDto.eperson) | async}">
|
||||
<td>{{epersonDto.eperson.id}}</td>
|
||||
<td>{{epersonDto.eperson.name}}</td>
|
||||
<td>{{epersonDto.eperson.email}}</td>
|
||||
<td>
|
||||
<div class="btn-group edit-field">
|
||||
<button (click)="toggleEditEPerson(eperson)"
|
||||
<button class="delete-button" (click)="toggleEditEPerson(epersonDto.eperson)"
|
||||
class="btn btn-outline-primary btn-sm access-control-editEPersonButton"
|
||||
title="{{labelPrefix + 'table.edit.buttons.edit' | translate: {name: eperson.name} }}">
|
||||
title="{{labelPrefix + 'table.edit.buttons.edit' | translate: {name: epersonDto.eperson.name} }}">
|
||||
<i class="fas fa-edit fa-fw"></i>
|
||||
</button>
|
||||
<button (click)="deleteEPerson(eperson)"
|
||||
<button [disabled]="!epersonDto.ableToDelete" (click)="deleteEPerson(epersonDto.eperson)"
|
||||
class="btn btn-outline-danger btn-sm access-control-deleteEPersonButton"
|
||||
title="{{labelPrefix + 'table.edit.buttons.remove' | translate: {name: eperson.name} }}">
|
||||
title="{{labelPrefix + 'table.edit.buttons.remove' | translate: {name: epersonDto.eperson.name} }}">
|
||||
<i class="fas fa-trash-alt fa-fw"></i>
|
||||
</button>
|
||||
</div>
|
||||
@@ -85,7 +85,7 @@
|
||||
|
||||
</ds-pagination>
|
||||
|
||||
<div *ngIf="(ePeople | async)?.payload?.totalElements == 0" class="alert alert-info w-100 mb-2" role="alert">
|
||||
<div *ngIf="(pageInfoState$ | async)?.totalElements == 0" class="alert alert-info w-100 mb-2" role="alert">
|
||||
{{labelPrefix + 'no-items' | translate}}
|
||||
</div>
|
||||
</div>
|
||||
|
@@ -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);
|
||||
});
|
||||
|
||||
})
|
||||
})
|
||||
});
|
||||
|
@@ -2,9 +2,9 @@ import { Component, OnDestroy, OnInit } from '@angular/core';
|
||||
import { FormBuilder } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { Observable } from 'rxjs';
|
||||
import { BehaviorSubject, combineLatest, Observable } from 'rxjs';
|
||||
import { Subscription } from 'rxjs/internal/Subscription';
|
||||
import { map, take } from 'rxjs/operators';
|
||||
import { map, switchMap, take } from 'rxjs/operators';
|
||||
import { PaginatedList } from '../../../core/data/paginated-list';
|
||||
import { RemoteData } from '../../../core/data/remote-data';
|
||||
import { EPersonDataService } from '../../../core/eperson/eperson-data.service';
|
||||
@@ -12,6 +12,16 @@ import { EPerson } from '../../../core/eperson/models/eperson.model';
|
||||
import { hasValue } from '../../../shared/empty.util';
|
||||
import { NotificationsService } from '../../../shared/notifications/notifications.service';
|
||||
import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model';
|
||||
import { EpersonDtoModel } from '../../../core/eperson/models/eperson-dto.model';
|
||||
import { FeatureID } from '../../../core/data/feature-authorization/feature-id';
|
||||
import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service';
|
||||
import { getAllSucceededRemoteDataPayload } from '../../../core/shared/operators';
|
||||
import { ErrorResponse, RestResponse } from '../../../core/cache/response.models';
|
||||
import { ConfirmationModalComponent } from '../../../shared/confirmation-modal/confirmation-modal.component';
|
||||
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { RequestService } from '../../../core/data/request.service';
|
||||
import { filter } from 'rxjs/internal/operators/filter';
|
||||
import { PageInfo } from '../../../core/shared/page-info.model';
|
||||
|
||||
@Component({
|
||||
selector: 'ds-epeople-registry',
|
||||
@@ -28,7 +38,17 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy {
|
||||
/**
|
||||
* A list of all the current EPeople within the repository or the result of the search
|
||||
*/
|
||||
ePeople: Observable<RemoteData<PaginatedList<EPerson>>>;
|
||||
ePeople$: BehaviorSubject<RemoteData<PaginatedList<EPerson>>> = new BehaviorSubject<RemoteData<PaginatedList<EPerson>>>({} as any);
|
||||
/**
|
||||
* A BehaviorSubject with the list of EpersonDtoModel objects made from the EPeople in the repository or
|
||||
* as the result of the search
|
||||
*/
|
||||
ePeopleDto$: BehaviorSubject<PaginatedList<EpersonDtoModel>> = new BehaviorSubject<PaginatedList<EpersonDtoModel>>({} as any);
|
||||
|
||||
/**
|
||||
* An observable for the pageInfo, needed to pass to the pagination component
|
||||
*/
|
||||
pageInfoState$: BehaviorSubject<PageInfo> = new BehaviorSubject<PageInfo>(undefined);
|
||||
|
||||
/**
|
||||
* Pagination config used to display the list of epeople
|
||||
@@ -59,8 +79,11 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy {
|
||||
constructor(private epersonService: EPersonDataService,
|
||||
private translateService: TranslateService,
|
||||
private notificationsService: NotificationsService,
|
||||
private authorizationService: AuthorizationDataService,
|
||||
private formBuilder: FormBuilder,
|
||||
private router: Router) {
|
||||
private router: Router,
|
||||
private modalService: NgbModal,
|
||||
public requestService: RequestService) {
|
||||
this.currentSearchQuery = '';
|
||||
this.currentSearchScope = 'metadata';
|
||||
this.searchForm = this.formBuilder.group(({
|
||||
@@ -70,6 +93,13 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
this.initialisePage();
|
||||
}
|
||||
|
||||
/**
|
||||
* This method will initialise the page
|
||||
*/
|
||||
initialisePage() {
|
||||
this.isEPersonFormShown = false;
|
||||
this.search({ scope: this.currentSearchScope, query: this.currentSearchQuery });
|
||||
this.subs.push(this.epersonService.getActiveEPerson().subscribe((eperson: EPerson) => {
|
||||
@@ -84,18 +114,10 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy {
|
||||
* @param event
|
||||
*/
|
||||
onPageChange(event) {
|
||||
this.config.currentPage = event;
|
||||
this.search({ scope: this.currentSearchScope, query: this.currentSearchQuery })
|
||||
}
|
||||
|
||||
/**
|
||||
* Force-update the list of EPeople by first clearing the cache related to EPeople, then performing
|
||||
* a new REST call
|
||||
*/
|
||||
public forceUpdateEPeople() {
|
||||
this.epersonService.clearEPersonRequests();
|
||||
this.isEPersonFormShown = false;
|
||||
this.search({ query: '', scope: 'metadata' })
|
||||
if (this.config.currentPage !== event) {
|
||||
this.config.currentPage = event;
|
||||
this.search({ scope: this.currentSearchScope, query: this.currentSearchQuery })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -115,10 +137,33 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy {
|
||||
this.currentSearchScope = scope;
|
||||
this.config.currentPage = 1;
|
||||
}
|
||||
this.ePeople = this.epersonService.searchByScope(this.currentSearchScope, this.currentSearchQuery, {
|
||||
this.subs.push(this.epersonService.searchByScope(this.currentSearchScope, this.currentSearchQuery, {
|
||||
currentPage: this.config.currentPage,
|
||||
elementsPerPage: this.config.pageSize
|
||||
});
|
||||
}).subscribe((peopleRD) => {
|
||||
this.ePeople$.next(peopleRD)
|
||||
}
|
||||
));
|
||||
|
||||
this.subs.push(this.ePeople$.pipe(
|
||||
getAllSucceededRemoteDataPayload(),
|
||||
switchMap((epeople) => {
|
||||
return combineLatest(...epeople.page.map((eperson) => {
|
||||
return this.authorizationService.isAuthorized(FeatureID.CanDelete, hasValue(eperson) ? eperson.self : undefined).pipe(
|
||||
map((authorized) => {
|
||||
const epersonDtoModel: EpersonDtoModel = new EpersonDtoModel();
|
||||
epersonDtoModel.ableToDelete = authorized;
|
||||
epersonDtoModel.eperson = eperson;
|
||||
return epersonDtoModel;
|
||||
})
|
||||
);
|
||||
})).pipe(map((dtos: EpersonDtoModel[]) => {
|
||||
return new PaginatedList(epeople.pageInfo, dtos);
|
||||
}))
|
||||
})).subscribe((value) => {
|
||||
this.ePeopleDto$.next(value);
|
||||
this.pageInfoState$.next(value.pageInfo);
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -160,16 +205,26 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy {
|
||||
*/
|
||||
deleteEPerson(ePerson: EPerson) {
|
||||
if (hasValue(ePerson.id)) {
|
||||
this.epersonService.deleteEPerson(ePerson).pipe(take(1)).subscribe((success: boolean) => {
|
||||
if (success) {
|
||||
this.notificationsService.success(this.translateService.get(this.labelPrefix + 'notification.deleted.success', { name: ePerson.name }));
|
||||
this.forceUpdateEPeople();
|
||||
} else {
|
||||
this.notificationsService.error(this.translateService.get(this.labelPrefix + 'notification.deleted.failure', { name: ePerson.name }));
|
||||
}
|
||||
this.epersonService.cancelEditEPerson();
|
||||
this.isEPersonFormShown = false;
|
||||
})
|
||||
const modalRef = this.modalService.open(ConfirmationModalComponent);
|
||||
modalRef.componentInstance.dso = ePerson;
|
||||
modalRef.componentInstance.headerLabel = 'confirmation-modal.delete-eperson.header';
|
||||
modalRef.componentInstance.infoLabel = 'confirmation-modal.delete-eperson.info';
|
||||
modalRef.componentInstance.cancelLabel = 'confirmation-modal.delete-eperson.cancel';
|
||||
modalRef.componentInstance.confirmLabel = 'confirmation-modal.delete-eperson.confirm';
|
||||
modalRef.componentInstance.response.pipe(take(1)).subscribe((confirm: boolean) => {
|
||||
if (confirm) {
|
||||
if (hasValue(ePerson.id)) {
|
||||
this.epersonService.deleteEPerson(ePerson).pipe(take(1)).subscribe((restResponse: RestResponse) => {
|
||||
if (restResponse.isSuccessful) {
|
||||
this.notificationsService.success(this.translateService.get(this.labelPrefix + 'notification.deleted.success', { name: ePerson.name }));
|
||||
this.reset();
|
||||
} else {
|
||||
const errorResponse = restResponse as ErrorResponse;
|
||||
this.notificationsService.error('Error occured when trying to delete EPerson with id: ' + ePerson.id + ' with code: ' + errorResponse.statusCode + ' and message: ' + errorResponse.errorMessage);
|
||||
}
|
||||
})
|
||||
}}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,6 +232,10 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy {
|
||||
* Unsub all subscriptions
|
||||
*/
|
||||
ngOnDestroy(): void {
|
||||
this.cleanupSubscribes();
|
||||
}
|
||||
|
||||
cleanupSubscribes() {
|
||||
this.subs.filter((sub) => hasValue(sub)).forEach((sub) => sub.unsubscribe());
|
||||
}
|
||||
|
||||
@@ -199,4 +258,18 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy {
|
||||
});
|
||||
this.search({ query: '' });
|
||||
}
|
||||
|
||||
/**
|
||||
* This method will ensure that the page gets reset and that the cache is cleared
|
||||
*/
|
||||
reset() {
|
||||
this.epersonService.getBrowseEndpoint().pipe(
|
||||
switchMap((href) => this.requestService.removeByHrefSubstring(href)),
|
||||
filter((isCached) => isCached),
|
||||
take(1)
|
||||
).subscribe(() => {
|
||||
this.cleanupSubscribes();
|
||||
this.initialisePage();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@@ -17,7 +17,7 @@
|
||||
<button class="btn btn-light" [disabled]="!(canReset$ | async)">
|
||||
<i class="fa fa-key"></i> {{'admin.access-control.epeople.actions.reset' | translate}}
|
||||
</button>
|
||||
<button class="btn btn-light" [disabled]="!(canDelete$ | async)">
|
||||
<button class="btn btn-light delete-button" [disabled]="!(canDelete$ | async)" (click)="delete()">
|
||||
<i class="fa fa-trash"></i> {{'admin.access-control.epeople.actions.delete' | translate}}
|
||||
</button>
|
||||
<button *ngIf="!isImpersonated" class="btn btn-light" [ngClass]="{'d-none' : !(canImpersonate$ | async)}" (click)="impersonate()">
|
||||
|
@@ -1,34 +1,25 @@
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { of as observableOf } from 'rxjs';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { NO_ERRORS_SCHEMA } from '@angular/core';
|
||||
import { async, ComponentFixture, inject, TestBed } from '@angular/core/testing';
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
|
||||
import { BrowserModule } from '@angular/platform-browser';
|
||||
import { BrowserModule, By } from '@angular/platform-browser';
|
||||
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { TranslateLoader, TranslateModule, TranslateService } from '@ngx-translate/core';
|
||||
import { TranslateLoader, TranslateModule } from '@ngx-translate/core';
|
||||
import { Observable } from 'rxjs/internal/Observable';
|
||||
import { RemoteDataBuildService } from '../../../../core/cache/builders/remote-data-build.service';
|
||||
import { ObjectCacheService } from '../../../../core/cache/object-cache.service';
|
||||
import { RestResponse } from '../../../../core/cache/response.models';
|
||||
import { DSOChangeAnalyzer } from '../../../../core/data/dso-change-analyzer.service';
|
||||
import { PaginatedList } from '../../../../core/data/paginated-list';
|
||||
import { RemoteData } from '../../../../core/data/remote-data';
|
||||
import { FindListOptions } from '../../../../core/data/request.models';
|
||||
import { EPersonDataService } from '../../../../core/eperson/eperson-data.service';
|
||||
import { EPerson } from '../../../../core/eperson/models/eperson.model';
|
||||
import { HALEndpointService } from '../../../../core/shared/hal-endpoint.service';
|
||||
import { PageInfo } from '../../../../core/shared/page-info.model';
|
||||
import { UUIDService } from '../../../../core/shared/uuid.service';
|
||||
import { FormBuilderService } from '../../../../shared/form/builder/form-builder.service';
|
||||
import { NotificationsService } from '../../../../shared/notifications/notifications.service';
|
||||
import { EPeopleRegistryComponent } from '../epeople-registry.component';
|
||||
import { EPersonFormComponent } from './eperson-form.component';
|
||||
import { EPersonMock, EPersonMock2 } from '../../../../shared/testing/eperson.mock';
|
||||
import { createSuccessfulRemoteDataObject$ } from '../../../../shared/remote-data.utils';
|
||||
import { getMockFormBuilderService } from '../../../../shared/mocks/form-builder-service.mock';
|
||||
import { getMockTranslateService } from '../../../../shared/mocks/translate.service.mock';
|
||||
import { NotificationsServiceStub } from '../../../../shared/testing/notifications-service.stub';
|
||||
import { TranslateLoaderMock } from '../../../../shared/mocks/translate-loader.mock';
|
||||
import { AuthService } from '../../../../core/auth/auth.service';
|
||||
@@ -36,11 +27,11 @@ import { AuthServiceStub } from '../../../../shared/testing/auth-service.stub';
|
||||
import { AuthorizationDataService } from '../../../../core/data/feature-authorization/authorization-data.service';
|
||||
import { GroupDataService } from '../../../../core/eperson/group-data.service';
|
||||
import { createPaginatedList } from '../../../../shared/testing/utils.test';
|
||||
import { RequestService } from '../../../../core/data/request.service';
|
||||
|
||||
describe('EPersonFormComponent', () => {
|
||||
let component: EPersonFormComponent;
|
||||
let fixture: ComponentFixture<EPersonFormComponent>;
|
||||
let translateService: TranslateService;
|
||||
let builderService: FormBuilderService;
|
||||
|
||||
let mockEPeople;
|
||||
@@ -111,7 +102,6 @@ describe('EPersonFormComponent', () => {
|
||||
}
|
||||
};
|
||||
builderService = getMockFormBuilderService();
|
||||
translateService = getMockTranslateService();
|
||||
authService = new AuthServiceStub();
|
||||
authorizationService = jasmine.createSpyObj('authorizationService', {
|
||||
isAuthorized: observableOf(true)
|
||||
@@ -129,22 +119,15 @@ describe('EPersonFormComponent', () => {
|
||||
}
|
||||
}),
|
||||
],
|
||||
declarations: [EPeopleRegistryComponent, EPersonFormComponent],
|
||||
providers: [EPersonFormComponent,
|
||||
declarations: [EPersonFormComponent],
|
||||
providers: [
|
||||
{ provide: EPersonDataService, useValue: ePersonDataServiceStub },
|
||||
{ provide: NotificationsService, useValue: new NotificationsServiceStub() },
|
||||
{ provide: GroupDataService, useValue: groupsDataService },
|
||||
{ provide: FormBuilderService, useValue: builderService },
|
||||
{ provide: DSOChangeAnalyzer, useValue: {} },
|
||||
{ provide: HttpClient, useValue: {} },
|
||||
{ provide: ObjectCacheService, useValue: {} },
|
||||
{ provide: UUIDService, useValue: {} },
|
||||
{ provide: Store, useValue: {} },
|
||||
{ provide: RemoteDataBuildService, useValue: {} },
|
||||
{ provide: HALEndpointService, useValue: {} },
|
||||
{ provide: NotificationsService, useValue: new NotificationsServiceStub() },
|
||||
{ provide: AuthService, useValue: authService },
|
||||
{ provide: AuthorizationDataService, useValue: authorizationService },
|
||||
{ provide: GroupDataService, useValue: groupsDataService },
|
||||
EPeopleRegistryComponent
|
||||
{ provide: RequestService, useValue: jasmine.createSpyObj('requestService', ['removeByHrefSubstring'])}
|
||||
],
|
||||
schemas: [NO_ERRORS_SCHEMA]
|
||||
}).compileComponents();
|
||||
@@ -156,9 +139,9 @@ describe('EPersonFormComponent', () => {
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create EPersonFormComponent', inject([EPersonFormComponent], (comp: EPersonFormComponent) => {
|
||||
expect(comp).toBeDefined();
|
||||
}));
|
||||
it('should create EPersonFormComponent', () => {
|
||||
expect(component).toBeDefined();
|
||||
});
|
||||
|
||||
describe('when submitting the form', () => {
|
||||
let firstName;
|
||||
@@ -283,4 +266,53 @@ describe('EPersonFormComponent', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete', () => {
|
||||
|
||||
let ePersonId;
|
||||
let eperson: EPerson;
|
||||
let modalService;
|
||||
|
||||
beforeEach(() => {
|
||||
spyOn(authService, 'impersonate').and.callThrough();
|
||||
ePersonId = 'testEPersonId';
|
||||
eperson = EPersonMock;
|
||||
component.epersonInitial = eperson;
|
||||
component.canDelete$ = observableOf(true);
|
||||
spyOn(component.epersonService, 'getActiveEPerson').and.returnValue(observableOf(eperson));
|
||||
modalService = (component as any).modalService;
|
||||
spyOn(modalService, 'open').and.returnValue(Object.assign({ componentInstance: Object.assign({ response: observableOf(true) }) }));
|
||||
fixture.detectChanges()
|
||||
|
||||
});
|
||||
|
||||
it ('the delete button should be active if the eperson can be deleted', () => {
|
||||
const deleteButton = fixture.debugElement.query(By.css('.delete-button'));
|
||||
expect(deleteButton.nativeElement.disabled).toBe(false);
|
||||
});
|
||||
|
||||
it ('the delete button should be disabled if the eperson cannot be deleted', () => {
|
||||
component.canDelete$ = observableOf(false);
|
||||
fixture.detectChanges()
|
||||
const deleteButton = fixture.debugElement.query(By.css('.delete-button'));
|
||||
expect(deleteButton.nativeElement.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it ('should call the epersonFormComponent delete when clicked on the button' , () => {
|
||||
spyOn(component, 'delete').and.stub();
|
||||
spyOn(component.epersonService, 'deleteEPerson').and.returnValue(observableOf(new RestResponse(true, 204, 'No Content')));
|
||||
const deleteButton = fixture.debugElement.query(By.css('.delete-button'));
|
||||
deleteButton.triggerEventHandler('click', null);
|
||||
expect(component.delete).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it ('should call the epersonService delete when clicked on the button' , () => {
|
||||
// ePersonDataServiceStub.activeEPerson = eperson;
|
||||
spyOn(component.epersonService, 'deleteEPerson').and.returnValue(observableOf(new RestResponse(true, 204, 'No Content')));
|
||||
const deleteButton = fixture.debugElement.query(By.css('.delete-button'));
|
||||
expect(deleteButton.nativeElement.disabled).toBe(false);
|
||||
deleteButton.triggerEventHandler('click', null);
|
||||
fixture.detectChanges()
|
||||
expect(component.epersonService.deleteEPerson).toHaveBeenCalledWith(eperson);
|
||||
});
|
||||
})
|
||||
});
|
||||
|
@@ -25,6 +25,9 @@ import { PaginationComponentOptions } from '../../../../shared/pagination/pagina
|
||||
import { AuthService } from '../../../../core/auth/auth.service';
|
||||
import { AuthorizationDataService } from '../../../../core/data/feature-authorization/authorization-data.service';
|
||||
import { FeatureID } from '../../../../core/data/feature-authorization/feature-id';
|
||||
import { ConfirmationModalComponent } from '../../../../shared/confirmation-modal/confirmation-modal.component';
|
||||
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { RequestService } from '../../../../core/data/request.service';
|
||||
|
||||
@Component({
|
||||
selector: 'ds-eperson-form',
|
||||
@@ -116,9 +119,8 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
|
||||
|
||||
/**
|
||||
* Observable whether or not the admin is allowed to delete the EPerson
|
||||
* TODO: Initialize the observable once the REST API supports this (currently hardcoded to return false)
|
||||
*/
|
||||
canDelete$: Observable<boolean> = of(false);
|
||||
canDelete$: Observable<boolean>;
|
||||
|
||||
/**
|
||||
* Observable whether or not the admin is allowed to impersonate the EPerson
|
||||
@@ -160,7 +162,9 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
|
||||
private translateService: TranslateService,
|
||||
private notificationsService: NotificationsService,
|
||||
private authService: AuthService,
|
||||
private authorizationService: AuthorizationDataService) {
|
||||
private authorizationService: AuthorizationDataService,
|
||||
private modalService: NgbModal,
|
||||
public requestService: RequestService) {
|
||||
this.subs.push(this.epersonService.getActiveEPerson().subscribe((eperson: EPerson) => {
|
||||
this.epersonInitial = eperson;
|
||||
if (hasValue(eperson)) {
|
||||
@@ -170,13 +174,20 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
this.initialisePage();
|
||||
}
|
||||
|
||||
/**
|
||||
* This method will initialise the page
|
||||
*/
|
||||
initialisePage() {
|
||||
combineLatest(
|
||||
this.translateService.get(`${this.messagePrefix}.firstName`),
|
||||
this.translateService.get(`${this.messagePrefix}.lastName`),
|
||||
this.translateService.get(`${this.messagePrefix}.email`),
|
||||
this.translateService.get(`${this.messagePrefix}.canLogIn`),
|
||||
this.translateService.get(`${this.messagePrefix}.requireCertificate`),
|
||||
this.translateService.get(`${this.messagePrefix}.emailHint`),
|
||||
this.translateService.get(`${this.messagePrefix}.firstName`),
|
||||
this.translateService.get(`${this.messagePrefix}.lastName`),
|
||||
this.translateService.get(`${this.messagePrefix}.email`),
|
||||
this.translateService.get(`${this.messagePrefix}.canLogIn`),
|
||||
this.translateService.get(`${this.messagePrefix}.requireCertificate`),
|
||||
this.translateService.get(`${this.messagePrefix}.emailHint`),
|
||||
).subscribe(([firstName, lastName, email, canLogIn, requireCertificate, emailHint]) => {
|
||||
this.firstName = new DynamicInputModel({
|
||||
id: 'firstName',
|
||||
@@ -208,19 +219,19 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
|
||||
hint: emailHint
|
||||
});
|
||||
this.canLogIn = new DynamicCheckboxModel(
|
||||
{
|
||||
id: 'canLogIn',
|
||||
label: canLogIn,
|
||||
name: 'canLogIn',
|
||||
value: (this.epersonInitial != null ? this.epersonInitial.canLogIn : true)
|
||||
});
|
||||
{
|
||||
id: 'canLogIn',
|
||||
label: canLogIn,
|
||||
name: 'canLogIn',
|
||||
value: (this.epersonInitial != null ? this.epersonInitial.canLogIn : true)
|
||||
});
|
||||
this.requireCertificate = new DynamicCheckboxModel(
|
||||
{
|
||||
id: 'requireCertificate',
|
||||
label: requireCertificate,
|
||||
name: 'requireCertificate',
|
||||
value: (this.epersonInitial != null ? this.epersonInitial.requireCertificate : false)
|
||||
});
|
||||
{
|
||||
id: 'requireCertificate',
|
||||
label: requireCertificate,
|
||||
name: 'requireCertificate',
|
||||
value: (this.epersonInitial != null ? this.epersonInitial.requireCertificate : false)
|
||||
});
|
||||
this.formModel = [
|
||||
this.firstName,
|
||||
this.lastName,
|
||||
@@ -245,7 +256,10 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
|
||||
});
|
||||
}));
|
||||
this.canImpersonate$ = this.epersonService.getActiveEPerson().pipe(
|
||||
switchMap((eperson) => this.authorizationService.isAuthorized(FeatureID.LoginOnBehalfOf, hasValue(eperson) ? eperson.self : undefined))
|
||||
switchMap((eperson) => this.authorizationService.isAuthorized(FeatureID.LoginOnBehalfOf, hasValue(eperson) ? eperson.self : undefined))
|
||||
);
|
||||
this.canDelete$ = this.epersonService.getActiveEPerson().pipe(
|
||||
switchMap((eperson) => this.authorizationService.isAuthorized(FeatureID.CanDelete, hasValue(eperson) ? eperson.self : undefined))
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -405,6 +419,35 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
|
||||
this.isImpersonated = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the EPerson from the Repository. The EPerson will be the only that this form is showing.
|
||||
* It'll either show a success or error message depending on whether the delete was successful or not.
|
||||
*/
|
||||
delete() {
|
||||
this.epersonService.getActiveEPerson().pipe(take(1)).subscribe((eperson: EPerson) => {
|
||||
const modalRef = this.modalService.open(ConfirmationModalComponent);
|
||||
modalRef.componentInstance.dso = eperson;
|
||||
modalRef.componentInstance.headerLabel = 'confirmation-modal.delete-eperson.header';
|
||||
modalRef.componentInstance.infoLabel = 'confirmation-modal.delete-eperson.info';
|
||||
modalRef.componentInstance.cancelLabel = 'confirmation-modal.delete-eperson.cancel';
|
||||
modalRef.componentInstance.confirmLabel = 'confirmation-modal.delete-eperson.confirm';
|
||||
modalRef.componentInstance.response.pipe(take(1)).subscribe((confirm: boolean) => {
|
||||
if (confirm) {
|
||||
if (hasValue(eperson.id)) {
|
||||
this.epersonService.deleteEPerson(eperson).pipe(take(1)).subscribe((restResponse: RestResponse) => {
|
||||
if (restResponse.isSuccessful) {
|
||||
this.notificationsService.success(this.translateService.get(this.labelPrefix + 'notification.deleted.success', { name: eperson.name }));
|
||||
this.reset();
|
||||
} else {
|
||||
this.notificationsService.error('Error occured when trying to delete EPerson with id: ' + eperson.id + ' with code: ' + restResponse.statusCode + ' and message: ' + restResponse.statusText);
|
||||
}
|
||||
this.cancelForm.emit();
|
||||
})
|
||||
}}
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop impersonating the EPerson
|
||||
*/
|
||||
@@ -420,4 +463,14 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
|
||||
this.onCancel();
|
||||
this.subs.filter((sub) => hasValue(sub)).forEach((sub) => sub.unsubscribe());
|
||||
}
|
||||
|
||||
/**
|
||||
* This method will ensure that the page gets reset and that the cache is cleared
|
||||
*/
|
||||
reset() {
|
||||
this.epersonService.getActiveEPerson().pipe(take(1)).subscribe((eperson: EPerson) => {
|
||||
this.requestService.removeByHrefSubstring(eperson.self);
|
||||
});
|
||||
this.initialisePage();
|
||||
}
|
||||
}
|
||||
|
@@ -20,6 +20,8 @@ import {
|
||||
COLLECTION_CREATE_PATH
|
||||
} from './collection-page-routing-paths';
|
||||
import { CollectionPageAdministratorGuard } from './collection-page-administrator.guard';
|
||||
import { MenuItemType } from '../shared/menu/initial-menus-state';
|
||||
import { LinkMenuItemModel } from '../shared/menu/menu-item/models/link.model';
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
@@ -69,7 +71,21 @@ import { CollectionPageAdministratorGuard } from './collection-page-administrato
|
||||
pathMatch: 'full',
|
||||
canActivate: [AuthenticatedGuard]
|
||||
}
|
||||
]
|
||||
],
|
||||
data: {
|
||||
menu: {
|
||||
public: [{
|
||||
id: 'statistics_collection_:id',
|
||||
active: true,
|
||||
visible: true,
|
||||
model: {
|
||||
type: MenuItemType.LINK,
|
||||
text: 'menu.section.statistics',
|
||||
link: 'statistics/collections/:id/',
|
||||
} as LinkMenuItemModel,
|
||||
}],
|
||||
},
|
||||
},
|
||||
},
|
||||
])
|
||||
],
|
||||
|
@@ -12,6 +12,8 @@ import { DSOBreadcrumbsService } from '../core/breadcrumbs/dso-breadcrumbs.servi
|
||||
import { LinkService } from '../core/cache/builders/link.service';
|
||||
import { COMMUNITY_EDIT_PATH, COMMUNITY_CREATE_PATH } from './community-page-routing-paths';
|
||||
import { CommunityPageAdministratorGuard } from './community-page-administrator.guard';
|
||||
import { MenuItemType } from '../shared/menu/initial-menus-state';
|
||||
import { LinkMenuItemModel } from '../shared/menu/menu-item/models/link.model';
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
@@ -45,7 +47,21 @@ import { CommunityPageAdministratorGuard } from './community-page-administrator.
|
||||
component: CommunityPageComponent,
|
||||
pathMatch: 'full',
|
||||
}
|
||||
]
|
||||
],
|
||||
data: {
|
||||
menu: {
|
||||
public: [{
|
||||
id: 'statistics_community_:id',
|
||||
active: true,
|
||||
visible: true,
|
||||
model: {
|
||||
type: MenuItemType.LINK,
|
||||
text: 'menu.section.statistics',
|
||||
link: 'statistics/communities/:id/',
|
||||
} as LinkMenuItemModel,
|
||||
}],
|
||||
},
|
||||
},
|
||||
},
|
||||
])
|
||||
],
|
||||
|
@@ -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
|
||||
}
|
||||
|
@@ -22,13 +22,14 @@ import { FieldChangeType } from '../../../core/data/object-updates/object-update
|
||||
import { MetadatumViewModel } from '../../../core/shared/metadata.models';
|
||||
import { RegistryService } from '../../../core/registry/registry.service';
|
||||
import { PaginatedList } from '../../../core/data/paginated-list';
|
||||
import { Metadata } from '../../../core/shared/metadata.utils';
|
||||
import { MetadataSchema } from '../../../core/metadata/metadata-schema.model';
|
||||
import { MetadataField } from '../../../core/metadata/metadata-field.model';
|
||||
import {
|
||||
createSuccessfulRemoteDataObject,
|
||||
createSuccessfulRemoteDataObject$
|
||||
} from '../../../shared/remote-data.utils';
|
||||
import { ObjectCacheService } from '../../../core/cache/object-cache.service';
|
||||
import { DSOSuccessResponse } from '../../../core/cache/response.models';
|
||||
|
||||
let comp: any;
|
||||
let fixture: ComponentFixture<ItemMetadataComponent>;
|
||||
@@ -43,6 +44,7 @@ const router = new RouterStub();
|
||||
let metadataFieldService;
|
||||
let paginatedMetadataFields;
|
||||
let routeStub;
|
||||
let objectCacheService;
|
||||
|
||||
const mdSchema = Object.assign(new MetadataSchema(), { prefix: 'dc' });
|
||||
const mdField1 = Object.assign(new MetadataField(), {
|
||||
@@ -101,6 +103,8 @@ const fieldUpdate3 = {
|
||||
changeType: undefined
|
||||
};
|
||||
|
||||
const operation1 = { op: 'remove', path: '/metadata/dc.title/1' };
|
||||
|
||||
let scheduler: TestScheduler;
|
||||
let item;
|
||||
describe('ItemMetadataComponent', () => {
|
||||
@@ -119,7 +123,9 @@ describe('ItemMetadataComponent', () => {
|
||||
;
|
||||
itemService = jasmine.createSpyObj('itemService', {
|
||||
update: createSuccessfulRemoteDataObject$(item),
|
||||
commitUpdates: {}
|
||||
commitUpdates: {},
|
||||
patch: observableOf(new DSOSuccessResponse(['item-selflink'], 200, 'OK')),
|
||||
findByHref: createSuccessfulRemoteDataObject$(item)
|
||||
});
|
||||
routeStub = {
|
||||
data: observableOf({}),
|
||||
@@ -148,9 +154,13 @@ describe('ItemMetadataComponent', () => {
|
||||
getLastModified: observableOf(date),
|
||||
hasUpdates: observableOf(true),
|
||||
isReinstatable: observableOf(false), // should always return something --> its in ngOnInit
|
||||
isValidPage: observableOf(true)
|
||||
isValidPage: observableOf(true),
|
||||
createPatch: observableOf([
|
||||
operation1
|
||||
])
|
||||
}
|
||||
);
|
||||
objectCacheService = jasmine.createSpyObj('objectCacheService', ['addPatch']);
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
imports: [SharedModule, TranslateModule.forRoot()],
|
||||
@@ -162,6 +172,7 @@ describe('ItemMetadataComponent', () => {
|
||||
{ provide: ActivatedRoute, useValue: routeStub },
|
||||
{ provide: NotificationsService, useValue: notificationsService },
|
||||
{ provide: RegistryService, useValue: metadataFieldService },
|
||||
{ provide: ObjectCacheService, useValue: objectCacheService },
|
||||
], schemas: [
|
||||
NO_ERRORS_SCHEMA
|
||||
]
|
||||
@@ -215,8 +226,8 @@ describe('ItemMetadataComponent', () => {
|
||||
});
|
||||
|
||||
it('it should call reinstateFieldUpdates on the objectUpdatesService with the correct url and metadata', () => {
|
||||
expect(objectUpdatesService.getUpdatedFields).toHaveBeenCalledWith(url, comp.item.metadataAsList);
|
||||
expect(itemService.update).toHaveBeenCalledWith(Object.assign(comp.item, { metadata: Metadata.toMetadataMap(comp.item.metadataAsList) }));
|
||||
expect(objectUpdatesService.createPatch).toHaveBeenCalledWith(url);
|
||||
expect(itemService.patch).toHaveBeenCalledWith(comp.item, [ operation1 ]);
|
||||
expect(objectUpdatesService.getFieldUpdates).toHaveBeenCalledWith(url, comp.item.metadataAsList);
|
||||
});
|
||||
});
|
||||
|
@@ -4,19 +4,19 @@ import { ItemDataService } from '../../../core/data/item-data.service';
|
||||
import { ObjectUpdatesService } from '../../../core/data/object-updates/object-updates.service';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { cloneDeep } from 'lodash';
|
||||
import { Observable } from 'rxjs';
|
||||
import { Identifiable } from '../../../core/data/object-updates/object-updates.reducer';
|
||||
import { first, switchMap, tap } from 'rxjs/operators';
|
||||
import { getSucceededRemoteData } from '../../../core/shared/operators';
|
||||
import { RemoteData } from '../../../core/data/remote-data';
|
||||
import { NotificationsService } from '../../../shared/notifications/notifications.service';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { MetadataValue, MetadatumViewModel } from '../../../core/shared/metadata.models';
|
||||
import { Metadata } from '../../../core/shared/metadata.utils';
|
||||
import { AbstractItemUpdateComponent } from '../abstract-item-update/abstract-item-update.component';
|
||||
import { UpdateDataService } from '../../../core/data/update-data.service';
|
||||
import { hasNoValue, hasValue } from '../../../shared/empty.util';
|
||||
import { hasNoValue, hasValue, isNotEmpty } from '../../../shared/empty.util';
|
||||
import { AlertType } from '../../../shared/alert/aletr-type';
|
||||
import { Operation } from 'fast-json-patch';
|
||||
import { METADATA_PATCH_OPERATION_SERVICE_TOKEN } from '../../../core/data/object-updates/patch-operation-service/metadata-patch-operation.service';
|
||||
import { DSOSuccessResponse, ErrorResponse } from '../../../core/cache/response.models';
|
||||
|
||||
@Component({
|
||||
selector: 'ds-item-metadata',
|
||||
@@ -87,7 +87,7 @@ export class ItemMetadataComponent extends AbstractItemUpdateComponent {
|
||||
* Sends all initial values of this item to the object updates service
|
||||
*/
|
||||
public initializeOriginalFields() {
|
||||
this.objectUpdatesService.initialize(this.url, this.item.metadataAsList, this.item.lastModified);
|
||||
this.objectUpdatesService.initialize(this.url, this.item.metadataAsList, this.item.lastModified, METADATA_PATCH_OPERATION_SERVICE_TOKEN);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -97,15 +97,23 @@ export class ItemMetadataComponent extends AbstractItemUpdateComponent {
|
||||
public submit() {
|
||||
this.isValid().pipe(first()).subscribe((isValid) => {
|
||||
if (isValid) {
|
||||
const metadata$: Observable<Identifiable[]> = this.objectUpdatesService.getUpdatedFields(this.url, this.item.metadataAsList) as Observable<MetadatumViewModel[]>;
|
||||
metadata$.pipe(
|
||||
this.objectUpdatesService.createPatch(this.url).pipe(
|
||||
first(),
|
||||
switchMap((metadata: MetadatumViewModel[]) => {
|
||||
const updatedItem: Item = Object.assign(cloneDeep(this.item), { metadata: Metadata.toMetadataMap(metadata) });
|
||||
return this.updateService.update(updatedItem);
|
||||
}),
|
||||
tap(() => this.updateService.commitUpdates()),
|
||||
getSucceededRemoteData()
|
||||
switchMap((patch: Operation[]) => {
|
||||
return this.updateService.patch(this.item, patch).pipe(
|
||||
tap((response) => {
|
||||
if (!response.isSuccessful) {
|
||||
this.notificationsService.error(this.getNotificationTitle('error'), (response as ErrorResponse).errorMessage);
|
||||
}
|
||||
}),
|
||||
switchMap((response: DSOSuccessResponse) => {
|
||||
if (isNotEmpty(response.resourceSelfLinks)) {
|
||||
return this.itemService.findByHref(response.resourceSelfLinks[0]);
|
||||
}
|
||||
}),
|
||||
getSucceededRemoteData()
|
||||
);
|
||||
})
|
||||
).subscribe(
|
||||
(rd: RemoteData<Item>) => {
|
||||
this.item = rd.payload;
|
||||
|
@@ -1,87 +1,87 @@
|
||||
<ds-metadata-field-wrapper [label]="label | translate">
|
||||
<div *ngVar="(originals$ | async)?.payload as originals">
|
||||
<h5 class="simple-view-element-header">{{"item.page.filesection.original.bundle" | translate}}</h5>
|
||||
<ds-pagination *ngIf="originals?.page?.length > 0"
|
||||
[hideGear]="true"
|
||||
[hidePagerWhenSinglePage]="true"
|
||||
[paginationOptions]="originalOptions"
|
||||
[pageInfoState]="originals"
|
||||
[collectionSize]="originals?.totalElements"
|
||||
[disableRouteParameterUpdate]="true"
|
||||
(pageChange)="switchOriginalPage($event)">
|
||||
<div *ngIf="hasValuesInBundle(originals)">
|
||||
<h5 class="simple-view-element-header">{{"item.page.filesection.original.bundle" | translate}}</h5>
|
||||
<ds-pagination *ngIf="originals?.page?.length > 0"
|
||||
[hideGear]="true"
|
||||
[hidePagerWhenSinglePage]="true"
|
||||
[paginationOptions]="originalOptions"
|
||||
[pageInfoState]="originals"
|
||||
[collectionSize]="originals?.totalElements"
|
||||
[disableRouteParameterUpdate]="true"
|
||||
(pageChange)="switchOriginalPage($event)">
|
||||
|
||||
|
||||
<div class="file-section row" *ngFor="let file of originals?.page;">
|
||||
<div class="col-3">
|
||||
<ds-thumbnail [thumbnail]="(file.thumbnail | async)?.payload"></ds-thumbnail>
|
||||
</div>
|
||||
<div class="col-7">
|
||||
<dl class="row">
|
||||
<dt class="col-md-4">{{"item.page.filesection.name" | translate}}</dt>
|
||||
<dd class="col-md-8">{{file.name}}</dd>
|
||||
|
||||
<div class="file-section row" *ngFor="let file of originals?.page;">
|
||||
<div class="col-3">
|
||||
<ds-thumbnail [thumbnail]="(file.thumbnail | async)?.payload"></ds-thumbnail>
|
||||
<dt class="col-md-4">{{"item.page.filesection.size" | translate}}</dt>
|
||||
<dd class="col-md-8">{{(file.sizeBytes) | dsFileSize }}</dd>
|
||||
|
||||
|
||||
<dt class="col-md-4">{{"item.page.filesection.format" | translate}}</dt>
|
||||
<dd class="col-md-8">{{(file.format | async)?.payload?.description}}</dd>
|
||||
|
||||
|
||||
<dt class="col-md-4">{{"item.page.filesection.description" | translate}}</dt>
|
||||
<dd class="col-md-8">{{file.firstMetadataValue("dc.description")}}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
<div class="col-2">
|
||||
<ds-file-download-link [href]="file._links.content.href" [download]="file.name">
|
||||
{{"item.page.filesection.download" | translate}}
|
||||
</ds-file-download-link>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-7">
|
||||
<dl class="row">
|
||||
<dt class="col-md-4">{{"item.page.filesection.name" | translate}}</dt>
|
||||
<dd class="col-md-8">{{file.name}}</dd>
|
||||
|
||||
<dt class="col-md-4">{{"item.page.filesection.size" | translate}}</dt>
|
||||
<dd class="col-md-8">{{(file.sizeBytes) | dsFileSize }}</dd>
|
||||
|
||||
|
||||
<dt class="col-md-4">{{"item.page.filesection.format" | translate}}</dt>
|
||||
<dd class="col-md-8">{{(file.format | async)?.payload?.description}}</dd>
|
||||
|
||||
|
||||
<dt class="col-md-4">{{"item.page.filesection.description" | translate}}</dt>
|
||||
<dd class="col-md-8">{{file.firstMetadataValue("dc.description")}}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
<div class="col-2">
|
||||
<ds-file-download-link [href]="file._links.content.href" [download]="file.name">
|
||||
{{"item.page.filesection.download" | translate}}
|
||||
</ds-file-download-link>
|
||||
</div>
|
||||
</div>
|
||||
</ds-pagination>
|
||||
</ds-pagination>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div *ngVar="(licenses$ | async)?.payload as licenses">
|
||||
<h5 class="simple-view-element-header">{{"item.page.filesection.license.bundle" | translate}}</h5>
|
||||
<ds-pagination *ngIf="licenses?.page?.length > 0"
|
||||
[hideGear]="true"
|
||||
[hidePagerWhenSinglePage]="true"
|
||||
[paginationOptions]="licenseOptions"
|
||||
[pageInfoState]="licenses"
|
||||
[collectionSize]="licenses?.totalElements"
|
||||
[disableRouteParameterUpdate]="true"
|
||||
(pageChange)="switchLicensePage($event)">
|
||||
<div *ngIf="hasValuesInBundle(licenses)">
|
||||
<h5 class="simple-view-element-header">{{"item.page.filesection.license.bundle" | translate}}</h5>
|
||||
<ds-pagination *ngIf="licenses?.page?.length > 0"
|
||||
[hideGear]="true"
|
||||
[hidePagerWhenSinglePage]="true"
|
||||
[paginationOptions]="licenseOptions"
|
||||
[pageInfoState]="licenses"
|
||||
[collectionSize]="licenses?.totalElements"
|
||||
[disableRouteParameterUpdate]="true"
|
||||
(pageChange)="switchLicensePage($event)">
|
||||
|
||||
|
||||
<div class="file-section row" *ngFor="let file of licenses?.page;">
|
||||
<div class="col-3">
|
||||
<ds-thumbnail [thumbnail]="(file.thumbnail | async)?.payload"></ds-thumbnail>
|
||||
</div>
|
||||
<div class="col-7">
|
||||
<dl class="row">
|
||||
<dt class="col-md-4">{{"item.page.filesection.name" | translate}}</dt>
|
||||
<dd class="col-md-8">{{file.name}}</dd>
|
||||
|
||||
<div class="file-section row" *ngFor="let file of licenses?.page;">
|
||||
<div class="col-3">
|
||||
<ds-thumbnail [thumbnail]="(file.thumbnail | async)?.payload"></ds-thumbnail>
|
||||
<dt class="col-md-4">{{"item.page.filesection.size" | translate}}</dt>
|
||||
<dd class="col-md-8">{{(file.sizeBytes) | dsFileSize }}</dd>
|
||||
|
||||
<dt class="col-md-4">{{"item.page.filesection.format" | translate}}</dt>
|
||||
<dd class="col-md-8">{{(file.format | async)?.payload?.description}}</dd>
|
||||
|
||||
|
||||
<dt class="col-md-4">{{"item.page.filesection.description" | translate}}</dt>
|
||||
<dd class="col-md-8">{{file.firstMetadataValue("dc.description")}}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
<div class="col-2">
|
||||
<ds-file-download-link [href]="file._links.content.href" [download]="file.name">
|
||||
{{"item.page.filesection.download" | translate}}
|
||||
</ds-file-download-link>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-7">
|
||||
<dl class="row">
|
||||
<dt class="col-md-4">{{"item.page.filesection.name" | translate}}</dt>
|
||||
<dd class="col-md-8">{{file.name}}</dd>
|
||||
|
||||
<dt class="col-md-4">{{"item.page.filesection.size" | translate}}</dt>
|
||||
<dd class="col-md-8">{{(file.sizeBytes) | dsFileSize }}</dd>
|
||||
|
||||
|
||||
<dt class="col-md-4">{{"item.page.filesection.format" | translate}}</dt>
|
||||
<dd class="col-md-8">{{(file.format | async)?.payload?.description}}</dd>
|
||||
|
||||
|
||||
<dt class="col-md-4">{{"item.page.filesection.description" | translate}}</dt>
|
||||
<dd class="col-md-8">{{file.firstMetadataValue("dc.description")}}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
<div class="col-2">
|
||||
<ds-file-download-link [href]="file._links.content.href" [download]="file.name">
|
||||
{{"item.page.filesection.download" | translate}}
|
||||
</ds-file-download-link>
|
||||
</div>
|
||||
</div>
|
||||
</ds-pagination>
|
||||
</ds-pagination>
|
||||
</div>
|
||||
</div>
|
||||
</ds-metadata-field-wrapper>
|
||||
|
@@ -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]
|
||||
|
@@ -10,6 +10,10 @@ import { PaginationComponentOptions } from '../../../../shared/pagination/pagina
|
||||
import { PaginatedList } from '../../../../core/data/paginated-list';
|
||||
import { RemoteData } from '../../../../core/data/remote-data';
|
||||
import { switchMap } from 'rxjs/operators';
|
||||
import { NotificationsService } from '../../../../shared/notifications/notifications.service';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { hasValue, isEmpty } from '../../../../shared/empty.util';
|
||||
import { tap } from 'rxjs/internal/operators/tap';
|
||||
|
||||
/**
|
||||
* This component renders the file section of the item
|
||||
@@ -31,14 +35,14 @@ export class FullFileSectionComponent extends FileSectionComponent implements On
|
||||
licenses$: Observable<RemoteData<PaginatedList<Bitstream>>>;
|
||||
|
||||
pageSize = 5;
|
||||
originalOptions = Object.assign(new PaginationComponentOptions(),{
|
||||
originalOptions = Object.assign(new PaginationComponentOptions(), {
|
||||
id: 'original-bitstreams-options',
|
||||
currentPage: 1,
|
||||
pageSize: this.pageSize
|
||||
});
|
||||
originalCurrentPage$ = new BehaviorSubject<number>(1);
|
||||
|
||||
licenseOptions = Object.assign(new PaginationComponentOptions(),{
|
||||
licenseOptions = Object.assign(new PaginationComponentOptions(), {
|
||||
id: 'license-bitstreams-options',
|
||||
currentPage: 1,
|
||||
pageSize: this.pageSize
|
||||
@@ -46,9 +50,11 @@ export class FullFileSectionComponent extends FileSectionComponent implements On
|
||||
licenseCurrentPage$ = new BehaviorSubject<number>(1);
|
||||
|
||||
constructor(
|
||||
bitstreamDataService: BitstreamDataService
|
||||
bitstreamDataService: BitstreamDataService,
|
||||
protected notificationsService: NotificationsService,
|
||||
protected translateService: TranslateService
|
||||
) {
|
||||
super(bitstreamDataService);
|
||||
super(bitstreamDataService, notificationsService, translateService);
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
@@ -57,21 +63,33 @@ export class FullFileSectionComponent extends FileSectionComponent implements On
|
||||
|
||||
initialize(): void {
|
||||
this.originals$ = this.originalCurrentPage$.pipe(
|
||||
switchMap((pageNumber: number) => this.bitstreamDataService.findAllByItemAndBundleName(
|
||||
this.item,
|
||||
'ORIGINAL',
|
||||
{ elementsPerPage: this.pageSize, currentPage: pageNumber },
|
||||
followLink( 'format')
|
||||
))
|
||||
switchMap((pageNumber: number) => this.bitstreamDataService.findAllByItemAndBundleName(
|
||||
this.item,
|
||||
'ORIGINAL',
|
||||
{elementsPerPage: this.pageSize, currentPage: pageNumber},
|
||||
followLink('format')
|
||||
)),
|
||||
tap((rd: RemoteData<PaginatedList<Bitstream>>) => {
|
||||
if (hasValue(rd.error)) {
|
||||
this.notificationsService.error(this.translateService.get('file-section.error.header'), `${rd.error.statusCode} ${rd.error.message}`);
|
||||
}
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
this.licenses$ = this.licenseCurrentPage$.pipe(
|
||||
switchMap((pageNumber: number) => this.bitstreamDataService.findAllByItemAndBundleName(
|
||||
this.item,
|
||||
'LICENSE',
|
||||
{ elementsPerPage: this.pageSize, currentPage: pageNumber },
|
||||
followLink( 'format')
|
||||
))
|
||||
switchMap((pageNumber: number) => this.bitstreamDataService.findAllByItemAndBundleName(
|
||||
this.item,
|
||||
'LICENSE',
|
||||
{elementsPerPage: this.pageSize, currentPage: pageNumber},
|
||||
followLink('format')
|
||||
)),
|
||||
tap((rd: RemoteData<PaginatedList<Bitstream>>) => {
|
||||
if (hasValue(rd.error)) {
|
||||
this.notificationsService.error(this.translateService.get('file-section.error.header'), `${rd.error.statusCode} ${rd.error.message}`);
|
||||
}
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
}
|
||||
@@ -93,4 +111,8 @@ export class FullFileSectionComponent extends FileSectionComponent implements On
|
||||
this.licenseOptions.currentPage = page;
|
||||
this.licenseCurrentPage$.next(page);
|
||||
}
|
||||
|
||||
hasValuesInBundle(bundle: PaginatedList<Bitstream>) {
|
||||
return hasValue(bundle) && !isEmpty(bundle.page);
|
||||
}
|
||||
}
|
||||
|
@@ -11,6 +11,8 @@ import { LinkService } from '../core/cache/builders/link.service';
|
||||
import { UploadBitstreamComponent } from './bitstreams/upload/upload-bitstream.component';
|
||||
import { UPLOAD_BITSTREAM_PATH, ITEM_EDIT_PATH } from './item-page-routing-paths';
|
||||
import { ItemPageAdministratorGuard } from './item-page-administrator.guard';
|
||||
import { MenuItemType } from '../shared/menu/initial-menus-state';
|
||||
import { LinkMenuItemModel } from '../shared/menu/menu-item/models/link.model';
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
@@ -43,6 +45,20 @@ import { ItemPageAdministratorGuard } from './item-page-administrator.guard';
|
||||
canActivate: [AuthenticatedGuard]
|
||||
}
|
||||
],
|
||||
data: {
|
||||
menu: {
|
||||
public: [{
|
||||
id: 'statistics_item_:id',
|
||||
active: true,
|
||||
visible: true,
|
||||
model: {
|
||||
type: MenuItemType.LINK,
|
||||
text: 'menu.section.statistics',
|
||||
link: 'statistics/items/:id/',
|
||||
} as LinkMenuItemModel,
|
||||
}],
|
||||
},
|
||||
},
|
||||
}
|
||||
])
|
||||
],
|
||||
|
@@ -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]
|
||||
|
@@ -4,10 +4,12 @@ import { BitstreamDataService } from '../../../../core/data/bitstream-data.servi
|
||||
|
||||
import { Bitstream } from '../../../../core/shared/bitstream.model';
|
||||
import { Item } from '../../../../core/shared/item.model';
|
||||
import { filter, takeWhile } from 'rxjs/operators';
|
||||
import { filter, take } from 'rxjs/operators';
|
||||
import { RemoteData } from '../../../../core/data/remote-data';
|
||||
import { hasNoValue, hasValue } from '../../../../shared/empty.util';
|
||||
import { hasValue } from '../../../../shared/empty.util';
|
||||
import { PaginatedList } from '../../../../core/data/paginated-list';
|
||||
import { NotificationsService } from '../../../../shared/notifications/notifications.service';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
|
||||
/**
|
||||
* This component renders the file section of the item
|
||||
@@ -36,7 +38,9 @@ export class FileSectionComponent implements OnInit {
|
||||
pageSize = 5;
|
||||
|
||||
constructor(
|
||||
protected bitstreamDataService: BitstreamDataService
|
||||
protected bitstreamDataService: BitstreamDataService,
|
||||
protected notificationsService: NotificationsService,
|
||||
protected translateService: TranslateService
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -58,14 +62,21 @@ export class FileSectionComponent implements OnInit {
|
||||
} else {
|
||||
this.currentPage++;
|
||||
}
|
||||
this.bitstreamDataService.findAllByItemAndBundleName(this.item, 'ORIGINAL', { currentPage: this.currentPage, elementsPerPage: this.pageSize }).pipe(
|
||||
filter((bitstreamsRD: RemoteData<PaginatedList<Bitstream>>) => hasValue(bitstreamsRD)),
|
||||
takeWhile((bitstreamsRD: RemoteData<PaginatedList<Bitstream>>) => hasNoValue(bitstreamsRD.payload) && hasNoValue(bitstreamsRD.error), true)
|
||||
this.bitstreamDataService.findAllByItemAndBundleName(this.item, 'ORIGINAL', {
|
||||
currentPage: this.currentPage,
|
||||
elementsPerPage: this.pageSize
|
||||
}).pipe(
|
||||
filter((bitstreamsRD: RemoteData<PaginatedList<Bitstream>>) => hasValue(bitstreamsRD) && (hasValue(bitstreamsRD.error) || hasValue(bitstreamsRD.payload))),
|
||||
take(1),
|
||||
).subscribe((bitstreamsRD: RemoteData<PaginatedList<Bitstream>>) => {
|
||||
const current: Bitstream[] = this.bitstreams$.getValue();
|
||||
this.bitstreams$.next([...current, ...bitstreamsRD.payload.page]);
|
||||
this.isLoading = false;
|
||||
this.isLastPage = this.currentPage === bitstreamsRD.payload.totalPages;
|
||||
if (bitstreamsRD.error) {
|
||||
this.notificationsService.error(this.translateService.get('file-section.error.header'), `${bitstreamsRD.error.statusCode} ${bitstreamsRD.error.message}`);
|
||||
} else if (hasValue(bitstreamsRD.payload)) {
|
||||
const current: Bitstream[] = this.bitstreams$.getValue();
|
||||
this.bitstreams$.next([...current, ...bitstreamsRD.payload.page]);
|
||||
this.isLoading = false;
|
||||
this.isLastPage = this.currentPage === bitstreamsRD.payload.totalPages;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@@ -69,6 +69,10 @@ import { SiteRegisterGuard } from './core/data/feature-authorization/feature-aut
|
||||
{ path: 'processes', loadChildren: './process-page/process-page.module#ProcessPageModule', canActivate: [AuthenticatedGuard, EndUserAgreementCurrentUserGuard] },
|
||||
{ path: INFO_MODULE_PATH, loadChildren: './info/info.module#InfoModule' },
|
||||
{ path: UNAUTHORIZED_PATH, component: UnauthorizedComponent },
|
||||
{
|
||||
path: 'statistics',
|
||||
loadChildren: './statistics-page/statistics-page-routing.module#StatisticsPageRoutingModule',
|
||||
},
|
||||
{ path: '**', pathMatch: 'full', component: PageNotFoundComponent },
|
||||
]}
|
||||
],
|
||||
|
6
src/app/core/cache/object-cache.service.ts
vendored
6
src/app/core/cache/object-cache.service.ts
vendored
@@ -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));
|
||||
|
@@ -171,6 +171,7 @@ import { EndUserAgreementCurrentUserGuard } from './end-user-agreement/end-user-
|
||||
import { EndUserAgreementCookieGuard } from './end-user-agreement/end-user-agreement-cookie.guard';
|
||||
import { EndUserAgreementService } from './end-user-agreement/end-user-agreement.service';
|
||||
import { SiteRegisterGuard } from './data/feature-authorization/feature-authorization-guard/site-register.guard';
|
||||
import { UsageReport } from './statistics/models/usage-report.model';
|
||||
|
||||
/**
|
||||
* When not in production, endpoint responses can be mocked for testing purposes
|
||||
@@ -371,7 +372,8 @@ export const models =
|
||||
Vocabulary,
|
||||
VocabularyEntry,
|
||||
VocabularyEntryDetail,
|
||||
ConfigurationProperty
|
||||
ConfigurationProperty,
|
||||
UsageReport,
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
|
@@ -30,6 +30,8 @@ import { RestResponse } from '../cache/response.models';
|
||||
import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service';
|
||||
import { configureRequest, getResponseFromEntry } from '../shared/operators';
|
||||
import { combineLatest as observableCombineLatest } from 'rxjs';
|
||||
import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils';
|
||||
import { PageInfo } from '../shared/page-info.model';
|
||||
|
||||
/**
|
||||
* A service to retrieve {@link Bitstream}s from the REST API
|
||||
@@ -165,8 +167,10 @@ export class BitstreamDataService extends DataService<Bitstream> {
|
||||
public findAllByItemAndBundleName(item: Item, bundleName: string, options?: FindListOptions, ...linksToFollow: Array<FollowLinkConfig<Bitstream>>): Observable<RemoteData<PaginatedList<Bitstream>>> {
|
||||
return this.bundleService.findByItemAndName(item, bundleName).pipe(
|
||||
switchMap((bundleRD: RemoteData<Bundle>) => {
|
||||
if (hasValue(bundleRD.payload)) {
|
||||
if (bundleRD.hasSucceeded && hasValue(bundleRD.payload)) {
|
||||
return this.findAllByBundle(bundleRD.payload, options, ...linksToFollow);
|
||||
} else if (!bundleRD.hasSucceeded && bundleRD.error.statusCode === 404) {
|
||||
return createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), []))
|
||||
} else {
|
||||
return [bundleRD as any];
|
||||
}
|
||||
|
@@ -22,6 +22,7 @@ import { FindListOptions, GetRequest } from './request.models';
|
||||
import { RequestService } from './request.service';
|
||||
import { PaginatedSearchOptions } from '../../shared/search/paginated-search-options.model';
|
||||
import { Bitstream } from '../shared/bitstream.model';
|
||||
import { RemoteDataError } from './remote-data-error';
|
||||
|
||||
/**
|
||||
* A service to retrieve {@link Bundle}s from the REST API
|
||||
@@ -71,13 +72,17 @@ export class BundleDataService extends DataService<Bundle> {
|
||||
if (hasValue(rd.payload) && hasValue(rd.payload.page)) {
|
||||
const matchingBundle = rd.payload.page.find((bundle: Bundle) =>
|
||||
bundle.name === bundleName);
|
||||
return new RemoteData(
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
undefined,
|
||||
matchingBundle
|
||||
);
|
||||
if (hasValue(matchingBundle)) {
|
||||
return new RemoteData(
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
undefined,
|
||||
matchingBundle
|
||||
);
|
||||
} else {
|
||||
return new RemoteData(false, false, false, new RemoteDataError(404, 'Not found', `The bundle with name ${bundleName} was not found.` ))
|
||||
}
|
||||
} else {
|
||||
return rd as any;
|
||||
}
|
||||
|
@@ -4,6 +4,7 @@
|
||||
export enum FeatureID {
|
||||
LoginOnBehalfOf = 'loginOnBehalfOf',
|
||||
AdministratorOf = 'administratorOf',
|
||||
CanDelete = 'canDelete',
|
||||
WithdrawItem = 'withdrawItem',
|
||||
ReinstateItem = 'reinstateItem',
|
||||
EPersonRegistration = 'epersonRegistration',
|
||||
|
@@ -20,6 +20,7 @@ import { switchMap, map } from 'rxjs/operators';
|
||||
import { BundleDataService } from './bundle-data.service';
|
||||
import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model';
|
||||
import { RestResponse } from '../cache/response.models';
|
||||
import { Operation } from 'fast-json-patch';
|
||||
|
||||
/* tslint:disable:max-classes-per-file */
|
||||
/**
|
||||
@@ -165,6 +166,10 @@ export class ItemTemplateDataService implements UpdateDataService<Item> {
|
||||
return this.dataService.update(object);
|
||||
}
|
||||
|
||||
patch(dso: Item, operations: Operation[]): Observable<RestResponse> {
|
||||
return this.dataService.patch(dso, operations);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find an item template by collection ID
|
||||
* @param collectionID
|
||||
|
@@ -2,6 +2,8 @@ import {type} from '../../../shared/ngrx/type';
|
||||
import {Action} from '@ngrx/store';
|
||||
import {Identifiable} from './object-updates.reducer';
|
||||
import {INotification} from '../../../shared/notifications/models/notification.model';
|
||||
import { InjectionToken } from '@angular/core';
|
||||
import { PatchOperationService } from './patch-operation-service/patch-operation.service';
|
||||
|
||||
/**
|
||||
* The list of ObjectUpdatesAction type definitions
|
||||
@@ -38,7 +40,8 @@ export class InitializeFieldsAction implements Action {
|
||||
payload: {
|
||||
url: string,
|
||||
fields: Identifiable[],
|
||||
lastModified: Date
|
||||
lastModified: Date,
|
||||
patchOperationServiceToken?: InjectionToken<PatchOperationService>
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -48,16 +51,15 @@ export class InitializeFieldsAction implements Action {
|
||||
* the unique url of the page for which the fields are being initialized
|
||||
* @param fields The identifiable fields of which the updates are kept track of
|
||||
* @param lastModified The last modified date of the object that belongs to the page
|
||||
* @param order A custom order to keep track of objects moving around
|
||||
* @param pageSize The page size used to fill empty pages for the custom order
|
||||
* @param page The first page to populate in the custom order
|
||||
* @param patchOperationServiceToken An InjectionToken referring to the {@link PatchOperationService} used for creating a patch
|
||||
*/
|
||||
constructor(
|
||||
url: string,
|
||||
fields: Identifiable[],
|
||||
lastModified: Date
|
||||
lastModified: Date,
|
||||
patchOperationServiceToken?: InjectionToken<PatchOperationService>
|
||||
) {
|
||||
this.payload = { url, fields, lastModified };
|
||||
this.payload = { url, fields, lastModified, patchOperationServiceToken };
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -231,7 +231,8 @@ describe('objectUpdatesReducer', () => {
|
||||
},
|
||||
fieldUpdates: {},
|
||||
virtualMetadataSources: {},
|
||||
lastModified: modDate
|
||||
lastModified: modDate,
|
||||
patchOperationServiceToken: undefined
|
||||
}
|
||||
};
|
||||
const newState = objectUpdatesReducer(testState, action);
|
||||
|
@@ -14,6 +14,8 @@ import {
|
||||
} from './object-updates.actions';
|
||||
import { hasNoValue, hasValue } from '../../../shared/empty.util';
|
||||
import {Relationship} from '../../shared/item-relationships/relationship.model';
|
||||
import { InjectionToken } from '@angular/core';
|
||||
import { PatchOperationService } from './patch-operation-service/patch-operation.service';
|
||||
|
||||
/**
|
||||
* Path where discarded objects are saved
|
||||
@@ -48,7 +50,7 @@ export interface Identifiable {
|
||||
*/
|
||||
export interface FieldUpdate {
|
||||
field: Identifiable,
|
||||
changeType: FieldChangeType
|
||||
changeType: FieldChangeType,
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -89,6 +91,7 @@ export interface ObjectUpdatesEntry {
|
||||
fieldUpdates: FieldUpdates;
|
||||
virtualMetadataSources: VirtualMetadataSources;
|
||||
lastModified: Date;
|
||||
patchOperationServiceToken?: InjectionToken<PatchOperationService>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -163,6 +166,7 @@ function initializeFieldsUpdate(state: any, action: InitializeFieldsAction) {
|
||||
const url: string = action.payload.url;
|
||||
const fields: Identifiable[] = action.payload.fields;
|
||||
const lastModifiedServer: Date = action.payload.lastModified;
|
||||
const patchOperationServiceToken: InjectionToken<PatchOperationService> = action.payload.patchOperationServiceToken;
|
||||
const fieldStates = createInitialFieldStates(fields);
|
||||
const newPageState = Object.assign(
|
||||
{},
|
||||
@@ -170,7 +174,8 @@ function initializeFieldsUpdate(state: any, action: InitializeFieldsAction) {
|
||||
{ fieldStates: fieldStates },
|
||||
{ fieldUpdates: {} },
|
||||
{ virtualMetadataSources: {} },
|
||||
{ lastModified: lastModifiedServer }
|
||||
{ lastModified: lastModifiedServer },
|
||||
{ patchOperationServiceToken }
|
||||
);
|
||||
return Object.assign({}, state, { [url]: newPageState });
|
||||
}
|
||||
|
@@ -12,6 +12,7 @@ import { Notification } from '../../../shared/notifications/models/notification.
|
||||
import { NotificationType } from '../../../shared/notifications/models/notification-type';
|
||||
import { OBJECT_UPDATES_TRASH_PATH } from './object-updates.reducer';
|
||||
import {Relationship} from '../../shared/item-relationships/relationship.model';
|
||||
import { Injector } from '@angular/core';
|
||||
|
||||
describe('ObjectUpdatesService', () => {
|
||||
let service: ObjectUpdatesService;
|
||||
@@ -31,6 +32,9 @@ describe('ObjectUpdatesService', () => {
|
||||
};
|
||||
|
||||
const modDate = new Date(2010, 2, 11);
|
||||
const injectionToken = 'fake-injection-token';
|
||||
let patchOperationService;
|
||||
let injector: Injector;
|
||||
|
||||
beforeEach(() => {
|
||||
const fieldStates = {
|
||||
@@ -40,11 +44,17 @@ describe('ObjectUpdatesService', () => {
|
||||
};
|
||||
|
||||
const objectEntry = {
|
||||
fieldStates, fieldUpdates, lastModified: modDate, virtualMetadataSources: {}
|
||||
fieldStates, fieldUpdates, lastModified: modDate, virtualMetadataSources: {}, patchOperationServiceToken: injectionToken
|
||||
};
|
||||
store = new Store<CoreState>(undefined, undefined, undefined);
|
||||
spyOn(store, 'dispatch');
|
||||
service = new ObjectUpdatesService(store);
|
||||
patchOperationService = jasmine.createSpyObj('patchOperationService', {
|
||||
fieldUpdatesToPatchOperations: []
|
||||
});
|
||||
injector = jasmine.createSpyObj('injector', {
|
||||
get: patchOperationService
|
||||
});
|
||||
service = new ObjectUpdatesService(store, injector);
|
||||
|
||||
spyOn(service as any, 'getObjectEntry').and.returnValue(observableOf(objectEntry));
|
||||
spyOn(service as any, 'getFieldState').and.callFake((uuid) => {
|
||||
@@ -277,4 +287,26 @@ describe('ObjectUpdatesService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('createPatch', () => {
|
||||
let result$;
|
||||
|
||||
beforeEach(() => {
|
||||
result$ = service.createPatch(url);
|
||||
});
|
||||
|
||||
it('should inject the service using the token stored in the entry', (done) => {
|
||||
result$.subscribe(() => {
|
||||
expect(injector.get).toHaveBeenCalledWith(injectionToken);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('should create a patch from the fieldUpdates using the injected service', (done) => {
|
||||
result$.subscribe(() => {
|
||||
expect(patchOperationService.fieldUpdatesToPatchOperations).toHaveBeenCalledWith(fieldUpdates);
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
@@ -1,4 +1,4 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Injectable, InjectionToken, Injector } from '@angular/core';
|
||||
import { createSelector, MemoizedSelector, select, Store } from '@ngrx/store';
|
||||
import { CoreState } from '../../core.reducers';
|
||||
import { coreSelector } from '../../core.selectors';
|
||||
@@ -26,6 +26,8 @@ import {
|
||||
import { distinctUntilChanged, filter, map, switchMap } from 'rxjs/operators';
|
||||
import { hasNoValue, hasValue, isEmpty, isNotEmpty, isNotEmptyOperator } from '../../../shared/empty.util';
|
||||
import { INotification } from '../../../shared/notifications/models/notification.model';
|
||||
import { Operation } from 'fast-json-patch';
|
||||
import { PatchOperationService } from './patch-operation-service/patch-operation.service';
|
||||
|
||||
function objectUpdatesStateSelector(): MemoizedSelector<CoreState, ObjectUpdatesState> {
|
||||
return createSelector(coreSelector, (state: CoreState) => state['cache/object-updates']);
|
||||
@@ -48,7 +50,8 @@ function virtualMetadataSourceSelector(url: string, source: string): MemoizedSel
|
||||
*/
|
||||
@Injectable()
|
||||
export class ObjectUpdatesService {
|
||||
constructor(private store: Store<CoreState>) {
|
||||
constructor(private store: Store<CoreState>,
|
||||
private injector: Injector) {
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -56,9 +59,10 @@ export class ObjectUpdatesService {
|
||||
* @param url The page's URL for which the changes are being mapped
|
||||
* @param fields The initial fields for the page's object
|
||||
* @param lastModified The date the object was last modified
|
||||
* @param patchOperationServiceToken An InjectionToken referring to the {@link PatchOperationService} used for creating a patch
|
||||
*/
|
||||
initialize(url, fields: Identifiable[], lastModified: Date): void {
|
||||
this.store.dispatch(new InitializeFieldsAction(url, fields, lastModified));
|
||||
initialize(url, fields: Identifiable[], lastModified: Date, patchOperationServiceToken?: InjectionToken<PatchOperationService>): void {
|
||||
this.store.dispatch(new InitializeFieldsAction(url, fields, lastModified, patchOperationServiceToken));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -339,4 +343,22 @@ export class ObjectUpdatesService {
|
||||
getLastModified(url: string): Observable<Date> {
|
||||
return this.getObjectEntry(url).pipe(map((entry: ObjectUpdatesEntry) => entry.lastModified));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a patch from the current object-updates state
|
||||
* The {@link ObjectUpdatesEntry} should contain a patchOperationServiceToken, in order to define how a patch should
|
||||
* be created. If it doesn't, an empty patch will be returned.
|
||||
* @param url The URL of the page for which the patch should be created
|
||||
*/
|
||||
createPatch(url: string): Observable<Operation[]> {
|
||||
return this.getObjectEntry(url).pipe(
|
||||
map((entry) => {
|
||||
let patch = [];
|
||||
if (hasValue(entry.patchOperationServiceToken)) {
|
||||
patch = this.injector.get(entry.patchOperationServiceToken).fieldUpdatesToPatchOperations(entry.fieldUpdates);
|
||||
}
|
||||
return patch;
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
@@ -0,0 +1,106 @@
|
||||
import { PatchOperationService } from './patch-operation.service';
|
||||
import { MetadatumViewModel } from '../../../shared/metadata.models';
|
||||
import { FieldUpdates } from '../object-updates.reducer';
|
||||
import { Operation } from 'fast-json-patch';
|
||||
import { FieldChangeType } from '../object-updates.actions';
|
||||
import { InjectionToken } from '@angular/core';
|
||||
import { MetadataPatchOperation } from './operations/metadata/metadata-patch-operation.model';
|
||||
import { hasValue } from '../../../../shared/empty.util';
|
||||
import { MetadataPatchAddOperation } from './operations/metadata/metadata-patch-add-operation.model';
|
||||
import { MetadataPatchRemoveOperation } from './operations/metadata/metadata-patch-remove-operation.model';
|
||||
import { MetadataPatchReplaceOperation } from './operations/metadata/metadata-patch-replace-operation.model';
|
||||
|
||||
/**
|
||||
* Token to use for injecting this service anywhere you want
|
||||
* This token can used to store in the object-updates store
|
||||
*/
|
||||
export const METADATA_PATCH_OPERATION_SERVICE_TOKEN = new InjectionToken<MetadataPatchOperationService>('MetadataPatchOperationService', {
|
||||
providedIn: 'root',
|
||||
factory: () => new MetadataPatchOperationService(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Service transforming {@link FieldUpdates} into {@link Operation}s for metadata values
|
||||
* This expects the fields within every {@link FieldUpdate} to be {@link MetadatumViewModel}s
|
||||
*/
|
||||
export class MetadataPatchOperationService implements PatchOperationService {
|
||||
|
||||
/**
|
||||
* Transform a {@link FieldUpdates} object into an array of fast-json-patch Operations for metadata values
|
||||
* This method first creates an array of {@link MetadataPatchOperation} wrapper operations, which are then
|
||||
* iterated over to create the actual patch operations. While iterating, it has the ability to check for previous
|
||||
* operations that would modify the operation's position and act accordingly.
|
||||
* @param fieldUpdates
|
||||
*/
|
||||
fieldUpdatesToPatchOperations(fieldUpdates: FieldUpdates): Operation[] {
|
||||
const metadataPatch = this.fieldUpdatesToMetadataPatchOperations(fieldUpdates);
|
||||
|
||||
// This map stores what metadata fields had a value deleted at which places
|
||||
// This is used to modify the place of operations to match previous operations
|
||||
const metadataRemoveMap = new Map<string, number[]>();
|
||||
const patch = [];
|
||||
metadataPatch.forEach((operation) => {
|
||||
// If this operation is removing or editing an existing value, first check the map for previous operations
|
||||
// If the map contains remove operations before this operation's place, lower the place by 1 for each
|
||||
if ((operation.op === MetadataPatchRemoveOperation.operationType || operation.op === MetadataPatchReplaceOperation.operationType) && hasValue((operation as any).place)) {
|
||||
if (metadataRemoveMap.has(operation.field)) {
|
||||
metadataRemoveMap.get(operation.field).forEach((index) => {
|
||||
if (index < (operation as any).place) {
|
||||
(operation as any).place--;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// If this is a remove operation, add its (updated) place to the map, so we can adjust following operations accordingly
|
||||
if (operation.op === MetadataPatchRemoveOperation.operationType && hasValue((operation as any).place)) {
|
||||
if (!metadataRemoveMap.has(operation.field)) {
|
||||
metadataRemoveMap.set(operation.field, []);
|
||||
}
|
||||
metadataRemoveMap.get(operation.field).push((operation as any).place);
|
||||
}
|
||||
|
||||
// Transform the updated operation into a fast-json-patch Operation and add it to the patch
|
||||
patch.push(operation.toOperation());
|
||||
});
|
||||
|
||||
return patch;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform a {@link FieldUpdates} object into an array of {@link MetadataPatchOperation} wrapper objects
|
||||
* These wrapper objects contain detailed information about the patch operation that needs to be creates for each update
|
||||
* This information can then be modified before creating the actual patch
|
||||
* @param fieldUpdates
|
||||
*/
|
||||
fieldUpdatesToMetadataPatchOperations(fieldUpdates: FieldUpdates): MetadataPatchOperation[] {
|
||||
const metadataPatch = [];
|
||||
|
||||
Object.keys(fieldUpdates).forEach((uuid) => {
|
||||
const update = fieldUpdates[uuid];
|
||||
const metadatum = update.field as MetadatumViewModel;
|
||||
const val = {
|
||||
value: metadatum.value,
|
||||
language: metadatum.language
|
||||
}
|
||||
|
||||
let operation: MetadataPatchOperation;
|
||||
switch (update.changeType) {
|
||||
case FieldChangeType.ADD:
|
||||
operation = new MetadataPatchAddOperation(metadatum.key, [ val ]);
|
||||
break;
|
||||
case FieldChangeType.REMOVE:
|
||||
operation = new MetadataPatchRemoveOperation(metadatum.key, metadatum.place);
|
||||
break;
|
||||
case FieldChangeType.UPDATE:
|
||||
operation = new MetadataPatchReplaceOperation(metadatum.key, metadatum.place, val);
|
||||
break;
|
||||
}
|
||||
|
||||
metadataPatch.push(operation);
|
||||
});
|
||||
|
||||
return metadataPatch;
|
||||
}
|
||||
|
||||
}
|
@@ -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 };
|
||||
}
|
||||
}
|
@@ -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;
|
||||
}
|
@@ -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}` };
|
||||
}
|
||||
}
|
@@ -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 };
|
||||
}
|
||||
}
|
@@ -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[];
|
||||
}
|
@@ -1,11 +1,14 @@
|
||||
import { Observable } from 'rxjs/internal/Observable';
|
||||
import { RemoteData } from './remote-data';
|
||||
import { RestRequestMethod } from './rest-request-method';
|
||||
import { Operation } from 'fast-json-patch';
|
||||
import { RestResponse } from '../cache/response.models';
|
||||
|
||||
/**
|
||||
* Represents a data service to update a given object
|
||||
*/
|
||||
export interface UpdateDataService<T> {
|
||||
patch(dso: T, operations: Operation[]): Observable<RestResponse>;
|
||||
update(object: T): Observable<RemoteData<T>>;
|
||||
commitUpdates(method?: RestRequestMethod);
|
||||
}
|
||||
|
@@ -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
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
@@ -3,7 +3,7 @@ import { Injectable } from '@angular/core';
|
||||
import { createSelector, select, Store } from '@ngrx/store';
|
||||
import { Operation } from 'fast-json-patch/lib/core';
|
||||
import { Observable } from 'rxjs';
|
||||
import { filter, find, map, skipWhile, switchMap, take, tap } from 'rxjs/operators';
|
||||
import { filter, find, map, take } from 'rxjs/operators';
|
||||
import {
|
||||
EPeopleRegistryCancelEPersonAction,
|
||||
EPeopleRegistryEditEPersonAction
|
||||
@@ -223,8 +223,8 @@ export class EPersonDataService extends DataService<EPerson> {
|
||||
* Method to delete an EPerson
|
||||
* @param ePerson The EPerson to delete
|
||||
*/
|
||||
public deleteEPerson(ePerson: EPerson): Observable<boolean> {
|
||||
return this.delete(ePerson.id).pipe(map((response: RestResponse) => response.isSuccessful));
|
||||
public deleteEPerson(ePerson: EPerson): Observable<RestResponse> {
|
||||
return this.delete(ePerson.id);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -299,34 +299,4 @@ export class EPersonDataService extends DataService<EPerson> {
|
||||
map((request: RequestEntry) => request.response)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a new FindListRequest with given search method
|
||||
*
|
||||
* @param searchMethod The search method for the object
|
||||
* @param options The [[FindListOptions]] object
|
||||
* @param linksToFollow The array of [[FollowLinkConfig]]
|
||||
* @return {Observable<RemoteData<PaginatedList<EPerson>>}
|
||||
* Return an observable that emits response from the server
|
||||
*/
|
||||
searchBy(searchMethod: string, options: FindListOptions = {}, ...linksToFollow: Array<FollowLinkConfig<EPerson>>): Observable<RemoteData<PaginatedList<EPerson>>> {
|
||||
const hrefObs = this.getSearchByHref(searchMethod, options, ...linksToFollow);
|
||||
|
||||
return hrefObs.pipe(
|
||||
find((href: string) => hasValue(href)),
|
||||
tap((href: string) => {
|
||||
this.requestService.removeByHrefSubstring(href);
|
||||
const request = new FindListRequest(this.requestService.generateRequestId(), href, options);
|
||||
|
||||
this.requestService.configure(request);
|
||||
}
|
||||
),
|
||||
switchMap((href) => this.requestService.getByHref(href)),
|
||||
skipWhile((requestEntry) => hasValue(requestEntry) && requestEntry.completed),
|
||||
switchMap((href) =>
|
||||
this.rdbService.buildList<EPerson>(hrefObs, ...linksToFollow) as Observable<RemoteData<PaginatedList<EPerson>>>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
17
src/app/core/eperson/models/eperson-dto.model.ts
Normal file
17
src/app/core/eperson/models/eperson-dto.model.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { EPerson } from './eperson.model';
|
||||
|
||||
/**
|
||||
* This class serves as a Data Transfer Model that contains the EPerson and whether or not it's able to be deleted
|
||||
*/
|
||||
export class EpersonDtoModel {
|
||||
|
||||
/**
|
||||
* The EPerson linked to this object
|
||||
*/
|
||||
public eperson: EPerson;
|
||||
/**
|
||||
* Whether or not the linked EPerson is able to be deleted
|
||||
*/
|
||||
public ableToDelete: boolean;
|
||||
|
||||
}
|
@@ -52,10 +52,13 @@ import { UUIDService } from '../shared/uuid.service';
|
||||
import { MetadataService } from './metadata.service';
|
||||
import { environment } from '../../../environments/environment';
|
||||
import { storeModuleConfig } from '../../app.reducer';
|
||||
import { HardRedirectService } from '../services/hard-redirect.service';
|
||||
import { URLCombiner } from '../url-combiner/url-combiner';
|
||||
|
||||
/* tslint:disable:max-classes-per-file */
|
||||
@Component({
|
||||
template: `<router-outlet></router-outlet>`
|
||||
template: `
|
||||
<router-outlet></router-outlet>`
|
||||
})
|
||||
class TestComponent {
|
||||
constructor(private metadata: MetadataService) {
|
||||
@@ -170,6 +173,7 @@ describe('MetadataService', () => {
|
||||
Title,
|
||||
// tslint:disable-next-line:no-empty
|
||||
{ provide: ItemDataService, useValue: { findById: () => {} } },
|
||||
{ provide: HardRedirectService, useValue: { rewriteDownloadURL: (a) => a, getRequestOrigin: () => environment.ui.baseUrl }},
|
||||
BrowseService,
|
||||
MetadataService
|
||||
],
|
||||
@@ -208,7 +212,7 @@ describe('MetadataService', () => {
|
||||
tick();
|
||||
expect(tagStore.get('citation_dissertation_name')[0].content).toEqual('Test PowerPoint Document');
|
||||
expect(tagStore.get('citation_dissertation_institution')[0].content).toEqual('Mock Publisher');
|
||||
expect(tagStore.get('citation_abstract_html_url')[0].content).toEqual([environment.ui.baseUrl, router.url].join(''));
|
||||
expect(tagStore.get('citation_abstract_html_url')[0].content).toEqual(new URLCombiner(environment.ui.baseUrl, router.url).toString());
|
||||
expect(tagStore.get('citation_pdf_url')[0].content).toEqual('https://dspace7.4science.it/dspace-spring-rest/api/core/bitstreams/99b00f3c-1cc6-4689-8158-91965bee6b28/content');
|
||||
}));
|
||||
|
||||
|
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
@@ -2,11 +2,12 @@ import {TestBed} from '@angular/core/testing';
|
||||
import {BrowserHardRedirectService} from './browser-hard-redirect.service';
|
||||
|
||||
describe('BrowserHardRedirectService', () => {
|
||||
|
||||
const origin = 'test origin';
|
||||
const mockLocation = {
|
||||
href: undefined,
|
||||
pathname: '/pathname',
|
||||
search: '/search',
|
||||
origin
|
||||
} as Location;
|
||||
|
||||
const service: BrowserHardRedirectService = new BrowserHardRedirectService(mockLocation);
|
||||
@@ -38,4 +39,12 @@ describe('BrowserHardRedirectService', () => {
|
||||
expect(service.getCurrentRoute()).toEqual(mockLocation.pathname + mockLocation.search);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when requesting the origin', () => {
|
||||
|
||||
it('should return the location origin', () => {
|
||||
expect(service.getRequestOrigin()).toEqual(origin);
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
@@ -11,11 +11,12 @@ export function locationProvider(): Location {
|
||||
* Service for performing hard redirects within the browser app module
|
||||
*/
|
||||
@Injectable()
|
||||
export class BrowserHardRedirectService implements HardRedirectService {
|
||||
export class BrowserHardRedirectService extends HardRedirectService {
|
||||
|
||||
constructor(
|
||||
@Inject(LocationToken) protected location: Location,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -32,4 +33,11 @@ export class BrowserHardRedirectService implements HardRedirectService {
|
||||
getCurrentRoute() {
|
||||
return this.location.pathname + this.location.search;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the hostname of the request
|
||||
*/
|
||||
getRequestOrigin() {
|
||||
return this.location.origin;
|
||||
}
|
||||
}
|
||||
|
57
src/app/core/services/hard-redirect.service.spec.ts
Normal file
57
src/app/core/services/hard-redirect.service.spec.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { HardRedirectService } from './hard-redirect.service';
|
||||
import { environment } from '../../../environments/environment';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { Injectable } from '@angular/core';
|
||||
|
||||
const requestOrigin = 'http://dspace-angular-ui.dspace.com';
|
||||
|
||||
describe('HardRedirectService', () => {
|
||||
let service: TestHardRedirectService;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({ providers: [TestHardRedirectService] });
|
||||
service = TestBed.get(TestHardRedirectService);
|
||||
});
|
||||
|
||||
describe('when calling rewriteDownloadURL', () => {
|
||||
let originalValue;
|
||||
const relativePath = '/test/url/path';
|
||||
const testURL = environment.rest.baseUrl + relativePath;
|
||||
beforeEach(() => {
|
||||
originalValue = environment.rewriteDownloadUrls;
|
||||
});
|
||||
|
||||
it('it should return the same url when rewriteDownloadURL is false', () => {
|
||||
environment.rewriteDownloadUrls = false;
|
||||
expect(service.rewriteDownloadURL(testURL)).toEqual(testURL);
|
||||
});
|
||||
|
||||
it('it should replace part of the url when rewriteDownloadURL is true', () => {
|
||||
environment.rewriteDownloadUrls = true;
|
||||
expect(service.rewriteDownloadURL(testURL)).toEqual(requestOrigin + environment.rest.nameSpace + relativePath);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
environment.rewriteDownloadUrls = originalValue;
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
@Injectable()
|
||||
class TestHardRedirectService extends HardRedirectService {
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
redirect(url: string) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
getCurrentRoute() {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
getRequestOrigin() {
|
||||
return requestOrigin;
|
||||
}
|
||||
}
|
@@ -1,4 +1,6 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { environment } from '../../../environments/environment';
|
||||
import { URLCombiner } from '../url-combiner/url-combiner';
|
||||
|
||||
/**
|
||||
* Service to take care of hard redirects
|
||||
@@ -19,4 +21,20 @@ export abstract class HardRedirectService {
|
||||
* e.g. /search?page=1&query=open%20access&f.dateIssued.min=1980&f.dateIssued.max=2020
|
||||
*/
|
||||
abstract getCurrentRoute();
|
||||
|
||||
/**
|
||||
* Get the hostname of the request
|
||||
*/
|
||||
abstract getRequestOrigin();
|
||||
|
||||
public rewriteDownloadURL(originalUrl: string): string {
|
||||
if (environment.rewriteDownloadUrls) {
|
||||
const hostName = this.getRequestOrigin();
|
||||
const namespace = environment.rest.nameSpace;
|
||||
const rewrittenUrl = new URLCombiner(hostName, namespace).toString();
|
||||
return originalUrl.replace(environment.rest.baseUrl, rewrittenUrl);
|
||||
} else {
|
||||
return originalUrl;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -7,8 +7,13 @@ describe('ServerHardRedirectService', () => {
|
||||
const mockResponse = jasmine.createSpyObj(['redirect', 'end']);
|
||||
|
||||
const service: ServerHardRedirectService = new ServerHardRedirectService(mockRequest, mockResponse);
|
||||
const origin = 'test-host';
|
||||
|
||||
beforeEach(() => {
|
||||
mockRequest.headers = {
|
||||
host: 'test-host',
|
||||
};
|
||||
|
||||
TestBed.configureTestingModule({});
|
||||
});
|
||||
|
||||
@@ -40,4 +45,12 @@ describe('ServerHardRedirectService', () => {
|
||||
expect(service.getCurrentRoute()).toEqual(mockRequest.originalUrl);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when requesting the origin', () => {
|
||||
|
||||
it('should return the location origin', () => {
|
||||
expect(service.getRequestOrigin()).toEqual(origin);
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
@@ -7,12 +7,13 @@ import { HardRedirectService } from './hard-redirect.service';
|
||||
* Service for performing hard redirects within the server app module
|
||||
*/
|
||||
@Injectable()
|
||||
export class ServerHardRedirectService implements HardRedirectService {
|
||||
export class ServerHardRedirectService extends HardRedirectService {
|
||||
|
||||
constructor(
|
||||
@Inject(REQUEST) protected req: Request,
|
||||
@Inject(RESPONSE) protected res: Response,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -59,4 +60,11 @@ export class ServerHardRedirectService implements HardRedirectService {
|
||||
getCurrentRoute() {
|
||||
return this.req.originalUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the hostname of the request
|
||||
*/
|
||||
getRequestOrigin() {
|
||||
return this.req.headers.host;
|
||||
}
|
||||
}
|
||||
|
@@ -14,7 +14,7 @@ import { MetadataSchema } from '../metadata/metadata-schema.model';
|
||||
import { BrowseDefinition } from './browse-definition.model';
|
||||
import { DSpaceObject } from './dspace-object.model';
|
||||
import { getUnauthorizedRoute } from '../../app-routing-paths';
|
||||
import { getEndUserAgreementPath } from '../../info/info-routing.module';
|
||||
import { getEndUserAgreementPath } from '../../info/info-routing-paths';
|
||||
|
||||
/**
|
||||
* This file contains custom RxJS operators that can be used in multiple places
|
||||
|
51
src/app/core/statistics/models/usage-report.model.ts
Normal file
51
src/app/core/statistics/models/usage-report.model.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { autoserialize, inheritSerialization } from 'cerialize';
|
||||
import { typedObject } from '../../cache/builders/build-decorators';
|
||||
import { excludeFromEquals } from '../../utilities/equals.decorators';
|
||||
import { ResourceType } from '../../shared/resource-type';
|
||||
import { HALResource } from '../../shared/hal-resource.model';
|
||||
import { USAGE_REPORT } from './usage-report.resource-type';
|
||||
import { HALLink } from '../../shared/hal-link.model';
|
||||
import { deserialize, autoserializeAs } from 'cerialize';
|
||||
|
||||
/**
|
||||
* A usage report.
|
||||
*/
|
||||
@typedObject
|
||||
@inheritSerialization(HALResource)
|
||||
export class UsageReport extends HALResource {
|
||||
|
||||
static type = USAGE_REPORT;
|
||||
|
||||
/**
|
||||
* The object type
|
||||
*/
|
||||
@excludeFromEquals
|
||||
@autoserialize
|
||||
type: ResourceType;
|
||||
|
||||
@autoserialize
|
||||
id: string;
|
||||
|
||||
@autoserializeAs('report-type')
|
||||
reportType: string;
|
||||
|
||||
@autoserialize
|
||||
points: Point[];
|
||||
|
||||
@deserialize
|
||||
_links: {
|
||||
self: HALLink;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A statistics data point.
|
||||
*/
|
||||
export interface Point {
|
||||
id: string;
|
||||
label: string;
|
||||
type: string;
|
||||
values: Array<{
|
||||
views: number;
|
||||
}>;
|
||||
}
|
@@ -0,0 +1,9 @@
|
||||
import { ResourceType } from '../../shared/resource-type';
|
||||
|
||||
/**
|
||||
* The resource type for License
|
||||
*
|
||||
* Needs to be in a separate file to prevent circular
|
||||
* dependencies in webpack.
|
||||
*/
|
||||
export const USAGE_REPORT = new ResourceType('usagereport');
|
62
src/app/core/statistics/usage-report-data.service.ts
Normal file
62
src/app/core/statistics/usage-report-data.service.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { NotificationsService } from '../../shared/notifications/notifications.service';
|
||||
import { dataService } from '../cache/builders/build-decorators';
|
||||
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
|
||||
import { ObjectCacheService } from '../cache/object-cache.service';
|
||||
import { CoreState } from '../core.reducers';
|
||||
import { HALEndpointService } from '../shared/hal-endpoint.service';
|
||||
import { DataService } from '../data/data.service';
|
||||
import { RequestService } from '../data/request.service';
|
||||
import { DefaultChangeAnalyzer } from '../data/default-change-analyzer.service';
|
||||
import { USAGE_REPORT } from './models/usage-report.resource-type';
|
||||
import { UsageReport } from './models/usage-report.model';
|
||||
import { Observable } from 'rxjs';
|
||||
import { getRemoteDataPayload, getSucceededRemoteData } from '../shared/operators';
|
||||
import { map } from 'rxjs/operators';
|
||||
|
||||
/**
|
||||
* A service to retrieve {@link UsageReport}s from the REST API
|
||||
*/
|
||||
@Injectable()
|
||||
@dataService(USAGE_REPORT)
|
||||
export class UsageReportService extends DataService<UsageReport> {
|
||||
|
||||
protected linkPath = 'statistics/usagereports';
|
||||
|
||||
constructor(
|
||||
protected comparator: DefaultChangeAnalyzer<UsageReport>,
|
||||
protected halService: HALEndpointService,
|
||||
protected http: HttpClient,
|
||||
protected notificationsService: NotificationsService,
|
||||
protected objectCache: ObjectCacheService,
|
||||
protected rdbService: RemoteDataBuildService,
|
||||
protected requestService: RequestService,
|
||||
protected store: Store<CoreState>,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
getStatistic(scope: string, type: string): Observable<UsageReport> {
|
||||
return this.findById(`${scope}_${type}`).pipe(
|
||||
getSucceededRemoteData(),
|
||||
getRemoteDataPayload(),
|
||||
);
|
||||
}
|
||||
|
||||
searchStatistics(uri: string, page: number, size: number): Observable<UsageReport[]> {
|
||||
return this.searchBy('object', {
|
||||
searchParams: [{
|
||||
fieldName: `uri`,
|
||||
fieldValue: uri,
|
||||
}],
|
||||
currentPage: page,
|
||||
elementsPerPage: size,
|
||||
}).pipe(
|
||||
getSucceededRemoteData(),
|
||||
getRemoteDataPayload(),
|
||||
map((list) => list.page),
|
||||
);
|
||||
}
|
||||
}
|
16
src/app/info/info-routing-paths.ts
Normal file
16
src/app/info/info-routing-paths.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { getInfoModulePath } from '../app-routing-paths';
|
||||
|
||||
export const END_USER_AGREEMENT_PATH = 'end-user-agreement';
|
||||
export const PRIVACY_PATH = 'privacy';
|
||||
|
||||
export function getEndUserAgreementPath() {
|
||||
return getSubPath(END_USER_AGREEMENT_PATH);
|
||||
}
|
||||
|
||||
export function getPrivacyPath() {
|
||||
return getSubPath(PRIVACY_PATH);
|
||||
}
|
||||
|
||||
function getSubPath(path: string) {
|
||||
return `${getInfoModulePath()}/${path}`;
|
||||
}
|
@@ -1,24 +1,9 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import { RouterModule } from '@angular/router';
|
||||
import { EndUserAgreementComponent } from './end-user-agreement/end-user-agreement.component';
|
||||
import { getInfoModulePath } from '../app-routing-paths';
|
||||
import { I18nBreadcrumbResolver } from '../core/breadcrumbs/i18n-breadcrumb.resolver';
|
||||
import { PrivacyComponent } from './privacy/privacy.component';
|
||||
|
||||
const END_USER_AGREEMENT_PATH = 'end-user-agreement';
|
||||
const PRIVACY_PATH = 'privacy';
|
||||
|
||||
export function getEndUserAgreementPath() {
|
||||
return getSubPath(END_USER_AGREEMENT_PATH);
|
||||
}
|
||||
|
||||
export function getPrivacyPath() {
|
||||
return getSubPath(PRIVACY_PATH);
|
||||
}
|
||||
|
||||
function getSubPath(path: string) {
|
||||
return `${getInfoModulePath()}/${path}`;
|
||||
}
|
||||
import { PRIVACY_PATH, END_USER_AGREEMENT_PATH } from './info-routing-paths';
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
|
@@ -64,19 +64,6 @@ export class NavbarComponent extends MenuComponent {
|
||||
link: `/community-list`
|
||||
} as LinkMenuItemModel
|
||||
},
|
||||
|
||||
/* Statistics */
|
||||
{
|
||||
id: 'statistics',
|
||||
active: false,
|
||||
visible: true,
|
||||
model: {
|
||||
type: MenuItemType.LINK,
|
||||
text: 'menu.section.statistics',
|
||||
link: ''
|
||||
} as LinkMenuItemModel,
|
||||
index: 2
|
||||
},
|
||||
];
|
||||
// Read the different Browse-By types from config and add them to the browse menu
|
||||
const types = environment.browseBy.types;
|
||||
|
@@ -3,6 +3,7 @@ import { FileDownloadLinkComponent } from './file-download-link.component';
|
||||
import { AuthService } from '../../core/auth/auth.service';
|
||||
import { FileService } from '../../core/shared/file.service';
|
||||
import { of as observableOf } from 'rxjs';
|
||||
import { HardRedirectService } from '../../core/services/hard-redirect.service';
|
||||
|
||||
describe('FileDownloadLinkComponent', () => {
|
||||
let component: FileDownloadLinkComponent;
|
||||
@@ -23,13 +24,14 @@ describe('FileDownloadLinkComponent', () => {
|
||||
beforeEach(async(() => {
|
||||
init();
|
||||
TestBed.configureTestingModule({
|
||||
declarations: [ FileDownloadLinkComponent ],
|
||||
declarations: [FileDownloadLinkComponent],
|
||||
providers: [
|
||||
{ provide: AuthService, useValue: authService },
|
||||
{ provide: FileService, useValue: fileService }
|
||||
{ provide: FileService, useValue: fileService },
|
||||
{ provide: HardRedirectService, useValue: { rewriteDownloadURL: (a) => a } },
|
||||
]
|
||||
})
|
||||
.compileComponents();
|
||||
.compileComponents();
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
|
@@ -2,6 +2,7 @@ import { Component, Input, OnInit } from '@angular/core';
|
||||
import { FileService } from '../../core/shared/file.service';
|
||||
import { Observable } from 'rxjs/internal/Observable';
|
||||
import { AuthService } from '../../core/auth/auth.service';
|
||||
import { HardRedirectService } from '../../core/services/hard-redirect.service';
|
||||
|
||||
@Component({
|
||||
selector: 'ds-file-download-link',
|
||||
@@ -30,10 +31,13 @@ export class FileDownloadLinkComponent implements OnInit {
|
||||
isAuthenticated$: Observable<boolean>;
|
||||
|
||||
constructor(private fileService: FileService,
|
||||
private authService: AuthService) { }
|
||||
private authService: AuthService,
|
||||
private redirectService: HardRedirectService) {
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
this.isAuthenticated$ = this.authService.isAuthenticated();
|
||||
this.href = this.redirectService.rewriteDownloadURL(this.href);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -44,5 +48,4 @@ export class FileDownloadLinkComponent implements OnInit {
|
||||
this.fileService.downloadFile(this.href);
|
||||
return false;
|
||||
}
|
||||
|
||||
}
|
||||
|
@@ -14,6 +14,7 @@ import { MenuEffects } from './menu.effects';
|
||||
describe('MenuEffects', () => {
|
||||
let menuEffects: MenuEffects;
|
||||
let routeDataMenuSection: MenuSection;
|
||||
let routeDataMenuSectionResolved: MenuSection;
|
||||
let routeDataMenuChildSection: MenuSection;
|
||||
let toBeRemovedMenuSection: MenuSection;
|
||||
let alreadyPresentMenuSection: MenuSection;
|
||||
@@ -23,13 +24,23 @@ describe('MenuEffects', () => {
|
||||
|
||||
function init() {
|
||||
routeDataMenuSection = {
|
||||
id: 'mockSection',
|
||||
id: 'mockSection_:idparam',
|
||||
active: false,
|
||||
visible: true,
|
||||
model: {
|
||||
type: MenuItemType.LINK,
|
||||
text: 'menu.section.mockSection',
|
||||
link: ''
|
||||
link: 'path/:linkparam'
|
||||
} as LinkMenuItemModel
|
||||
};
|
||||
routeDataMenuSectionResolved = {
|
||||
id: 'mockSection_id_param_resolved',
|
||||
active: false,
|
||||
visible: true,
|
||||
model: {
|
||||
type: MenuItemType.LINK,
|
||||
text: 'menu.section.mockSection',
|
||||
link: 'path/link_param_resolved'
|
||||
} as LinkMenuItemModel
|
||||
};
|
||||
routeDataMenuChildSection = {
|
||||
@@ -70,6 +81,10 @@ describe('MenuEffects', () => {
|
||||
menu: {
|
||||
[MenuID.PUBLIC]: [routeDataMenuSection, alreadyPresentMenuSection]
|
||||
}
|
||||
},
|
||||
params: {
|
||||
idparam: 'id_param_resolved',
|
||||
linkparam: 'link_param_resolved',
|
||||
}
|
||||
},
|
||||
firstChild: {
|
||||
@@ -120,7 +135,7 @@ describe('MenuEffects', () => {
|
||||
});
|
||||
|
||||
expect(menuEffects.buildRouteMenuSections$).toBeObservable(expected);
|
||||
expect(menuService.addSection).toHaveBeenCalledWith(MenuID.PUBLIC, routeDataMenuSection);
|
||||
expect(menuService.addSection).toHaveBeenCalledWith(MenuID.PUBLIC, routeDataMenuSectionResolved);
|
||||
expect(menuService.addSection).toHaveBeenCalledWith(MenuID.PUBLIC, routeDataMenuChildSection);
|
||||
expect(menuService.addSection).not.toHaveBeenCalledWith(MenuID.PUBLIC, alreadyPresentMenuSection);
|
||||
expect(menuService.removeSection).toHaveBeenCalledWith(MenuID.PUBLIC, toBeRemovedMenuSection.id);
|
||||
|
@@ -19,7 +19,7 @@ export class MenuEffects {
|
||||
/**
|
||||
* On route change, build menu sections for every menu type depending on the current route data
|
||||
*/
|
||||
@Effect({ dispatch: false })
|
||||
@Effect({dispatch: false})
|
||||
public buildRouteMenuSections$: Observable<Action> = this.actions$
|
||||
.pipe(
|
||||
ofType(ROUTER_NAVIGATED),
|
||||
@@ -68,17 +68,52 @@ export class MenuEffects {
|
||||
*/
|
||||
resolveRouteMenuSections(route: ActivatedRoute, menuID: MenuID): MenuSection[] {
|
||||
const data = route.snapshot.data;
|
||||
const params = route.snapshot.params;
|
||||
const last: boolean = hasNoValue(route.firstChild);
|
||||
|
||||
if (hasValue(data) && hasValue(data.menu) && hasValue(data.menu[menuID])) {
|
||||
|
||||
let menuSections: MenuSection[] | MenuSection = data.menu[menuID];
|
||||
menuSections = this.resolveSubstitutions(menuSections, params);
|
||||
|
||||
if (!last) {
|
||||
return [...data.menu[menuID], ...this.resolveRouteMenuSections(route.firstChild, menuID)]
|
||||
return [...menuSections, ...this.resolveRouteMenuSections(route.firstChild, menuID)]
|
||||
} else {
|
||||
return [...data.menu[menuID]];
|
||||
return [...menuSections];
|
||||
}
|
||||
}
|
||||
|
||||
return !last ? this.resolveRouteMenuSections(route.firstChild, menuID) : [];
|
||||
}
|
||||
|
||||
private resolveSubstitutions(object, params) {
|
||||
|
||||
let resolved;
|
||||
if (typeof object === 'string') {
|
||||
resolved = object;
|
||||
let match: RegExpMatchArray;
|
||||
do {
|
||||
match = resolved.match(/:(\w+)/);
|
||||
if (match) {
|
||||
const substitute = params[match[1]];
|
||||
if (hasValue(substitute)) {
|
||||
resolved = resolved.replace(match[0], `${substitute}`);
|
||||
}
|
||||
}
|
||||
} while (match);
|
||||
} else if (Array.isArray(object)) {
|
||||
resolved = [];
|
||||
object.forEach((entry, index) => {
|
||||
resolved[index] = this.resolveSubstitutions(object[index], params);
|
||||
});
|
||||
} else if (typeof object === 'object') {
|
||||
resolved = {};
|
||||
Object.keys(object).forEach((key) => {
|
||||
resolved[key] = this.resolveSubstitutions(object[key], params);
|
||||
});
|
||||
} else {
|
||||
resolved = object;
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
}
|
||||
|
@@ -0,0 +1,109 @@
|
||||
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { CollectionStatisticsPageComponent } from './collection-statistics-page.component';
|
||||
import { StatisticsTableComponent } from '../statistics-table/statistics-table.component';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { UsageReportService } from '../../core/statistics/usage-report-data.service';
|
||||
import { of as observableOf } from 'rxjs';
|
||||
import { RemoteData } from '../../core/data/remote-data';
|
||||
import { Collection } from '../../core/shared/collection.model';
|
||||
import { DebugElement } from '@angular/core';
|
||||
import { By } from '@angular/platform-browser';
|
||||
import { UsageReport } from '../../core/statistics/models/usage-report.model';
|
||||
import { SharedModule } from '../../shared/shared.module';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { DSONameService } from '../../core/breadcrumbs/dso-name.service';
|
||||
import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service';
|
||||
|
||||
describe('CollectionStatisticsPageComponent', () => {
|
||||
|
||||
let component: CollectionStatisticsPageComponent;
|
||||
let de: DebugElement;
|
||||
let fixture: ComponentFixture<CollectionStatisticsPageComponent>;
|
||||
|
||||
beforeEach(async(() => {
|
||||
|
||||
const activatedRoute = {
|
||||
data: observableOf({
|
||||
scope: new RemoteData(
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
undefined,
|
||||
Object.assign(new Collection(), {
|
||||
id: 'collection_id',
|
||||
}),
|
||||
)
|
||||
})
|
||||
};
|
||||
|
||||
const router = {
|
||||
};
|
||||
|
||||
const usageReportService = {
|
||||
getStatistic: (scope, type) => undefined,
|
||||
};
|
||||
|
||||
spyOn(usageReportService, 'getStatistic').and.callFake(
|
||||
(scope, type) => observableOf(
|
||||
Object.assign(
|
||||
new UsageReport(), {
|
||||
id: `${scope}-${type}-report`,
|
||||
points: [],
|
||||
}
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
const nameService = {
|
||||
getName: () => observableOf('test dso name'),
|
||||
};
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
imports: [
|
||||
TranslateModule.forRoot(),
|
||||
CommonModule,
|
||||
SharedModule,
|
||||
],
|
||||
declarations: [
|
||||
CollectionStatisticsPageComponent,
|
||||
StatisticsTableComponent,
|
||||
],
|
||||
providers: [
|
||||
{ provide: ActivatedRoute, useValue: activatedRoute },
|
||||
{ provide: Router, useValue: router },
|
||||
{ provide: UsageReportService, useValue: usageReportService },
|
||||
{ provide: DSpaceObjectDataService, useValue: {} },
|
||||
{ provide: DSONameService, useValue: nameService },
|
||||
],
|
||||
})
|
||||
.compileComponents();
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(CollectionStatisticsPageComponent);
|
||||
component = fixture.componentInstance;
|
||||
de = fixture.debugElement;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should resolve to the correct collection', () => {
|
||||
expect(de.query(By.css('.header')).nativeElement.id)
|
||||
.toEqual('collection_id');
|
||||
});
|
||||
|
||||
it('should show a statistics table for each usage report', () => {
|
||||
expect(de.query(By.css('ds-statistics-table.collection_id-TotalVisits-report')).nativeElement)
|
||||
.toBeTruthy();
|
||||
expect(de.query(By.css('ds-statistics-table.collection_id-TotalVisitsPerMonth-report')).nativeElement)
|
||||
.toBeTruthy();
|
||||
expect(de.query(By.css('ds-statistics-table.collection_id-TopCountries-report')).nativeElement)
|
||||
.toBeTruthy();
|
||||
expect(de.query(By.css('ds-statistics-table.collection_id-TopCities-report')).nativeElement)
|
||||
.toBeTruthy();
|
||||
});
|
||||
});
|
@@ -0,0 +1,41 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { StatisticsPageComponent } from '../statistics-page/statistics-page.component';
|
||||
import { UsageReportService } from '../../core/statistics/usage-report-data.service';
|
||||
import { ActivatedRoute , Router} from '@angular/router';
|
||||
import { Collection } from '../../core/shared/collection.model';
|
||||
import { DSONameService } from '../../core/breadcrumbs/dso-name.service';
|
||||
|
||||
/**
|
||||
* Component representing the statistics page for a collection.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'ds-collection-statistics-page',
|
||||
templateUrl: '../statistics-page/statistics-page.component.html',
|
||||
styleUrls: ['./collection-statistics-page.component.scss']
|
||||
})
|
||||
export class CollectionStatisticsPageComponent extends StatisticsPageComponent<Collection> {
|
||||
|
||||
/**
|
||||
* The report types to show on this statistics page.
|
||||
*/
|
||||
types: string[] = [
|
||||
'TotalVisits',
|
||||
'TotalVisitsPerMonth',
|
||||
'TopCountries',
|
||||
'TopCities',
|
||||
];
|
||||
|
||||
constructor(
|
||||
protected route: ActivatedRoute,
|
||||
protected router: Router,
|
||||
protected usageReportService: UsageReportService,
|
||||
protected nameService: DSONameService,
|
||||
) {
|
||||
super(
|
||||
route,
|
||||
router,
|
||||
usageReportService,
|
||||
nameService,
|
||||
);
|
||||
}
|
||||
}
|
@@ -0,0 +1,109 @@
|
||||
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { CommunityStatisticsPageComponent } from './community-statistics-page.component';
|
||||
import { StatisticsTableComponent } from '../statistics-table/statistics-table.component';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { UsageReportService } from '../../core/statistics/usage-report-data.service';
|
||||
import { of as observableOf } from 'rxjs';
|
||||
import { RemoteData } from '../../core/data/remote-data';
|
||||
import { Community } from '../../core/shared/community.model';
|
||||
import { DebugElement } from '@angular/core';
|
||||
import { By } from '@angular/platform-browser';
|
||||
import { UsageReport } from '../../core/statistics/models/usage-report.model';
|
||||
import { SharedModule } from '../../shared/shared.module';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { DSONameService } from '../../core/breadcrumbs/dso-name.service';
|
||||
import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service';
|
||||
|
||||
describe('CommunityStatisticsPageComponent', () => {
|
||||
|
||||
let component: CommunityStatisticsPageComponent;
|
||||
let de: DebugElement;
|
||||
let fixture: ComponentFixture<CommunityStatisticsPageComponent>;
|
||||
|
||||
beforeEach(async(() => {
|
||||
|
||||
const activatedRoute = {
|
||||
data: observableOf({
|
||||
scope: new RemoteData(
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
undefined,
|
||||
Object.assign(new Community(), {
|
||||
id: 'community_id',
|
||||
}),
|
||||
)
|
||||
})
|
||||
};
|
||||
|
||||
const router = {
|
||||
};
|
||||
|
||||
const usageReportService = {
|
||||
getStatistic: (scope, type) => undefined,
|
||||
};
|
||||
|
||||
spyOn(usageReportService, 'getStatistic').and.callFake(
|
||||
(scope, type) => observableOf(
|
||||
Object.assign(
|
||||
new UsageReport(), {
|
||||
id: `${scope}-${type}-report`,
|
||||
points: [],
|
||||
}
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
const nameService = {
|
||||
getName: () => observableOf('test dso name'),
|
||||
};
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
imports: [
|
||||
TranslateModule.forRoot(),
|
||||
CommonModule,
|
||||
SharedModule,
|
||||
],
|
||||
declarations: [
|
||||
CommunityStatisticsPageComponent,
|
||||
StatisticsTableComponent,
|
||||
],
|
||||
providers: [
|
||||
{ provide: ActivatedRoute, useValue: activatedRoute },
|
||||
{ provide: Router, useValue: router },
|
||||
{ provide: UsageReportService, useValue: usageReportService },
|
||||
{ provide: DSpaceObjectDataService, useValue: {} },
|
||||
{ provide: DSONameService, useValue: nameService },
|
||||
],
|
||||
})
|
||||
.compileComponents();
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(CommunityStatisticsPageComponent);
|
||||
component = fixture.componentInstance;
|
||||
de = fixture.debugElement;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should resolve to the correct community', () => {
|
||||
expect(de.query(By.css('.header')).nativeElement.id)
|
||||
.toEqual('community_id');
|
||||
});
|
||||
|
||||
it('should show a statistics table for each usage report', () => {
|
||||
expect(de.query(By.css('ds-statistics-table.community_id-TotalVisits-report')).nativeElement)
|
||||
.toBeTruthy();
|
||||
expect(de.query(By.css('ds-statistics-table.community_id-TotalVisitsPerMonth-report')).nativeElement)
|
||||
.toBeTruthy();
|
||||
expect(de.query(By.css('ds-statistics-table.community_id-TopCountries-report')).nativeElement)
|
||||
.toBeTruthy();
|
||||
expect(de.query(By.css('ds-statistics-table.community_id-TopCities-report')).nativeElement)
|
||||
.toBeTruthy();
|
||||
});
|
||||
});
|
@@ -0,0 +1,41 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { StatisticsPageComponent } from '../statistics-page/statistics-page.component';
|
||||
import { UsageReportService } from '../../core/statistics/usage-report-data.service';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { Community } from '../../core/shared/community.model';
|
||||
import { DSONameService } from '../../core/breadcrumbs/dso-name.service';
|
||||
|
||||
/**
|
||||
* Component representing the statistics page for a community.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'ds-community-statistics-page',
|
||||
templateUrl: '../statistics-page/statistics-page.component.html',
|
||||
styleUrls: ['./community-statistics-page.component.scss']
|
||||
})
|
||||
export class CommunityStatisticsPageComponent extends StatisticsPageComponent<Community> {
|
||||
|
||||
/**
|
||||
* The report types to show on this statistics page.
|
||||
*/
|
||||
types: string[] = [
|
||||
'TotalVisits',
|
||||
'TotalVisitsPerMonth',
|
||||
'TopCountries',
|
||||
'TopCities',
|
||||
];
|
||||
|
||||
constructor(
|
||||
protected route: ActivatedRoute,
|
||||
protected router: Router,
|
||||
protected usageReportService: UsageReportService,
|
||||
protected nameService: DSONameService,
|
||||
) {
|
||||
super(
|
||||
route,
|
||||
router,
|
||||
usageReportService,
|
||||
nameService,
|
||||
);
|
||||
}
|
||||
}
|
@@ -0,0 +1,111 @@
|
||||
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { ItemStatisticsPageComponent } from './item-statistics-page.component';
|
||||
import { StatisticsTableComponent } from '../statistics-table/statistics-table.component';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { UsageReportService } from '../../core/statistics/usage-report-data.service';
|
||||
import { of as observableOf } from 'rxjs';
|
||||
import { RemoteData } from '../../core/data/remote-data';
|
||||
import { Item } from '../../core/shared/item.model';
|
||||
import { DebugElement } from '@angular/core';
|
||||
import { By } from '@angular/platform-browser';
|
||||
import { UsageReport } from '../../core/statistics/models/usage-report.model';
|
||||
import { SharedModule } from '../../shared/shared.module';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { DSONameService } from '../../core/breadcrumbs/dso-name.service';
|
||||
import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service';
|
||||
|
||||
describe('ItemStatisticsPageComponent', () => {
|
||||
|
||||
let component: ItemStatisticsPageComponent;
|
||||
let de: DebugElement;
|
||||
let fixture: ComponentFixture<ItemStatisticsPageComponent>;
|
||||
|
||||
beforeEach(async(() => {
|
||||
|
||||
const activatedRoute = {
|
||||
data: observableOf({
|
||||
scope: new RemoteData(
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
undefined,
|
||||
Object.assign(new Item(), {
|
||||
id: 'item_id',
|
||||
}),
|
||||
)
|
||||
})
|
||||
};
|
||||
|
||||
const router = {
|
||||
};
|
||||
|
||||
const usageReportService = {
|
||||
getStatistic: (scope, type) => undefined,
|
||||
};
|
||||
|
||||
spyOn(usageReportService, 'getStatistic').and.callFake(
|
||||
(scope, type) => observableOf(
|
||||
Object.assign(
|
||||
new UsageReport(), {
|
||||
id: `${scope}-${type}-report`,
|
||||
points: [],
|
||||
}
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
const nameService = {
|
||||
getName: () => observableOf('test dso name'),
|
||||
};
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
imports: [
|
||||
TranslateModule.forRoot(),
|
||||
CommonModule,
|
||||
SharedModule,
|
||||
],
|
||||
declarations: [
|
||||
ItemStatisticsPageComponent,
|
||||
StatisticsTableComponent,
|
||||
],
|
||||
providers: [
|
||||
{ provide: ActivatedRoute, useValue: activatedRoute },
|
||||
{ provide: Router, useValue: router },
|
||||
{ provide: UsageReportService, useValue: usageReportService },
|
||||
{ provide: DSpaceObjectDataService, useValue: {} },
|
||||
{ provide: DSONameService, useValue: nameService },
|
||||
],
|
||||
})
|
||||
.compileComponents();
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(ItemStatisticsPageComponent);
|
||||
component = fixture.componentInstance;
|
||||
de = fixture.debugElement;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should resolve to the correct item', () => {
|
||||
expect(de.query(By.css('.header')).nativeElement.id)
|
||||
.toEqual('item_id');
|
||||
});
|
||||
|
||||
it('should show a statistics table for each usage report', () => {
|
||||
expect(de.query(By.css('ds-statistics-table.item_id-TotalVisits-report')).nativeElement)
|
||||
.toBeTruthy();
|
||||
expect(de.query(By.css('ds-statistics-table.item_id-TotalVisitsPerMonth-report')).nativeElement)
|
||||
.toBeTruthy();
|
||||
expect(de.query(By.css('ds-statistics-table.item_id-TotalDownloads-report')).nativeElement)
|
||||
.toBeTruthy();
|
||||
expect(de.query(By.css('ds-statistics-table.item_id-TopCountries-report')).nativeElement)
|
||||
.toBeTruthy();
|
||||
expect(de.query(By.css('ds-statistics-table.item_id-TopCities-report')).nativeElement)
|
||||
.toBeTruthy();
|
||||
});
|
||||
});
|
@@ -0,0 +1,42 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { StatisticsPageComponent } from '../statistics-page/statistics-page.component';
|
||||
import { UsageReportService } from '../../core/statistics/usage-report-data.service';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { Item } from '../../core/shared/item.model';
|
||||
import { DSONameService } from '../../core/breadcrumbs/dso-name.service';
|
||||
|
||||
/**
|
||||
* Component representing the statistics page for an item.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'ds-item-statistics-page',
|
||||
templateUrl: '../statistics-page/statistics-page.component.html',
|
||||
styleUrls: ['./item-statistics-page.component.scss']
|
||||
})
|
||||
export class ItemStatisticsPageComponent extends StatisticsPageComponent<Item> {
|
||||
|
||||
/**
|
||||
* The report types to show on this statistics page.
|
||||
*/
|
||||
types: string[] = [
|
||||
'TotalVisits',
|
||||
'TotalVisitsPerMonth',
|
||||
'TotalDownloads',
|
||||
'TopCountries',
|
||||
'TopCities',
|
||||
];
|
||||
|
||||
constructor(
|
||||
protected route: ActivatedRoute,
|
||||
protected router: Router,
|
||||
protected usageReportService: UsageReportService,
|
||||
protected nameService: DSONameService,
|
||||
) {
|
||||
super(
|
||||
route,
|
||||
router,
|
||||
usageReportService,
|
||||
nameService,
|
||||
);
|
||||
}
|
||||
}
|
@@ -0,0 +1,100 @@
|
||||
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { SiteStatisticsPageComponent } from './site-statistics-page.component';
|
||||
import { StatisticsTableComponent } from '../statistics-table/statistics-table.component';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { UsageReportService } from '../../core/statistics/usage-report-data.service';
|
||||
import { of as observableOf } from 'rxjs';
|
||||
import { Site } from '../../core/shared/site.model';
|
||||
import { DebugElement } from '@angular/core';
|
||||
import { By } from '@angular/platform-browser';
|
||||
import { UsageReport } from '../../core/statistics/models/usage-report.model';
|
||||
import { SharedModule } from '../../shared/shared.module';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { DSONameService } from '../../core/breadcrumbs/dso-name.service';
|
||||
import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service';
|
||||
import { SiteDataService } from '../../core/data/site-data.service';
|
||||
|
||||
describe('SiteStatisticsPageComponent', () => {
|
||||
|
||||
let component: SiteStatisticsPageComponent;
|
||||
let de: DebugElement;
|
||||
let fixture: ComponentFixture<SiteStatisticsPageComponent>;
|
||||
|
||||
beforeEach(async(() => {
|
||||
|
||||
const activatedRoute = {
|
||||
};
|
||||
|
||||
const router = {
|
||||
};
|
||||
|
||||
const usageReportService = {
|
||||
searchStatistics: () => observableOf([
|
||||
Object.assign(
|
||||
new UsageReport(), {
|
||||
id: `site_id-TotalVisits-report`,
|
||||
points: [],
|
||||
}
|
||||
),
|
||||
]),
|
||||
};
|
||||
|
||||
const nameService = {
|
||||
getName: () => observableOf('test dso name'),
|
||||
};
|
||||
|
||||
const siteService = {
|
||||
find: () => observableOf(Object.assign(new Site(), {
|
||||
id: 'site_id',
|
||||
_links: {
|
||||
self: {
|
||||
href: 'test_site_link',
|
||||
},
|
||||
},
|
||||
}))
|
||||
};
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
imports: [
|
||||
TranslateModule.forRoot(),
|
||||
CommonModule,
|
||||
SharedModule,
|
||||
],
|
||||
declarations: [
|
||||
SiteStatisticsPageComponent,
|
||||
StatisticsTableComponent,
|
||||
],
|
||||
providers: [
|
||||
{ provide: ActivatedRoute, useValue: activatedRoute },
|
||||
{ provide: Router, useValue: router },
|
||||
{ provide: UsageReportService, useValue: usageReportService },
|
||||
{ provide: DSpaceObjectDataService, useValue: {} },
|
||||
{ provide: DSONameService, useValue: nameService },
|
||||
{ provide: SiteDataService, useValue: siteService },
|
||||
],
|
||||
})
|
||||
.compileComponents();
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(SiteStatisticsPageComponent);
|
||||
component = fixture.componentInstance;
|
||||
de = fixture.debugElement;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should resolve to the correct site', () => {
|
||||
expect(de.query(By.css('.header')).nativeElement.id)
|
||||
.toEqual('site_id');
|
||||
});
|
||||
|
||||
it('should show a statistics table for each usage report', () => {
|
||||
expect(de.query(By.css('ds-statistics-table.site_id-TotalVisits-report')).nativeElement)
|
||||
.toBeTruthy();
|
||||
});
|
||||
});
|
@@ -0,0 +1,53 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { StatisticsPageComponent } from '../statistics-page/statistics-page.component';
|
||||
import { SiteDataService } from '../../core/data/site-data.service';
|
||||
import { UsageReportService } from '../../core/statistics/usage-report-data.service';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { Site } from '../../core/shared/site.model';
|
||||
import { DSONameService } from '../../core/breadcrumbs/dso-name.service';
|
||||
import { switchMap } from 'rxjs/operators';
|
||||
|
||||
/**
|
||||
* Component representing the site-wide statistics page.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'ds-site-statistics-page',
|
||||
templateUrl: '../statistics-page/statistics-page.component.html',
|
||||
styleUrls: ['./site-statistics-page.component.scss']
|
||||
})
|
||||
export class SiteStatisticsPageComponent extends StatisticsPageComponent<Site> {
|
||||
|
||||
/**
|
||||
* The report types to show on this statistics page.
|
||||
*/
|
||||
types: string[] = [
|
||||
'TotalVisits',
|
||||
];
|
||||
|
||||
constructor(
|
||||
protected route: ActivatedRoute,
|
||||
protected router: Router,
|
||||
protected usageReportService: UsageReportService,
|
||||
protected nameService: DSONameService,
|
||||
protected siteService: SiteDataService,
|
||||
) {
|
||||
super(
|
||||
route,
|
||||
router,
|
||||
usageReportService,
|
||||
nameService,
|
||||
);
|
||||
}
|
||||
|
||||
protected getScope$() {
|
||||
return this.siteService.find();
|
||||
}
|
||||
|
||||
protected getReports$() {
|
||||
return this.scope$.pipe(
|
||||
switchMap((scope) =>
|
||||
this.usageReportService.searchStatistics(scope._links.self.href, 0, 10),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
81
src/app/statistics-page/statistics-page-routing.module.ts
Normal file
81
src/app/statistics-page/statistics-page-routing.module.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import { RouterModule } from '@angular/router';
|
||||
import { I18nBreadcrumbResolver } from '../core/breadcrumbs/i18n-breadcrumb.resolver';
|
||||
import { I18nBreadcrumbsService } from '../core/breadcrumbs/i18n-breadcrumbs.service';
|
||||
import { StatisticsPageModule } from './statistics-page.module';
|
||||
import { SiteStatisticsPageComponent } from './site-statistics-page/site-statistics-page.component';
|
||||
import { ItemPageResolver } from '../+item-page/item-page.resolver';
|
||||
import { ItemStatisticsPageComponent } from './item-statistics-page/item-statistics-page.component';
|
||||
import { CollectionPageResolver } from '../+collection-page/collection-page.resolver';
|
||||
import { CollectionStatisticsPageComponent } from './collection-statistics-page/collection-statistics-page.component';
|
||||
import { CommunityPageResolver } from '../+community-page/community-page.resolver';
|
||||
import { CommunityStatisticsPageComponent } from './community-statistics-page/community-statistics-page.component';
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
StatisticsPageModule,
|
||||
RouterModule.forChild([
|
||||
{
|
||||
path: '',
|
||||
resolve: {
|
||||
breadcrumb: I18nBreadcrumbResolver
|
||||
},
|
||||
data: {
|
||||
title: 'statistics.title',
|
||||
breadcrumbKey: 'statistics'
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
component: SiteStatisticsPageComponent,
|
||||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
path: `items/:id`,
|
||||
resolve: {
|
||||
scope: ItemPageResolver,
|
||||
breadcrumb: I18nBreadcrumbResolver
|
||||
},
|
||||
data: {
|
||||
title: 'statistics.title',
|
||||
breadcrumbKey: 'statistics'
|
||||
},
|
||||
component: ItemStatisticsPageComponent,
|
||||
},
|
||||
{
|
||||
path: `collections/:id`,
|
||||
resolve: {
|
||||
scope: CollectionPageResolver,
|
||||
breadcrumb: I18nBreadcrumbResolver
|
||||
},
|
||||
data: {
|
||||
title: 'statistics.title',
|
||||
breadcrumbKey: 'statistics'
|
||||
},
|
||||
component: CollectionStatisticsPageComponent,
|
||||
},
|
||||
{
|
||||
path: `communities/:id`,
|
||||
resolve: {
|
||||
scope: CommunityPageResolver,
|
||||
breadcrumb: I18nBreadcrumbResolver
|
||||
},
|
||||
data: {
|
||||
title: 'statistics.title',
|
||||
breadcrumbKey: 'statistics'
|
||||
},
|
||||
component: CommunityStatisticsPageComponent,
|
||||
},
|
||||
]
|
||||
)
|
||||
],
|
||||
providers: [
|
||||
I18nBreadcrumbResolver,
|
||||
I18nBreadcrumbsService,
|
||||
CollectionPageResolver,
|
||||
CommunityPageResolver,
|
||||
]
|
||||
})
|
||||
export class StatisticsPageRoutingModule {
|
||||
}
|
39
src/app/statistics-page/statistics-page.module.ts
Normal file
39
src/app/statistics-page/statistics-page.module.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { CoreModule } from '../core/core.module';
|
||||
import { SharedModule } from '../shared/shared.module';
|
||||
import { StatisticsModule } from '../statistics/statistics.module';
|
||||
import { UsageReportService } from '../core/statistics/usage-report-data.service';
|
||||
import { SiteStatisticsPageComponent } from './site-statistics-page/site-statistics-page.component';
|
||||
import { StatisticsTableComponent } from './statistics-table/statistics-table.component';
|
||||
import { ItemStatisticsPageComponent } from './item-statistics-page/item-statistics-page.component';
|
||||
import { CollectionStatisticsPageComponent } from './collection-statistics-page/collection-statistics-page.component';
|
||||
import { CommunityStatisticsPageComponent } from './community-statistics-page/community-statistics-page.component';
|
||||
|
||||
const components = [
|
||||
StatisticsTableComponent,
|
||||
SiteStatisticsPageComponent,
|
||||
ItemStatisticsPageComponent,
|
||||
CollectionStatisticsPageComponent,
|
||||
CommunityStatisticsPageComponent,
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
CommonModule,
|
||||
SharedModule,
|
||||
CoreModule.forRoot(),
|
||||
StatisticsModule.forRoot()
|
||||
],
|
||||
declarations: components,
|
||||
providers: [
|
||||
UsageReportService,
|
||||
],
|
||||
exports: components
|
||||
})
|
||||
|
||||
/**
|
||||
* This module handles all components and pipes that are necessary for the search page
|
||||
*/
|
||||
export class StatisticsPageModule {
|
||||
}
|
@@ -0,0 +1,29 @@
|
||||
<div class="container">
|
||||
|
||||
<ng-container *ngVar="(scope$ | async) as scope">
|
||||
<h2 *ngIf="scope"
|
||||
class="header"
|
||||
id="{{ scope.id }}">
|
||||
{{ 'statistics.header' | translate: { scope: getName(scope) } }}
|
||||
</h2>
|
||||
</ng-container>
|
||||
|
||||
<ng-container *ngVar="reports$ | async as reports">
|
||||
|
||||
<ng-container *ngIf="!reports">
|
||||
<ds-loading></ds-loading>
|
||||
</ng-container>
|
||||
|
||||
<ng-container *ngIf="reports">
|
||||
<ds-statistics-table *ngFor="let report of reports"
|
||||
[report]="report"
|
||||
class="m-2 {{ report.id }}">
|
||||
</ds-statistics-table>
|
||||
<div *ngIf="!(hasData$ | async)">
|
||||
{{ 'statistics.page.no-data' | translate }}
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
</ng-container>
|
||||
|
||||
</div>
|
@@ -0,0 +1,84 @@
|
||||
import { OnInit } from '@angular/core';
|
||||
import { combineLatest, Observable } from 'rxjs';
|
||||
import { UsageReportService } from '../../core/statistics/usage-report-data.service';
|
||||
import { map, switchMap } from 'rxjs/operators';
|
||||
import { UsageReport } from '../../core/statistics/models/usage-report.model';
|
||||
import { RemoteData } from '../../core/data/remote-data';
|
||||
import { getRemoteDataPayload, getSucceededRemoteData, redirectToPageNotFoundOn404 } from '../../core/shared/operators';
|
||||
import { DSpaceObject } from '../../core/shared/dspace-object.model';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { DSONameService } from '../../core/breadcrumbs/dso-name.service';
|
||||
|
||||
/**
|
||||
* Class representing an abstract statistics page component.
|
||||
*/
|
||||
export abstract class StatisticsPageComponent<T extends DSpaceObject> implements OnInit {
|
||||
|
||||
/**
|
||||
* The scope dso for this statistics page, as an Observable.
|
||||
*/
|
||||
scope$: Observable<DSpaceObject>;
|
||||
|
||||
/**
|
||||
* The report types to show on this statistics page.
|
||||
*/
|
||||
types: string[];
|
||||
|
||||
/**
|
||||
* The usage report types to show on this statistics page, as an Observable list.
|
||||
*/
|
||||
reports$: Observable<UsageReport[]>;
|
||||
|
||||
hasData$: Observable<boolean>;
|
||||
|
||||
constructor(
|
||||
protected route: ActivatedRoute,
|
||||
protected router: Router,
|
||||
protected usageReportService: UsageReportService,
|
||||
protected nameService: DSONameService,
|
||||
) {
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.scope$ = this.getScope$();
|
||||
this.reports$ = this.getReports$();
|
||||
this.hasData$ = this.reports$.pipe(
|
||||
map((reports) => reports.some(
|
||||
(report) => report.points.length > 0
|
||||
)),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the scope dso for this statistics page, as an Observable.
|
||||
*/
|
||||
protected getScope$(): Observable<DSpaceObject> {
|
||||
return this.route.data.pipe(
|
||||
map((data) => data.scope as RemoteData<T>),
|
||||
redirectToPageNotFoundOn404(this.router),
|
||||
getSucceededRemoteData(),
|
||||
getRemoteDataPayload(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the usage reports for this statistics page, as an Observable list
|
||||
*/
|
||||
protected getReports$(): Observable<UsageReport[]> {
|
||||
return this.scope$.pipe(
|
||||
switchMap((scope) =>
|
||||
combineLatest(
|
||||
this.types.map((type) => this.usageReportService.getStatistic(scope.id, type))
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the name of the scope dso.
|
||||
* @param scope the scope dso to get the name for
|
||||
*/
|
||||
getName(scope: DSpaceObject): string {
|
||||
return this.nameService.getName(scope);
|
||||
}
|
||||
}
|
@@ -0,0 +1,36 @@
|
||||
<div *ngIf="hasData"
|
||||
class="m-1">
|
||||
|
||||
<h3 class="m-1">
|
||||
{{ 'statistics.table.title.' + report.reportType | translate }}
|
||||
</h3>
|
||||
|
||||
<table class="table table-striped">
|
||||
|
||||
<tbody>
|
||||
|
||||
<tr>
|
||||
<th scope="col"></th>
|
||||
<th scope="col"
|
||||
*ngFor="let header of headers"
|
||||
class="{{header}}-header">
|
||||
{{ header }}
|
||||
</th>
|
||||
</tr>
|
||||
|
||||
<tr *ngFor="let point of report.points"
|
||||
class="{{point.id}}-data">
|
||||
<th scope="row">
|
||||
{{ getLabel(point) | async }}
|
||||
</th>
|
||||
<td *ngFor="let header of headers"
|
||||
class="{{point.id}}-{{header}}-data">
|
||||
{{ point.values[header] }}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
|
||||
</table>
|
||||
|
||||
</div>
|
@@ -0,0 +1,8 @@
|
||||
th, td {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
td {
|
||||
width: 50px;
|
||||
max-width: 50px;
|
||||
}
|
@@ -0,0 +1,98 @@
|
||||
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { StatisticsTableComponent } from './statistics-table.component';
|
||||
import { UsageReport } from '../../core/statistics/models/usage-report.model';
|
||||
import { DebugElement } from '@angular/core';
|
||||
import { By } from '@angular/platform-browser';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service';
|
||||
import { DSONameService } from '../../core/breadcrumbs/dso-name.service';
|
||||
|
||||
describe('StatisticsTableComponent', () => {
|
||||
|
||||
let component: StatisticsTableComponent;
|
||||
let de: DebugElement;
|
||||
let fixture: ComponentFixture<StatisticsTableComponent>;
|
||||
|
||||
beforeEach(async(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [
|
||||
TranslateModule.forRoot(),
|
||||
],
|
||||
declarations: [
|
||||
StatisticsTableComponent,
|
||||
],
|
||||
providers: [
|
||||
{ provide: DSpaceObjectDataService, useValue: {} },
|
||||
{ provide: DSONameService, useValue: {} },
|
||||
],
|
||||
})
|
||||
.compileComponents();
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(StatisticsTableComponent);
|
||||
component = fixture.componentInstance;
|
||||
de = fixture.debugElement;
|
||||
component.report = Object.assign(new UsageReport(), {
|
||||
points: [],
|
||||
});
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
describe('when the storage report is empty', () => {
|
||||
|
||||
it ('should not display a table', () => {
|
||||
expect(de.query(By.css('table'))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when the storage report has data', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
component.report = Object.assign(new UsageReport(), {
|
||||
points: [
|
||||
{
|
||||
id: 'item_1',
|
||||
values: {
|
||||
views: 7,
|
||||
downloads: 4,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'item_2',
|
||||
values: {
|
||||
views: 8,
|
||||
downloads: 8,
|
||||
},
|
||||
}
|
||||
]
|
||||
});
|
||||
component.ngOnInit();
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it ('should display a table with the correct data', () => {
|
||||
|
||||
expect(de.query(By.css('table'))).toBeTruthy();
|
||||
|
||||
expect(de.query(By.css('th.views-header')).nativeElement.innerText)
|
||||
.toEqual('views');
|
||||
expect(de.query(By.css('th.downloads-header')).nativeElement.innerText)
|
||||
.toEqual('downloads');
|
||||
|
||||
expect(de.query(By.css('td.item_1-views-data')).nativeElement.innerText)
|
||||
.toEqual('7');
|
||||
expect(de.query(By.css('td.item_1-downloads-data')).nativeElement.innerText)
|
||||
.toEqual('4');
|
||||
expect(de.query(By.css('td.item_2-views-data')).nativeElement.innerText)
|
||||
.toEqual('8');
|
||||
expect(de.query(By.css('td.item_2-downloads-data')).nativeElement.innerText)
|
||||
.toEqual('8');
|
||||
});
|
||||
});
|
||||
});
|
@@ -0,0 +1,67 @@
|
||||
import { Component, Input, OnInit } from '@angular/core';
|
||||
import { Point, UsageReport } from '../../core/statistics/models/usage-report.model';
|
||||
import { Observable, of } from 'rxjs';
|
||||
import { DSONameService } from '../../core/breadcrumbs/dso-name.service';
|
||||
import { map } from 'rxjs/operators';
|
||||
import { getRemoteDataPayload, getSucceededRemoteData } from '../../core/shared/operators';
|
||||
import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service';
|
||||
|
||||
/**
|
||||
* Component representing a statistics table for a given usage report.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'ds-statistics-table',
|
||||
templateUrl: './statistics-table.component.html',
|
||||
styleUrls: ['./statistics-table.component.scss']
|
||||
})
|
||||
export class StatisticsTableComponent implements OnInit {
|
||||
|
||||
/**
|
||||
* The usage report to display a statistics table for
|
||||
*/
|
||||
@Input()
|
||||
report: UsageReport;
|
||||
|
||||
/**
|
||||
* Boolean indicating whether the usage report has data
|
||||
*/
|
||||
hasData: boolean;
|
||||
|
||||
/**
|
||||
* The table headers
|
||||
*/
|
||||
headers: string[];
|
||||
|
||||
constructor(
|
||||
protected dsoService: DSpaceObjectDataService,
|
||||
protected nameService: DSONameService,
|
||||
) {
|
||||
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
this.hasData = this.report.points.length > 0;
|
||||
if (this.hasData) {
|
||||
this.headers = Object.keys(this.report.points[0].values);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the row label to display for a statistics point.
|
||||
* @param point the statistics point to get the label for
|
||||
*/
|
||||
getLabel(point: Point): Observable<string> {
|
||||
switch (this.report.reportType) {
|
||||
case 'TotalVisits':
|
||||
return this.dsoService.findById(point.id).pipe(
|
||||
getSucceededRemoteData(),
|
||||
getRemoteDataPayload(),
|
||||
map((item) => this.nameService.getName(item)),
|
||||
);
|
||||
case 'TopCities':
|
||||
case 'topCountries':
|
||||
default:
|
||||
return of(point.label);
|
||||
}
|
||||
}
|
||||
}
|
@@ -258,6 +258,10 @@
|
||||
|
||||
"admin.access-control.epeople.form.notification.edited.failure": "Failed to edit EPerson \"{{name}}\"",
|
||||
|
||||
"admin.access-control.epeople.form.notification.deleted.success": "Successfully deleted EPerson \"{{name}}\"",
|
||||
|
||||
"admin.access-control.epeople.form.notification.deleted.failure": "Failed to delete EPerson \"{{name}}\"",
|
||||
|
||||
"admin.access-control.epeople.form.groupsEPersonIsMemberOf": "Member of these groups:",
|
||||
|
||||
"admin.access-control.epeople.form.table.id": "ID",
|
||||
@@ -1065,6 +1069,13 @@
|
||||
|
||||
"confirmation-modal.export-metadata.confirm": "Export",
|
||||
|
||||
"confirmation-modal.delete-eperson.header": "Delete EPerson \"{{ dsoName }}\"",
|
||||
|
||||
"confirmation-modal.delete-eperson.info": "Are you sure you want to delete EPerson \"{{ dsoName }}\"",
|
||||
|
||||
"confirmation-modal.delete-eperson.cancel": "Cancel",
|
||||
|
||||
"confirmation-modal.delete-eperson.confirm": "Delete",
|
||||
|
||||
|
||||
"error.bitstream": "Error fetching bitstream",
|
||||
@@ -1107,6 +1118,10 @@
|
||||
|
||||
|
||||
|
||||
"file-section.error.header": "Error obtaining files for this item",
|
||||
|
||||
|
||||
|
||||
"footer.copyright": "copyright © 2002-{{ year }}",
|
||||
|
||||
"footer.link.dspace": "DSpace software",
|
||||
@@ -1437,6 +1452,8 @@
|
||||
|
||||
"item.edit.metadata.notifications.discarded.title": "Changed discarded",
|
||||
|
||||
"item.edit.metadata.notifications.error.title": "An error occurred",
|
||||
|
||||
"item.edit.metadata.notifications.invalid.content": "Your changes were not saved. Please make sure all fields are valid before you save.",
|
||||
|
||||
"item.edit.metadata.notifications.invalid.title": "Metadata invalid",
|
||||
@@ -2846,6 +2863,30 @@
|
||||
|
||||
|
||||
|
||||
"statistics.title": "Statistics",
|
||||
|
||||
"statistics.header": "Statistics for {{ scope }}",
|
||||
|
||||
"statistics.breadcrumbs": "Statistics",
|
||||
|
||||
"statistics.page.no-data": "No data available",
|
||||
|
||||
"statistics.table.no-data": "No data available",
|
||||
|
||||
"statistics.table.title.TotalVisits": "Total visits",
|
||||
|
||||
"statistics.table.title.TotalVisitsPerMonth": "Total visits per month",
|
||||
|
||||
"statistics.table.title.TotalDownloads": "File Visits",
|
||||
|
||||
"statistics.table.title.TopCountries": "Top country views",
|
||||
|
||||
"statistics.table.title.TopCities": "Top city views",
|
||||
|
||||
"statistics.table.header.views": "Views",
|
||||
|
||||
|
||||
|
||||
"submission.edit.title": "Edit Submission",
|
||||
|
||||
"submission.general.cannot_submit": "You have not the privilege to make a new submission.",
|
||||
|
@@ -31,4 +31,5 @@ export interface GlobalConfig extends Config {
|
||||
item: ItemPageConfig;
|
||||
collection: CollectionPageConfig;
|
||||
theme: Theme;
|
||||
rewriteDownloadUrls: boolean;
|
||||
}
|
||||
|
@@ -16,13 +16,12 @@ export const environment: GlobalConfig = {
|
||||
},
|
||||
// The REST API server settings.
|
||||
// NOTE: these must be "synced" with the 'dspace.server.url' setting in your backend's local.cfg.
|
||||
// The 'nameSpace' must always end in "/api" as that's the subpath of the REST API in the backend.
|
||||
rest: {
|
||||
ssl: true,
|
||||
host: 'dspace7.4science.cloud',
|
||||
port: 443,
|
||||
// NOTE: Space is capitalized because 'namespace' is a reserved string in TypeScript
|
||||
nameSpace: '/server/api',
|
||||
nameSpace: '/server',
|
||||
},
|
||||
// Caching settings
|
||||
cache: {
|
||||
@@ -216,4 +215,6 @@ export const environment: GlobalConfig = {
|
||||
theme: {
|
||||
name: 'default',
|
||||
},
|
||||
// Whether the UI should rewrite file download URLs to match its domain. Only necessary to enable when running UI and REST API on separate domains
|
||||
rewriteDownloadUrls: false,
|
||||
};
|
||||
|
@@ -4,7 +4,7 @@ export const environment = {
|
||||
* e.g.
|
||||
* rest: {
|
||||
* host: 'rest.api',
|
||||
* nameSpace: '/rest/api',
|
||||
* nameSpace: '/rest',
|
||||
* }
|
||||
*/
|
||||
};
|
||||
|
Reference in New Issue
Block a user