diff --git a/.codecov.yml b/.codecov.yml index 43a06c0eb3..3dba42ef37 100644 --- a/.codecov.yml +++ b/.codecov.yml @@ -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 diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index b4063b0550..5c0163bf8e 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -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. diff --git a/.travis.yml b/.travis.yml index 0ef2e7b74e..6cd6d26c59 100644 --- a/.travis.yml +++ b/.travis.yml @@ -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: diff --git a/docker/environment.dev.ts b/docker/environment.dev.ts index 573c8ebb67..0e603ef11d 100644 --- a/docker/environment.dev.ts +++ b/docker/environment.dev.ts @@ -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' } }; diff --git a/docs/Configuration.md b/docs/Configuration.md index 4be21d046d..ac5ca3ef72 100644 --- a/docs/Configuration.md +++ b/docs/Configuration.md @@ -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 `` 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` `` 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" +``` + + + + + diff --git a/scripts/set-env.ts b/scripts/set-env.ts index 5570b77218..0aa106538c 100644 --- a/scripts/set-env.ts +++ b/scripts/set-env.ts @@ -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); diff --git a/src/app/+admin/admin-access-control/epeople-registry/epeople-registry.component.html b/src/app/+admin/admin-access-control/epeople-registry/epeople-registry.component.html index 9cf733a394..5cb8e77a3e 100644 --- a/src/app/+admin/admin-access-control/epeople-registry/epeople-registry.component.html +++ b/src/app/+admin/admin-access-control/epeople-registry/epeople-registry.component.html @@ -4,7 +4,7 @@ -
@@ -40,10 +40,10 @@ @@ -59,21 +59,21 @@ - - {{eperson.id}} - {{eperson.name}} - {{eperson.email}} + + {{epersonDto.eperson.id}} + {{epersonDto.eperson.name}} + {{epersonDto.eperson.email}}
- -
@@ -85,7 +85,7 @@
- diff --git a/src/app/+admin/admin-access-control/epeople-registry/epeople-registry.component.spec.ts b/src/app/+admin/admin-access-control/epeople-registry/epeople-registry.component.spec.ts index 4cc68a5540..0ff07d688c 100644 --- a/src/app/+admin/admin-access-control/epeople-registry/epeople-registry.component.spec.ts +++ b/src/app/+admin/admin-access-control/epeople-registry/epeople-registry.component.spec.ts @@ -24,6 +24,8 @@ import { getMockTranslateService } from '../../../shared/mocks/translate.service import { TranslateLoaderMock } from '../../../shared/mocks/translate-loader.mock'; import { NotificationsServiceStub } from '../../../shared/testing/notifications-service.stub'; import { RouterStub } from '../../../shared/testing/router.stub'; +import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service'; +import { RequestService } from '../../../core/data/request.service'; describe('EPeopleRegistryComponent', () => { let component: EPeopleRegistryComponent; @@ -33,6 +35,8 @@ describe('EPeopleRegistryComponent', () => { let mockEPeople; let ePersonDataServiceStub: any; + let authorizationService: AuthorizationDataService; + let modalService; beforeEach(async(() => { mockEPeople = [EPersonMock, EPersonMock2]; @@ -82,6 +86,9 @@ describe('EPeopleRegistryComponent', () => { return '/admin/access-control/epeople'; } }; + authorizationService = jasmine.createSpyObj('authorizationService', { + isAuthorized: observableOf(true) + }); builderService = getMockFormBuilderService(); translateService = getMockTranslateService(); TestBed.configureTestingModule({ @@ -94,11 +101,13 @@ describe('EPeopleRegistryComponent', () => { }), ], declarations: [EPeopleRegistryComponent], - providers: [EPeopleRegistryComponent, + providers: [ { provide: EPersonDataService, useValue: ePersonDataServiceStub }, { provide: NotificationsService, useValue: new NotificationsServiceStub() }, + { provide: AuthorizationDataService, useValue: authorizationService }, { provide: FormBuilderService, useValue: builderService }, { provide: Router, useValue: new RouterStub() }, + { provide: RequestService, useValue: jasmine.createSpyObj('requestService', ['removeByHrefSubstring'])} ], schemas: [NO_ERRORS_SCHEMA] }).compileComponents(); @@ -107,12 +116,14 @@ describe('EPeopleRegistryComponent', () => { beforeEach(() => { fixture = TestBed.createComponent(EPeopleRegistryComponent); component = fixture.componentInstance; + modalService = (component as any).modalService; + spyOn(modalService, 'open').and.returnValue(Object.assign({ componentInstance: Object.assign({ response: observableOf(true) }) })); fixture.detectChanges(); }); - it('should create EPeopleRegistryComponent', inject([EPeopleRegistryComponent], (comp: EPeopleRegistryComponent) => { - expect(comp).toBeDefined(); - })); + it('should create EPeopleRegistryComponent', () => { + expect(component).toBeDefined(); + }); it('should display list of ePeople', () => { const ePeopleIdsFound = fixture.debugElement.queryAll(By.css('#epeople tr td:first-child')); @@ -215,4 +226,20 @@ describe('EPeopleRegistryComponent', () => { }); }); + describe('delete EPerson button when the isAuthorized returns false', () => { + let ePeopleDeleteButton; + beforeEach(() => { + authorizationService = jasmine.createSpyObj('authorizationService', { + isAuthorized: observableOf(false) + }); + }); + + it ('should be disabled', () => { + ePeopleDeleteButton = fixture.debugElement.queryAll(By.css('#epeople tr td div button.delete-button')); + ePeopleDeleteButton.forEach((deleteButton) => { + expect(deleteButton.nativeElement.disabled).toBe(true); + }); + + }) + }) }); diff --git a/src/app/+admin/admin-access-control/epeople-registry/epeople-registry.component.ts b/src/app/+admin/admin-access-control/epeople-registry/epeople-registry.component.ts index e88ba84418..2f989490a7 100644 --- a/src/app/+admin/admin-access-control/epeople-registry/epeople-registry.component.ts +++ b/src/app/+admin/admin-access-control/epeople-registry/epeople-registry.component.ts @@ -2,9 +2,9 @@ import { Component, OnDestroy, OnInit } from '@angular/core'; import { FormBuilder } from '@angular/forms'; import { Router } from '@angular/router'; import { TranslateService } from '@ngx-translate/core'; -import { Observable } from 'rxjs'; +import { BehaviorSubject, combineLatest, Observable } from 'rxjs'; import { Subscription } from 'rxjs/internal/Subscription'; -import { map, take } from 'rxjs/operators'; +import { map, switchMap, take } from 'rxjs/operators'; import { PaginatedList } from '../../../core/data/paginated-list'; import { RemoteData } from '../../../core/data/remote-data'; import { EPersonDataService } from '../../../core/eperson/eperson-data.service'; @@ -12,6 +12,16 @@ import { EPerson } from '../../../core/eperson/models/eperson.model'; import { hasValue } from '../../../shared/empty.util'; import { NotificationsService } from '../../../shared/notifications/notifications.service'; import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model'; +import { EpersonDtoModel } from '../../../core/eperson/models/eperson-dto.model'; +import { FeatureID } from '../../../core/data/feature-authorization/feature-id'; +import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service'; +import { getAllSucceededRemoteDataPayload } from '../../../core/shared/operators'; +import { ErrorResponse, RestResponse } from '../../../core/cache/response.models'; +import { ConfirmationModalComponent } from '../../../shared/confirmation-modal/confirmation-modal.component'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import { RequestService } from '../../../core/data/request.service'; +import { filter } from 'rxjs/internal/operators/filter'; +import { PageInfo } from '../../../core/shared/page-info.model'; @Component({ selector: 'ds-epeople-registry', @@ -28,7 +38,17 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy { /** * A list of all the current EPeople within the repository or the result of the search */ - ePeople: Observable>>; + ePeople$: BehaviorSubject>> = new BehaviorSubject>>({} as any); + /** + * A BehaviorSubject with the list of EpersonDtoModel objects made from the EPeople in the repository or + * as the result of the search + */ + ePeopleDto$: BehaviorSubject> = new BehaviorSubject>({} as any); + + /** + * An observable for the pageInfo, needed to pass to the pagination component + */ + pageInfoState$: BehaviorSubject = new BehaviorSubject(undefined); /** * Pagination config used to display the list of epeople @@ -59,8 +79,11 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy { constructor(private epersonService: EPersonDataService, private translateService: TranslateService, private notificationsService: NotificationsService, + private authorizationService: AuthorizationDataService, private formBuilder: FormBuilder, - private router: Router) { + private router: Router, + private modalService: NgbModal, + public requestService: RequestService) { this.currentSearchQuery = ''; this.currentSearchScope = 'metadata'; this.searchForm = this.formBuilder.group(({ @@ -70,6 +93,13 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy { } ngOnInit() { + this.initialisePage(); + } + + /** + * This method will initialise the page + */ + initialisePage() { this.isEPersonFormShown = false; this.search({ scope: this.currentSearchScope, query: this.currentSearchQuery }); this.subs.push(this.epersonService.getActiveEPerson().subscribe((eperson: EPerson) => { @@ -84,18 +114,10 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy { * @param event */ onPageChange(event) { - this.config.currentPage = event; - this.search({ scope: this.currentSearchScope, query: this.currentSearchQuery }) - } - - /** - * Force-update the list of EPeople by first clearing the cache related to EPeople, then performing - * a new REST call - */ - public forceUpdateEPeople() { - this.epersonService.clearEPersonRequests(); - this.isEPersonFormShown = false; - this.search({ query: '', scope: 'metadata' }) + if (this.config.currentPage !== event) { + this.config.currentPage = event; + this.search({ scope: this.currentSearchScope, query: this.currentSearchQuery }) + } } /** @@ -115,10 +137,33 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy { this.currentSearchScope = scope; this.config.currentPage = 1; } - this.ePeople = this.epersonService.searchByScope(this.currentSearchScope, this.currentSearchQuery, { + this.subs.push(this.epersonService.searchByScope(this.currentSearchScope, this.currentSearchQuery, { currentPage: this.config.currentPage, elementsPerPage: this.config.pageSize - }); + }).subscribe((peopleRD) => { + this.ePeople$.next(peopleRD) + } + )); + + this.subs.push(this.ePeople$.pipe( + getAllSucceededRemoteDataPayload(), + switchMap((epeople) => { + return combineLatest(...epeople.page.map((eperson) => { + return this.authorizationService.isAuthorized(FeatureID.CanDelete, hasValue(eperson) ? eperson.self : undefined).pipe( + map((authorized) => { + const epersonDtoModel: EpersonDtoModel = new EpersonDtoModel(); + epersonDtoModel.ableToDelete = authorized; + epersonDtoModel.eperson = eperson; + return epersonDtoModel; + }) + ); + })).pipe(map((dtos: EpersonDtoModel[]) => { + return new PaginatedList(epeople.pageInfo, dtos); + })) + })).subscribe((value) => { + this.ePeopleDto$.next(value); + this.pageInfoState$.next(value.pageInfo); + })); } /** @@ -160,16 +205,26 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy { */ deleteEPerson(ePerson: EPerson) { if (hasValue(ePerson.id)) { - this.epersonService.deleteEPerson(ePerson).pipe(take(1)).subscribe((success: boolean) => { - if (success) { - this.notificationsService.success(this.translateService.get(this.labelPrefix + 'notification.deleted.success', { name: ePerson.name })); - this.forceUpdateEPeople(); - } else { - this.notificationsService.error(this.translateService.get(this.labelPrefix + 'notification.deleted.failure', { name: ePerson.name })); - } - this.epersonService.cancelEditEPerson(); - this.isEPersonFormShown = false; - }) + const modalRef = this.modalService.open(ConfirmationModalComponent); + modalRef.componentInstance.dso = ePerson; + modalRef.componentInstance.headerLabel = 'confirmation-modal.delete-eperson.header'; + modalRef.componentInstance.infoLabel = 'confirmation-modal.delete-eperson.info'; + modalRef.componentInstance.cancelLabel = 'confirmation-modal.delete-eperson.cancel'; + modalRef.componentInstance.confirmLabel = 'confirmation-modal.delete-eperson.confirm'; + modalRef.componentInstance.response.pipe(take(1)).subscribe((confirm: boolean) => { + if (confirm) { + if (hasValue(ePerson.id)) { + this.epersonService.deleteEPerson(ePerson).pipe(take(1)).subscribe((restResponse: RestResponse) => { + if (restResponse.isSuccessful) { + this.notificationsService.success(this.translateService.get(this.labelPrefix + 'notification.deleted.success', { name: ePerson.name })); + this.reset(); + } else { + const errorResponse = restResponse as ErrorResponse; + this.notificationsService.error('Error occured when trying to delete EPerson with id: ' + ePerson.id + ' with code: ' + errorResponse.statusCode + ' and message: ' + errorResponse.errorMessage); + } + }) + }} + }); } } @@ -177,6 +232,10 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy { * Unsub all subscriptions */ ngOnDestroy(): void { + this.cleanupSubscribes(); + } + + cleanupSubscribes() { this.subs.filter((sub) => hasValue(sub)).forEach((sub) => sub.unsubscribe()); } @@ -199,4 +258,18 @@ export class EPeopleRegistryComponent implements OnInit, OnDestroy { }); this.search({ query: '' }); } + + /** + * This method will ensure that the page gets reset and that the cache is cleared + */ + reset() { + this.epersonService.getBrowseEndpoint().pipe( + switchMap((href) => this.requestService.removeByHrefSubstring(href)), + filter((isCached) => isCached), + take(1) + ).subscribe(() => { + this.cleanupSubscribes(); + this.initialisePage(); + }); + } } diff --git a/src/app/+admin/admin-access-control/epeople-registry/eperson-form/eperson-form.component.html b/src/app/+admin/admin-access-control/epeople-registry/eperson-form/eperson-form.component.html index 34fdef89bf..3f744240e5 100644 --- a/src/app/+admin/admin-access-control/epeople-registry/eperson-form/eperson-form.component.html +++ b/src/app/+admin/admin-access-control/epeople-registry/eperson-form/eperson-form.component.html @@ -17,7 +17,7 @@ -
-
-
{{"item.page.filesection.license.bundle" | translate}}
- +
+
{{"item.page.filesection.license.bundle" | translate}}
+ +
+
+ +
+
+
+
{{"item.page.filesection.name" | translate}}
+
{{file.name}}
-
-
- +
{{"item.page.filesection.size" | translate}}
+
{{(file.sizeBytes) | dsFileSize }}
+ +
{{"item.page.filesection.format" | translate}}
+
{{(file.format | async)?.payload?.description}}
+ + +
{{"item.page.filesection.description" | translate}}
+
{{file.firstMetadataValue("dc.description")}}
+
+
+
+ + {{"item.page.filesection.download" | translate}} + +
-
-
-
{{"item.page.filesection.name" | translate}}
-
{{file.name}}
- -
{{"item.page.filesection.size" | translate}}
-
{{(file.sizeBytes) | dsFileSize }}
- - -
{{"item.page.filesection.format" | translate}}
-
{{(file.format | async)?.payload?.description}}
- - -
{{"item.page.filesection.description" | translate}}
-
{{file.firstMetadataValue("dc.description")}}
-
-
-
- - {{"item.page.filesection.download" | translate}} - -
-
-
+ +
diff --git a/src/app/+item-page/full/field-components/file-section/full-file-section.component.spec.ts b/src/app/+item-page/full/field-components/file-section/full-file-section.component.spec.ts index 970420f252..4d4b713648 100644 --- a/src/app/+item-page/full/field-components/file-section/full-file-section.component.spec.ts +++ b/src/app/+item-page/full/field-components/file-section/full-file-section.component.spec.ts @@ -14,6 +14,8 @@ import {Bitstream} from '../../../../core/shared/bitstream.model'; import {of as observableOf} from 'rxjs'; import {MockBitstreamFormat1} from '../../../../shared/mocks/item.mock'; import {By} from '@angular/platform-browser'; +import { NotificationsService } from '../../../../shared/notifications/notifications.service'; +import { NotificationsServiceStub } from '../../../../shared/testing/notifications-service.stub'; describe('FullFileSectionComponent', () => { let comp: FullFileSectionComponent; @@ -61,7 +63,8 @@ describe('FullFileSectionComponent', () => { }), BrowserAnimationsModule], declarations: [FullFileSectionComponent, VarDirective, FileSizePipe, MetadataFieldWrapperComponent], providers: [ - {provide: BitstreamDataService, useValue: bitstreamDataService} + {provide: BitstreamDataService, useValue: bitstreamDataService}, + {provide: NotificationsService, useValue: new NotificationsServiceStub()} ], schemas: [NO_ERRORS_SCHEMA] diff --git a/src/app/+item-page/full/field-components/file-section/full-file-section.component.ts b/src/app/+item-page/full/field-components/file-section/full-file-section.component.ts index fdbe662ed9..bd3b2f7063 100644 --- a/src/app/+item-page/full/field-components/file-section/full-file-section.component.ts +++ b/src/app/+item-page/full/field-components/file-section/full-file-section.component.ts @@ -10,6 +10,10 @@ import { PaginationComponentOptions } from '../../../../shared/pagination/pagina import { PaginatedList } from '../../../../core/data/paginated-list'; import { RemoteData } from '../../../../core/data/remote-data'; import { switchMap } from 'rxjs/operators'; +import { NotificationsService } from '../../../../shared/notifications/notifications.service'; +import { TranslateService } from '@ngx-translate/core'; +import { hasValue, isEmpty } from '../../../../shared/empty.util'; +import { tap } from 'rxjs/internal/operators/tap'; /** * This component renders the file section of the item @@ -31,14 +35,14 @@ export class FullFileSectionComponent extends FileSectionComponent implements On licenses$: Observable>>; pageSize = 5; - originalOptions = Object.assign(new PaginationComponentOptions(),{ + originalOptions = Object.assign(new PaginationComponentOptions(), { id: 'original-bitstreams-options', currentPage: 1, pageSize: this.pageSize }); originalCurrentPage$ = new BehaviorSubject(1); - licenseOptions = Object.assign(new PaginationComponentOptions(),{ + licenseOptions = Object.assign(new PaginationComponentOptions(), { id: 'license-bitstreams-options', currentPage: 1, pageSize: this.pageSize @@ -46,9 +50,11 @@ export class FullFileSectionComponent extends FileSectionComponent implements On licenseCurrentPage$ = new BehaviorSubject(1); constructor( - bitstreamDataService: BitstreamDataService + bitstreamDataService: BitstreamDataService, + protected notificationsService: NotificationsService, + protected translateService: TranslateService ) { - super(bitstreamDataService); + super(bitstreamDataService, notificationsService, translateService); } ngOnInit(): void { @@ -57,21 +63,33 @@ export class FullFileSectionComponent extends FileSectionComponent implements On initialize(): void { this.originals$ = this.originalCurrentPage$.pipe( - switchMap((pageNumber: number) => this.bitstreamDataService.findAllByItemAndBundleName( - this.item, - 'ORIGINAL', - { elementsPerPage: this.pageSize, currentPage: pageNumber }, - followLink( 'format') - )) + switchMap((pageNumber: number) => this.bitstreamDataService.findAllByItemAndBundleName( + this.item, + 'ORIGINAL', + {elementsPerPage: this.pageSize, currentPage: pageNumber}, + followLink('format') + )), + tap((rd: RemoteData>) => { + if (hasValue(rd.error)) { + this.notificationsService.error(this.translateService.get('file-section.error.header'), `${rd.error.statusCode} ${rd.error.message}`); + } + } + ) ); this.licenses$ = this.licenseCurrentPage$.pipe( - switchMap((pageNumber: number) => this.bitstreamDataService.findAllByItemAndBundleName( - this.item, - 'LICENSE', - { elementsPerPage: this.pageSize, currentPage: pageNumber }, - followLink( 'format') - )) + switchMap((pageNumber: number) => this.bitstreamDataService.findAllByItemAndBundleName( + this.item, + 'LICENSE', + {elementsPerPage: this.pageSize, currentPage: pageNumber}, + followLink('format') + )), + tap((rd: RemoteData>) => { + if (hasValue(rd.error)) { + this.notificationsService.error(this.translateService.get('file-section.error.header'), `${rd.error.statusCode} ${rd.error.message}`); + } + } + ) ); } @@ -93,4 +111,8 @@ export class FullFileSectionComponent extends FileSectionComponent implements On this.licenseOptions.currentPage = page; this.licenseCurrentPage$.next(page); } + + hasValuesInBundle(bundle: PaginatedList) { + return hasValue(bundle) && !isEmpty(bundle.page); + } } diff --git a/src/app/+item-page/item-page-routing.module.ts b/src/app/+item-page/item-page-routing.module.ts index 66dbcbb10d..e4f17326a4 100644 --- a/src/app/+item-page/item-page-routing.module.ts +++ b/src/app/+item-page/item-page-routing.module.ts @@ -11,6 +11,8 @@ import { LinkService } from '../core/cache/builders/link.service'; import { UploadBitstreamComponent } from './bitstreams/upload/upload-bitstream.component'; import { UPLOAD_BITSTREAM_PATH, ITEM_EDIT_PATH } from './item-page-routing-paths'; import { ItemPageAdministratorGuard } from './item-page-administrator.guard'; +import { MenuItemType } from '../shared/menu/initial-menus-state'; +import { LinkMenuItemModel } from '../shared/menu/menu-item/models/link.model'; @NgModule({ imports: [ @@ -43,6 +45,20 @@ import { ItemPageAdministratorGuard } from './item-page-administrator.guard'; canActivate: [AuthenticatedGuard] } ], + data: { + menu: { + public: [{ + id: 'statistics_item_:id', + active: true, + visible: true, + model: { + type: MenuItemType.LINK, + text: 'menu.section.statistics', + link: 'statistics/items/:id/', + } as LinkMenuItemModel, + }], + }, + }, } ]) ], diff --git a/src/app/+item-page/simple/field-components/file-section/file-section.component.spec.ts b/src/app/+item-page/simple/field-components/file-section/file-section.component.spec.ts index 1b7fa75ce5..330aaadfe0 100644 --- a/src/app/+item-page/simple/field-components/file-section/file-section.component.spec.ts +++ b/src/app/+item-page/simple/field-components/file-section/file-section.component.spec.ts @@ -15,6 +15,8 @@ import {FileSizePipe} from '../../../../shared/utils/file-size-pipe'; import {PageInfo} from '../../../../core/shared/page-info.model'; import {MetadataFieldWrapperComponent} from '../../../field-components/metadata-field-wrapper/metadata-field-wrapper.component'; import {createPaginatedList} from '../../../../shared/testing/utils.test'; +import { NotificationsService } from '../../../../shared/notifications/notifications.service'; +import { NotificationsServiceStub } from '../../../../shared/testing/notifications-service.stub'; describe('FileSectionComponent', () => { let comp: FileSectionComponent; @@ -62,7 +64,8 @@ describe('FileSectionComponent', () => { }), BrowserAnimationsModule], declarations: [FileSectionComponent, VarDirective, FileSizePipe, MetadataFieldWrapperComponent], providers: [ - {provide: BitstreamDataService, useValue: bitstreamDataService} + {provide: BitstreamDataService, useValue: bitstreamDataService}, + {provide: NotificationsService, useValue: new NotificationsServiceStub()} ], schemas: [NO_ERRORS_SCHEMA] diff --git a/src/app/+item-page/simple/field-components/file-section/file-section.component.ts b/src/app/+item-page/simple/field-components/file-section/file-section.component.ts index 25b214e200..4b60691e09 100644 --- a/src/app/+item-page/simple/field-components/file-section/file-section.component.ts +++ b/src/app/+item-page/simple/field-components/file-section/file-section.component.ts @@ -4,10 +4,12 @@ import { BitstreamDataService } from '../../../../core/data/bitstream-data.servi import { Bitstream } from '../../../../core/shared/bitstream.model'; import { Item } from '../../../../core/shared/item.model'; -import { filter, takeWhile } from 'rxjs/operators'; +import { filter, take } from 'rxjs/operators'; import { RemoteData } from '../../../../core/data/remote-data'; -import { hasNoValue, hasValue } from '../../../../shared/empty.util'; +import { hasValue } from '../../../../shared/empty.util'; import { PaginatedList } from '../../../../core/data/paginated-list'; +import { NotificationsService } from '../../../../shared/notifications/notifications.service'; +import { TranslateService } from '@ngx-translate/core'; /** * This component renders the file section of the item @@ -36,7 +38,9 @@ export class FileSectionComponent implements OnInit { pageSize = 5; constructor( - protected bitstreamDataService: BitstreamDataService + protected bitstreamDataService: BitstreamDataService, + protected notificationsService: NotificationsService, + protected translateService: TranslateService ) { } @@ -58,14 +62,21 @@ export class FileSectionComponent implements OnInit { } else { this.currentPage++; } - this.bitstreamDataService.findAllByItemAndBundleName(this.item, 'ORIGINAL', { currentPage: this.currentPage, elementsPerPage: this.pageSize }).pipe( - filter((bitstreamsRD: RemoteData>) => hasValue(bitstreamsRD)), - takeWhile((bitstreamsRD: RemoteData>) => hasNoValue(bitstreamsRD.payload) && hasNoValue(bitstreamsRD.error), true) + this.bitstreamDataService.findAllByItemAndBundleName(this.item, 'ORIGINAL', { + currentPage: this.currentPage, + elementsPerPage: this.pageSize + }).pipe( + filter((bitstreamsRD: RemoteData>) => hasValue(bitstreamsRD) && (hasValue(bitstreamsRD.error) || hasValue(bitstreamsRD.payload))), + take(1), ).subscribe((bitstreamsRD: RemoteData>) => { - const current: Bitstream[] = this.bitstreams$.getValue(); - this.bitstreams$.next([...current, ...bitstreamsRD.payload.page]); - this.isLoading = false; - this.isLastPage = this.currentPage === bitstreamsRD.payload.totalPages; + if (bitstreamsRD.error) { + this.notificationsService.error(this.translateService.get('file-section.error.header'), `${bitstreamsRD.error.statusCode} ${bitstreamsRD.error.message}`); + } else if (hasValue(bitstreamsRD.payload)) { + const current: Bitstream[] = this.bitstreams$.getValue(); + this.bitstreams$.next([...current, ...bitstreamsRD.payload.page]); + this.isLoading = false; + this.isLastPage = this.currentPage === bitstreamsRD.payload.totalPages; + } }); } } diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts index 50e2f6b532..ecb27efbb3 100644 --- a/src/app/app-routing.module.ts +++ b/src/app/app-routing.module.ts @@ -69,6 +69,10 @@ import { SiteRegisterGuard } from './core/data/feature-authorization/feature-aut { path: 'processes', loadChildren: './process-page/process-page.module#ProcessPageModule', canActivate: [AuthenticatedGuard, EndUserAgreementCurrentUserGuard] }, { path: INFO_MODULE_PATH, loadChildren: './info/info.module#InfoModule' }, { path: UNAUTHORIZED_PATH, component: UnauthorizedComponent }, + { + path: 'statistics', + loadChildren: './statistics-page/statistics-page-routing.module#StatisticsPageRoutingModule', + }, { path: '**', pathMatch: 'full', component: PageNotFoundComponent }, ]} ], diff --git a/src/app/core/cache/object-cache.service.ts b/src/app/core/cache/object-cache.service.ts index d82a1f31fe..745133373d 100644 --- a/src/app/core/cache/object-cache.service.ts +++ b/src/app/core/cache/object-cache.service.ts @@ -270,7 +270,7 @@ export class ObjectCacheService { /** * Add operations to the existing list of operations for an ObjectCacheEntry * Makes sure the ServerSyncBuffer for this ObjectCacheEntry is updated - * @param {string} uuid + * @param selfLink * the uuid of the ObjectCacheEntry * @param {Operation[]} patch * list of operations to perform @@ -295,8 +295,8 @@ export class ObjectCacheService { /** * Apply the existing operations on an ObjectCacheEntry in the store * NB: this does not make any server side changes - * @param {string} uuid - * the uuid of the ObjectCacheEntry + * @param selfLink + * the link of the ObjectCacheEntry */ private applyPatchesToCachedObject(selfLink: string) { this.store.dispatch(new ApplyPatchObjectCacheAction(selfLink)); diff --git a/src/app/core/core.module.ts b/src/app/core/core.module.ts index 63fd8119b4..2203377603 100644 --- a/src/app/core/core.module.ts +++ b/src/app/core/core.module.ts @@ -171,6 +171,7 @@ import { EndUserAgreementCurrentUserGuard } from './end-user-agreement/end-user- import { EndUserAgreementCookieGuard } from './end-user-agreement/end-user-agreement-cookie.guard'; import { EndUserAgreementService } from './end-user-agreement/end-user-agreement.service'; import { SiteRegisterGuard } from './data/feature-authorization/feature-authorization-guard/site-register.guard'; +import { UsageReport } from './statistics/models/usage-report.model'; /** * When not in production, endpoint responses can be mocked for testing purposes @@ -371,7 +372,8 @@ export const models = Vocabulary, VocabularyEntry, VocabularyEntryDetail, - ConfigurationProperty + ConfigurationProperty, + UsageReport, ]; @NgModule({ diff --git a/src/app/core/data/bitstream-data.service.ts b/src/app/core/data/bitstream-data.service.ts index 4c24f5d78b..ca0338116f 100644 --- a/src/app/core/data/bitstream-data.service.ts +++ b/src/app/core/data/bitstream-data.service.ts @@ -30,6 +30,8 @@ import { RestResponse } from '../cache/response.models'; import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service'; import { configureRequest, getResponseFromEntry } from '../shared/operators'; import { combineLatest as observableCombineLatest } from 'rxjs'; +import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; +import { PageInfo } from '../shared/page-info.model'; /** * A service to retrieve {@link Bitstream}s from the REST API @@ -165,8 +167,10 @@ export class BitstreamDataService extends DataService { public findAllByItemAndBundleName(item: Item, bundleName: string, options?: FindListOptions, ...linksToFollow: Array>): Observable>> { return this.bundleService.findByItemAndName(item, bundleName).pipe( switchMap((bundleRD: RemoteData) => { - if (hasValue(bundleRD.payload)) { + if (bundleRD.hasSucceeded && hasValue(bundleRD.payload)) { return this.findAllByBundle(bundleRD.payload, options, ...linksToFollow); + } else if (!bundleRD.hasSucceeded && bundleRD.error.statusCode === 404) { + return createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), [])) } else { return [bundleRD as any]; } diff --git a/src/app/core/data/bundle-data.service.ts b/src/app/core/data/bundle-data.service.ts index de0e8a4337..e651ed354f 100644 --- a/src/app/core/data/bundle-data.service.ts +++ b/src/app/core/data/bundle-data.service.ts @@ -22,6 +22,7 @@ import { FindListOptions, GetRequest } from './request.models'; import { RequestService } from './request.service'; import { PaginatedSearchOptions } from '../../shared/search/paginated-search-options.model'; import { Bitstream } from '../shared/bitstream.model'; +import { RemoteDataError } from './remote-data-error'; /** * A service to retrieve {@link Bundle}s from the REST API @@ -71,13 +72,17 @@ export class BundleDataService extends DataService { if (hasValue(rd.payload) && hasValue(rd.payload.page)) { const matchingBundle = rd.payload.page.find((bundle: Bundle) => bundle.name === bundleName); - return new RemoteData( - false, - false, - true, - undefined, - matchingBundle - ); + if (hasValue(matchingBundle)) { + return new RemoteData( + false, + false, + true, + undefined, + matchingBundle + ); + } else { + return new RemoteData(false, false, false, new RemoteDataError(404, 'Not found', `The bundle with name ${bundleName} was not found.` )) + } } else { return rd as any; } diff --git a/src/app/core/data/feature-authorization/feature-id.ts b/src/app/core/data/feature-authorization/feature-id.ts index 27d6618e44..450d5057aa 100644 --- a/src/app/core/data/feature-authorization/feature-id.ts +++ b/src/app/core/data/feature-authorization/feature-id.ts @@ -4,6 +4,7 @@ export enum FeatureID { LoginOnBehalfOf = 'loginOnBehalfOf', AdministratorOf = 'administratorOf', + CanDelete = 'canDelete', WithdrawItem = 'withdrawItem', ReinstateItem = 'reinstateItem', EPersonRegistration = 'epersonRegistration', diff --git a/src/app/core/data/item-template-data.service.ts b/src/app/core/data/item-template-data.service.ts index a44d48e9bd..4f26f47eee 100644 --- a/src/app/core/data/item-template-data.service.ts +++ b/src/app/core/data/item-template-data.service.ts @@ -20,6 +20,7 @@ import { switchMap, map } from 'rxjs/operators'; import { BundleDataService } from './bundle-data.service'; import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; import { RestResponse } from '../cache/response.models'; +import { Operation } from 'fast-json-patch'; /* tslint:disable:max-classes-per-file */ /** @@ -165,6 +166,10 @@ export class ItemTemplateDataService implements UpdateDataService { return this.dataService.update(object); } + patch(dso: Item, operations: Operation[]): Observable { + return this.dataService.patch(dso, operations); + } + /** * Find an item template by collection ID * @param collectionID diff --git a/src/app/core/data/object-updates/object-updates.actions.ts b/src/app/core/data/object-updates/object-updates.actions.ts index f26be768b1..ff0babdd14 100644 --- a/src/app/core/data/object-updates/object-updates.actions.ts +++ b/src/app/core/data/object-updates/object-updates.actions.ts @@ -2,6 +2,8 @@ import {type} from '../../../shared/ngrx/type'; import {Action} from '@ngrx/store'; import {Identifiable} from './object-updates.reducer'; import {INotification} from '../../../shared/notifications/models/notification.model'; +import { InjectionToken } from '@angular/core'; +import { PatchOperationService } from './patch-operation-service/patch-operation.service'; /** * The list of ObjectUpdatesAction type definitions @@ -38,7 +40,8 @@ export class InitializeFieldsAction implements Action { payload: { url: string, fields: Identifiable[], - lastModified: Date + lastModified: Date, + patchOperationServiceToken?: InjectionToken }; /** @@ -48,16 +51,15 @@ export class InitializeFieldsAction implements Action { * the unique url of the page for which the fields are being initialized * @param fields The identifiable fields of which the updates are kept track of * @param lastModified The last modified date of the object that belongs to the page - * @param order A custom order to keep track of objects moving around - * @param pageSize The page size used to fill empty pages for the custom order - * @param page The first page to populate in the custom order + * @param patchOperationServiceToken An InjectionToken referring to the {@link PatchOperationService} used for creating a patch */ constructor( url: string, fields: Identifiable[], - lastModified: Date + lastModified: Date, + patchOperationServiceToken?: InjectionToken ) { - this.payload = { url, fields, lastModified }; + this.payload = { url, fields, lastModified, patchOperationServiceToken }; } } diff --git a/src/app/core/data/object-updates/object-updates.reducer.spec.ts b/src/app/core/data/object-updates/object-updates.reducer.spec.ts index cb7f44039c..4a14e2e874 100644 --- a/src/app/core/data/object-updates/object-updates.reducer.spec.ts +++ b/src/app/core/data/object-updates/object-updates.reducer.spec.ts @@ -231,7 +231,8 @@ describe('objectUpdatesReducer', () => { }, fieldUpdates: {}, virtualMetadataSources: {}, - lastModified: modDate + lastModified: modDate, + patchOperationServiceToken: undefined } }; const newState = objectUpdatesReducer(testState, action); diff --git a/src/app/core/data/object-updates/object-updates.reducer.ts b/src/app/core/data/object-updates/object-updates.reducer.ts index b1626a5ff5..94bb845aa8 100644 --- a/src/app/core/data/object-updates/object-updates.reducer.ts +++ b/src/app/core/data/object-updates/object-updates.reducer.ts @@ -14,6 +14,8 @@ import { } from './object-updates.actions'; import { hasNoValue, hasValue } from '../../../shared/empty.util'; import {Relationship} from '../../shared/item-relationships/relationship.model'; +import { InjectionToken } from '@angular/core'; +import { PatchOperationService } from './patch-operation-service/patch-operation.service'; /** * Path where discarded objects are saved @@ -48,7 +50,7 @@ export interface Identifiable { */ export interface FieldUpdate { field: Identifiable, - changeType: FieldChangeType + changeType: FieldChangeType, } /** @@ -89,6 +91,7 @@ export interface ObjectUpdatesEntry { fieldUpdates: FieldUpdates; virtualMetadataSources: VirtualMetadataSources; lastModified: Date; + patchOperationServiceToken?: InjectionToken; } /** @@ -163,6 +166,7 @@ function initializeFieldsUpdate(state: any, action: InitializeFieldsAction) { const url: string = action.payload.url; const fields: Identifiable[] = action.payload.fields; const lastModifiedServer: Date = action.payload.lastModified; + const patchOperationServiceToken: InjectionToken = action.payload.patchOperationServiceToken; const fieldStates = createInitialFieldStates(fields); const newPageState = Object.assign( {}, @@ -170,7 +174,8 @@ function initializeFieldsUpdate(state: any, action: InitializeFieldsAction) { { fieldStates: fieldStates }, { fieldUpdates: {} }, { virtualMetadataSources: {} }, - { lastModified: lastModifiedServer } + { lastModified: lastModifiedServer }, + { patchOperationServiceToken } ); return Object.assign({}, state, { [url]: newPageState }); } diff --git a/src/app/core/data/object-updates/object-updates.service.spec.ts b/src/app/core/data/object-updates/object-updates.service.spec.ts index 04018b8de2..ae73dc851f 100644 --- a/src/app/core/data/object-updates/object-updates.service.spec.ts +++ b/src/app/core/data/object-updates/object-updates.service.spec.ts @@ -12,6 +12,7 @@ import { Notification } from '../../../shared/notifications/models/notification. import { NotificationType } from '../../../shared/notifications/models/notification-type'; import { OBJECT_UPDATES_TRASH_PATH } from './object-updates.reducer'; import {Relationship} from '../../shared/item-relationships/relationship.model'; +import { Injector } from '@angular/core'; describe('ObjectUpdatesService', () => { let service: ObjectUpdatesService; @@ -31,6 +32,9 @@ describe('ObjectUpdatesService', () => { }; const modDate = new Date(2010, 2, 11); + const injectionToken = 'fake-injection-token'; + let patchOperationService; + let injector: Injector; beforeEach(() => { const fieldStates = { @@ -40,11 +44,17 @@ describe('ObjectUpdatesService', () => { }; const objectEntry = { - fieldStates, fieldUpdates, lastModified: modDate, virtualMetadataSources: {} + fieldStates, fieldUpdates, lastModified: modDate, virtualMetadataSources: {}, patchOperationServiceToken: injectionToken }; store = new Store(undefined, undefined, undefined); spyOn(store, 'dispatch'); - service = new ObjectUpdatesService(store); + patchOperationService = jasmine.createSpyObj('patchOperationService', { + fieldUpdatesToPatchOperations: [] + }); + injector = jasmine.createSpyObj('injector', { + get: patchOperationService + }); + service = new ObjectUpdatesService(store, injector); spyOn(service as any, 'getObjectEntry').and.returnValue(observableOf(objectEntry)); spyOn(service as any, 'getFieldState').and.callFake((uuid) => { @@ -277,4 +287,26 @@ describe('ObjectUpdatesService', () => { }); }); + describe('createPatch', () => { + let result$; + + beforeEach(() => { + result$ = service.createPatch(url); + }); + + it('should inject the service using the token stored in the entry', (done) => { + result$.subscribe(() => { + expect(injector.get).toHaveBeenCalledWith(injectionToken); + done(); + }); + }); + + it('should create a patch from the fieldUpdates using the injected service', (done) => { + result$.subscribe(() => { + expect(patchOperationService.fieldUpdatesToPatchOperations).toHaveBeenCalledWith(fieldUpdates); + done(); + }); + }); + }); + }); diff --git a/src/app/core/data/object-updates/object-updates.service.ts b/src/app/core/data/object-updates/object-updates.service.ts index 84f0f06035..8bd32e54e2 100644 --- a/src/app/core/data/object-updates/object-updates.service.ts +++ b/src/app/core/data/object-updates/object-updates.service.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@angular/core'; +import { Injectable, InjectionToken, Injector } from '@angular/core'; import { createSelector, MemoizedSelector, select, Store } from '@ngrx/store'; import { CoreState } from '../../core.reducers'; import { coreSelector } from '../../core.selectors'; @@ -26,6 +26,8 @@ import { import { distinctUntilChanged, filter, map, switchMap } from 'rxjs/operators'; import { hasNoValue, hasValue, isEmpty, isNotEmpty, isNotEmptyOperator } from '../../../shared/empty.util'; import { INotification } from '../../../shared/notifications/models/notification.model'; +import { Operation } from 'fast-json-patch'; +import { PatchOperationService } from './patch-operation-service/patch-operation.service'; function objectUpdatesStateSelector(): MemoizedSelector { return createSelector(coreSelector, (state: CoreState) => state['cache/object-updates']); @@ -48,7 +50,8 @@ function virtualMetadataSourceSelector(url: string, source: string): MemoizedSel */ @Injectable() export class ObjectUpdatesService { - constructor(private store: Store) { + constructor(private store: Store, + private injector: Injector) { } /** @@ -56,9 +59,10 @@ export class ObjectUpdatesService { * @param url The page's URL for which the changes are being mapped * @param fields The initial fields for the page's object * @param lastModified The date the object was last modified + * @param patchOperationServiceToken An InjectionToken referring to the {@link PatchOperationService} used for creating a patch */ - initialize(url, fields: Identifiable[], lastModified: Date): void { - this.store.dispatch(new InitializeFieldsAction(url, fields, lastModified)); + initialize(url, fields: Identifiable[], lastModified: Date, patchOperationServiceToken?: InjectionToken): void { + this.store.dispatch(new InitializeFieldsAction(url, fields, lastModified, patchOperationServiceToken)); } /** @@ -339,4 +343,22 @@ export class ObjectUpdatesService { getLastModified(url: string): Observable { return this.getObjectEntry(url).pipe(map((entry: ObjectUpdatesEntry) => entry.lastModified)); } + + /** + * Create a patch from the current object-updates state + * The {@link ObjectUpdatesEntry} should contain a patchOperationServiceToken, in order to define how a patch should + * be created. If it doesn't, an empty patch will be returned. + * @param url The URL of the page for which the patch should be created + */ + createPatch(url: string): Observable { + return this.getObjectEntry(url).pipe( + map((entry) => { + let patch = []; + if (hasValue(entry.patchOperationServiceToken)) { + patch = this.injector.get(entry.patchOperationServiceToken).fieldUpdatesToPatchOperations(entry.fieldUpdates); + } + return patch; + }) + ); + } } diff --git a/src/app/core/data/object-updates/patch-operation-service/metadata-patch-operation.service.spec.ts b/src/app/core/data/object-updates/patch-operation-service/metadata-patch-operation.service.spec.ts new file mode 100644 index 0000000000..f3578b1bde --- /dev/null +++ b/src/app/core/data/object-updates/patch-operation-service/metadata-patch-operation.service.spec.ts @@ -0,0 +1,252 @@ +import { MetadataPatchOperationService } from './metadata-patch-operation.service'; +import { FieldUpdates } from '../object-updates.reducer'; +import { Operation } from 'fast-json-patch'; +import { FieldChangeType } from '../object-updates.actions'; +import { MetadatumViewModel } from '../../../shared/metadata.models'; + +describe('MetadataPatchOperationService', () => { + let service: MetadataPatchOperationService; + + beforeEach(() => { + service = new MetadataPatchOperationService(); + }); + + describe('fieldUpdatesToPatchOperations', () => { + let fieldUpdates: FieldUpdates; + let expected: Operation[]; + let result: Operation[]; + + describe('when fieldUpdates contains a single remove', () => { + beforeEach(() => { + fieldUpdates = Object.assign({ + update1: { + field: Object.assign(new MetadatumViewModel(), { + key: 'dc.title', + value: 'Deleted title', + place: 0 + }), + changeType: FieldChangeType.REMOVE + } + }); + expected = [ + { op: 'remove', path: '/metadata/dc.title/0' } + ] as any[]; + result = service.fieldUpdatesToPatchOperations(fieldUpdates); + }); + + it('should contain a single remove operation with the correct path', () => { + expect(result).toEqual(expected); + }); + }); + + describe('when fieldUpdates contains a single add', () => { + beforeEach(() => { + fieldUpdates = Object.assign({ + update1: { + field: Object.assign(new MetadatumViewModel(), { + key: 'dc.title', + value: 'Added title', + place: 0 + }), + changeType: FieldChangeType.ADD + } + }); + expected = [ + { op: 'add', path: '/metadata/dc.title/-', value: [ { value: 'Added title', language: undefined } ] } + ] as any[]; + result = service.fieldUpdatesToPatchOperations(fieldUpdates); + }); + + it('should contain a single add operation with the correct path', () => { + expect(result).toEqual(expected); + }); + }); + + describe('when fieldUpdates contains a single update', () => { + beforeEach(() => { + fieldUpdates = Object.assign({ + update1: { + field: Object.assign(new MetadatumViewModel(), { + key: 'dc.title', + value: 'Changed title', + place: 0 + }), + changeType: FieldChangeType.UPDATE + } + }); + expected = [ + { op: 'replace', path: '/metadata/dc.title/0', value: { value: 'Changed title', language: undefined } } + ] as any[]; + result = service.fieldUpdatesToPatchOperations(fieldUpdates); + }); + + it('should contain a single replace operation with the correct path', () => { + expect(result).toEqual(expected); + }); + }); + + describe('when fieldUpdates contains multiple removes with incrementing indexes', () => { + beforeEach(() => { + fieldUpdates = Object.assign({ + update1: { + field: Object.assign(new MetadatumViewModel(), { + key: 'dc.title', + value: 'First deleted title', + place: 0 + }), + changeType: FieldChangeType.REMOVE + }, + update2: { + field: Object.assign(new MetadatumViewModel(), { + key: 'dc.title', + value: 'Second deleted title', + place: 1 + }), + changeType: FieldChangeType.REMOVE + }, + update3: { + field: Object.assign(new MetadatumViewModel(), { + key: 'dc.title', + value: 'Third deleted title', + place: 2 + }), + changeType: FieldChangeType.REMOVE + } + }); + expected = [ + { op: 'remove', path: '/metadata/dc.title/0' }, + { op: 'remove', path: '/metadata/dc.title/0' }, + { op: 'remove', path: '/metadata/dc.title/0' } + ] as any[]; + result = service.fieldUpdatesToPatchOperations(fieldUpdates); + }); + + it('should contain all the remove operations on the same index', () => { + expect(result).toEqual(expected); + }); + }); + + describe('when fieldUpdates contains multiple removes with decreasing indexes', () => { + beforeEach(() => { + fieldUpdates = Object.assign({ + update1: { + field: Object.assign(new MetadatumViewModel(), { + key: 'dc.title', + value: 'Third deleted title', + place: 2 + }), + changeType: FieldChangeType.REMOVE + }, + update2: { + field: Object.assign(new MetadatumViewModel(), { + key: 'dc.title', + value: 'Second deleted title', + place: 1 + }), + changeType: FieldChangeType.REMOVE + }, + update3: { + field: Object.assign(new MetadatumViewModel(), { + key: 'dc.title', + value: 'First deleted title', + place: 0 + }), + changeType: FieldChangeType.REMOVE + } + }); + expected = [ + { op: 'remove', path: '/metadata/dc.title/2' }, + { op: 'remove', path: '/metadata/dc.title/1' }, + { op: 'remove', path: '/metadata/dc.title/0' } + ] as any[]; + result = service.fieldUpdatesToPatchOperations(fieldUpdates); + }); + + it('should contain all the remove operations with their corresponding indexes', () => { + expect(result).toEqual(expected); + }); + }); + + describe('when fieldUpdates contains multiple removes with random indexes', () => { + beforeEach(() => { + fieldUpdates = Object.assign({ + update1: { + field: Object.assign(new MetadatumViewModel(), { + key: 'dc.title', + value: 'Second deleted title', + place: 1 + }), + changeType: FieldChangeType.REMOVE + }, + update2: { + field: Object.assign(new MetadatumViewModel(), { + key: 'dc.title', + value: 'Third deleted title', + place: 2 + }), + changeType: FieldChangeType.REMOVE + }, + update3: { + field: Object.assign(new MetadatumViewModel(), { + key: 'dc.title', + value: 'First deleted title', + place: 0 + }), + changeType: FieldChangeType.REMOVE + } + }); + expected = [ + { op: 'remove', path: '/metadata/dc.title/1' }, + { op: 'remove', path: '/metadata/dc.title/1' }, + { op: 'remove', path: '/metadata/dc.title/0' } + ] as any[]; + result = service.fieldUpdatesToPatchOperations(fieldUpdates); + }); + + it('should contain all the remove operations with the correct indexes taking previous operations into account', () => { + expect(result).toEqual(expected); + }); + }); + + describe('when fieldUpdates contains multiple removes and updates with random indexes', () => { + beforeEach(() => { + fieldUpdates = Object.assign({ + update1: { + field: Object.assign(new MetadatumViewModel(), { + key: 'dc.title', + value: 'Second deleted title', + place: 1 + }), + changeType: FieldChangeType.REMOVE + }, + update2: { + field: Object.assign(new MetadatumViewModel(), { + key: 'dc.title', + value: 'Third changed title', + place: 2 + }), + changeType: FieldChangeType.UPDATE + }, + update3: { + field: Object.assign(new MetadatumViewModel(), { + key: 'dc.title', + value: 'First deleted title', + place: 0 + }), + changeType: FieldChangeType.REMOVE + } + }); + expected = [ + { op: 'remove', path: '/metadata/dc.title/1' }, + { op: 'replace', path: '/metadata/dc.title/1', value: { value: 'Third changed title', language: undefined } }, + { op: 'remove', path: '/metadata/dc.title/0' } + ] as any[]; + result = service.fieldUpdatesToPatchOperations(fieldUpdates); + }); + + it('should contain all the remove and replace operations with the correct indexes taking previous remove operations into account', () => { + expect(result).toEqual(expected); + }); + }); + }); +}); diff --git a/src/app/core/data/object-updates/patch-operation-service/metadata-patch-operation.service.ts b/src/app/core/data/object-updates/patch-operation-service/metadata-patch-operation.service.ts new file mode 100644 index 0000000000..3b590cf58c --- /dev/null +++ b/src/app/core/data/object-updates/patch-operation-service/metadata-patch-operation.service.ts @@ -0,0 +1,106 @@ +import { PatchOperationService } from './patch-operation.service'; +import { MetadatumViewModel } from '../../../shared/metadata.models'; +import { FieldUpdates } from '../object-updates.reducer'; +import { Operation } from 'fast-json-patch'; +import { FieldChangeType } from '../object-updates.actions'; +import { InjectionToken } from '@angular/core'; +import { MetadataPatchOperation } from './operations/metadata/metadata-patch-operation.model'; +import { hasValue } from '../../../../shared/empty.util'; +import { MetadataPatchAddOperation } from './operations/metadata/metadata-patch-add-operation.model'; +import { MetadataPatchRemoveOperation } from './operations/metadata/metadata-patch-remove-operation.model'; +import { MetadataPatchReplaceOperation } from './operations/metadata/metadata-patch-replace-operation.model'; + +/** + * Token to use for injecting this service anywhere you want + * This token can used to store in the object-updates store + */ +export const METADATA_PATCH_OPERATION_SERVICE_TOKEN = new InjectionToken('MetadataPatchOperationService', { + providedIn: 'root', + factory: () => new MetadataPatchOperationService(), +}); + +/** + * Service transforming {@link FieldUpdates} into {@link Operation}s for metadata values + * This expects the fields within every {@link FieldUpdate} to be {@link MetadatumViewModel}s + */ +export class MetadataPatchOperationService implements PatchOperationService { + + /** + * Transform a {@link FieldUpdates} object into an array of fast-json-patch Operations for metadata values + * This method first creates an array of {@link MetadataPatchOperation} wrapper operations, which are then + * iterated over to create the actual patch operations. While iterating, it has the ability to check for previous + * operations that would modify the operation's position and act accordingly. + * @param fieldUpdates + */ + fieldUpdatesToPatchOperations(fieldUpdates: FieldUpdates): Operation[] { + const metadataPatch = this.fieldUpdatesToMetadataPatchOperations(fieldUpdates); + + // This map stores what metadata fields had a value deleted at which places + // This is used to modify the place of operations to match previous operations + const metadataRemoveMap = new Map(); + const patch = []; + metadataPatch.forEach((operation) => { + // If this operation is removing or editing an existing value, first check the map for previous operations + // If the map contains remove operations before this operation's place, lower the place by 1 for each + if ((operation.op === MetadataPatchRemoveOperation.operationType || operation.op === MetadataPatchReplaceOperation.operationType) && hasValue((operation as any).place)) { + if (metadataRemoveMap.has(operation.field)) { + metadataRemoveMap.get(operation.field).forEach((index) => { + if (index < (operation as any).place) { + (operation as any).place--; + } + }); + } + } + + // If this is a remove operation, add its (updated) place to the map, so we can adjust following operations accordingly + if (operation.op === MetadataPatchRemoveOperation.operationType && hasValue((operation as any).place)) { + if (!metadataRemoveMap.has(operation.field)) { + metadataRemoveMap.set(operation.field, []); + } + metadataRemoveMap.get(operation.field).push((operation as any).place); + } + + // Transform the updated operation into a fast-json-patch Operation and add it to the patch + patch.push(operation.toOperation()); + }); + + return patch; + } + + /** + * Transform a {@link FieldUpdates} object into an array of {@link MetadataPatchOperation} wrapper objects + * These wrapper objects contain detailed information about the patch operation that needs to be creates for each update + * This information can then be modified before creating the actual patch + * @param fieldUpdates + */ + fieldUpdatesToMetadataPatchOperations(fieldUpdates: FieldUpdates): MetadataPatchOperation[] { + const metadataPatch = []; + + Object.keys(fieldUpdates).forEach((uuid) => { + const update = fieldUpdates[uuid]; + const metadatum = update.field as MetadatumViewModel; + const val = { + value: metadatum.value, + language: metadatum.language + } + + let operation: MetadataPatchOperation; + switch (update.changeType) { + case FieldChangeType.ADD: + operation = new MetadataPatchAddOperation(metadatum.key, [ val ]); + break; + case FieldChangeType.REMOVE: + operation = new MetadataPatchRemoveOperation(metadatum.key, metadatum.place); + break; + case FieldChangeType.UPDATE: + operation = new MetadataPatchReplaceOperation(metadatum.key, metadatum.place, val); + break; + } + + metadataPatch.push(operation); + }); + + return metadataPatch; + } + +} diff --git a/src/app/core/data/object-updates/patch-operation-service/operations/metadata/metadata-patch-add-operation.model.ts b/src/app/core/data/object-updates/patch-operation-service/operations/metadata/metadata-patch-add-operation.model.ts new file mode 100644 index 0000000000..7f9b1d772f --- /dev/null +++ b/src/app/core/data/object-updates/patch-operation-service/operations/metadata/metadata-patch-add-operation.model.ts @@ -0,0 +1,27 @@ +import { MetadataPatchOperation } from './metadata-patch-operation.model'; +import { Operation } from 'fast-json-patch'; + +/** + * Wrapper object for a metadata patch add Operation + */ +export class MetadataPatchAddOperation extends MetadataPatchOperation { + static operationType = 'add'; + + /** + * The metadata value(s) to add to the field + */ + value: any; + + constructor(field: string, value: any) { + super(MetadataPatchAddOperation.operationType, field); + this.value = value; + } + + /** + * Transform the MetadataPatchOperation into a fast-json-patch Operation by constructing its path and other properties + * using the information provided. + */ + toOperation(): Operation { + return { op: this.op as any, path: `/metadata/${this.field}/-`, value: this.value }; + } +} diff --git a/src/app/core/data/object-updates/patch-operation-service/operations/metadata/metadata-patch-operation.model.ts b/src/app/core/data/object-updates/patch-operation-service/operations/metadata/metadata-patch-operation.model.ts new file mode 100644 index 0000000000..fb7c826fc9 --- /dev/null +++ b/src/app/core/data/object-updates/patch-operation-service/operations/metadata/metadata-patch-operation.model.ts @@ -0,0 +1,29 @@ +import { Operation } from 'fast-json-patch'; + +/** + * Wrapper object for metadata patch Operations + * It should contain at least the operation type and metadata field. An abstract method to transform this object + * into a fast-json-patch Operation is defined in each instance extending from this. + */ +export abstract class MetadataPatchOperation { + /** + * The operation to perform + */ + op: string; + + /** + * The metadata field this operation is intended for + */ + field: string; + + constructor(op: string, field: string) { + this.op = op; + this.field = field; + } + + /** + * Transform the MetadataPatchOperation into a fast-json-patch Operation by constructing its path and other properties + * using the information provided. + */ + abstract toOperation(): Operation; +} diff --git a/src/app/core/data/object-updates/patch-operation-service/operations/metadata/metadata-patch-remove-operation.model.ts b/src/app/core/data/object-updates/patch-operation-service/operations/metadata/metadata-patch-remove-operation.model.ts new file mode 100644 index 0000000000..61fbae1980 --- /dev/null +++ b/src/app/core/data/object-updates/patch-operation-service/operations/metadata/metadata-patch-remove-operation.model.ts @@ -0,0 +1,27 @@ +import { MetadataPatchOperation } from './metadata-patch-operation.model'; +import { Operation } from 'fast-json-patch'; + +/** + * Wrapper object for a metadata patch remove Operation + */ +export class MetadataPatchRemoveOperation extends MetadataPatchOperation { + static operationType = 'remove'; + + /** + * The place of the metadata value to remove within its field + */ + place: number; + + constructor(field: string, place: number) { + super(MetadataPatchRemoveOperation.operationType, field); + this.place = place; + } + + /** + * Transform the MetadataPatchOperation into a fast-json-patch Operation by constructing its path and other properties + * using the information provided. + */ + toOperation(): Operation { + return { op: this.op as any, path: `/metadata/${this.field}/${this.place}` }; + } +} diff --git a/src/app/core/data/object-updates/patch-operation-service/operations/metadata/metadata-patch-replace-operation.model.ts b/src/app/core/data/object-updates/patch-operation-service/operations/metadata/metadata-patch-replace-operation.model.ts new file mode 100644 index 0000000000..e889bede0b --- /dev/null +++ b/src/app/core/data/object-updates/patch-operation-service/operations/metadata/metadata-patch-replace-operation.model.ts @@ -0,0 +1,33 @@ +import { MetadataPatchOperation } from './metadata-patch-operation.model'; +import { Operation } from 'fast-json-patch'; + +/** + * Wrapper object for a metadata patch replace Operation + */ +export class MetadataPatchReplaceOperation extends MetadataPatchOperation { + static operationType = 'replace'; + + /** + * The place of the metadata value within its field to modify + */ + place: number; + + /** + * The new value to replace the metadata with + */ + value: any; + + constructor(field: string, place: number, value: any) { + super(MetadataPatchReplaceOperation.operationType, field); + this.place = place; + this.value = value; + } + + /** + * Transform the MetadataPatchOperation into a fast-json-patch Operation by constructing its path and other properties + * using the information provided. + */ + toOperation(): Operation { + return { op: this.op as any, path: `/metadata/${this.field}/${this.place}`, value: this.value }; + } +} diff --git a/src/app/core/data/object-updates/patch-operation-service/patch-operation.service.ts b/src/app/core/data/object-updates/patch-operation-service/patch-operation.service.ts new file mode 100644 index 0000000000..7c67f9a2e5 --- /dev/null +++ b/src/app/core/data/object-updates/patch-operation-service/patch-operation.service.ts @@ -0,0 +1,15 @@ +import { FieldUpdates } from '../object-updates.reducer'; +import { Operation } from 'fast-json-patch'; + +/** + * Interface for a service dealing with the transformations of patch operations from the object-updates store + * The implementations of this service know how to deal with the fields of a FieldUpdate and how to transform them + * into patch Operations. + */ +export interface PatchOperationService { + /** + * Transform a {@link FieldUpdates} object into an array of fast-json-patch Operations + * @param fieldUpdates + */ + fieldUpdatesToPatchOperations(fieldUpdates: FieldUpdates): Operation[]; +} diff --git a/src/app/core/data/update-data.service.ts b/src/app/core/data/update-data.service.ts index 34835e14c1..f572c75982 100644 --- a/src/app/core/data/update-data.service.ts +++ b/src/app/core/data/update-data.service.ts @@ -1,11 +1,14 @@ import { Observable } from 'rxjs/internal/Observable'; import { RemoteData } from './remote-data'; import { RestRequestMethod } from './rest-request-method'; +import { Operation } from 'fast-json-patch'; +import { RestResponse } from '../cache/response.models'; /** * Represents a data service to update a given object */ export interface UpdateDataService { + patch(dso: T, operations: Operation[]): Observable; update(object: T): Observable>; commitUpdates(method?: RestRequestMethod); } diff --git a/src/app/core/dspace-rest-v2/dspace-rest-v2.service.ts b/src/app/core/dspace-rest-v2/dspace-rest-v2.service.ts index 8cb730c358..5394b6d83f 100644 --- a/src/app/core/dspace-rest-v2/dspace-rest-v2.service.ts +++ b/src/app/core/dspace-rest-v2/dspace-rest-v2.service.ts @@ -5,7 +5,7 @@ import { HttpClient, HttpHeaders, HttpParams, HttpResponse } from '@angular/comm import { DSpaceRESTV2Response } from './dspace-rest-v2-response.model'; import { RestRequestMethod } from '../data/rest-request-method'; -import { hasNoValue, isNotEmpty } from '../../shared/empty.util'; +import { hasNoValue, hasValue, isNotEmpty } from '../../shared/empty.util'; import { DSpaceObject } from '../shared/dspace-object.model'; export const DEFAULT_CONTENT_TYPE = 'application/json; charset=utf-8'; @@ -53,7 +53,7 @@ export class DSpaceRESTv2Service { return observableThrowError({ statusCode: err.status, statusText: err.statusText, - message: err.message + message: (hasValue(err.error) && isNotEmpty(err.error.message)) ? err.error.message : err.message }); })); } @@ -116,7 +116,7 @@ export class DSpaceRESTv2Service { return observableThrowError({ statusCode: err.status, statusText: err.statusText, - message: err.message + message: (hasValue(err.error) && isNotEmpty(err.error.message)) ? err.error.message : err.message }); })); } diff --git a/src/app/core/eperson/eperson-data.service.ts b/src/app/core/eperson/eperson-data.service.ts index 415977a46f..a1428aee73 100644 --- a/src/app/core/eperson/eperson-data.service.ts +++ b/src/app/core/eperson/eperson-data.service.ts @@ -3,7 +3,7 @@ import { Injectable } from '@angular/core'; import { createSelector, select, Store } from '@ngrx/store'; import { Operation } from 'fast-json-patch/lib/core'; import { Observable } from 'rxjs'; -import { filter, find, map, skipWhile, switchMap, take, tap } from 'rxjs/operators'; +import { filter, find, map, take } from 'rxjs/operators'; import { EPeopleRegistryCancelEPersonAction, EPeopleRegistryEditEPersonAction @@ -223,8 +223,8 @@ export class EPersonDataService extends DataService { * Method to delete an EPerson * @param ePerson The EPerson to delete */ - public deleteEPerson(ePerson: EPerson): Observable { - return this.delete(ePerson.id).pipe(map((response: RestResponse) => response.isSuccessful)); + public deleteEPerson(ePerson: EPerson): Observable { + return this.delete(ePerson.id); } /** @@ -299,34 +299,4 @@ export class EPersonDataService extends DataService { map((request: RequestEntry) => request.response) ); } - - /** - * Make a new FindListRequest with given search method - * - * @param searchMethod The search method for the object - * @param options The [[FindListOptions]] object - * @param linksToFollow The array of [[FollowLinkConfig]] - * @return {Observable>} - * Return an observable that emits response from the server - */ - searchBy(searchMethod: string, options: FindListOptions = {}, ...linksToFollow: Array>): Observable>> { - const hrefObs = this.getSearchByHref(searchMethod, options, ...linksToFollow); - - return hrefObs.pipe( - find((href: string) => hasValue(href)), - tap((href: string) => { - this.requestService.removeByHrefSubstring(href); - const request = new FindListRequest(this.requestService.generateRequestId(), href, options); - - this.requestService.configure(request); - } - ), - switchMap((href) => this.requestService.getByHref(href)), - skipWhile((requestEntry) => hasValue(requestEntry) && requestEntry.completed), - switchMap((href) => - this.rdbService.buildList(hrefObs, ...linksToFollow) as Observable>> - ) - ); - } - } diff --git a/src/app/core/eperson/models/eperson-dto.model.ts b/src/app/core/eperson/models/eperson-dto.model.ts new file mode 100644 index 0000000000..f491f6f8be --- /dev/null +++ b/src/app/core/eperson/models/eperson-dto.model.ts @@ -0,0 +1,17 @@ +import { EPerson } from './eperson.model'; + +/** + * This class serves as a Data Transfer Model that contains the EPerson and whether or not it's able to be deleted + */ +export class EpersonDtoModel { + + /** + * The EPerson linked to this object + */ + public eperson: EPerson; + /** + * Whether or not the linked EPerson is able to be deleted + */ + public ableToDelete: boolean; + +} diff --git a/src/app/core/metadata/metadata.service.spec.ts b/src/app/core/metadata/metadata.service.spec.ts index 74c4522bbd..28fe8e1acc 100644 --- a/src/app/core/metadata/metadata.service.spec.ts +++ b/src/app/core/metadata/metadata.service.spec.ts @@ -52,10 +52,13 @@ import { UUIDService } from '../shared/uuid.service'; import { MetadataService } from './metadata.service'; import { environment } from '../../../environments/environment'; import { storeModuleConfig } from '../../app.reducer'; +import { HardRedirectService } from '../services/hard-redirect.service'; +import { URLCombiner } from '../url-combiner/url-combiner'; /* tslint:disable:max-classes-per-file */ @Component({ - template: `` + template: ` + ` }) class TestComponent { constructor(private metadata: MetadataService) { @@ -170,6 +173,7 @@ describe('MetadataService', () => { Title, // tslint:disable-next-line:no-empty { provide: ItemDataService, useValue: { findById: () => {} } }, + { provide: HardRedirectService, useValue: { rewriteDownloadURL: (a) => a, getRequestOrigin: () => environment.ui.baseUrl }}, BrowseService, MetadataService ], @@ -208,7 +212,7 @@ describe('MetadataService', () => { tick(); expect(tagStore.get('citation_dissertation_name')[0].content).toEqual('Test PowerPoint Document'); expect(tagStore.get('citation_dissertation_institution')[0].content).toEqual('Mock Publisher'); - expect(tagStore.get('citation_abstract_html_url')[0].content).toEqual([environment.ui.baseUrl, router.url].join('')); + expect(tagStore.get('citation_abstract_html_url')[0].content).toEqual(new URLCombiner(environment.ui.baseUrl, router.url).toString()); expect(tagStore.get('citation_pdf_url')[0].content).toEqual('https://dspace7.4science.it/dspace-spring-rest/api/core/bitstreams/99b00f3c-1cc6-4689-8158-91965bee6b28/content'); })); diff --git a/src/app/core/metadata/metadata.service.ts b/src/app/core/metadata/metadata.service.ts index 02d2b0c86b..90171bac10 100644 --- a/src/app/core/metadata/metadata.service.ts +++ b/src/app/core/metadata/metadata.service.ts @@ -1,4 +1,4 @@ -import { Inject, Injectable } from '@angular/core'; +import { Injectable } from '@angular/core'; import { Meta, MetaDefinition, Title } from '@angular/platform-browser'; import { ActivatedRoute, NavigationEnd, Router } from '@angular/router'; @@ -20,7 +20,8 @@ import { Bitstream } from '../shared/bitstream.model'; import { DSpaceObject } from '../shared/dspace-object.model'; import { Item } from '../shared/item.model'; import { getFirstSucceededRemoteDataPayload, getFirstSucceededRemoteListPayload } from '../shared/operators'; -import { environment } from '../../../environments/environment'; +import { HardRedirectService } from '../services/hard-redirect.service'; +import { URLCombiner } from '../url-combiner/url-combiner'; @Injectable() export class MetadataService { @@ -39,6 +40,7 @@ export class MetadataService { private dsoNameService: DSONameService, private bitstreamDataService: BitstreamDataService, private bitstreamFormatDataService: BitstreamFormatDataService, + private redirectService: HardRedirectService ) { // TODO: determine what open graph meta tags are needed and whether // the differ per route. potentially add image based on DSpaceObject @@ -254,7 +256,7 @@ export class MetadataService { */ private setCitationAbstractUrlTag(): void { if (this.currentObject.value instanceof Item) { - const value = [environment.ui.baseUrl, this.router.url].join(''); + const value = new URLCombiner(this.redirectService.getRequestOrigin(), this.router.url).toString(); this.addMetaTag('citation_abstract_html_url', value); } } @@ -279,7 +281,8 @@ export class MetadataService { getFirstSucceededRemoteDataPayload() ).subscribe((format: BitstreamFormat) => { if (format.mimetype === 'application/pdf') { - this.addMetaTag('citation_pdf_url', bitstream._links.content.href); + const rewrittenURL= this.redirectService.rewriteDownloadURL(bitstream._links.content.href); + this.addMetaTag('citation_pdf_url', rewrittenURL); } }); } diff --git a/src/app/core/services/browser-hard-redirect.service.spec.ts b/src/app/core/services/browser-hard-redirect.service.spec.ts index 9d4c5df9a2..64518579aa 100644 --- a/src/app/core/services/browser-hard-redirect.service.spec.ts +++ b/src/app/core/services/browser-hard-redirect.service.spec.ts @@ -2,11 +2,12 @@ import {TestBed} from '@angular/core/testing'; import {BrowserHardRedirectService} from './browser-hard-redirect.service'; describe('BrowserHardRedirectService', () => { - + const origin = 'test origin'; const mockLocation = { href: undefined, pathname: '/pathname', search: '/search', + origin } as Location; const service: BrowserHardRedirectService = new BrowserHardRedirectService(mockLocation); @@ -38,4 +39,12 @@ describe('BrowserHardRedirectService', () => { expect(service.getCurrentRoute()).toEqual(mockLocation.pathname + mockLocation.search); }); }); + + describe('when requesting the origin', () => { + + it('should return the location origin', () => { + expect(service.getRequestOrigin()).toEqual(origin); + }); + }); + }); diff --git a/src/app/core/services/browser-hard-redirect.service.ts b/src/app/core/services/browser-hard-redirect.service.ts index 0d14b6b834..4b7424bee2 100644 --- a/src/app/core/services/browser-hard-redirect.service.ts +++ b/src/app/core/services/browser-hard-redirect.service.ts @@ -11,11 +11,12 @@ export function locationProvider(): Location { * Service for performing hard redirects within the browser app module */ @Injectable() -export class BrowserHardRedirectService implements HardRedirectService { +export class BrowserHardRedirectService extends HardRedirectService { constructor( @Inject(LocationToken) protected location: Location, ) { + super(); } /** @@ -32,4 +33,11 @@ export class BrowserHardRedirectService implements HardRedirectService { getCurrentRoute() { return this.location.pathname + this.location.search; } + + /** + * Get the hostname of the request + */ + getRequestOrigin() { + return this.location.origin; + } } diff --git a/src/app/core/services/hard-redirect.service.spec.ts b/src/app/core/services/hard-redirect.service.spec.ts new file mode 100644 index 0000000000..f7e2ba8955 --- /dev/null +++ b/src/app/core/services/hard-redirect.service.spec.ts @@ -0,0 +1,57 @@ +import { HardRedirectService } from './hard-redirect.service'; +import { environment } from '../../../environments/environment'; +import { TestBed } from '@angular/core/testing'; +import { Injectable } from '@angular/core'; + +const requestOrigin = 'http://dspace-angular-ui.dspace.com'; + +describe('HardRedirectService', () => { + let service: TestHardRedirectService; + + beforeEach(() => { + TestBed.configureTestingModule({ providers: [TestHardRedirectService] }); + service = TestBed.get(TestHardRedirectService); + }); + + describe('when calling rewriteDownloadURL', () => { + let originalValue; + const relativePath = '/test/url/path'; + const testURL = environment.rest.baseUrl + relativePath; + beforeEach(() => { + originalValue = environment.rewriteDownloadUrls; + }); + + it('it should return the same url when rewriteDownloadURL is false', () => { + environment.rewriteDownloadUrls = false; + expect(service.rewriteDownloadURL(testURL)).toEqual(testURL); + }); + + it('it should replace part of the url when rewriteDownloadURL is true', () => { + environment.rewriteDownloadUrls = true; + expect(service.rewriteDownloadURL(testURL)).toEqual(requestOrigin + environment.rest.nameSpace + relativePath); + }); + + afterEach(() => { + environment.rewriteDownloadUrls = originalValue; + }) + }); +}); + +@Injectable() +class TestHardRedirectService extends HardRedirectService { + constructor() { + super(); + } + + redirect(url: string) { + return undefined; + } + + getCurrentRoute() { + return undefined; + } + + getRequestOrigin() { + return requestOrigin; + } +} diff --git a/src/app/core/services/hard-redirect.service.ts b/src/app/core/services/hard-redirect.service.ts index 09757a1250..a09521dae5 100644 --- a/src/app/core/services/hard-redirect.service.ts +++ b/src/app/core/services/hard-redirect.service.ts @@ -1,4 +1,6 @@ import { Injectable } from '@angular/core'; +import { environment } from '../../../environments/environment'; +import { URLCombiner } from '../url-combiner/url-combiner'; /** * Service to take care of hard redirects @@ -19,4 +21,20 @@ export abstract class HardRedirectService { * e.g. /search?page=1&query=open%20access&f.dateIssued.min=1980&f.dateIssued.max=2020 */ abstract getCurrentRoute(); + + /** + * Get the hostname of the request + */ + abstract getRequestOrigin(); + + public rewriteDownloadURL(originalUrl: string): string { + if (environment.rewriteDownloadUrls) { + const hostName = this.getRequestOrigin(); + const namespace = environment.rest.nameSpace; + const rewrittenUrl = new URLCombiner(hostName, namespace).toString(); + return originalUrl.replace(environment.rest.baseUrl, rewrittenUrl); + } else { + return originalUrl; + } + } } diff --git a/src/app/core/services/server-hard-redirect.service.spec.ts b/src/app/core/services/server-hard-redirect.service.spec.ts index 2d09c21eb9..dc89517468 100644 --- a/src/app/core/services/server-hard-redirect.service.spec.ts +++ b/src/app/core/services/server-hard-redirect.service.spec.ts @@ -7,8 +7,13 @@ describe('ServerHardRedirectService', () => { const mockResponse = jasmine.createSpyObj(['redirect', 'end']); const service: ServerHardRedirectService = new ServerHardRedirectService(mockRequest, mockResponse); + const origin = 'test-host'; beforeEach(() => { + mockRequest.headers = { + host: 'test-host', + }; + TestBed.configureTestingModule({}); }); @@ -40,4 +45,12 @@ describe('ServerHardRedirectService', () => { expect(service.getCurrentRoute()).toEqual(mockRequest.originalUrl); }); }); + + describe('when requesting the origin', () => { + + it('should return the location origin', () => { + expect(service.getRequestOrigin()).toEqual(origin); + }); + }); + }); diff --git a/src/app/core/services/server-hard-redirect.service.ts b/src/app/core/services/server-hard-redirect.service.ts index 79755d2dc9..65b404ca6c 100644 --- a/src/app/core/services/server-hard-redirect.service.ts +++ b/src/app/core/services/server-hard-redirect.service.ts @@ -7,12 +7,13 @@ import { HardRedirectService } from './hard-redirect.service'; * Service for performing hard redirects within the server app module */ @Injectable() -export class ServerHardRedirectService implements HardRedirectService { +export class ServerHardRedirectService extends HardRedirectService { constructor( @Inject(REQUEST) protected req: Request, @Inject(RESPONSE) protected res: Response, ) { + super(); } /** @@ -59,4 +60,11 @@ export class ServerHardRedirectService implements HardRedirectService { getCurrentRoute() { return this.req.originalUrl; } + + /** + * Get the hostname of the request + */ + getRequestOrigin() { + return this.req.headers.host; + } } diff --git a/src/app/core/shared/operators.ts b/src/app/core/shared/operators.ts index 17823c0447..ad2588f2b9 100644 --- a/src/app/core/shared/operators.ts +++ b/src/app/core/shared/operators.ts @@ -14,7 +14,7 @@ import { MetadataSchema } from '../metadata/metadata-schema.model'; import { BrowseDefinition } from './browse-definition.model'; import { DSpaceObject } from './dspace-object.model'; import { getUnauthorizedRoute } from '../../app-routing-paths'; -import { getEndUserAgreementPath } from '../../info/info-routing.module'; +import { getEndUserAgreementPath } from '../../info/info-routing-paths'; /** * This file contains custom RxJS operators that can be used in multiple places diff --git a/src/app/core/statistics/models/usage-report.model.ts b/src/app/core/statistics/models/usage-report.model.ts new file mode 100644 index 0000000000..4350bfd7d8 --- /dev/null +++ b/src/app/core/statistics/models/usage-report.model.ts @@ -0,0 +1,51 @@ +import { autoserialize, inheritSerialization } from 'cerialize'; +import { typedObject } from '../../cache/builders/build-decorators'; +import { excludeFromEquals } from '../../utilities/equals.decorators'; +import { ResourceType } from '../../shared/resource-type'; +import { HALResource } from '../../shared/hal-resource.model'; +import { USAGE_REPORT } from './usage-report.resource-type'; +import { HALLink } from '../../shared/hal-link.model'; +import { deserialize, autoserializeAs } from 'cerialize'; + +/** + * A usage report. + */ +@typedObject +@inheritSerialization(HALResource) +export class UsageReport extends HALResource { + + static type = USAGE_REPORT; + + /** + * The object type + */ + @excludeFromEquals + @autoserialize + type: ResourceType; + + @autoserialize + id: string; + + @autoserializeAs('report-type') + reportType: string; + + @autoserialize + points: Point[]; + + @deserialize + _links: { + self: HALLink; + }; +} + +/** + * A statistics data point. + */ +export interface Point { + id: string; + label: string; + type: string; + values: Array<{ + views: number; + }>; +} diff --git a/src/app/core/statistics/models/usage-report.resource-type.ts b/src/app/core/statistics/models/usage-report.resource-type.ts new file mode 100644 index 0000000000..650a51b3c3 --- /dev/null +++ b/src/app/core/statistics/models/usage-report.resource-type.ts @@ -0,0 +1,9 @@ +import { ResourceType } from '../../shared/resource-type'; + +/** + * The resource type for License + * + * Needs to be in a separate file to prevent circular + * dependencies in webpack. + */ +export const USAGE_REPORT = new ResourceType('usagereport'); diff --git a/src/app/core/statistics/usage-report-data.service.ts b/src/app/core/statistics/usage-report-data.service.ts new file mode 100644 index 0000000000..08dd111384 --- /dev/null +++ b/src/app/core/statistics/usage-report-data.service.ts @@ -0,0 +1,62 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { Store } from '@ngrx/store'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { dataService } from '../cache/builders/build-decorators'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { CoreState } from '../core.reducers'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { DataService } from '../data/data.service'; +import { RequestService } from '../data/request.service'; +import { DefaultChangeAnalyzer } from '../data/default-change-analyzer.service'; +import { USAGE_REPORT } from './models/usage-report.resource-type'; +import { UsageReport } from './models/usage-report.model'; +import { Observable } from 'rxjs'; +import { getRemoteDataPayload, getSucceededRemoteData } from '../shared/operators'; +import { map } from 'rxjs/operators'; + +/** + * A service to retrieve {@link UsageReport}s from the REST API + */ +@Injectable() +@dataService(USAGE_REPORT) +export class UsageReportService extends DataService { + + protected linkPath = 'statistics/usagereports'; + + constructor( + protected comparator: DefaultChangeAnalyzer, + protected halService: HALEndpointService, + protected http: HttpClient, + protected notificationsService: NotificationsService, + protected objectCache: ObjectCacheService, + protected rdbService: RemoteDataBuildService, + protected requestService: RequestService, + protected store: Store, + ) { + super(); + } + + getStatistic(scope: string, type: string): Observable { + return this.findById(`${scope}_${type}`).pipe( + getSucceededRemoteData(), + getRemoteDataPayload(), + ); + } + + searchStatistics(uri: string, page: number, size: number): Observable { + return this.searchBy('object', { + searchParams: [{ + fieldName: `uri`, + fieldValue: uri, + }], + currentPage: page, + elementsPerPage: size, + }).pipe( + getSucceededRemoteData(), + getRemoteDataPayload(), + map((list) => list.page), + ); + } +} diff --git a/src/app/info/info-routing-paths.ts b/src/app/info/info-routing-paths.ts new file mode 100644 index 0000000000..8ec6dbcb8d --- /dev/null +++ b/src/app/info/info-routing-paths.ts @@ -0,0 +1,16 @@ +import { getInfoModulePath } from '../app-routing-paths'; + +export const END_USER_AGREEMENT_PATH = 'end-user-agreement'; +export const PRIVACY_PATH = 'privacy'; + +export function getEndUserAgreementPath() { + return getSubPath(END_USER_AGREEMENT_PATH); +} + +export function getPrivacyPath() { + return getSubPath(PRIVACY_PATH); +} + +function getSubPath(path: string) { + return `${getInfoModulePath()}/${path}`; +} diff --git a/src/app/info/info-routing.module.ts b/src/app/info/info-routing.module.ts index 86ff7fb334..799572f9b9 100644 --- a/src/app/info/info-routing.module.ts +++ b/src/app/info/info-routing.module.ts @@ -1,24 +1,9 @@ import { NgModule } from '@angular/core'; import { RouterModule } from '@angular/router'; import { EndUserAgreementComponent } from './end-user-agreement/end-user-agreement.component'; -import { getInfoModulePath } from '../app-routing-paths'; import { I18nBreadcrumbResolver } from '../core/breadcrumbs/i18n-breadcrumb.resolver'; import { PrivacyComponent } from './privacy/privacy.component'; - -const END_USER_AGREEMENT_PATH = 'end-user-agreement'; -const PRIVACY_PATH = 'privacy'; - -export function getEndUserAgreementPath() { - return getSubPath(END_USER_AGREEMENT_PATH); -} - -export function getPrivacyPath() { - return getSubPath(PRIVACY_PATH); -} - -function getSubPath(path: string) { - return `${getInfoModulePath()}/${path}`; -} +import { PRIVACY_PATH, END_USER_AGREEMENT_PATH } from './info-routing-paths'; @NgModule({ imports: [ diff --git a/src/app/navbar/navbar.component.ts b/src/app/navbar/navbar.component.ts index ae9000352a..454b68a81c 100644 --- a/src/app/navbar/navbar.component.ts +++ b/src/app/navbar/navbar.component.ts @@ -64,19 +64,6 @@ export class NavbarComponent extends MenuComponent { link: `/community-list` } as LinkMenuItemModel }, - - /* Statistics */ - { - id: 'statistics', - active: false, - visible: true, - model: { - type: MenuItemType.LINK, - text: 'menu.section.statistics', - link: '' - } as LinkMenuItemModel, - index: 2 - }, ]; // Read the different Browse-By types from config and add them to the browse menu const types = environment.browseBy.types; diff --git a/src/app/shared/file-download-link/file-download-link.component.spec.ts b/src/app/shared/file-download-link/file-download-link.component.spec.ts index ac1751d43d..56702787d3 100644 --- a/src/app/shared/file-download-link/file-download-link.component.spec.ts +++ b/src/app/shared/file-download-link/file-download-link.component.spec.ts @@ -3,6 +3,7 @@ import { FileDownloadLinkComponent } from './file-download-link.component'; import { AuthService } from '../../core/auth/auth.service'; import { FileService } from '../../core/shared/file.service'; import { of as observableOf } from 'rxjs'; +import { HardRedirectService } from '../../core/services/hard-redirect.service'; describe('FileDownloadLinkComponent', () => { let component: FileDownloadLinkComponent; @@ -23,13 +24,14 @@ describe('FileDownloadLinkComponent', () => { beforeEach(async(() => { init(); TestBed.configureTestingModule({ - declarations: [ FileDownloadLinkComponent ], + declarations: [FileDownloadLinkComponent], providers: [ { provide: AuthService, useValue: authService }, - { provide: FileService, useValue: fileService } + { provide: FileService, useValue: fileService }, + { provide: HardRedirectService, useValue: { rewriteDownloadURL: (a) => a } }, ] }) - .compileComponents(); + .compileComponents(); })); beforeEach(() => { diff --git a/src/app/shared/file-download-link/file-download-link.component.ts b/src/app/shared/file-download-link/file-download-link.component.ts index 9df7c191ff..71283642c9 100644 --- a/src/app/shared/file-download-link/file-download-link.component.ts +++ b/src/app/shared/file-download-link/file-download-link.component.ts @@ -2,6 +2,7 @@ import { Component, Input, OnInit } from '@angular/core'; import { FileService } from '../../core/shared/file.service'; import { Observable } from 'rxjs/internal/Observable'; import { AuthService } from '../../core/auth/auth.service'; +import { HardRedirectService } from '../../core/services/hard-redirect.service'; @Component({ selector: 'ds-file-download-link', @@ -30,10 +31,13 @@ export class FileDownloadLinkComponent implements OnInit { isAuthenticated$: Observable; constructor(private fileService: FileService, - private authService: AuthService) { } + private authService: AuthService, + private redirectService: HardRedirectService) { + } ngOnInit() { this.isAuthenticated$ = this.authService.isAuthenticated(); + this.href = this.redirectService.rewriteDownloadURL(this.href); } /** @@ -44,5 +48,4 @@ export class FileDownloadLinkComponent implements OnInit { this.fileService.downloadFile(this.href); return false; } - } diff --git a/src/app/shared/menu/menu.effects.spec.ts b/src/app/shared/menu/menu.effects.spec.ts index 11b468eded..911dbe1de6 100644 --- a/src/app/shared/menu/menu.effects.spec.ts +++ b/src/app/shared/menu/menu.effects.spec.ts @@ -14,6 +14,7 @@ import { MenuEffects } from './menu.effects'; describe('MenuEffects', () => { let menuEffects: MenuEffects; let routeDataMenuSection: MenuSection; + let routeDataMenuSectionResolved: MenuSection; let routeDataMenuChildSection: MenuSection; let toBeRemovedMenuSection: MenuSection; let alreadyPresentMenuSection: MenuSection; @@ -23,13 +24,23 @@ describe('MenuEffects', () => { function init() { routeDataMenuSection = { - id: 'mockSection', + id: 'mockSection_:idparam', active: false, visible: true, model: { type: MenuItemType.LINK, text: 'menu.section.mockSection', - link: '' + link: 'path/:linkparam' + } as LinkMenuItemModel + }; + routeDataMenuSectionResolved = { + id: 'mockSection_id_param_resolved', + active: false, + visible: true, + model: { + type: MenuItemType.LINK, + text: 'menu.section.mockSection', + link: 'path/link_param_resolved' } as LinkMenuItemModel }; routeDataMenuChildSection = { @@ -70,6 +81,10 @@ describe('MenuEffects', () => { menu: { [MenuID.PUBLIC]: [routeDataMenuSection, alreadyPresentMenuSection] } + }, + params: { + idparam: 'id_param_resolved', + linkparam: 'link_param_resolved', } }, firstChild: { @@ -120,7 +135,7 @@ describe('MenuEffects', () => { }); expect(menuEffects.buildRouteMenuSections$).toBeObservable(expected); - expect(menuService.addSection).toHaveBeenCalledWith(MenuID.PUBLIC, routeDataMenuSection); + expect(menuService.addSection).toHaveBeenCalledWith(MenuID.PUBLIC, routeDataMenuSectionResolved); expect(menuService.addSection).toHaveBeenCalledWith(MenuID.PUBLIC, routeDataMenuChildSection); expect(menuService.addSection).not.toHaveBeenCalledWith(MenuID.PUBLIC, alreadyPresentMenuSection); expect(menuService.removeSection).toHaveBeenCalledWith(MenuID.PUBLIC, toBeRemovedMenuSection.id); diff --git a/src/app/shared/menu/menu.effects.ts b/src/app/shared/menu/menu.effects.ts index be314cfd49..a9aa07daad 100644 --- a/src/app/shared/menu/menu.effects.ts +++ b/src/app/shared/menu/menu.effects.ts @@ -19,7 +19,7 @@ export class MenuEffects { /** * On route change, build menu sections for every menu type depending on the current route data */ - @Effect({ dispatch: false }) + @Effect({dispatch: false}) public buildRouteMenuSections$: Observable = this.actions$ .pipe( ofType(ROUTER_NAVIGATED), @@ -68,17 +68,52 @@ export class MenuEffects { */ resolveRouteMenuSections(route: ActivatedRoute, menuID: MenuID): MenuSection[] { const data = route.snapshot.data; + const params = route.snapshot.params; const last: boolean = hasNoValue(route.firstChild); if (hasValue(data) && hasValue(data.menu) && hasValue(data.menu[menuID])) { + + let menuSections: MenuSection[] | MenuSection = data.menu[menuID]; + menuSections = this.resolveSubstitutions(menuSections, params); + if (!last) { - return [...data.menu[menuID], ...this.resolveRouteMenuSections(route.firstChild, menuID)] + return [...menuSections, ...this.resolveRouteMenuSections(route.firstChild, menuID)] } else { - return [...data.menu[menuID]]; + return [...menuSections]; } } return !last ? this.resolveRouteMenuSections(route.firstChild, menuID) : []; } + private resolveSubstitutions(object, params) { + + let resolved; + if (typeof object === 'string') { + resolved = object; + let match: RegExpMatchArray; + do { + match = resolved.match(/:(\w+)/); + if (match) { + const substitute = params[match[1]]; + if (hasValue(substitute)) { + resolved = resolved.replace(match[0], `${substitute}`); + } + } + } while (match); + } else if (Array.isArray(object)) { + resolved = []; + object.forEach((entry, index) => { + resolved[index] = this.resolveSubstitutions(object[index], params); + }); + } else if (typeof object === 'object') { + resolved = {}; + Object.keys(object).forEach((key) => { + resolved[key] = this.resolveSubstitutions(object[key], params); + }); + } else { + resolved = object; + } + return resolved; + } } diff --git a/src/app/statistics-page/collection-statistics-page/collection-statistics-page.component.scss b/src/app/statistics-page/collection-statistics-page/collection-statistics-page.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/statistics-page/collection-statistics-page/collection-statistics-page.component.spec.ts b/src/app/statistics-page/collection-statistics-page/collection-statistics-page.component.spec.ts new file mode 100644 index 0000000000..110757670c --- /dev/null +++ b/src/app/statistics-page/collection-statistics-page/collection-statistics-page.component.spec.ts @@ -0,0 +1,109 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { CollectionStatisticsPageComponent } from './collection-statistics-page.component'; +import { StatisticsTableComponent } from '../statistics-table/statistics-table.component'; +import { TranslateModule } from '@ngx-translate/core'; +import { ActivatedRoute, Router } from '@angular/router'; +import { UsageReportService } from '../../core/statistics/usage-report-data.service'; +import { of as observableOf } from 'rxjs'; +import { RemoteData } from '../../core/data/remote-data'; +import { Collection } from '../../core/shared/collection.model'; +import { DebugElement } from '@angular/core'; +import { By } from '@angular/platform-browser'; +import { UsageReport } from '../../core/statistics/models/usage-report.model'; +import { SharedModule } from '../../shared/shared.module'; +import { CommonModule } from '@angular/common'; +import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; +import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service'; + +describe('CollectionStatisticsPageComponent', () => { + + let component: CollectionStatisticsPageComponent; + let de: DebugElement; + let fixture: ComponentFixture; + + beforeEach(async(() => { + + const activatedRoute = { + data: observableOf({ + scope: new RemoteData( + false, + false, + true, + undefined, + Object.assign(new Collection(), { + id: 'collection_id', + }), + ) + }) + }; + + const router = { + }; + + const usageReportService = { + getStatistic: (scope, type) => undefined, + }; + + spyOn(usageReportService, 'getStatistic').and.callFake( + (scope, type) => observableOf( + Object.assign( + new UsageReport(), { + id: `${scope}-${type}-report`, + points: [], + } + ) + ) + ); + + const nameService = { + getName: () => observableOf('test dso name'), + }; + + TestBed.configureTestingModule({ + imports: [ + TranslateModule.forRoot(), + CommonModule, + SharedModule, + ], + declarations: [ + CollectionStatisticsPageComponent, + StatisticsTableComponent, + ], + providers: [ + { provide: ActivatedRoute, useValue: activatedRoute }, + { provide: Router, useValue: router }, + { provide: UsageReportService, useValue: usageReportService }, + { provide: DSpaceObjectDataService, useValue: {} }, + { provide: DSONameService, useValue: nameService }, + ], + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(CollectionStatisticsPageComponent); + component = fixture.componentInstance; + de = fixture.debugElement; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should resolve to the correct collection', () => { + expect(de.query(By.css('.header')).nativeElement.id) + .toEqual('collection_id'); + }); + + it('should show a statistics table for each usage report', () => { + expect(de.query(By.css('ds-statistics-table.collection_id-TotalVisits-report')).nativeElement) + .toBeTruthy(); + expect(de.query(By.css('ds-statistics-table.collection_id-TotalVisitsPerMonth-report')).nativeElement) + .toBeTruthy(); + expect(de.query(By.css('ds-statistics-table.collection_id-TopCountries-report')).nativeElement) + .toBeTruthy(); + expect(de.query(By.css('ds-statistics-table.collection_id-TopCities-report')).nativeElement) + .toBeTruthy(); + }); +}); diff --git a/src/app/statistics-page/collection-statistics-page/collection-statistics-page.component.ts b/src/app/statistics-page/collection-statistics-page/collection-statistics-page.component.ts new file mode 100644 index 0000000000..05f4641d81 --- /dev/null +++ b/src/app/statistics-page/collection-statistics-page/collection-statistics-page.component.ts @@ -0,0 +1,41 @@ +import { Component } from '@angular/core'; +import { StatisticsPageComponent } from '../statistics-page/statistics-page.component'; +import { UsageReportService } from '../../core/statistics/usage-report-data.service'; +import { ActivatedRoute , Router} from '@angular/router'; +import { Collection } from '../../core/shared/collection.model'; +import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; + +/** + * Component representing the statistics page for a collection. + */ +@Component({ + selector: 'ds-collection-statistics-page', + templateUrl: '../statistics-page/statistics-page.component.html', + styleUrls: ['./collection-statistics-page.component.scss'] +}) +export class CollectionStatisticsPageComponent extends StatisticsPageComponent { + + /** + * The report types to show on this statistics page. + */ + types: string[] = [ + 'TotalVisits', + 'TotalVisitsPerMonth', + 'TopCountries', + 'TopCities', + ]; + + constructor( + protected route: ActivatedRoute, + protected router: Router, + protected usageReportService: UsageReportService, + protected nameService: DSONameService, + ) { + super( + route, + router, + usageReportService, + nameService, + ); + } +} diff --git a/src/app/statistics-page/community-statistics-page/community-statistics-page.component.scss b/src/app/statistics-page/community-statistics-page/community-statistics-page.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/statistics-page/community-statistics-page/community-statistics-page.component.spec.ts b/src/app/statistics-page/community-statistics-page/community-statistics-page.component.spec.ts new file mode 100644 index 0000000000..a5771dfb38 --- /dev/null +++ b/src/app/statistics-page/community-statistics-page/community-statistics-page.component.spec.ts @@ -0,0 +1,109 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { CommunityStatisticsPageComponent } from './community-statistics-page.component'; +import { StatisticsTableComponent } from '../statistics-table/statistics-table.component'; +import { TranslateModule } from '@ngx-translate/core'; +import { ActivatedRoute, Router } from '@angular/router'; +import { UsageReportService } from '../../core/statistics/usage-report-data.service'; +import { of as observableOf } from 'rxjs'; +import { RemoteData } from '../../core/data/remote-data'; +import { Community } from '../../core/shared/community.model'; +import { DebugElement } from '@angular/core'; +import { By } from '@angular/platform-browser'; +import { UsageReport } from '../../core/statistics/models/usage-report.model'; +import { SharedModule } from '../../shared/shared.module'; +import { CommonModule } from '@angular/common'; +import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; +import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service'; + +describe('CommunityStatisticsPageComponent', () => { + + let component: CommunityStatisticsPageComponent; + let de: DebugElement; + let fixture: ComponentFixture; + + beforeEach(async(() => { + + const activatedRoute = { + data: observableOf({ + scope: new RemoteData( + false, + false, + true, + undefined, + Object.assign(new Community(), { + id: 'community_id', + }), + ) + }) + }; + + const router = { + }; + + const usageReportService = { + getStatistic: (scope, type) => undefined, + }; + + spyOn(usageReportService, 'getStatistic').and.callFake( + (scope, type) => observableOf( + Object.assign( + new UsageReport(), { + id: `${scope}-${type}-report`, + points: [], + } + ) + ) + ); + + const nameService = { + getName: () => observableOf('test dso name'), + }; + + TestBed.configureTestingModule({ + imports: [ + TranslateModule.forRoot(), + CommonModule, + SharedModule, + ], + declarations: [ + CommunityStatisticsPageComponent, + StatisticsTableComponent, + ], + providers: [ + { provide: ActivatedRoute, useValue: activatedRoute }, + { provide: Router, useValue: router }, + { provide: UsageReportService, useValue: usageReportService }, + { provide: DSpaceObjectDataService, useValue: {} }, + { provide: DSONameService, useValue: nameService }, + ], + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(CommunityStatisticsPageComponent); + component = fixture.componentInstance; + de = fixture.debugElement; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should resolve to the correct community', () => { + expect(de.query(By.css('.header')).nativeElement.id) + .toEqual('community_id'); + }); + + it('should show a statistics table for each usage report', () => { + expect(de.query(By.css('ds-statistics-table.community_id-TotalVisits-report')).nativeElement) + .toBeTruthy(); + expect(de.query(By.css('ds-statistics-table.community_id-TotalVisitsPerMonth-report')).nativeElement) + .toBeTruthy(); + expect(de.query(By.css('ds-statistics-table.community_id-TopCountries-report')).nativeElement) + .toBeTruthy(); + expect(de.query(By.css('ds-statistics-table.community_id-TopCities-report')).nativeElement) + .toBeTruthy(); + }); +}); diff --git a/src/app/statistics-page/community-statistics-page/community-statistics-page.component.ts b/src/app/statistics-page/community-statistics-page/community-statistics-page.component.ts new file mode 100644 index 0000000000..65d5fe88e5 --- /dev/null +++ b/src/app/statistics-page/community-statistics-page/community-statistics-page.component.ts @@ -0,0 +1,41 @@ +import { Component } from '@angular/core'; +import { StatisticsPageComponent } from '../statistics-page/statistics-page.component'; +import { UsageReportService } from '../../core/statistics/usage-report-data.service'; +import { ActivatedRoute, Router } from '@angular/router'; +import { Community } from '../../core/shared/community.model'; +import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; + +/** + * Component representing the statistics page for a community. + */ +@Component({ + selector: 'ds-community-statistics-page', + templateUrl: '../statistics-page/statistics-page.component.html', + styleUrls: ['./community-statistics-page.component.scss'] +}) +export class CommunityStatisticsPageComponent extends StatisticsPageComponent { + + /** + * The report types to show on this statistics page. + */ + types: string[] = [ + 'TotalVisits', + 'TotalVisitsPerMonth', + 'TopCountries', + 'TopCities', + ]; + + constructor( + protected route: ActivatedRoute, + protected router: Router, + protected usageReportService: UsageReportService, + protected nameService: DSONameService, + ) { + super( + route, + router, + usageReportService, + nameService, + ); + } +} diff --git a/src/app/statistics-page/item-statistics-page/item-statistics-page.component.scss b/src/app/statistics-page/item-statistics-page/item-statistics-page.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/statistics-page/item-statistics-page/item-statistics-page.component.spec.ts b/src/app/statistics-page/item-statistics-page/item-statistics-page.component.spec.ts new file mode 100644 index 0000000000..c0bf98ef19 --- /dev/null +++ b/src/app/statistics-page/item-statistics-page/item-statistics-page.component.spec.ts @@ -0,0 +1,111 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { ItemStatisticsPageComponent } from './item-statistics-page.component'; +import { StatisticsTableComponent } from '../statistics-table/statistics-table.component'; +import { TranslateModule } from '@ngx-translate/core'; +import { ActivatedRoute, Router } from '@angular/router'; +import { UsageReportService } from '../../core/statistics/usage-report-data.service'; +import { of as observableOf } from 'rxjs'; +import { RemoteData } from '../../core/data/remote-data'; +import { Item } from '../../core/shared/item.model'; +import { DebugElement } from '@angular/core'; +import { By } from '@angular/platform-browser'; +import { UsageReport } from '../../core/statistics/models/usage-report.model'; +import { SharedModule } from '../../shared/shared.module'; +import { CommonModule } from '@angular/common'; +import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; +import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service'; + +describe('ItemStatisticsPageComponent', () => { + + let component: ItemStatisticsPageComponent; + let de: DebugElement; + let fixture: ComponentFixture; + + beforeEach(async(() => { + + const activatedRoute = { + data: observableOf({ + scope: new RemoteData( + false, + false, + true, + undefined, + Object.assign(new Item(), { + id: 'item_id', + }), + ) + }) + }; + + const router = { + }; + + const usageReportService = { + getStatistic: (scope, type) => undefined, + }; + + spyOn(usageReportService, 'getStatistic').and.callFake( + (scope, type) => observableOf( + Object.assign( + new UsageReport(), { + id: `${scope}-${type}-report`, + points: [], + } + ) + ) + ); + + const nameService = { + getName: () => observableOf('test dso name'), + }; + + TestBed.configureTestingModule({ + imports: [ + TranslateModule.forRoot(), + CommonModule, + SharedModule, + ], + declarations: [ + ItemStatisticsPageComponent, + StatisticsTableComponent, + ], + providers: [ + { provide: ActivatedRoute, useValue: activatedRoute }, + { provide: Router, useValue: router }, + { provide: UsageReportService, useValue: usageReportService }, + { provide: DSpaceObjectDataService, useValue: {} }, + { provide: DSONameService, useValue: nameService }, + ], + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(ItemStatisticsPageComponent); + component = fixture.componentInstance; + de = fixture.debugElement; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should resolve to the correct item', () => { + expect(de.query(By.css('.header')).nativeElement.id) + .toEqual('item_id'); + }); + + it('should show a statistics table for each usage report', () => { + expect(de.query(By.css('ds-statistics-table.item_id-TotalVisits-report')).nativeElement) + .toBeTruthy(); + expect(de.query(By.css('ds-statistics-table.item_id-TotalVisitsPerMonth-report')).nativeElement) + .toBeTruthy(); + expect(de.query(By.css('ds-statistics-table.item_id-TotalDownloads-report')).nativeElement) + .toBeTruthy(); + expect(de.query(By.css('ds-statistics-table.item_id-TopCountries-report')).nativeElement) + .toBeTruthy(); + expect(de.query(By.css('ds-statistics-table.item_id-TopCities-report')).nativeElement) + .toBeTruthy(); + }); +}); diff --git a/src/app/statistics-page/item-statistics-page/item-statistics-page.component.ts b/src/app/statistics-page/item-statistics-page/item-statistics-page.component.ts new file mode 100644 index 0000000000..fb9ced4520 --- /dev/null +++ b/src/app/statistics-page/item-statistics-page/item-statistics-page.component.ts @@ -0,0 +1,42 @@ +import { Component } from '@angular/core'; +import { StatisticsPageComponent } from '../statistics-page/statistics-page.component'; +import { UsageReportService } from '../../core/statistics/usage-report-data.service'; +import { ActivatedRoute, Router } from '@angular/router'; +import { Item } from '../../core/shared/item.model'; +import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; + +/** + * Component representing the statistics page for an item. + */ +@Component({ + selector: 'ds-item-statistics-page', + templateUrl: '../statistics-page/statistics-page.component.html', + styleUrls: ['./item-statistics-page.component.scss'] +}) +export class ItemStatisticsPageComponent extends StatisticsPageComponent { + + /** + * The report types to show on this statistics page. + */ + types: string[] = [ + 'TotalVisits', + 'TotalVisitsPerMonth', + 'TotalDownloads', + 'TopCountries', + 'TopCities', + ]; + + constructor( + protected route: ActivatedRoute, + protected router: Router, + protected usageReportService: UsageReportService, + protected nameService: DSONameService, + ) { + super( + route, + router, + usageReportService, + nameService, + ); + } +} diff --git a/src/app/statistics-page/site-statistics-page/site-statistics-page.component.scss b/src/app/statistics-page/site-statistics-page/site-statistics-page.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/statistics-page/site-statistics-page/site-statistics-page.component.spec.ts b/src/app/statistics-page/site-statistics-page/site-statistics-page.component.spec.ts new file mode 100644 index 0000000000..6f2247b433 --- /dev/null +++ b/src/app/statistics-page/site-statistics-page/site-statistics-page.component.spec.ts @@ -0,0 +1,100 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { SiteStatisticsPageComponent } from './site-statistics-page.component'; +import { StatisticsTableComponent } from '../statistics-table/statistics-table.component'; +import { TranslateModule } from '@ngx-translate/core'; +import { ActivatedRoute, Router } from '@angular/router'; +import { UsageReportService } from '../../core/statistics/usage-report-data.service'; +import { of as observableOf } from 'rxjs'; +import { Site } from '../../core/shared/site.model'; +import { DebugElement } from '@angular/core'; +import { By } from '@angular/platform-browser'; +import { UsageReport } from '../../core/statistics/models/usage-report.model'; +import { SharedModule } from '../../shared/shared.module'; +import { CommonModule } from '@angular/common'; +import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; +import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service'; +import { SiteDataService } from '../../core/data/site-data.service'; + +describe('SiteStatisticsPageComponent', () => { + + let component: SiteStatisticsPageComponent; + let de: DebugElement; + let fixture: ComponentFixture; + + beforeEach(async(() => { + + const activatedRoute = { + }; + + const router = { + }; + + const usageReportService = { + searchStatistics: () => observableOf([ + Object.assign( + new UsageReport(), { + id: `site_id-TotalVisits-report`, + points: [], + } + ), + ]), + }; + + const nameService = { + getName: () => observableOf('test dso name'), + }; + + const siteService = { + find: () => observableOf(Object.assign(new Site(), { + id: 'site_id', + _links: { + self: { + href: 'test_site_link', + }, + }, + })) + }; + + TestBed.configureTestingModule({ + imports: [ + TranslateModule.forRoot(), + CommonModule, + SharedModule, + ], + declarations: [ + SiteStatisticsPageComponent, + StatisticsTableComponent, + ], + providers: [ + { provide: ActivatedRoute, useValue: activatedRoute }, + { provide: Router, useValue: router }, + { provide: UsageReportService, useValue: usageReportService }, + { provide: DSpaceObjectDataService, useValue: {} }, + { provide: DSONameService, useValue: nameService }, + { provide: SiteDataService, useValue: siteService }, + ], + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(SiteStatisticsPageComponent); + component = fixture.componentInstance; + de = fixture.debugElement; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should resolve to the correct site', () => { + expect(de.query(By.css('.header')).nativeElement.id) + .toEqual('site_id'); + }); + + it('should show a statistics table for each usage report', () => { + expect(de.query(By.css('ds-statistics-table.site_id-TotalVisits-report')).nativeElement) + .toBeTruthy(); + }); +}); diff --git a/src/app/statistics-page/site-statistics-page/site-statistics-page.component.ts b/src/app/statistics-page/site-statistics-page/site-statistics-page.component.ts new file mode 100644 index 0000000000..fd1319723c --- /dev/null +++ b/src/app/statistics-page/site-statistics-page/site-statistics-page.component.ts @@ -0,0 +1,53 @@ +import { Component } from '@angular/core'; +import { StatisticsPageComponent } from '../statistics-page/statistics-page.component'; +import { SiteDataService } from '../../core/data/site-data.service'; +import { UsageReportService } from '../../core/statistics/usage-report-data.service'; +import { ActivatedRoute, Router } from '@angular/router'; +import { Site } from '../../core/shared/site.model'; +import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; +import { switchMap } from 'rxjs/operators'; + +/** + * Component representing the site-wide statistics page. + */ +@Component({ + selector: 'ds-site-statistics-page', + templateUrl: '../statistics-page/statistics-page.component.html', + styleUrls: ['./site-statistics-page.component.scss'] +}) +export class SiteStatisticsPageComponent extends StatisticsPageComponent { + + /** + * The report types to show on this statistics page. + */ + types: string[] = [ + 'TotalVisits', + ]; + + constructor( + protected route: ActivatedRoute, + protected router: Router, + protected usageReportService: UsageReportService, + protected nameService: DSONameService, + protected siteService: SiteDataService, + ) { + super( + route, + router, + usageReportService, + nameService, + ); + } + + protected getScope$() { + return this.siteService.find(); + } + + protected getReports$() { + return this.scope$.pipe( + switchMap((scope) => + this.usageReportService.searchStatistics(scope._links.self.href, 0, 10), + ), + ); + } +} diff --git a/src/app/statistics-page/statistics-page-routing.module.ts b/src/app/statistics-page/statistics-page-routing.module.ts new file mode 100644 index 0000000000..44943b94d2 --- /dev/null +++ b/src/app/statistics-page/statistics-page-routing.module.ts @@ -0,0 +1,81 @@ +import { NgModule } from '@angular/core'; +import { RouterModule } from '@angular/router'; +import { I18nBreadcrumbResolver } from '../core/breadcrumbs/i18n-breadcrumb.resolver'; +import { I18nBreadcrumbsService } from '../core/breadcrumbs/i18n-breadcrumbs.service'; +import { StatisticsPageModule } from './statistics-page.module'; +import { SiteStatisticsPageComponent } from './site-statistics-page/site-statistics-page.component'; +import { ItemPageResolver } from '../+item-page/item-page.resolver'; +import { ItemStatisticsPageComponent } from './item-statistics-page/item-statistics-page.component'; +import { CollectionPageResolver } from '../+collection-page/collection-page.resolver'; +import { CollectionStatisticsPageComponent } from './collection-statistics-page/collection-statistics-page.component'; +import { CommunityPageResolver } from '../+community-page/community-page.resolver'; +import { CommunityStatisticsPageComponent } from './community-statistics-page/community-statistics-page.component'; + +@NgModule({ + imports: [ + StatisticsPageModule, + RouterModule.forChild([ + { + path: '', + resolve: { + breadcrumb: I18nBreadcrumbResolver + }, + data: { + title: 'statistics.title', + breadcrumbKey: 'statistics' + }, + children: [ + { + path: '', + component: SiteStatisticsPageComponent, + }, + ] + }, + { + path: `items/:id`, + resolve: { + scope: ItemPageResolver, + breadcrumb: I18nBreadcrumbResolver + }, + data: { + title: 'statistics.title', + breadcrumbKey: 'statistics' + }, + component: ItemStatisticsPageComponent, + }, + { + path: `collections/:id`, + resolve: { + scope: CollectionPageResolver, + breadcrumb: I18nBreadcrumbResolver + }, + data: { + title: 'statistics.title', + breadcrumbKey: 'statistics' + }, + component: CollectionStatisticsPageComponent, + }, + { + path: `communities/:id`, + resolve: { + scope: CommunityPageResolver, + breadcrumb: I18nBreadcrumbResolver + }, + data: { + title: 'statistics.title', + breadcrumbKey: 'statistics' + }, + component: CommunityStatisticsPageComponent, + }, + ] + ) + ], + providers: [ + I18nBreadcrumbResolver, + I18nBreadcrumbsService, + CollectionPageResolver, + CommunityPageResolver, + ] +}) +export class StatisticsPageRoutingModule { +} diff --git a/src/app/statistics-page/statistics-page.module.ts b/src/app/statistics-page/statistics-page.module.ts new file mode 100644 index 0000000000..068ded63aa --- /dev/null +++ b/src/app/statistics-page/statistics-page.module.ts @@ -0,0 +1,39 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { CoreModule } from '../core/core.module'; +import { SharedModule } from '../shared/shared.module'; +import { StatisticsModule } from '../statistics/statistics.module'; +import { UsageReportService } from '../core/statistics/usage-report-data.service'; +import { SiteStatisticsPageComponent } from './site-statistics-page/site-statistics-page.component'; +import { StatisticsTableComponent } from './statistics-table/statistics-table.component'; +import { ItemStatisticsPageComponent } from './item-statistics-page/item-statistics-page.component'; +import { CollectionStatisticsPageComponent } from './collection-statistics-page/collection-statistics-page.component'; +import { CommunityStatisticsPageComponent } from './community-statistics-page/community-statistics-page.component'; + +const components = [ + StatisticsTableComponent, + SiteStatisticsPageComponent, + ItemStatisticsPageComponent, + CollectionStatisticsPageComponent, + CommunityStatisticsPageComponent, +]; + +@NgModule({ + imports: [ + CommonModule, + SharedModule, + CoreModule.forRoot(), + StatisticsModule.forRoot() + ], + declarations: components, + providers: [ + UsageReportService, + ], + exports: components +}) + +/** + * This module handles all components and pipes that are necessary for the search page + */ +export class StatisticsPageModule { +} diff --git a/src/app/statistics-page/statistics-page/statistics-page.component.html b/src/app/statistics-page/statistics-page/statistics-page.component.html new file mode 100644 index 0000000000..5cf1e9c8b5 --- /dev/null +++ b/src/app/statistics-page/statistics-page/statistics-page.component.html @@ -0,0 +1,29 @@ +
+ + +

+ {{ 'statistics.header' | translate: { scope: getName(scope) } }} +

+
+ + + + + + + + + + +
+ {{ 'statistics.page.no-data' | translate }} +
+
+ +
+ +
diff --git a/src/app/statistics-page/statistics-page/statistics-page.component.scss b/src/app/statistics-page/statistics-page/statistics-page.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/statistics-page/statistics-page/statistics-page.component.ts b/src/app/statistics-page/statistics-page/statistics-page.component.ts new file mode 100644 index 0000000000..e034a35dca --- /dev/null +++ b/src/app/statistics-page/statistics-page/statistics-page.component.ts @@ -0,0 +1,84 @@ +import { OnInit } from '@angular/core'; +import { combineLatest, Observable } from 'rxjs'; +import { UsageReportService } from '../../core/statistics/usage-report-data.service'; +import { map, switchMap } from 'rxjs/operators'; +import { UsageReport } from '../../core/statistics/models/usage-report.model'; +import { RemoteData } from '../../core/data/remote-data'; +import { getRemoteDataPayload, getSucceededRemoteData, redirectToPageNotFoundOn404 } from '../../core/shared/operators'; +import { DSpaceObject } from '../../core/shared/dspace-object.model'; +import { ActivatedRoute, Router } from '@angular/router'; +import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; + +/** + * Class representing an abstract statistics page component. + */ +export abstract class StatisticsPageComponent implements OnInit { + + /** + * The scope dso for this statistics page, as an Observable. + */ + scope$: Observable; + + /** + * The report types to show on this statistics page. + */ + types: string[]; + + /** + * The usage report types to show on this statistics page, as an Observable list. + */ + reports$: Observable; + + hasData$: Observable; + + constructor( + protected route: ActivatedRoute, + protected router: Router, + protected usageReportService: UsageReportService, + protected nameService: DSONameService, + ) { + } + + ngOnInit(): void { + this.scope$ = this.getScope$(); + this.reports$ = this.getReports$(); + this.hasData$ = this.reports$.pipe( + map((reports) => reports.some( + (report) => report.points.length > 0 + )), + ); + } + + /** + * Get the scope dso for this statistics page, as an Observable. + */ + protected getScope$(): Observable { + return this.route.data.pipe( + map((data) => data.scope as RemoteData), + redirectToPageNotFoundOn404(this.router), + getSucceededRemoteData(), + getRemoteDataPayload(), + ); + } + + /** + * Get the usage reports for this statistics page, as an Observable list + */ + protected getReports$(): Observable { + return this.scope$.pipe( + switchMap((scope) => + combineLatest( + this.types.map((type) => this.usageReportService.getStatistic(scope.id, type)) + ), + ), + ); + } + + /** + * Get the name of the scope dso. + * @param scope the scope dso to get the name for + */ + getName(scope: DSpaceObject): string { + return this.nameService.getName(scope); + } +} diff --git a/src/app/statistics-page/statistics-table/statistics-table.component.html b/src/app/statistics-page/statistics-table/statistics-table.component.html new file mode 100644 index 0000000000..3ecd256812 --- /dev/null +++ b/src/app/statistics-page/statistics-table/statistics-table.component.html @@ -0,0 +1,36 @@ +
+ +

+ {{ 'statistics.table.title.' + report.reportType | translate }} +

+ + + + + + + + + + + + + + + + + +
+ {{ header }} +
+ {{ getLabel(point) | async }} + + {{ point.values[header] }} +
+ +
diff --git a/src/app/statistics-page/statistics-table/statistics-table.component.scss b/src/app/statistics-page/statistics-table/statistics-table.component.scss new file mode 100644 index 0000000000..4e173c040a --- /dev/null +++ b/src/app/statistics-page/statistics-table/statistics-table.component.scss @@ -0,0 +1,8 @@ +th, td { + padding: 0.5rem; +} + +td { + width: 50px; + max-width: 50px; +} diff --git a/src/app/statistics-page/statistics-table/statistics-table.component.spec.ts b/src/app/statistics-page/statistics-table/statistics-table.component.spec.ts new file mode 100644 index 0000000000..f22adea37d --- /dev/null +++ b/src/app/statistics-page/statistics-table/statistics-table.component.spec.ts @@ -0,0 +1,98 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { StatisticsTableComponent } from './statistics-table.component'; +import { UsageReport } from '../../core/statistics/models/usage-report.model'; +import { DebugElement } from '@angular/core'; +import { By } from '@angular/platform-browser'; +import { TranslateModule } from '@ngx-translate/core'; +import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service'; +import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; + +describe('StatisticsTableComponent', () => { + + let component: StatisticsTableComponent; + let de: DebugElement; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + TranslateModule.forRoot(), + ], + declarations: [ + StatisticsTableComponent, + ], + providers: [ + { provide: DSpaceObjectDataService, useValue: {} }, + { provide: DSONameService, useValue: {} }, + ], + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(StatisticsTableComponent); + component = fixture.componentInstance; + de = fixture.debugElement; + component.report = Object.assign(new UsageReport(), { + points: [], + }); + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + describe('when the storage report is empty', () => { + + it ('should not display a table', () => { + expect(de.query(By.css('table'))).toBeNull(); + }); + }); + + describe('when the storage report has data', () => { + + beforeEach(() => { + component.report = Object.assign(new UsageReport(), { + points: [ + { + id: 'item_1', + values: { + views: 7, + downloads: 4, + }, + }, + { + id: 'item_2', + values: { + views: 8, + downloads: 8, + }, + } + ] + }); + component.ngOnInit(); + fixture.detectChanges(); + }); + + it ('should display a table with the correct data', () => { + + expect(de.query(By.css('table'))).toBeTruthy(); + + expect(de.query(By.css('th.views-header')).nativeElement.innerText) + .toEqual('views'); + expect(de.query(By.css('th.downloads-header')).nativeElement.innerText) + .toEqual('downloads'); + + expect(de.query(By.css('td.item_1-views-data')).nativeElement.innerText) + .toEqual('7'); + expect(de.query(By.css('td.item_1-downloads-data')).nativeElement.innerText) + .toEqual('4'); + expect(de.query(By.css('td.item_2-views-data')).nativeElement.innerText) + .toEqual('8'); + expect(de.query(By.css('td.item_2-downloads-data')).nativeElement.innerText) + .toEqual('8'); + }); + }); +}); diff --git a/src/app/statistics-page/statistics-table/statistics-table.component.ts b/src/app/statistics-page/statistics-table/statistics-table.component.ts new file mode 100644 index 0000000000..8924fb8a7c --- /dev/null +++ b/src/app/statistics-page/statistics-table/statistics-table.component.ts @@ -0,0 +1,67 @@ +import { Component, Input, OnInit } from '@angular/core'; +import { Point, UsageReport } from '../../core/statistics/models/usage-report.model'; +import { Observable, of } from 'rxjs'; +import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; +import { map } from 'rxjs/operators'; +import { getRemoteDataPayload, getSucceededRemoteData } from '../../core/shared/operators'; +import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service'; + +/** + * Component representing a statistics table for a given usage report. + */ +@Component({ + selector: 'ds-statistics-table', + templateUrl: './statistics-table.component.html', + styleUrls: ['./statistics-table.component.scss'] +}) +export class StatisticsTableComponent implements OnInit { + + /** + * The usage report to display a statistics table for + */ + @Input() + report: UsageReport; + + /** + * Boolean indicating whether the usage report has data + */ + hasData: boolean; + + /** + * The table headers + */ + headers: string[]; + + constructor( + protected dsoService: DSpaceObjectDataService, + protected nameService: DSONameService, + ) { + + } + + ngOnInit() { + this.hasData = this.report.points.length > 0; + if (this.hasData) { + this.headers = Object.keys(this.report.points[0].values); + } + } + + /** + * Get the row label to display for a statistics point. + * @param point the statistics point to get the label for + */ + getLabel(point: Point): Observable { + switch (this.report.reportType) { + case 'TotalVisits': + return this.dsoService.findById(point.id).pipe( + getSucceededRemoteData(), + getRemoteDataPayload(), + map((item) => this.nameService.getName(item)), + ); + case 'TopCities': + case 'topCountries': + default: + return of(point.label); + } + } +} diff --git a/src/assets/i18n/en.json5 b/src/assets/i18n/en.json5 index 600ce0aed1..84d874388c 100644 --- a/src/assets/i18n/en.json5 +++ b/src/assets/i18n/en.json5 @@ -258,6 +258,10 @@ "admin.access-control.epeople.form.notification.edited.failure": "Failed to edit EPerson \"{{name}}\"", + "admin.access-control.epeople.form.notification.deleted.success": "Successfully deleted EPerson \"{{name}}\"", + + "admin.access-control.epeople.form.notification.deleted.failure": "Failed to delete EPerson \"{{name}}\"", + "admin.access-control.epeople.form.groupsEPersonIsMemberOf": "Member of these groups:", "admin.access-control.epeople.form.table.id": "ID", @@ -1065,6 +1069,13 @@ "confirmation-modal.export-metadata.confirm": "Export", + "confirmation-modal.delete-eperson.header": "Delete EPerson \"{{ dsoName }}\"", + + "confirmation-modal.delete-eperson.info": "Are you sure you want to delete EPerson \"{{ dsoName }}\"", + + "confirmation-modal.delete-eperson.cancel": "Cancel", + + "confirmation-modal.delete-eperson.confirm": "Delete", "error.bitstream": "Error fetching bitstream", @@ -1107,6 +1118,10 @@ + "file-section.error.header": "Error obtaining files for this item", + + + "footer.copyright": "copyright © 2002-{{ year }}", "footer.link.dspace": "DSpace software", @@ -1437,6 +1452,8 @@ "item.edit.metadata.notifications.discarded.title": "Changed discarded", + "item.edit.metadata.notifications.error.title": "An error occurred", + "item.edit.metadata.notifications.invalid.content": "Your changes were not saved. Please make sure all fields are valid before you save.", "item.edit.metadata.notifications.invalid.title": "Metadata invalid", @@ -2846,6 +2863,30 @@ + "statistics.title": "Statistics", + + "statistics.header": "Statistics for {{ scope }}", + + "statistics.breadcrumbs": "Statistics", + + "statistics.page.no-data": "No data available", + + "statistics.table.no-data": "No data available", + + "statistics.table.title.TotalVisits": "Total visits", + + "statistics.table.title.TotalVisitsPerMonth": "Total visits per month", + + "statistics.table.title.TotalDownloads": "File Visits", + + "statistics.table.title.TopCountries": "Top country views", + + "statistics.table.title.TopCities": "Top city views", + + "statistics.table.header.views": "Views", + + + "submission.edit.title": "Edit Submission", "submission.general.cannot_submit": "You have not the privilege to make a new submission.", diff --git a/src/config/global-config.interface.ts b/src/config/global-config.interface.ts index acef3404eb..07ee4ca444 100644 --- a/src/config/global-config.interface.ts +++ b/src/config/global-config.interface.ts @@ -31,4 +31,5 @@ export interface GlobalConfig extends Config { item: ItemPageConfig; collection: CollectionPageConfig; theme: Theme; + rewriteDownloadUrls: boolean; } diff --git a/src/environments/environment.common.ts b/src/environments/environment.common.ts index 32ae2f54b0..4f73339690 100644 --- a/src/environments/environment.common.ts +++ b/src/environments/environment.common.ts @@ -16,13 +16,12 @@ export const environment: GlobalConfig = { }, // The REST API server settings. // NOTE: these must be "synced" with the 'dspace.server.url' setting in your backend's local.cfg. - // The 'nameSpace' must always end in "/api" as that's the subpath of the REST API in the backend. rest: { ssl: true, host: 'dspace7.4science.cloud', port: 443, // NOTE: Space is capitalized because 'namespace' is a reserved string in TypeScript - nameSpace: '/server/api', + nameSpace: '/server', }, // Caching settings cache: { @@ -216,4 +215,6 @@ export const environment: GlobalConfig = { theme: { name: 'default', }, + // Whether the UI should rewrite file download URLs to match its domain. Only necessary to enable when running UI and REST API on separate domains + rewriteDownloadUrls: false, }; diff --git a/src/environments/environment.template.ts b/src/environments/environment.template.ts index d0282ad607..6f6510e1ab 100644 --- a/src/environments/environment.template.ts +++ b/src/environments/environment.template.ts @@ -4,7 +4,7 @@ export const environment = { * e.g. * rest: { * host: 'rest.api', - * nameSpace: '/rest/api', + * nameSpace: '/rest', * } */ };