Merge branch 'main' into fix_com_col_refresh

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

View File

@@ -7,12 +7,20 @@
# Settings related to code coverage analysis
coverage:
status:
# Configuration for project-level checks. This checks how the PR changes overall coverage.
project:
default:
# For each PR, auto compare coverage to previous commit.
# Require that overall (project) coverage does NOT drop more than 0.5%
target: auto
threshold: 0.5%
# Configuration for patch-level checks. This checks the relative coverage of the new PR code ONLY.
patch:
default:
# For each PR, make sure the coverage of the new code is within 1% of current overall coverage.
# We let 'patch' be more lenient as we only require *project* coverage to not drop significantly.
target: auto
threshold: 1%
# Turn PR comments "off". This feature adds the code coverage summary as a
# comment on each PR. See https://docs.codecov.io/docs/pull-request-comments

View File

@@ -20,6 +20,7 @@ _This checklist provides a reminder of what we are going to look for when review
- [ ] My PR is small in size (e.g. less than 1,000 lines of code, not including comments & specs/tests), or I have provided reasons as to why that's not possible.
- [ ] My PR passes [TSLint](https://palantir.github.io/tslint/) validation using `yarn run lint`
- [ ] My PR doesn't introduce circular dependencies
- [ ] My PR includes [TypeDoc](https://typedoc.org/) comments for _all new (or modified) public methods and classes_. It also includes TypeDoc for large or complex private methods.
- [ ] My PR passes all specs/tests and includes new/updated specs or tests based on the [Code Testing Guide](https://wiki.lyrasis.org/display/DSPACE/Code+Testing+Guide).
- [ ] If my PR includes new, third-party dependencies (in `package.json`), I've made sure their licenses align with the [DSpace BSD License](https://github.com/DSpace/DSpace/blob/main/LICENSE) based on the [Licensing of Contributions](https://wiki.lyrasis.org/display/DSPACE/Code+Contribution+Guidelines#CodeContributionGuidelines-LicensingofContributions) documentation.

View File

@@ -24,7 +24,7 @@ env:
# Direct that step to utilize a DSpace REST service that has been started in docker.
DSPACE_REST_HOST: localhost
DSPACE_REST_PORT: 8080
DSPACE_REST_NAMESPACE: '/server/api'
DSPACE_REST_NAMESPACE: '/server'
DSPACE_REST_SSL: false
before_install:

View File

@@ -13,6 +13,6 @@ export const environment = {
host: 'localhost',
port: 8080,
// NOTE: Space is capitalized because 'namespace' is a reserved string in TypeScript
nameSpace: '/server/api'
nameSpace: '/server'
}
};

View File

@@ -42,7 +42,7 @@ export const environment = {
host: 'dspace7.4science.cloud',
port: 443,
// NOTE: Space is capitalized because 'namespace' is a reserved string in TypeScript
nameSpace: '/server/api'
nameSpace: '/server'
}
};
```
@@ -52,7 +52,7 @@ Alternately you can set the following environment variables. If any of these are
DSPACE_REST_SSL=true
DSPACE_REST_HOST=dspace7.4science.cloud
DSPACE_REST_PORT=443
DSPACE_REST_NAMESPACE=/server/api
DSPACE_REST_NAMESPACE=/server
```
## Supporting analytics services other than Google Analytics
@@ -63,3 +63,70 @@ Angulartics can be configured to work with a number of other services besides Go
In order to start using one of these services, select it from the [Angulartics Providers page](https://angulartics.github.io/angulartics2/#providers), and follow the instructions on how to configure it.
The Google Analytics script was added in [`main.browser.ts`](https://github.com/DSpace/dspace-angular/blob/ff04760f4af91ac3e7add5e7424a46cb2439e874/src/main.browser.ts#L33) instead of the `<head>` tag in `index.html` to ensure events get sent when the page is shown in a client's browser, and not when it's rendered on the universal server. Likely you'll want to do the same when adding a new service.
## SEO when hosting REST Api and UI on different servers
Indexers such as Google Scholar require that files are hosted on the same domain as the page that links them. In DSpace 7, Bitstreams are served from the REST server. So if you use different servers for the REST api and the UI you'll want to ensure that Bitstream downloads are proxied through the UI server.
In order to achieve this we'll need to do two things:
- **Proxy the Bitstream downloads through the UI server.** You'll need to put a webserver such as httpd or nginx in front of the UI server in order to achieve this. [Below](#apache-http-server-config) you'll find a section explaining how to do it in httpd.
- **Update the URLs for Bitstream downloads to match the UI server.** This can be done using a setting in the UI environment file.
### UI config
If you set the property `rewriteDownloadUrls` to `true` in your `environment.prod.ts` file, the [origin](https://developer.mozilla.org/en-US/docs/Glossary/Origin) of any download URL will be replaced by the origin of the UI. This will also happen for the `citation_pdf_url` `<meta>` tag on Item pages.
The app will determine the UI origin currently in use, so the external UI URL doesn't need to be configured anywhere and rewrites will still work if you host the UI from multiple domains.
### Apache HTTP Server config
#### Basics
In order to be able to host bitstreams from the UI Server you'll need to enable mod_proxy and add the following to the httpd config of your UI server:
```
ProxyPassMatch "/server/api/core/bitstreams/([^/]+)/content" "http://rest.api/server/api/core/bitstreams/$1/content"
ProxyPassReverse "/server/api/core/bitstreams/([^/]+)/content" "http://rest.api/server/api/core/bitstreams/$1/content"
```
Replace http://rest.api in with the correct origin for your REST server.
The `ProxyPassMatch` line forwards all requests matching the regular expression for a bitstream download URL to the corresponding path on the REST server
The `ProxyPassReverse` ensures that if the REST server were to return redirect response, httpd would also swap out its hostname for the hostname of the UI before forwarding the response to the client.
#### Using HTTPS
If your REST server uses https, you'll need to enable mod_ssl and ensure `SSLProxyEngine on` is part of your UI server's httpd config as well
If the UI hostname doesn't match the CN in the SSL certificate of the REST server (which is likely if they're on different domains), you'll also need to add the following lines
```
SSLProxyCheckPeerCN off
SSLProxyCheckPeerName off
```
These are two names for [the same directive](https://httpd.apache.org/docs/trunk/mod/mod_ssl.html#sslproxycheckpeername) that have been used for various versions of httpd, old versions need the former, then some in-between versions need both, and newer versions only need the latter. Keeping them both doesn't harm anything.
So the entire config becomes:
```
SSLProxyEngine on
SSLProxyCheckPeerCN off
SSLProxyCheckPeerName off
ProxyPassMatch "/server/api/core/bitstreams/([^/]+)/content" "https://rest.api/server/api/core/bitstreams/$1/content"
ProxyPassReverse "/server/api/core/bitstreams/([^/]+)/content" "https://rest.api/server/api/core/bitstreams/$1/content"
```
If you don't want httpd to verify the certificate of the REST server, you can also turn all checks off with the following config:
```
SSLProxyEngine on
SSLProxyVerify none
SSLProxyCheckPeerCN off
SSLProxyCheckPeerName off
SSLProxyCheckPeerExpire off
ProxyPassMatch "/server/api/core/bitstreams/([^/]+)/content" "https://rest.api/server/api/core/bitstreams/$1/content"
ProxyPassReverse "/server/api/core/bitstreams/([^/]+)/content" "https://rest.api/server/api/core/bitstreams/$1/content"
```

View File

@@ -57,8 +57,8 @@ function generateEnvironmentFile(file: GlobalConfig): void {
// TODO remove workaround in beta 5
if (file.rest.nameSpace.match("(.*)/api/?$") !== null) {
const newValue = getNameSpace(file.rest.nameSpace);
console.log(colors.white.bgMagenta.bold(`The rest.nameSpace property in your environment file or in your DSPACE_REST_NAMESPACE environment variable ends with '/api'.\nThis is deprecated. As '/api' isn't configurable on the rest side, it shouldn't be repeated in every environment file.\nPlease change the rest nameSpace to '${newValue}'`));
file.rest.nameSpace = getNameSpace(file.rest.nameSpace);
console.log(colors.white.bgMagenta.bold(`The rest.nameSpace property in your environment file or in your DSPACE_REST_NAMESPACE environment variable ends with '/api'.\nThis is deprecated. As '/api' isn't configurable on the rest side, it shouldn't be repeated in every environment file.\nPlease change the rest nameSpace to '${file.rest.nameSpace}'`));
}
const contents = `export const environment = ` + JSON.stringify(file);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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