diff --git a/.codecov.yml b/.codecov.yml new file mode 100644 index 0000000000..3dba42ef37 --- /dev/null +++ b/.codecov.yml @@ -0,0 +1,29 @@ +# DSpace configuration for Codecov.io coverage reports +# These override the default YAML settings at +# https://docs.codecov.io/docs/codecov-yaml#section-default-yaml +# Can be validated via instructions at: +# https://docs.codecov.io/docs/codecov-yaml#validate-your-repository-yaml + +# 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 +# However, this same info is available from the Codecov checks in the PR's +# "Checks" tab in GitHub. So, the comment is unnecessary. +comment: false diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index b4063b0550..be15b0a507 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,7 +1,7 @@ ## References _Add references/links to any related issues or PRs. These may include:_ -* Fixes [GitHub issue](https://github.com/DSpace/dspace-angular/issues), if any -* Requires [REST API PR](https://github.com/DSpace/DSpace/pulls), if any +* Fixes #[issue-number] +* Requires DSpace/DSpace#[pr-number] (if a REST API PR is required to test this) ## Description Short summary of changes (1-2 sentences). @@ -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 13a159bfd0..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: @@ -60,7 +60,7 @@ after_script: # Shutdown docker after everything runs - docker-compose -f ./docker/docker-compose-travis.yml down -# After a successful build and test (see 'script'), send code coverage reports to coveralls.io -# These code coverage reports are generated by the coveralls node module in our package.json +# After a successful build and test (see 'script'), send code coverage reports to codecov.io +# These code coverage reports are generated by the codecov node module in our package.json after_success: - - cat coverage/dspace-angular/lcov.info | ./node_modules/coveralls/bin/coveralls.js + - codecov diff --git a/README.md b/README.md index 4addff3e1a..fb0c3076af 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -[![Build Status](https://travis-ci.com/DSpace/dspace-angular.svg?branch=main)](https://travis-ci.com/DSpace/dspace-angular) [![Coverage Status](https://coveralls.io/repos/github/DSpace/dspace-angular/badge.svg?branch=main)](https://coveralls.io/github/DSpace/dspace-angular?branch=main) [![Universal Angular](https://img.shields.io/badge/universal-angular2-brightgreen.svg?style=flat)](https://github.com/angular/universal) +[![Build Status](https://travis-ci.com/DSpace/dspace-angular.svg?branch=main)](https://travis-ci.com/DSpace/dspace-angular) [![Coverage Status](https://codecov.io/gh/DSpace/dspace-angular/branch/main/graph/badge.svg)](https://codecov.io/gh/DSpace/dspace-angular) [![Universal Angular](https://img.shields.io/badge/universal-angular2-brightgreen.svg?style=flat)](https://github.com/angular/universal) dspace-angular ============== 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/package.json b/package.json index 52afb7c4c0..ae4abd2e41 100644 --- a/package.json +++ b/package.json @@ -97,6 +97,7 @@ "json5": "^2.1.0", "jsonschema": "1.2.2", "jwt-decode": "^2.2.0", + "klaro": "^0.6.3", "moment": "^2.22.1", "morgan": "^1.9.1", "ng-mocks": "^8.1.0", @@ -136,10 +137,10 @@ "@types/js-cookie": "2.1.0", "@types/lodash": "^4.14.110", "@types/node": "11.15.3", + "codecov": "^3.7.2", "codelyzer": "^5.0.0", "compression-webpack-plugin": "^3.0.1", "copy-webpack-plugin": "^5.1.1", - "coveralls": "^3.0.0", "css-loader": "3.4.0", "cssnano": "^4.1.10", "deep-freeze": "0.0.1", 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/server.ts b/server.ts index 478dd063f6..c640a95ef4 100644 --- a/server.ts +++ b/server.ts @@ -15,7 +15,6 @@ * import for `ngExpressEngine`. */ -import 'zone.js/dist/zone-node'; import 'reflect-metadata'; import 'rxjs'; @@ -34,6 +33,7 @@ import { enableProdMode, NgModuleFactory, Type } from '@angular/core'; import { REQUEST, RESPONSE } from '@nguniversal/express-engine/tokens'; import { environment } from './src/environments/environment'; import { createProxyMiddleware } from 'http-proxy-middleware'; +import { hasValue, hasNoValue } from './src/app/shared/empty.util'; /* * Set path for the browser application's dist folder @@ -99,7 +99,6 @@ app.engine('html', (_, options, callback) => /* * Register the view engines for html and ejs */ -app.set('view engine', 'ejs'); app.set('view engine', 'html'); /* @@ -131,56 +130,31 @@ app.get('*.*', cacheControl, express.static(DIST_FOLDER, { index: false })); * The callback function to serve server side angular */ function ngApp(req, res) { - // Object to be set to window.dspace when CSR is used - // this allows us to pass the info in the original request - // to the dspace7-angular instance running in the client's browser - const dspace = { - originalRequest: { - headers: req.headers, - body: req.body, - method: req.method, - params: req.params, - reportProgress: req.reportProgress, - withCredentials: req.withCredentials, - responseType: req.responseType, - urlWithParams: req.urlWithParams - } - }; - - // callback function for the case when SSR throws an error. - function onHandleError(parentZoneDelegate, currentZone, targetZone, error) { - if (!res._headerSent) { - console.warn('Error in SSR, serving for direct CSR. Error details : ', error); - res.sendFile('index.csr.ejs', { - root: DIST_FOLDER, - scripts: `` - }); - } - } - if (environment.universal.preboot) { - // If preboot is enabled, create a new zone for SSR, and - // register the error handler for when it throws an error - Zone.current.fork({ name: 'CSR fallback', onHandleError }).run(() => { - res.render(DIST_FOLDER + '/index.html', { - req, - res, - preboot: environment.universal.preboot, - async: environment.universal.async, - time: environment.universal.time, - baseUrl: environment.ui.nameSpace, - originUrl: environment.ui.baseUrl, - requestUrl: req.originalUrl - }); - }); + res.render(DIST_FOLDER + '/index.html', { + req, + res, + preboot: environment.universal.preboot, + async: environment.universal.async, + time: environment.universal.time, + baseUrl: environment.ui.nameSpace, + originUrl: environment.ui.baseUrl, + requestUrl: req.originalUrl + }, (err, data) => { + if (hasNoValue(err) && hasValue(data)) { + res.send(data); + } else { + console.warn('Error in SSR, serving for direct CSR.'); + if (hasValue(err)) { + console.warn('Error details : ', err); + } + res.sendFile(DIST_FOLDER + '/index.html'); + } + }) } else { - // If preboot is disabled, just serve the client side ejs template and pass it the required - // variables + // If preboot is disabled, just serve the client console.log('Universal off, serving for direct CSR'); - res.render('index-csr.ejs', { - root: DIST_FOLDER, - scripts: `` - }); + res.sendFile(DIST_FOLDER + '/index.html'); } } 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 @@ -
- - -
-
- +
+
diff --git a/src/app/+item-page/edit-item-page/item-status/item-status.component.spec.ts b/src/app/+item-page/edit-item-page/item-status/item-status.component.spec.ts index abb2839551..9c28f097a4 100644 --- a/src/app/+item-page/edit-item-page/item-status/item-status.component.spec.ts +++ b/src/app/+item-page/edit-item-page/item-status/item-status.component.spec.ts @@ -12,6 +12,7 @@ import { By } from '@angular/platform-browser'; import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; import { of as observableOf } from 'rxjs'; import { createSuccessfulRemoteDataObject } from '../../../shared/remote-data.utils'; +import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service'; describe('ItemStatusComponent', () => { let comp: ItemStatusComponent; @@ -20,7 +21,10 @@ describe('ItemStatusComponent', () => { const mockItem = Object.assign(new Item(), { id: 'fake-id', handle: 'fake/handle', - lastModified: '2018' + lastModified: '2018', + _links: { + self: { href: 'test-item-selflink' } + } }); const itemPageUrl = `items/${mockItem.id}`; @@ -31,13 +35,20 @@ describe('ItemStatusComponent', () => { } }; + let authorizationService: AuthorizationDataService; + beforeEach(async(() => { + authorizationService = jasmine.createSpyObj('authorizationService', { + isAuthorized: observableOf(true) + }); + TestBed.configureTestingModule({ imports: [CommonModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule], declarations: [ItemStatusComponent], providers: [ { provide: ActivatedRoute, useValue: routeStub }, - { provide: HostWindowService, useValue: new HostWindowServiceStub(0) } + { provide: HostWindowService, useValue: new HostWindowServiceStub(0) }, + { provide: AuthorizationDataService, useValue: authorizationService }, ], schemas: [CUSTOM_ELEMENTS_SCHEMA] }).compileComponents(); })); diff --git a/src/app/+item-page/edit-item-page/item-status/item-status.component.ts b/src/app/+item-page/edit-item-page/item-status/item-status.component.ts index 2696c90353..dd043330d6 100644 --- a/src/app/+item-page/edit-item-page/item-status/item-status.component.ts +++ b/src/app/+item-page/edit-item-page/item-status/item-status.component.ts @@ -3,15 +3,19 @@ import { fadeIn, fadeInOut } from '../../../shared/animations/fade'; import { Item } from '../../../core/shared/item.model'; import { ActivatedRoute } from '@angular/router'; import { ItemOperation } from '../item-operation/itemOperation.model'; -import { first, map } from 'rxjs/operators'; +import { distinctUntilChanged, first, map } from 'rxjs/operators'; import { Observable } from 'rxjs'; import { RemoteData } from '../../../core/data/remote-data'; import { getItemEditRoute, getItemPageRoute } from '../../item-page-routing-paths'; +import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service'; +import { FeatureID } from '../../../core/data/feature-authorization/feature-id'; +import { hasValue } from '../../../shared/empty.util'; +import { BehaviorSubject } from 'rxjs/internal/BehaviorSubject'; @Component({ selector: 'ds-item-status', templateUrl: './item-status.component.html', - changeDetection: ChangeDetectionStrategy.OnPush, + changeDetection: ChangeDetectionStrategy.Default, animations: [ fadeIn, fadeInOut @@ -40,14 +44,15 @@ export class ItemStatusComponent implements OnInit { * The possible actions that can be performed on the item * key: id value: url to action's component */ - operations: ItemOperation[]; + operations$: BehaviorSubject = new BehaviorSubject([]); /** * The keys of the actions (to loop over) */ actionsKeys; - constructor(private route: ActivatedRoute) { + constructor(private route: ActivatedRoute, + private authorizationService: AuthorizationDataService) { } ngOnInit(): void { @@ -67,21 +72,43 @@ export class ItemStatusComponent implements OnInit { i18n example: 'item.edit.tabs.status.buttons..label' The value is supposed to be a href for the button */ - this.operations = []; - this.operations.push(new ItemOperation('authorizations', this.getCurrentUrl(item) + '/authorizations')); - this.operations.push(new ItemOperation('mappedCollections', this.getCurrentUrl(item) + '/mapper')); - if (item.isWithdrawn) { - this.operations.push(new ItemOperation('reinstate', this.getCurrentUrl(item) + '/reinstate')); - } else { - this.operations.push(new ItemOperation('withdraw', this.getCurrentUrl(item) + '/withdraw')); - } + const operations = []; + operations.push(new ItemOperation('authorizations', this.getCurrentUrl(item) + '/authorizations')); + operations.push(new ItemOperation('mappedCollections', this.getCurrentUrl(item) + '/mapper')); + operations.push(undefined); + // Store the index of the "withdraw" or "reinstate" operation, because it's added asynchronously + const indexOfWithdrawReinstate = operations.length - 1; if (item.isDiscoverable) { - this.operations.push(new ItemOperation('private', this.getCurrentUrl(item) + '/private')); + operations.push(new ItemOperation('private', this.getCurrentUrl(item) + '/private')); } else { - this.operations.push(new ItemOperation('public', this.getCurrentUrl(item) + '/public')); + operations.push(new ItemOperation('public', this.getCurrentUrl(item) + '/public')); + } + operations.push(new ItemOperation('delete', this.getCurrentUrl(item) + '/delete')); + operations.push(new ItemOperation('move', this.getCurrentUrl(item) + '/move')); + + this.operations$.next(operations); + + if (item.isWithdrawn) { + this.authorizationService.isAuthorized(FeatureID.ReinstateItem, item.self).pipe(distinctUntilChanged()).subscribe((authorized) => { + const newOperations = [...this.operations$.value]; + if (authorized) { + newOperations[indexOfWithdrawReinstate] = new ItemOperation('reinstate', this.getCurrentUrl(item) + '/reinstate'); + } else { + newOperations[indexOfWithdrawReinstate] = undefined; + } + this.operations$.next(newOperations); + }); + } else { + this.authorizationService.isAuthorized(FeatureID.WithdrawItem, item.self).pipe(distinctUntilChanged()).subscribe((authorized) => { + const newOperations = [...this.operations$.value]; + if (authorized) { + newOperations[indexOfWithdrawReinstate] = new ItemOperation('withdraw', this.getCurrentUrl(item) + '/withdraw'); + } else { + newOperations[indexOfWithdrawReinstate] = undefined; + } + this.operations$.next(newOperations); + }); } - this.operations.push(new ItemOperation('delete', this.getCurrentUrl(item) + '/delete')); - this.operations.push(new ItemOperation('move', this.getCurrentUrl(item) + '/move')); }); } @@ -102,4 +129,8 @@ export class ItemStatusComponent implements OnInit { return getItemEditRoute(item.id); } + trackOperation(index: number, operation: ItemOperation) { + return hasValue(operation) ? operation.operationKey : undefined; + } + } diff --git a/src/app/+item-page/full/field-components/file-section/full-file-section.component.html b/src/app/+item-page/full/field-components/file-section/full-file-section.component.html index c6f9f8e944..00218b66d1 100644 --- a/src/app/+item-page/full/field-components/file-section/full-file-section.component.html +++ b/src/app/+item-page/full/field-components/file-section/full-file-section.component.html @@ -1,87 +1,87 @@
-
{{"item.page.filesection.original.bundle" | translate}}
- +
+
{{"item.page.filesection.original.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}} - -
-
-
+ +
-
-
{{"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-administrator.guard.ts b/src/app/+item-page/item-page-administrator.guard.ts new file mode 100644 index 0000000000..eae76348ad --- /dev/null +++ b/src/app/+item-page/item-page-administrator.guard.ts @@ -0,0 +1,30 @@ +import { Injectable } from '@angular/core'; +import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from '@angular/router'; +import { AuthorizationDataService } from '../core/data/feature-authorization/authorization-data.service'; +import { ItemPageResolver } from './item-page.resolver'; +import { Item } from '../core/shared/item.model'; +import { DsoPageFeatureGuard } from '../core/data/feature-authorization/feature-authorization-guard/dso-page-feature.guard'; +import { Observable } from 'rxjs/internal/Observable'; +import { FeatureID } from '../core/data/feature-authorization/feature-id'; +import { of as observableOf } from 'rxjs'; + +@Injectable({ + providedIn: 'root' +}) +/** + * Guard for preventing unauthorized access to certain {@link Item} pages requiring administrator rights + */ +export class ItemPageAdministratorGuard extends DsoPageFeatureGuard { + constructor(protected resolver: ItemPageResolver, + protected authorizationService: AuthorizationDataService, + protected router: Router) { + super(resolver, authorizationService, router); + } + + /** + * Check administrator authorization rights + */ + getFeatureID(next: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { + return observableOf(FeatureID.AdministratorOf); + } +} diff --git a/src/app/+item-page/item-page-routing.module.ts b/src/app/+item-page/item-page-routing.module.ts index 088aab326d..e4f17326a4 100644 --- a/src/app/+item-page/item-page-routing.module.ts +++ b/src/app/+item-page/item-page-routing.module.ts @@ -10,6 +10,9 @@ import { DSOBreadcrumbsService } from '../core/breadcrumbs/dso-breadcrumbs.servi import { LinkService } from '../core/cache/builders/link.service'; import { UploadBitstreamComponent } from './bitstreams/upload/upload-bitstream.component'; import { UPLOAD_BITSTREAM_PATH, ITEM_EDIT_PATH } from './item-page-routing-paths'; +import { ItemPageAdministratorGuard } from './item-page-administrator.guard'; +import { MenuItemType } from '../shared/menu/initial-menus-state'; +import { LinkMenuItemModel } from '../shared/menu/menu-item/models/link.model'; @NgModule({ imports: [ @@ -34,7 +37,7 @@ import { UPLOAD_BITSTREAM_PATH, ITEM_EDIT_PATH } from './item-page-routing-paths { path: ITEM_EDIT_PATH, loadChildren: './edit-item-page/edit-item-page.module#EditItemPageModule', - canActivate: [AuthenticatedGuard] + canActivate: [ItemPageAdministratorGuard] }, { path: UPLOAD_BITSTREAM_PATH, @@ -42,6 +45,20 @@ import { UPLOAD_BITSTREAM_PATH, ITEM_EDIT_PATH } from './item-page-routing-paths canActivate: [AuthenticatedGuard] } ], + data: { + menu: { + public: [{ + id: 'statistics_item_:id', + active: true, + visible: true, + model: { + type: MenuItemType.LINK, + text: 'menu.section.statistics', + link: 'statistics/items/:id/', + } as LinkMenuItemModel, + }], + }, + }, } ]) ], @@ -49,7 +66,8 @@ import { UPLOAD_BITSTREAM_PATH, ITEM_EDIT_PATH } from './item-page-routing-paths ItemPageResolver, ItemBreadcrumbResolver, DSOBreadcrumbsService, - LinkService + LinkService, + ItemPageAdministratorGuard ] }) diff --git a/src/app/+item-page/simple/field-components/file-section/file-section.component.spec.ts b/src/app/+item-page/simple/field-components/file-section/file-section.component.spec.ts index 1b7fa75ce5..330aaadfe0 100644 --- a/src/app/+item-page/simple/field-components/file-section/file-section.component.spec.ts +++ b/src/app/+item-page/simple/field-components/file-section/file-section.component.spec.ts @@ -15,6 +15,8 @@ import {FileSizePipe} from '../../../../shared/utils/file-size-pipe'; import {PageInfo} from '../../../../core/shared/page-info.model'; import {MetadataFieldWrapperComponent} from '../../../field-components/metadata-field-wrapper/metadata-field-wrapper.component'; import {createPaginatedList} from '../../../../shared/testing/utils.test'; +import { NotificationsService } from '../../../../shared/notifications/notifications.service'; +import { NotificationsServiceStub } from '../../../../shared/testing/notifications-service.stub'; describe('FileSectionComponent', () => { let comp: FileSectionComponent; @@ -62,7 +64,8 @@ describe('FileSectionComponent', () => { }), BrowserAnimationsModule], declarations: [FileSectionComponent, VarDirective, FileSizePipe, MetadataFieldWrapperComponent], providers: [ - {provide: BitstreamDataService, useValue: bitstreamDataService} + {provide: BitstreamDataService, useValue: bitstreamDataService}, + {provide: NotificationsService, useValue: new NotificationsServiceStub()} ], schemas: [NO_ERRORS_SCHEMA] diff --git a/src/app/+item-page/simple/field-components/file-section/file-section.component.ts b/src/app/+item-page/simple/field-components/file-section/file-section.component.ts index 25b214e200..4b60691e09 100644 --- a/src/app/+item-page/simple/field-components/file-section/file-section.component.ts +++ b/src/app/+item-page/simple/field-components/file-section/file-section.component.ts @@ -4,10 +4,12 @@ import { BitstreamDataService } from '../../../../core/data/bitstream-data.servi import { Bitstream } from '../../../../core/shared/bitstream.model'; import { Item } from '../../../../core/shared/item.model'; -import { filter, takeWhile } from 'rxjs/operators'; +import { filter, take } from 'rxjs/operators'; import { RemoteData } from '../../../../core/data/remote-data'; -import { hasNoValue, hasValue } from '../../../../shared/empty.util'; +import { hasValue } from '../../../../shared/empty.util'; import { PaginatedList } from '../../../../core/data/paginated-list'; +import { NotificationsService } from '../../../../shared/notifications/notifications.service'; +import { TranslateService } from '@ngx-translate/core'; /** * This component renders the file section of the item @@ -36,7 +38,9 @@ export class FileSectionComponent implements OnInit { pageSize = 5; constructor( - protected bitstreamDataService: BitstreamDataService + protected bitstreamDataService: BitstreamDataService, + protected notificationsService: NotificationsService, + protected translateService: TranslateService ) { } @@ -58,14 +62,21 @@ export class FileSectionComponent implements OnInit { } else { this.currentPage++; } - this.bitstreamDataService.findAllByItemAndBundleName(this.item, 'ORIGINAL', { currentPage: this.currentPage, elementsPerPage: this.pageSize }).pipe( - filter((bitstreamsRD: RemoteData>) => hasValue(bitstreamsRD)), - takeWhile((bitstreamsRD: RemoteData>) => hasNoValue(bitstreamsRD.payload) && hasNoValue(bitstreamsRD.error), true) + this.bitstreamDataService.findAllByItemAndBundleName(this.item, 'ORIGINAL', { + currentPage: this.currentPage, + elementsPerPage: this.pageSize + }).pipe( + filter((bitstreamsRD: RemoteData>) => hasValue(bitstreamsRD) && (hasValue(bitstreamsRD.error) || hasValue(bitstreamsRD.payload))), + take(1), ).subscribe((bitstreamsRD: RemoteData>) => { - const current: Bitstream[] = this.bitstreams$.getValue(); - this.bitstreams$.next([...current, ...bitstreamsRD.payload.page]); - this.isLoading = false; - this.isLastPage = this.currentPage === bitstreamsRD.payload.totalPages; + if (bitstreamsRD.error) { + this.notificationsService.error(this.translateService.get('file-section.error.header'), `${bitstreamsRD.error.statusCode} ${bitstreamsRD.error.message}`); + } else if (hasValue(bitstreamsRD.payload)) { + const current: Bitstream[] = this.bitstreams$.getValue(); + this.bitstreams$.next([...current, ...bitstreamsRD.payload.page]); + this.isLoading = false; + this.isLastPage = this.currentPage === bitstreamsRD.payload.totalPages; + } }); } } diff --git a/src/app/+item-page/simple/item-page.component.ts b/src/app/+item-page/simple/item-page.component.ts index 10deef23e4..87d2294ff9 100644 --- a/src/app/+item-page/simple/item-page.component.ts +++ b/src/app/+item-page/simple/item-page.component.ts @@ -11,7 +11,7 @@ import { Item } from '../../core/shared/item.model'; import { MetadataService } from '../../core/metadata/metadata.service'; import { fadeInOut } from '../../shared/animations/fade'; -import { redirectToPageNotFoundOn404 } from '../../core/shared/operators'; +import { redirectOn404Or401 } from '../../core/shared/operators'; import { ViewMode } from '../../core/shared/view-mode.model'; /** @@ -56,7 +56,7 @@ export class ItemPageComponent implements OnInit { ngOnInit(): void { this.itemRD$ = this.route.data.pipe( map((data) => data.item as RemoteData), - redirectToPageNotFoundOn404(this.router) + redirectOn404Or401(this.router) ); this.metadataService.processRemoteData(this.itemRD$); } diff --git a/src/app/app-routing-paths.ts b/src/app/app-routing-paths.ts index 5d529edeb7..2b57a1957c 100644 --- a/src/app/app-routing-paths.ts +++ b/src/app/app-routing-paths.ts @@ -55,8 +55,19 @@ export function getDSORoute(dso: DSpaceObject): string { } } -export const UNAUTHORIZED_PATH = 'unauthorized'; +export const UNAUTHORIZED_PATH = '401'; export function getUnauthorizedRoute() { return `/${UNAUTHORIZED_PATH}`; } + +export const PAGE_NOT_FOUND_PATH = '404'; + +export function getPageNotFoundRoute() { + return `/${PAGE_NOT_FOUND_PATH}`; +} + +export const INFO_MODULE_PATH = 'info'; +export function getInfoModulePath() { + return `/${INFO_MODULE_PATH}`; +} diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts index b0317f68ea..ecb27efbb3 100644 --- a/src/app/app-routing.module.ts +++ b/src/app/app-routing.module.ts @@ -1,5 +1,6 @@ import { NgModule } from '@angular/core'; import { RouterModule } from '@angular/router'; +import { AuthBlockingGuard } from './core/auth/auth-blocking.guard'; import { PageNotFoundComponent } from './pagenotfound/pagenotfound.component'; import { AuthenticatedGuard } from './core/auth/authenticated.guard'; @@ -12,55 +13,69 @@ import { REGISTER_PATH, PROFILE_MODULE_PATH, ADMIN_MODULE_PATH, - BITSTREAM_MODULE_PATH + BITSTREAM_MODULE_PATH, + INFO_MODULE_PATH } from './app-routing-paths'; import { COLLECTION_MODULE_PATH } from './+collection-page/collection-page-routing-paths'; import { COMMUNITY_MODULE_PATH } from './+community-page/community-page-routing-paths'; import { ITEM_MODULE_PATH } from './+item-page/item-page-routing-paths'; +import { ReloadGuard } from './core/reload/reload.guard'; +import { EndUserAgreementCurrentUserGuard } from './core/end-user-agreement/end-user-agreement-current-user.guard'; +import { SiteRegisterGuard } from './core/data/feature-authorization/feature-authorization-guard/site-register.guard'; @NgModule({ imports: [ RouterModule.forRoot([ - { path: '', redirectTo: '/home', pathMatch: 'full' }, - { path: 'reload/:rnd', redirectTo: '/home', pathMatch: 'full' }, - { path: 'home', loadChildren: './+home-page/home-page.module#HomePageModule', data: { showBreadcrumbs: false } }, - { path: 'community-list', loadChildren: './community-list-page/community-list-page.module#CommunityListPageModule' }, - { path: 'id', loadChildren: './+lookup-by-id/lookup-by-id.module#LookupIdModule' }, - { path: 'handle', loadChildren: './+lookup-by-id/lookup-by-id.module#LookupIdModule' }, - { path: REGISTER_PATH, loadChildren: './register-page/register-page.module#RegisterPageModule' }, - { path: FORGOT_PASSWORD_PATH, loadChildren: './forgot-password/forgot-password.module#ForgotPasswordModule' }, - { path: COMMUNITY_MODULE_PATH, loadChildren: './+community-page/community-page.module#CommunityPageModule' }, - { path: COLLECTION_MODULE_PATH, loadChildren: './+collection-page/collection-page.module#CollectionPageModule' }, - { path: ITEM_MODULE_PATH, loadChildren: './+item-page/item-page.module#ItemPageModule' }, - { path: BITSTREAM_MODULE_PATH, loadChildren: './+bitstream-page/bitstream-page.module#BitstreamPageModule' }, - { - path: 'mydspace', - loadChildren: './+my-dspace-page/my-dspace-page.module#MyDSpacePageModule', - canActivate: [AuthenticatedGuard] - }, - { path: 'search', loadChildren: './+search-page/search-page-routing.module#SearchPageRoutingModule' }, - { path: 'browse', loadChildren: './+browse-by/browse-by.module#BrowseByModule'}, - { path: ADMIN_MODULE_PATH, loadChildren: './+admin/admin.module#AdminModule', canActivate: [SiteAdministratorGuard] }, - { path: 'login', loadChildren: './+login-page/login-page.module#LoginPageModule' }, - { path: 'logout', loadChildren: './+logout-page/logout-page.module#LogoutPageModule' }, - { path: 'submit', loadChildren: './+submit-page/submit-page.module#SubmitPageModule' }, - { path: 'import-external', loadChildren: './+import-external-page/import-external-page.module#ImportExternalPageModule' }, - { - path: 'workspaceitems', - loadChildren: './+workspaceitems-edit-page/workspaceitems-edit-page.module#WorkspaceitemsEditPageModule' - }, - { - path: WORKFLOW_ITEM_MODULE_PATH, - loadChildren: './+workflowitems-edit-page/workflowitems-edit-page.module#WorkflowItemsEditPageModule' - }, - { - path: PROFILE_MODULE_PATH, - loadChildren: './profile-page/profile-page.module#ProfilePageModule', canActivate: [AuthenticatedGuard] - }, - { path: 'processes', loadChildren: './process-page/process-page.module#ProcessPageModule', canActivate: [AuthenticatedGuard] }, - { path: UNAUTHORIZED_PATH, component: UnauthorizedComponent }, - { path: '**', pathMatch: 'full', component: PageNotFoundComponent }, - ], + { path: '', canActivate: [AuthBlockingGuard], + children: [ + { path: '', redirectTo: '/home', pathMatch: 'full' }, + { path: 'reload/:rnd', component: PageNotFoundComponent, pathMatch: 'full', canActivate: [ReloadGuard] }, + { path: 'home', loadChildren: './+home-page/home-page.module#HomePageModule', data: { showBreadcrumbs: false }, canActivate: [EndUserAgreementCurrentUserGuard] }, + { path: 'community-list', loadChildren: './community-list-page/community-list-page.module#CommunityListPageModule', canActivate: [EndUserAgreementCurrentUserGuard] }, + { path: 'id', loadChildren: './+lookup-by-id/lookup-by-id.module#LookupIdModule', canActivate: [EndUserAgreementCurrentUserGuard] }, + { path: 'handle', loadChildren: './+lookup-by-id/lookup-by-id.module#LookupIdModule', canActivate: [EndUserAgreementCurrentUserGuard] }, + { path: REGISTER_PATH, loadChildren: './register-page/register-page.module#RegisterPageModule', canActivate: [SiteRegisterGuard] }, + { path: FORGOT_PASSWORD_PATH, loadChildren: './forgot-password/forgot-password.module#ForgotPasswordModule', canActivate: [EndUserAgreementCurrentUserGuard] }, + { path: COMMUNITY_MODULE_PATH, loadChildren: './+community-page/community-page.module#CommunityPageModule', canActivate: [EndUserAgreementCurrentUserGuard] }, + { path: COLLECTION_MODULE_PATH, loadChildren: './+collection-page/collection-page.module#CollectionPageModule', canActivate: [EndUserAgreementCurrentUserGuard] }, + { path: ITEM_MODULE_PATH, loadChildren: './+item-page/item-page.module#ItemPageModule', canActivate: [EndUserAgreementCurrentUserGuard] }, + { path: BITSTREAM_MODULE_PATH, loadChildren: './+bitstream-page/bitstream-page.module#BitstreamPageModule', canActivate: [EndUserAgreementCurrentUserGuard] }, + { + path: 'mydspace', + loadChildren: './+my-dspace-page/my-dspace-page.module#MyDSpacePageModule', + canActivate: [AuthenticatedGuard, EndUserAgreementCurrentUserGuard] + }, + { path: 'search', loadChildren: './+search-page/search-page-routing.module#SearchPageRoutingModule', canActivate: [EndUserAgreementCurrentUserGuard] }, + { path: 'browse', loadChildren: './+browse-by/browse-by.module#BrowseByModule', canActivate: [EndUserAgreementCurrentUserGuard] }, + { path: ADMIN_MODULE_PATH, loadChildren: './+admin/admin.module#AdminModule', canActivate: [SiteAdministratorGuard, EndUserAgreementCurrentUserGuard] }, + { path: 'login', loadChildren: './+login-page/login-page.module#LoginPageModule' }, + { path: 'logout', loadChildren: './+logout-page/logout-page.module#LogoutPageModule' }, + { path: 'submit', loadChildren: './+submit-page/submit-page.module#SubmitPageModule', canActivate: [EndUserAgreementCurrentUserGuard] }, + { path: 'import-external', loadChildren: './+import-external-page/import-external-page.module#ImportExternalPageModule', canActivate: [EndUserAgreementCurrentUserGuard] }, + { + path: 'workspaceitems', + loadChildren: './+workspaceitems-edit-page/workspaceitems-edit-page.module#WorkspaceitemsEditPageModule', + canActivate: [EndUserAgreementCurrentUserGuard] + }, + { + path: WORKFLOW_ITEM_MODULE_PATH, + loadChildren: './+workflowitems-edit-page/workflowitems-edit-page.module#WorkflowItemsEditPageModule', + canActivate: [EndUserAgreementCurrentUserGuard] + }, + { + path: PROFILE_MODULE_PATH, + loadChildren: './profile-page/profile-page.module#ProfilePageModule', canActivate: [AuthenticatedGuard, EndUserAgreementCurrentUserGuard] + }, + { path: 'processes', loadChildren: './process-page/process-page.module#ProcessPageModule', canActivate: [AuthenticatedGuard, EndUserAgreementCurrentUserGuard] }, + { path: INFO_MODULE_PATH, loadChildren: './info/info.module#InfoModule' }, + { path: UNAUTHORIZED_PATH, component: UnauthorizedComponent }, + { + path: 'statistics', + loadChildren: './statistics-page/statistics-page-routing.module#StatisticsPageRoutingModule', + }, + { path: '**', pathMatch: 'full', component: PageNotFoundComponent }, + ]} + ], { onSameUrlNavigation: 'reload', }) diff --git a/src/app/app.component.html b/src/app/app.component.html index 8656970f31..fa534855e7 100644 --- a/src/app/app.component.html +++ b/src/app/app.component.html @@ -1,4 +1,4 @@ -
+
+ +
+ diff --git a/src/app/app.component.scss b/src/app/app.component.scss index 7793b7529c..b18e7e1402 100644 --- a/src/app/app.component.scss +++ b/src/app/app.component.scss @@ -47,3 +47,7 @@ ds-admin-sidebar { position: fixed; z-index: $sidebar-z-index; } + +.ds-full-screen-loader { + height: 100vh; +} diff --git a/src/app/app.component.spec.ts b/src/app/app.component.spec.ts index da3cf9537b..31507831be 100644 --- a/src/app/app.component.spec.ts +++ b/src/app/app.component.spec.ts @@ -1,9 +1,8 @@ +import * as ngrx from '@ngrx/store'; import { async, ComponentFixture, inject, TestBed } from '@angular/core/testing'; -import { CUSTOM_ELEMENTS_SCHEMA, DebugElement } from '@angular/core'; +import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; import { CommonModule } from '@angular/common'; -import { By } from '@angular/platform-browser'; import { ActivatedRoute, Router } from '@angular/router'; - import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; import { Store, StoreModule } from '@ngrx/store'; import { Angulartics2GoogleAnalytics } from 'angulartics2/ga'; @@ -32,11 +31,11 @@ import { RouterMock } from './shared/mocks/router.mock'; import { Angulartics2DSpace } from './statistics/angulartics/dspace-provider'; import { storeModuleConfig } from './app.reducer'; import { LocaleService } from './core/locale/locale.service'; +import { authReducer } from './core/auth/auth.reducer'; +import { cold } from 'jasmine-marbles'; let comp: AppComponent; let fixture: ComponentFixture; -let de: DebugElement; -let el: HTMLElement; const menuService = new MenuServiceStub(); describe('App component', () => { @@ -52,7 +51,7 @@ describe('App component', () => { return TestBed.configureTestingModule({ imports: [ CommonModule, - StoreModule.forRoot({}, storeModuleConfig), + StoreModule.forRoot(authReducer, storeModuleConfig), TranslateModule.forRoot({ loader: { provide: TranslateLoader, @@ -82,12 +81,19 @@ describe('App component', () => { // synchronous beforeEach beforeEach(() => { - fixture = TestBed.createComponent(AppComponent); + spyOnProperty(ngrx, 'select').and.callFake(() => { + return () => { + return () => cold('a', { + a: { + core: { auth: { loading: false } } + } + }) + }; + }); + fixture = TestBed.createComponent(AppComponent); comp = fixture.componentInstance; // component test instance - // query for the
by CSS element selector - de = fixture.debugElement.query(By.css('div.outer-wrapper')); - el = de.nativeElement; + fixture.detectChanges(); }); it('should create component', inject([AppComponent], (app: AppComponent) => { diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 10f81a9adc..43ae0534ad 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -1,11 +1,11 @@ -import { delay, filter, map, take } from 'rxjs/operators'; +import { delay, map, distinctUntilChanged, filter, take } from 'rxjs/operators'; import { AfterViewInit, ChangeDetectionStrategy, Component, HostListener, Inject, - OnInit, + OnInit, Optional, ViewEncapsulation } from '@angular/core'; import { NavigationCancel, NavigationEnd, NavigationStart, Router } from '@angular/router'; @@ -19,7 +19,7 @@ import { MetadataService } from './core/metadata/metadata.service'; import { HostWindowResizeAction } from './shared/host-window.actions'; import { HostWindowState } from './shared/search/host-window.reducer'; import { NativeWindowRef, NativeWindowService } from './core/services/window.service'; -import { isAuthenticated } from './core/auth/selectors'; +import { isAuthenticationBlocking } from './core/auth/selectors'; import { AuthService } from './core/auth/auth.service'; import { CSSVariableService } from './shared/sass-helper/sass-helper.service'; import { MenuService } from './shared/menu/menu.service'; @@ -31,8 +31,8 @@ import { Angulartics2DSpace } from './statistics/angulartics/dspace-provider'; import { environment } from '../environments/environment'; import { models } from './core/core.module'; import { LocaleService } from './core/locale/locale.service'; - -export const LANG_COOKIE = 'language_cookie'; +import { hasValue } from './shared/empty.util'; +import { KlaroService } from './shared/cookies/klaro.service'; @Component({ selector: 'ds-app', @@ -52,6 +52,11 @@ export class AppComponent implements OnInit, AfterViewInit { notificationOptions = environment.notifications; models; + /** + * Whether or not the authentication is currently blocking the UI + */ + isNotAuthBlocking$: Observable; + constructor( @Inject(NativeWindowService) private _window: NativeWindowRef, private translate: TranslateService, @@ -64,8 +69,10 @@ export class AppComponent implements OnInit, AfterViewInit { private cssService: CSSVariableService, private menuService: MenuService, private windowService: HostWindowService, - private localeService: LocaleService + private localeService: LocaleService, + @Optional() private cookiesService: KlaroService ) { + /* Use models object so all decorators are actually called */ this.models = models; // Load all the languages that are defined as active from the config file @@ -86,19 +93,25 @@ export class AppComponent implements OnInit, AfterViewInit { console.info(environment); } this.storeCSSVariables(); + } ngOnInit() { + this.isNotAuthBlocking$ = this.store.pipe(select(isAuthenticationBlocking)).pipe( + map((isBlocking: boolean) => isBlocking === false), + distinctUntilChanged() + ); + this.isNotAuthBlocking$ + .pipe( + filter((notBlocking: boolean) => notBlocking), + take(1) + ).subscribe(() => this.initializeKlaro()); + const env: string = environment.production ? 'Production' : 'Development'; const color: string = environment.production ? 'red' : 'green'; console.info(`Environment: %c${env}`, `color: ${color}; font-weight: bold;`); this.dispatchWindowSize(this._window.nativeWindow.innerWidth, this._window.nativeWindow.innerHeight); - // Whether is not authenticathed try to retrieve a possible stored auth token - this.store.pipe(select(isAuthenticated), - take(1), - filter((authenticated) => !authenticated) - ).subscribe((authenticated) => this.authService.checkAuthenticationToken()); this.sidebarVisible = this.menuService.isMenuVisible(MenuID.ADMIN); this.collapsedSidebarWidth = this.cssService.getVariable('collapsedSidebarWidth'); @@ -154,4 +167,9 @@ export class AppComponent implements OnInit, AfterViewInit { ); } + private initializeKlaro() { + if (hasValue(this.cookiesService)) { + this.cookiesService.initialize() + } + } } diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 33454ed6c5..f1cdd5f2e5 100755 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -1,11 +1,11 @@ import { APP_BASE_HREF, CommonModule } from '@angular/common'; import { HttpClientModule } from '@angular/common/http'; -import { NgModule } from '@angular/core'; +import { APP_INITIALIZER, NgModule } from '@angular/core'; import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; import { EffectsModule } from '@ngrx/effects'; import { RouterStateSerializer, StoreRouterConnectingModule } from '@ngrx/router-store'; -import { MetaReducer, StoreModule, USER_PROVIDED_META_REDUCERS } from '@ngrx/store'; +import { MetaReducer, Store, StoreModule, USER_PROVIDED_META_REDUCERS } from '@ngrx/store'; import { StoreDevtoolsModule } from '@ngrx/store-devtools'; import { DYNAMIC_MATCHER_PROVIDERS } from '@ng-dynamic-forms/core'; import { TranslateModule } from '@ngx-translate/core'; @@ -21,6 +21,7 @@ import { AppComponent } from './app.component'; import { appEffects } from './app.effects'; import { appMetaReducers, debugMetaReducers } from './app.metareducers'; import { appReducers, AppState, storeModuleConfig } from './app.reducer'; +import { CheckAuthenticationTokenAction } from './core/auth/auth.actions'; import { CoreModule } from './core/core.module'; import { ClientCookieService } from './core/services/client-cookie.service'; @@ -91,6 +92,15 @@ const PROVIDERS = [ useClass: DSpaceRouterStateSerializer }, ClientCookieService, + // Check the authentication token when the app initializes + { + provide: APP_INITIALIZER, + useFactory: (store: Store,) => { + return () => store.dispatch(new CheckAuthenticationTokenAction()); + }, + deps: [ Store ], + multi: true + }, ...DYNAMIC_MATCHER_PROVIDERS, ]; diff --git a/src/app/community-list-page/community-list-page.module.ts b/src/app/community-list-page/community-list-page.module.ts index 2e3914fe03..57b016bc6e 100644 --- a/src/app/community-list-page/community-list-page.module.ts +++ b/src/app/community-list-page/community-list-page.module.ts @@ -4,7 +4,6 @@ import { SharedModule } from '../shared/shared.module'; import { CommunityListPageComponent } from './community-list-page.component'; import { CommunityListPageRoutingModule } from './community-list-page.routing.module'; import { CommunityListComponent } from './community-list/community-list.component'; -import { CdkTreeModule } from '@angular/cdk/tree'; /** * The page which houses a title and the community list, as described in community-list.component @@ -13,8 +12,7 @@ import { CdkTreeModule } from '@angular/cdk/tree'; imports: [ CommonModule, SharedModule, - CommunityListPageRoutingModule, - CdkTreeModule, + CommunityListPageRoutingModule ], declarations: [ CommunityListPageComponent, diff --git a/src/app/core/auth/auth-blocking.guard.spec.ts b/src/app/core/auth/auth-blocking.guard.spec.ts new file mode 100644 index 0000000000..2a89b01a85 --- /dev/null +++ b/src/app/core/auth/auth-blocking.guard.spec.ts @@ -0,0 +1,62 @@ +import { Store } from '@ngrx/store'; +import * as ngrx from '@ngrx/store'; +import { cold, getTestScheduler, initTestScheduler, resetTestScheduler } from 'jasmine-marbles/es6'; +import { of as observableOf } from 'rxjs'; +import { AppState } from '../../app.reducer'; +import { AuthBlockingGuard } from './auth-blocking.guard'; + +describe('AuthBlockingGuard', () => { + let guard: AuthBlockingGuard; + beforeEach(() => { + guard = new AuthBlockingGuard(new Store(undefined, undefined, undefined)); + initTestScheduler(); + }); + + afterEach(() => { + getTestScheduler().flush(); + resetTestScheduler(); + }); + + describe(`canActivate`, () => { + + describe(`when authState.loading is undefined`, () => { + beforeEach(() => { + spyOnProperty(ngrx, 'select').and.callFake(() => { + return () => { + return () => observableOf(undefined); + }; + }) + }); + it(`should not emit anything`, () => { + expect(guard.canActivate()).toBeObservable(cold('|')); + }); + }); + + describe(`when authState.loading is true`, () => { + beforeEach(() => { + spyOnProperty(ngrx, 'select').and.callFake(() => { + return () => { + return () => observableOf(true); + }; + }) + }); + it(`should not emit anything`, () => { + expect(guard.canActivate()).toBeObservable(cold('|')); + }); + }); + + describe(`when authState.loading is false`, () => { + beforeEach(() => { + spyOnProperty(ngrx, 'select').and.callFake(() => { + return () => { + return () => observableOf(false); + }; + }) + }); + it(`should succeed`, () => { + expect(guard.canActivate()).toBeObservable(cold('(a|)', { a: true })); + }); + }); + }); + +}); diff --git a/src/app/core/auth/auth-blocking.guard.ts b/src/app/core/auth/auth-blocking.guard.ts new file mode 100644 index 0000000000..9054f66f8b --- /dev/null +++ b/src/app/core/auth/auth-blocking.guard.ts @@ -0,0 +1,34 @@ +import { Injectable } from '@angular/core'; +import { CanActivate } from '@angular/router'; +import { select, Store } from '@ngrx/store'; +import { Observable } from 'rxjs'; +import { distinctUntilChanged, filter, map, take } from 'rxjs/operators'; +import { AppState } from '../../app.reducer'; +import { isAuthenticationBlocking } from './selectors'; + +/** + * A guard that blocks the loading of any + * route until the authentication status has loaded. + * To ensure all rest requests get the correct auth header. + */ +@Injectable({ + providedIn: 'root' +}) +export class AuthBlockingGuard implements CanActivate { + + constructor(private store: Store) { + } + + /** + * True when the authentication isn't blocking everything + */ + canActivate(): Observable { + return this.store.pipe(select(isAuthenticationBlocking)).pipe( + map((isBlocking: boolean) => isBlocking === false), + distinctUntilChanged(), + filter((finished: boolean) => finished === true), + take(1), + ); + } + +} diff --git a/src/app/core/auth/auth.actions.ts b/src/app/core/auth/auth.actions.ts index be4bdf2a26..f80be89034 100644 --- a/src/app/core/auth/auth.actions.ts +++ b/src/app/core/auth/auth.actions.ts @@ -34,6 +34,7 @@ export const AuthActionTypes = { RETRIEVE_AUTHENTICATED_EPERSON: type('dspace/auth/RETRIEVE_AUTHENTICATED_EPERSON'), RETRIEVE_AUTHENTICATED_EPERSON_SUCCESS: type('dspace/auth/RETRIEVE_AUTHENTICATED_EPERSON_SUCCESS'), RETRIEVE_AUTHENTICATED_EPERSON_ERROR: type('dspace/auth/RETRIEVE_AUTHENTICATED_EPERSON_ERROR'), + REDIRECT_AFTER_LOGIN_SUCCESS: type('dspace/auth/REDIRECT_AFTER_LOGIN_SUCCESS') }; /* tslint:disable:max-classes-per-file */ @@ -335,6 +336,20 @@ export class SetRedirectUrlAction implements Action { } } +/** + * Start loading for a hard redirect + * @class StartHardRedirectLoadingAction + * @implements {Action} + */ +export class RedirectAfterLoginSuccessAction implements Action { + public type: string = AuthActionTypes.REDIRECT_AFTER_LOGIN_SUCCESS; + payload: string; + + constructor(url: string) { + this.payload = url; + } +} + /** * Retrieve the authenticated eperson. * @class RetrieveAuthenticatedEpersonAction @@ -402,8 +417,8 @@ export type AuthActions | RetrieveAuthMethodsSuccessAction | RetrieveAuthMethodsErrorAction | RetrieveTokenAction - | ResetAuthenticationMessagesAction | RetrieveAuthenticatedEpersonAction | RetrieveAuthenticatedEpersonErrorAction | RetrieveAuthenticatedEpersonSuccessAction - | SetRedirectUrlAction; + | SetRedirectUrlAction + | RedirectAfterLoginSuccessAction; diff --git a/src/app/core/auth/auth.effects.ts b/src/app/core/auth/auth.effects.ts index 37ef3b79bc..ab18dcb508 100644 --- a/src/app/core/auth/auth.effects.ts +++ b/src/app/core/auth/auth.effects.ts @@ -27,6 +27,7 @@ import { CheckAuthenticationTokenCookieAction, LogOutErrorAction, LogOutSuccessAction, + RedirectAfterLoginSuccessAction, RefreshTokenAction, RefreshTokenErrorAction, RefreshTokenSuccessAction, @@ -79,7 +80,26 @@ export class AuthEffects { public authenticatedSuccess$: Observable = this.actions$.pipe( ofType(AuthActionTypes.AUTHENTICATED_SUCCESS), tap((action: AuthenticatedSuccessAction) => this.authService.storeToken(action.payload.authToken)), - map((action: AuthenticatedSuccessAction) => new RetrieveAuthenticatedEpersonAction(action.payload.userHref)) + switchMap((action: AuthenticatedSuccessAction) => this.authService.getRedirectUrl().pipe( + take(1), + map((redirectUrl: string) => [action, redirectUrl]) + )), + map(([action, redirectUrl]: [AuthenticatedSuccessAction, string]) => { + if (hasValue(redirectUrl)) { + return new RedirectAfterLoginSuccessAction(redirectUrl); + } else { + return new RetrieveAuthenticatedEpersonAction(action.payload.userHref); + } + }) + ); + + @Effect({ dispatch: false }) + public redirectAfterLoginSuccess$: Observable = this.actions$.pipe( + ofType(AuthActionTypes.REDIRECT_AFTER_LOGIN_SUCCESS), + tap((action: RedirectAfterLoginSuccessAction) => { + this.authService.clearRedirectUrl(); + this.authService.navigateToRedirectUrl(action.payload); + }) ); // It means "reacts to this action but don't send another" @@ -201,13 +221,6 @@ export class AuthEffects { tap(() => this.authService.refreshAfterLogout()) ); - @Effect({ dispatch: false }) - public redirectToLogin$: Observable = this.actions$ - .pipe(ofType(AuthActionTypes.REDIRECT_AUTHENTICATION_REQUIRED), - tap(() => this.authService.removeToken()), - tap(() => this.authService.redirectToLogin()) - ); - @Effect({ dispatch: false }) public redirectToLoginTokenExpired$: Observable = this.actions$ .pipe( diff --git a/src/app/core/auth/auth.interceptor.ts b/src/app/core/auth/auth.interceptor.ts index f4e7aa2fd3..3366cdb3d8 100644 --- a/src/app/core/auth/auth.interceptor.ts +++ b/src/app/core/auth/auth.interceptor.ts @@ -251,7 +251,6 @@ export class AuthInterceptor implements HttpInterceptor { // Pass on the new request instead of the original request. return next.handle(newReq).pipe( - // tap((response) => console.log('next.handle: ', response)), map((response) => { // Intercept a Login/Logout response if (response instanceof HttpResponse && this.isSuccess(response) && this.isAuthRequest(response)) { diff --git a/src/app/core/auth/auth.reducer.spec.ts b/src/app/core/auth/auth.reducer.spec.ts index cf934a7f47..4c6f1e2a25 100644 --- a/src/app/core/auth/auth.reducer.spec.ts +++ b/src/app/core/auth/auth.reducer.spec.ts @@ -42,6 +42,7 @@ describe('authReducer', () => { initialState = { authenticated: false, loaded: false, + blocking: true, loading: false, }; const action = new AuthenticateAction('user', 'password'); @@ -49,6 +50,7 @@ describe('authReducer', () => { state = { authenticated: false, loaded: false, + blocking: true, error: undefined, loading: true, info: undefined @@ -62,6 +64,7 @@ describe('authReducer', () => { authenticated: false, loaded: false, error: undefined, + blocking: true, loading: true, info: undefined }; @@ -76,6 +79,7 @@ describe('authReducer', () => { authenticated: false, loaded: false, error: undefined, + blocking: true, loading: true, info: undefined }; @@ -84,6 +88,7 @@ describe('authReducer', () => { state = { authenticated: false, loaded: false, + blocking: false, loading: false, info: undefined, authToken: undefined, @@ -96,6 +101,7 @@ describe('authReducer', () => { it('should properly set the state, in response to a AUTHENTICATED action', () => { initialState = { authenticated: false, + blocking: false, loaded: false, error: undefined, loading: true, @@ -103,8 +109,15 @@ describe('authReducer', () => { }; const action = new AuthenticatedAction(mockTokenInfo); const newState = authReducer(initialState, action); - - expect(newState).toEqual(initialState); + state = { + authenticated: false, + blocking: true, + loaded: false, + error: undefined, + loading: true, + info: undefined + }; + expect(newState).toEqual(state); }); it('should properly set the state, in response to a AUTHENTICATED_SUCCESS action', () => { @@ -112,6 +125,7 @@ describe('authReducer', () => { authenticated: false, loaded: false, error: undefined, + blocking: true, loading: true, info: undefined }; @@ -122,6 +136,7 @@ describe('authReducer', () => { authToken: mockTokenInfo, loaded: false, error: undefined, + blocking: true, loading: true, info: undefined }; @@ -133,6 +148,7 @@ describe('authReducer', () => { authenticated: false, loaded: false, error: undefined, + blocking: true, loading: true, info: undefined }; @@ -143,6 +159,7 @@ describe('authReducer', () => { authToken: undefined, error: 'Test error message', loaded: true, + blocking: false, loading: false, info: undefined }; @@ -153,6 +170,7 @@ describe('authReducer', () => { initialState = { authenticated: false, loaded: false, + blocking: false, loading: false, }; const action = new CheckAuthenticationTokenAction(); @@ -160,6 +178,7 @@ describe('authReducer', () => { state = { authenticated: false, loaded: false, + blocking: true, loading: true, }; expect(newState).toEqual(state); @@ -169,6 +188,7 @@ describe('authReducer', () => { initialState = { authenticated: false, loaded: false, + blocking: false, loading: true, }; const action = new CheckAuthenticationTokenCookieAction(); @@ -176,6 +196,7 @@ describe('authReducer', () => { state = { authenticated: false, loaded: false, + blocking: true, loading: true, }; expect(newState).toEqual(state); @@ -187,6 +208,7 @@ describe('authReducer', () => { authToken: mockTokenInfo, loaded: true, error: undefined, + blocking: false, loading: false, info: undefined, userId: EPersonMock.id @@ -204,6 +226,7 @@ describe('authReducer', () => { authToken: mockTokenInfo, loaded: true, error: undefined, + blocking: false, loading: false, info: undefined, userId: EPersonMock.id @@ -216,7 +239,8 @@ describe('authReducer', () => { authToken: undefined, error: undefined, loaded: false, - loading: false, + blocking: true, + loading: true, info: undefined, refreshing: false, userId: undefined @@ -230,6 +254,7 @@ describe('authReducer', () => { authToken: mockTokenInfo, loaded: true, error: undefined, + blocking: false, loading: false, info: undefined, userId: EPersonMock.id @@ -242,6 +267,7 @@ describe('authReducer', () => { authToken: mockTokenInfo, loaded: true, error: 'Test error message', + blocking: false, loading: false, info: undefined, userId: EPersonMock.id @@ -255,6 +281,7 @@ describe('authReducer', () => { authToken: mockTokenInfo, loaded: false, error: undefined, + blocking: true, loading: true, info: undefined }; @@ -265,6 +292,7 @@ describe('authReducer', () => { authToken: mockTokenInfo, loaded: true, error: undefined, + blocking: false, loading: false, info: undefined, userId: EPersonMock.id @@ -277,6 +305,7 @@ describe('authReducer', () => { authenticated: false, loaded: false, error: undefined, + blocking: true, loading: true, info: undefined }; @@ -287,6 +316,7 @@ describe('authReducer', () => { authToken: undefined, error: 'Test error message', loaded: true, + blocking: false, loading: false, info: undefined }; @@ -299,6 +329,7 @@ describe('authReducer', () => { authToken: mockTokenInfo, loaded: true, error: undefined, + blocking: false, loading: false, info: undefined, userId: EPersonMock.id @@ -311,6 +342,7 @@ describe('authReducer', () => { authToken: mockTokenInfo, loaded: true, error: undefined, + blocking: false, loading: false, info: undefined, userId: EPersonMock.id, @@ -325,6 +357,7 @@ describe('authReducer', () => { authToken: mockTokenInfo, loaded: true, error: undefined, + blocking: false, loading: false, info: undefined, userId: EPersonMock.id, @@ -338,6 +371,7 @@ describe('authReducer', () => { authToken: newTokenInfo, loaded: true, error: undefined, + blocking: false, loading: false, info: undefined, userId: EPersonMock.id, @@ -352,6 +386,7 @@ describe('authReducer', () => { authToken: mockTokenInfo, loaded: true, error: undefined, + blocking: false, loading: false, info: undefined, userId: EPersonMock.id, @@ -364,6 +399,7 @@ describe('authReducer', () => { authToken: undefined, error: undefined, loaded: false, + blocking: false, loading: false, info: undefined, refreshing: false, @@ -378,6 +414,7 @@ describe('authReducer', () => { authToken: mockTokenInfo, loaded: true, error: undefined, + blocking: false, loading: false, info: undefined, userId: EPersonMock.id @@ -387,6 +424,7 @@ describe('authReducer', () => { authenticated: false, authToken: undefined, loaded: false, + blocking: false, loading: false, error: undefined, info: 'Message', @@ -410,6 +448,7 @@ describe('authReducer', () => { initialState = { authenticated: false, loaded: false, + blocking: false, loading: false, }; const action = new AddAuthenticationMessageAction('Message'); @@ -417,6 +456,7 @@ describe('authReducer', () => { state = { authenticated: false, loaded: false, + blocking: false, loading: false, info: 'Message' }; @@ -427,6 +467,7 @@ describe('authReducer', () => { initialState = { authenticated: false, loaded: false, + blocking: false, loading: false, error: 'Error', info: 'Message' @@ -436,6 +477,7 @@ describe('authReducer', () => { state = { authenticated: false, loaded: false, + blocking: false, loading: false, error: undefined, info: undefined @@ -447,6 +489,7 @@ describe('authReducer', () => { initialState = { authenticated: false, loaded: false, + blocking: false, loading: false }; const action = new SetRedirectUrlAction('redirect.url'); @@ -454,6 +497,7 @@ describe('authReducer', () => { state = { authenticated: false, loaded: false, + blocking: false, loading: false, redirectUrl: 'redirect.url' }; @@ -464,6 +508,7 @@ describe('authReducer', () => { initialState = { authenticated: false, loaded: false, + blocking: false, loading: false, authMethods: [] }; @@ -472,6 +517,7 @@ describe('authReducer', () => { state = { authenticated: false, loaded: false, + blocking: true, loading: true, authMethods: [] }; @@ -482,6 +528,7 @@ describe('authReducer', () => { initialState = { authenticated: false, loaded: false, + blocking: true, loading: true, authMethods: [] }; @@ -494,6 +541,7 @@ describe('authReducer', () => { state = { authenticated: false, loaded: false, + blocking: false, loading: false, authMethods: authMethods }; @@ -504,6 +552,7 @@ describe('authReducer', () => { initialState = { authenticated: false, loaded: false, + blocking: true, loading: true, authMethods: [] }; @@ -513,6 +562,7 @@ describe('authReducer', () => { state = { authenticated: false, loaded: false, + blocking: false, loading: false, authMethods: [new AuthMethod(AuthMethodType.Password)] }; diff --git a/src/app/core/auth/auth.reducer.ts b/src/app/core/auth/auth.reducer.ts index 34c8fe2b41..6d5635f263 100644 --- a/src/app/core/auth/auth.reducer.ts +++ b/src/app/core/auth/auth.reducer.ts @@ -39,6 +39,10 @@ export interface AuthState { // true when loading loading: boolean; + // true when everything else should wait for authorization + // to complete + blocking: boolean; + // info message info?: string; @@ -62,6 +66,7 @@ export interface AuthState { const initialState: AuthState = { authenticated: false, loaded: false, + blocking: true, loading: false, authMethods: [] }; @@ -86,7 +91,8 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut case AuthActionTypes.CHECK_AUTHENTICATION_TOKEN: case AuthActionTypes.CHECK_AUTHENTICATION_TOKEN_COOKIE: return Object.assign({}, state, { - loading: true + loading: true, + blocking: true }); case AuthActionTypes.AUTHENTICATED_ERROR: @@ -96,6 +102,7 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut authToken: undefined, error: (action as AuthenticationErrorAction).payload.message, loaded: true, + blocking: false, loading: false }); @@ -110,6 +117,7 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut loaded: true, error: undefined, loading: false, + blocking: false, info: undefined, userId: (action as RetrieveAuthenticatedEpersonSuccessAction).payload }); @@ -119,6 +127,7 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut authenticated: false, authToken: undefined, error: (action as AuthenticationErrorAction).payload.message, + blocking: false, loading: false }); @@ -132,25 +141,39 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut error: (action as LogOutErrorAction).payload.message }); - case AuthActionTypes.LOG_OUT_SUCCESS: case AuthActionTypes.REFRESH_TOKEN_ERROR: return Object.assign({}, state, { authenticated: false, authToken: undefined, error: undefined, loaded: false, + blocking: false, loading: false, info: undefined, refreshing: false, userId: undefined }); + case AuthActionTypes.LOG_OUT_SUCCESS: + return Object.assign({}, state, { + authenticated: false, + authToken: undefined, + error: undefined, + loaded: false, + blocking: true, + loading: true, + info: undefined, + refreshing: false, + userId: undefined + }); + case AuthActionTypes.REDIRECT_AUTHENTICATION_REQUIRED: case AuthActionTypes.REDIRECT_TOKEN_EXPIRED: return Object.assign({}, state, { authenticated: false, authToken: undefined, loaded: false, + blocking: false, loading: false, info: (action as RedirectWhenTokenExpiredAction as RedirectWhenAuthenticationIsRequiredAction).payload, userId: undefined @@ -181,18 +204,21 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut // next three cases are used by dynamic rendering of login methods case AuthActionTypes.RETRIEVE_AUTH_METHODS: return Object.assign({}, state, { - loading: true + loading: true, + blocking: true }); case AuthActionTypes.RETRIEVE_AUTH_METHODS_SUCCESS: return Object.assign({}, state, { loading: false, + blocking: false, authMethods: (action as RetrieveAuthMethodsSuccessAction).payload }); case AuthActionTypes.RETRIEVE_AUTH_METHODS_ERROR: return Object.assign({}, state, { loading: false, + blocking: false, authMethods: [new AuthMethod(AuthMethodType.Password)] }); @@ -201,6 +227,12 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut redirectUrl: (action as SetRedirectUrlAction).payload, }); + case AuthActionTypes.REDIRECT_AFTER_LOGIN_SUCCESS: + return Object.assign({}, state, { + loading: true, + blocking: true, + }); + default: return state; } diff --git a/src/app/core/auth/auth.service.spec.ts b/src/app/core/auth/auth.service.spec.ts index 7f2c1e29cc..d3c2b6c44d 100644 --- a/src/app/core/auth/auth.service.spec.ts +++ b/src/app/core/auth/auth.service.spec.ts @@ -27,6 +27,7 @@ import { EPersonDataService } from '../eperson/eperson-data.service'; import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; import { authMethodsMock } from '../../shared/testing/auth-service.stub'; import { AuthMethod } from './models/auth.method'; +import { HardRedirectService } from '../services/hard-redirect.service'; describe('AuthService test', () => { @@ -48,6 +49,7 @@ describe('AuthService test', () => { let authenticatedState; let unAuthenticatedState; let linkService; + let hardRedirectService; function init() { mockStore = jasmine.createSpyObj('store', { @@ -77,6 +79,7 @@ describe('AuthService test', () => { linkService = { resolveLinks: {} }; + hardRedirectService = jasmine.createSpyObj('hardRedirectService', ['redirect']); spyOn(linkService, 'resolveLinks').and.returnValue({ authenticated: true, eperson: observableOf({ payload: {} }) }); } @@ -104,6 +107,7 @@ describe('AuthService test', () => { { provide: ActivatedRoute, useValue: routeStub }, { provide: Store, useValue: mockStore }, { provide: EPersonDataService, useValue: mockEpersonDataService }, + { provide: HardRedirectService, useValue: hardRedirectService }, CookieService, AuthService ], @@ -210,7 +214,7 @@ describe('AuthService test', () => { (state as any).core = Object.create({}); (state as any).core.auth = authenticatedState; }); - authService = new AuthService({}, window, undefined, authReqService, mockEpersonDataService, router, routeService, cookieService, store); + authService = new AuthService({}, window, undefined, authReqService, mockEpersonDataService, router, routeService, cookieService, store, hardRedirectService); })); it('should return true when user is logged in', () => { @@ -289,7 +293,7 @@ describe('AuthService test', () => { (state as any).core = Object.create({}); (state as any).core.auth = authenticatedState; }); - authService = new AuthService({}, window, undefined, authReqService, mockEpersonDataService, router, routeService, cookieService, store); + authService = new AuthService({}, window, undefined, authReqService, mockEpersonDataService, router, routeService, cookieService, store, hardRedirectService); storage = (authService as any).storage; routeServiceMock = TestBed.get(RouteService); routerStub = TestBed.get(Router); @@ -318,36 +322,28 @@ describe('AuthService test', () => { expect(storage.remove).toHaveBeenCalled(); }); - it('should set redirect url to previous page', () => { - spyOn(routeServiceMock, 'getHistory').and.callThrough(); - spyOn(routerStub, 'navigateByUrl'); - authService.redirectAfterLoginSuccess(true); - expect(routeServiceMock.getHistory).toHaveBeenCalled(); - expect(routerStub.navigateByUrl).toHaveBeenCalledWith('/collection/123'); + it('should redirect to reload with redirect url', () => { + authService.navigateToRedirectUrl('/collection/123'); + // Reload with redirect URL set to /collection/123 + expect(hardRedirectService.redirect).toHaveBeenCalledWith(jasmine.stringMatching(new RegExp('/reload/[0-9]*\\?redirect=' + encodeURIComponent('/collection/123')))); }); - it('should set redirect url to current page', () => { - spyOn(routeServiceMock, 'getHistory').and.callThrough(); - spyOn(routerStub, 'navigateByUrl'); - authService.redirectAfterLoginSuccess(false); - expect(routeServiceMock.getHistory).toHaveBeenCalled(); - expect(routerStub.navigateByUrl).toHaveBeenCalledWith('/home'); + it('should redirect to reload with /home', () => { + authService.navigateToRedirectUrl('/home'); + // Reload with redirect URL set to /home + expect(hardRedirectService.redirect).toHaveBeenCalledWith(jasmine.stringMatching(new RegExp('/reload/[0-9]*\\?redirect=' + encodeURIComponent('/home')))); }); - it('should redirect to / and not to /login', () => { - spyOn(routeServiceMock, 'getHistory').and.returnValue(observableOf(['/login', '/login'])); - spyOn(routerStub, 'navigateByUrl'); - authService.redirectAfterLoginSuccess(true); - expect(routeServiceMock.getHistory).toHaveBeenCalled(); - expect(routerStub.navigateByUrl).toHaveBeenCalledWith('/'); + it('should redirect to regular reload and not to /login', () => { + authService.navigateToRedirectUrl('/login'); + // Reload without a redirect URL + expect(hardRedirectService.redirect).toHaveBeenCalledWith(jasmine.stringMatching(new RegExp('/reload/[0-9]*(?!\\?)$'))); }); - it('should redirect to / when no redirect url is found', () => { - spyOn(routeServiceMock, 'getHistory').and.returnValue(observableOf([''])); - spyOn(routerStub, 'navigateByUrl'); - authService.redirectAfterLoginSuccess(true); - expect(routeServiceMock.getHistory).toHaveBeenCalled(); - expect(routerStub.navigateByUrl).toHaveBeenCalledWith('/'); + it('should redirect to regular reload when no redirect url is found', () => { + authService.navigateToRedirectUrl(undefined); + // Reload without a redirect URL + expect(hardRedirectService.redirect).toHaveBeenCalledWith(jasmine.stringMatching(new RegExp('/reload/[0-9]*(?!\\?)$'))); }); describe('impersonate', () => { @@ -464,6 +460,14 @@ describe('AuthService test', () => { }); }); }); + + describe('refreshAfterLogout', () => { + it('should call navigateToRedirectUrl with no url', () => { + spyOn(authService as any, 'navigateToRedirectUrl').and.stub(); + authService.refreshAfterLogout(); + expect((authService as any).navigateToRedirectUrl).toHaveBeenCalled(); + }); + }); }); describe('when user is not logged in', () => { @@ -496,7 +500,7 @@ describe('AuthService test', () => { (state as any).core = Object.create({}); (state as any).core.auth = unAuthenticatedState; }); - authService = new AuthService({}, window, undefined, authReqService, mockEpersonDataService, router, routeService, cookieService, store); + authService = new AuthService({}, window, undefined, authReqService, mockEpersonDataService, router, routeService, cookieService, store, hardRedirectService); })); it('should return null for the shortlived token', () => { diff --git a/src/app/core/auth/auth.service.ts b/src/app/core/auth/auth.service.ts index 7d854d9d4d..06906346ed 100644 --- a/src/app/core/auth/auth.service.ts +++ b/src/app/core/auth/auth.service.ts @@ -1,11 +1,10 @@ import { Inject, Injectable, Optional } from '@angular/core'; -import { PRIMARY_OUTLET, Router, UrlSegmentGroup, UrlTree } from '@angular/router'; +import { Router } from '@angular/router'; import { HttpHeaders } from '@angular/common/http'; import { REQUEST, RESPONSE } from '@nguniversal/express-engine/tokens'; import { Observable, of as observableOf } from 'rxjs'; -import { distinctUntilChanged, filter, map, startWith, switchMap, take, withLatestFrom } from 'rxjs/operators'; -import { RouterReducerState } from '@ngrx/router-store'; +import { map, startWith, switchMap, take } from 'rxjs/operators'; import { select, Store } from '@ngrx/store'; import { CookieAttributes } from 'js-cookie'; @@ -14,7 +13,15 @@ import { AuthRequestService } from './auth-request.service'; import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service'; import { AuthStatus } from './models/auth-status.model'; import { AuthTokenInfo, TOKENITEM } from './models/auth-token-info.model'; -import { hasValue, hasValueOperator, isEmpty, isNotEmpty, isNotNull, isNotUndefined } from '../../shared/empty.util'; +import { + hasValue, + hasValueOperator, + isEmpty, + isNotEmpty, + isNotNull, + isNotUndefined, + hasNoValue +} from '../../shared/empty.util'; import { CookieService } from '../services/cookie.service'; import { getAuthenticatedUserId, @@ -24,7 +31,7 @@ import { isTokenRefreshing, isAuthenticatedLoaded } from './selectors'; -import { AppState, routerStateSelector } from '../../app.reducer'; +import { AppState } from '../../app.reducer'; import { CheckAuthenticationTokenAction, ResetAuthenticationMessagesAction, @@ -36,6 +43,7 @@ import { RouteService } from '../services/route.service'; import { EPersonDataService } from '../eperson/eperson-data.service'; import { getAllSucceededRemoteDataPayload } from '../shared/operators'; import { AuthMethod } from './models/auth.method'; +import { HardRedirectService } from '../services/hard-redirect.service'; export const LOGIN_ROUTE = '/login'; export const LOGOUT_ROUTE = '/logout'; @@ -62,43 +70,13 @@ export class AuthService { protected router: Router, protected routeService: RouteService, protected storage: CookieService, - protected store: Store + protected store: Store, + protected hardRedirectService: HardRedirectService ) { this.store.pipe( select(isAuthenticated), startWith(false) ).subscribe((authenticated: boolean) => this._authenticated = authenticated); - - // If current route is different from the one setted in authentication guard - // and is not the login route, clear redirect url and messages - const routeUrl$ = this.store.pipe( - select(routerStateSelector), - filter((routerState: RouterReducerState) => isNotUndefined(routerState) - && isNotUndefined(routerState.state) && isNotEmpty(routerState.state.url)), - filter((routerState: RouterReducerState) => !this.isLoginRoute(routerState.state.url)), - map((routerState: RouterReducerState) => routerState.state.url) - ); - const redirectUrl$ = this.store.pipe(select(getRedirectUrl), distinctUntilChanged()); - routeUrl$.pipe( - withLatestFrom(redirectUrl$), - map(([routeUrl, redirectUrl]) => [routeUrl, redirectUrl]) - ).pipe(filter(([routeUrl, redirectUrl]) => isNotEmpty(redirectUrl) && (routeUrl !== redirectUrl))) - .subscribe(() => { - this.clearRedirectUrl(); - }); - } - - /** - * Check if is a login page route - * - * @param {string} url - * @returns {Boolean}. - */ - protected isLoginRoute(url: string) { - const urlTree: UrlTree = this.router.parseUrl(url); - const g: UrlSegmentGroup = urlTree.root.children[PRIMARY_OUTLET]; - const segment = '/' + g.toString(); - return segment === LOGIN_ROUTE; } /** @@ -409,69 +387,38 @@ export class AuthService { } /** - * Redirect to the route navigated before the login + * Perform a hard redirect to the URL + * @param redirectUrl */ - public redirectAfterLoginSuccess(isStandalonePage: boolean) { - this.getRedirectUrl().pipe( - take(1)) - .subscribe((redirectUrl) => { - - if (isNotEmpty(redirectUrl)) { - this.clearRedirectUrl(); - this.router.onSameUrlNavigation = 'reload'; - this.navigateToRedirectUrl(redirectUrl); - } else { - // If redirectUrl is empty use history. - this.routeService.getHistory().pipe( - take(1) - ).subscribe((history) => { - let redirUrl; - if (isStandalonePage) { - // For standalone login pages, use the previous route. - redirUrl = history[history.length - 2] || ''; - } else { - redirUrl = history[history.length - 1] || ''; - } - this.navigateToRedirectUrl(redirUrl); - }); - } - }); - - } - - protected navigateToRedirectUrl(redirectUrl: string) { - const url = decodeURIComponent(redirectUrl); - // in case the user navigates directly to /login (via bookmark, etc), or the route history is not found. - if (isEmpty(url) || url.startsWith(LOGIN_ROUTE)) { - this.router.navigateByUrl('/'); - /* TODO Reenable hard redirect when REST API can handle x-forwarded-for, see https://github.com/DSpace/DSpace/pull/2207 */ - // this._window.nativeWindow.location.href = '/'; - } else { - /* TODO Reenable hard redirect when REST API can handle x-forwarded-for, see https://github.com/DSpace/DSpace/pull/2207 */ - // this._window.nativeWindow.location.href = url; - this.router.navigateByUrl(url); + public navigateToRedirectUrl(redirectUrl: string) { + let url = `/reload/${new Date().getTime()}`; + if (isNotEmpty(redirectUrl) && !redirectUrl.startsWith(LOGIN_ROUTE)) { + url += `?redirect=${encodeURIComponent(redirectUrl)}`; } + this.hardRedirectService.redirect(url); } /** * Refresh route navigated */ public refreshAfterLogout() { - // Hard redirect to the reload page with a unique number behind it - // so that all state is definitely lost - this._window.nativeWindow.location.href = `/reload/${new Date().getTime()}`; + this.navigateToRedirectUrl(undefined); } /** * Get redirect url */ getRedirectUrl(): Observable { - const redirectUrl = this.storage.get(REDIRECT_COOKIE); - if (isNotEmpty(redirectUrl)) { - return observableOf(redirectUrl); - } else { - return this.store.pipe(select(getRedirectUrl)); - } + return this.store.pipe( + select(getRedirectUrl), + map((urlFromStore: string) => { + if (hasValue(urlFromStore)) { + return urlFromStore; + } else { + return this.storage.get(REDIRECT_COOKIE); + } + }) + ); } /** @@ -488,6 +435,20 @@ export class AuthService { this.store.dispatch(new SetRedirectUrlAction(isNotUndefined(url) ? url : '')); } + /** + * Set the redirect url if the current one has not been set yet + * @param newRedirectUrl + */ + setRedirectUrlIfNotSet(newRedirectUrl: string) { + this.getRedirectUrl().pipe( + take(1)) + .subscribe((currentRedirectUrl) => { + if (hasNoValue(currentRedirectUrl)) { + this.setRedirectUrl(newRedirectUrl); + } + }) + } + /** * Clear redirect url */ diff --git a/src/app/core/auth/authenticated.guard.ts b/src/app/core/auth/authenticated.guard.ts index 7a2f39854c..0b9eeec509 100644 --- a/src/app/core/auth/authenticated.guard.ts +++ b/src/app/core/auth/authenticated.guard.ts @@ -1,21 +1,26 @@ import { Injectable } from '@angular/core'; -import { ActivatedRouteSnapshot, CanActivate, CanLoad, Route, Router, RouterStateSnapshot } from '@angular/router'; +import { + ActivatedRouteSnapshot, + CanActivate, + Router, + RouterStateSnapshot, + UrlTree +} from '@angular/router'; import { Observable } from 'rxjs'; -import { take } from 'rxjs/operators'; +import { map, find, switchMap } from 'rxjs/operators'; import { select, Store } from '@ngrx/store'; import { CoreState } from '../core.reducers'; -import { isAuthenticated } from './selectors'; -import { AuthService } from './auth.service'; -import { RedirectWhenAuthenticationIsRequiredAction } from './auth.actions'; +import { isAuthenticated, isAuthenticationLoading } from './selectors'; +import { AuthService, LOGIN_ROUTE } from './auth.service'; /** * Prevent unauthorized activating and loading of routes * @class AuthenticatedGuard */ @Injectable() -export class AuthenticatedGuard implements CanActivate, CanLoad { +export class AuthenticatedGuard implements CanActivate { /** * @constructor @@ -24,46 +29,37 @@ export class AuthenticatedGuard implements CanActivate, CanLoad { /** * True when user is authenticated + * UrlTree with redirect to login page when user isn't authenticated * @method canActivate */ - canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { + canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { const url = state.url; return this.handleAuth(url); } /** * True when user is authenticated + * UrlTree with redirect to login page when user isn't authenticated * @method canActivateChild */ - canActivateChild(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { + canActivateChild(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { return this.canActivate(route, state); } - /** - * True when user is authenticated - * @method canLoad - */ - canLoad(route: Route): Observable { - const url = `/${route.path}`; - - return this.handleAuth(url); - } - - private handleAuth(url: string): Observable { - // get observable - const observable = this.store.pipe(select(isAuthenticated)); - + private handleAuth(url: string): Observable { // redirect to sign in page if user is not authenticated - observable.pipe( - // .filter(() => isEmpty(this.router.routerState.snapshot.url) || this.router.routerState.snapshot.url === url) - take(1)) - .subscribe((authenticated) => { - if (!authenticated) { + return this.store.pipe(select(isAuthenticationLoading)).pipe( + find((isLoading: boolean) => isLoading === false), + switchMap(() => this.store.pipe(select(isAuthenticated))), + map((authenticated) => { + if (authenticated) { + return authenticated; + } else { this.authService.setRedirectUrl(url); - this.store.dispatch(new RedirectWhenAuthenticationIsRequiredAction('Login required')); + this.authService.removeToken(); + return this.router.createUrlTree([LOGIN_ROUTE]); } - }); - - return observable; + }) + ); } } diff --git a/src/app/core/auth/selectors.ts b/src/app/core/auth/selectors.ts index 173f82e810..c4e95a0fb3 100644 --- a/src/app/core/auth/selectors.ts +++ b/src/app/core/auth/selectors.ts @@ -65,6 +65,14 @@ const _getAuthenticationInfo = (state: AuthState) => state.info; */ const _isLoading = (state: AuthState) => state.loading; +/** + * Returns true if everything else should wait for authentication. + * @function _isBlocking + * @param {State} state + * @returns {boolean} + */ +const _isBlocking = (state: AuthState) => state.blocking; + /** * Returns true if a refresh token request is in progress. * @function _isRefreshing @@ -170,6 +178,16 @@ export const isAuthenticatedLoaded = createSelector(getAuthState, _isAuthenticat */ export const isAuthenticationLoading = createSelector(getAuthState, _isLoading); +/** + * Returns true if the authentication should block everything else + * + * @function isAuthenticationBlocking + * @param {AuthState} state + * @param {any} props + * @return {boolean} + */ +export const isAuthenticationBlocking = createSelector(getAuthState, _isBlocking); + /** * Returns true if the refresh token request is loading. * @function isTokenRefreshing diff --git a/src/app/core/auth/server-auth.service.ts b/src/app/core/auth/server-auth.service.ts index 7b78255001..88a4ac406e 100644 --- a/src/app/core/auth/server-auth.service.ts +++ b/src/app/core/auth/server-auth.service.ts @@ -2,7 +2,7 @@ import { Injectable } from '@angular/core'; import { HttpHeaders } from '@angular/common/http'; import { Observable } from 'rxjs'; -import { filter, map, take } from 'rxjs/operators'; +import { map } from 'rxjs/operators'; import { isNotEmpty } from '../../shared/empty.util'; import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service'; @@ -58,32 +58,4 @@ export class ServerAuthService extends AuthService { map((status: AuthStatus) => Object.assign(new AuthStatus(), status)) ); } - - /** - * Redirect to the route navigated before the login - */ - public redirectAfterLoginSuccess(isStandalonePage: boolean) { - this.getRedirectUrl().pipe( - take(1)) - .subscribe((redirectUrl) => { - if (isNotEmpty(redirectUrl)) { - // override the route reuse strategy - this.router.routeReuseStrategy.shouldReuseRoute = () => { - return false; - }; - this.router.navigated = false; - const url = decodeURIComponent(redirectUrl); - this.router.navigateByUrl(url); - } else { - // If redirectUrl is empty use history. For ssr the history array should contain the requested url. - this.routeService.getHistory().pipe( - filter((history) => history.length > 0), - take(1) - ).subscribe((history) => { - this.navigateToRedirectUrl(history[history.length - 1] || ''); - }); - } - }) - } - } diff --git a/src/app/core/breadcrumbs/dso-breadcrumb.resolver.ts b/src/app/core/breadcrumbs/dso-breadcrumb.resolver.ts index 09292fec21..03d4db3f5d 100644 --- a/src/app/core/breadcrumbs/dso-breadcrumb.resolver.ts +++ b/src/app/core/breadcrumbs/dso-breadcrumb.resolver.ts @@ -3,12 +3,13 @@ import { Injectable } from '@angular/core'; import { ActivatedRouteSnapshot, Resolve, RouterStateSnapshot } from '@angular/router'; import { DSOBreadcrumbsService } from './dso-breadcrumbs.service'; import { DataService } from '../data/data.service'; -import { getRemoteDataPayload, getSucceededRemoteData } from '../shared/operators'; -import { map } from 'rxjs/operators'; +import { getRemoteDataPayload } from '../shared/operators'; +import { filter, map, take } from 'rxjs/operators'; import { Observable } from 'rxjs'; import { DSpaceObject } from '../shared/dspace-object.model'; import { ChildHALResource } from '../shared/child-hal-resource.model'; import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; +import { hasValue } from '../../shared/empty.util'; /** * The class that resolves the BreadcrumbConfig object for a DSpaceObject @@ -29,12 +30,17 @@ export abstract class DSOBreadcrumbResolver> { const uuid = route.params.id; return this.dataService.findById(uuid, ...this.followLinks).pipe( - getSucceededRemoteData(), + filter((rd) => hasValue(rd.error) || hasValue(rd.payload)), + take(1), getRemoteDataPayload(), map((object: T) => { - const fullPath = state.url; - const url = fullPath.substr(0, fullPath.indexOf(uuid)) + uuid; - return { provider: this.breadcrumbService, key: object, url: url }; + if (hasValue(object)) { + const fullPath = state.url; + const url = fullPath.substr(0, fullPath.indexOf(uuid)) + uuid; + return {provider: this.breadcrumbService, key: object, url: url}; + } else { + return undefined; + } }) ); } diff --git a/src/app/core/cache/object-cache.service.ts b/src/app/core/cache/object-cache.service.ts index d82a1f31fe..745133373d 100644 --- a/src/app/core/cache/object-cache.service.ts +++ b/src/app/core/cache/object-cache.service.ts @@ -270,7 +270,7 @@ export class ObjectCacheService { /** * Add operations to the existing list of operations for an ObjectCacheEntry * Makes sure the ServerSyncBuffer for this ObjectCacheEntry is updated - * @param {string} uuid + * @param selfLink * the uuid of the ObjectCacheEntry * @param {Operation[]} patch * list of operations to perform @@ -295,8 +295,8 @@ export class ObjectCacheService { /** * Apply the existing operations on an ObjectCacheEntry in the store * NB: this does not make any server side changes - * @param {string} uuid - * the uuid of the ObjectCacheEntry + * @param selfLink + * the link of the ObjectCacheEntry */ private applyPatchesToCachedObject(selfLink: string) { this.store.dispatch(new ApplyPatchObjectCacheAction(selfLink)); diff --git a/src/app/core/cache/response.models.ts b/src/app/core/cache/response.models.ts index 5f19185d1c..b33080b641 100644 --- a/src/app/core/cache/response.models.ts +++ b/src/app/core/cache/response.models.ts @@ -5,7 +5,6 @@ import { PageInfo } from '../shared/page-info.model'; import { ConfigObject } from '../config/models/config.model'; import { FacetValue } from '../../shared/search/facet-value.model'; import { SearchFilterConfig } from '../../shared/search/search-filter-config.model'; -import { IntegrationModel } from '../integration/models/integration.model'; import { PaginatedList } from '../data/paginated-list'; import { SubmissionObject } from '../submission/models/submission-object.model'; import { DSpaceObject } from '../shared/dspace-object.model'; @@ -181,17 +180,6 @@ export class TokenResponse extends RestResponse { } } -export class IntegrationSuccessResponse extends RestResponse { - constructor( - public dataDefinition: PaginatedList, - public statusCode: number, - public statusText: string, - public pageInfo?: PageInfo - ) { - super(true, statusCode, statusText); - } -} - export class PostPatchSuccessResponse extends RestResponse { constructor( public dataDefinition: any, diff --git a/src/app/core/core.module.ts b/src/app/core/core.module.ts index d262bfd0d6..95cd89e87d 100644 --- a/src/app/core/core.module.ts +++ b/src/app/core/core.module.ts @@ -1,6 +1,7 @@ import { CommonModule } from '@angular/common'; import { HTTP_INTERCEPTORS, HttpClient } from '@angular/common/http'; import { ModuleWithProviders, NgModule, Optional, SkipSelf } from '@angular/core'; + import { DynamicFormLayoutService, DynamicFormService, DynamicFormValidationService } from '@ng-dynamic-forms/core'; import { EffectsModule } from '@ngrx/effects'; @@ -16,8 +17,8 @@ import { MenuService } from '../shared/menu/menu.service'; import { EndpointMockingRestService } from '../shared/mocks/dspace-rest-v2/endpoint-mocking-rest.service'; import { MOCK_RESPONSE_MAP, - ResponseMapMock, - mockResponseMap + mockResponseMap, + ResponseMapMock } from '../shared/mocks/dspace-rest-v2/mocks/response-map.mock'; import { NotificationsService } from '../shared/notifications/notifications.service'; import { SelectableListService } from '../shared/object-list/selectable-list/selectable-list.service'; @@ -82,9 +83,6 @@ import { EPersonDataService } from './eperson/eperson-data.service'; import { EpersonResponseParsingService } from './eperson/eperson-response-parsing.service'; import { EPerson } from './eperson/models/eperson.model'; import { Group } from './eperson/models/group.model'; -import { AuthorityService } from './integration/authority.service'; -import { IntegrationResponseParsingService } from './integration/integration-response-parsing.service'; -import { AuthorityValue } from './integration/models/authority.value'; import { JsonPatchOperationsBuilder } from './json-patch/builder/json-patch-operations-builder'; import { MetadataField } from './metadata/metadata-field.model'; import { MetadataSchema } from './metadata/metadata-schema.model'; @@ -162,8 +160,20 @@ import { SubmissionCcLicenseDataService } from './submission/submission-cc-licen import { SubmissionCcLicence } from './submission/models/submission-cc-license.model'; import { SubmissionCcLicenceUrl } from './submission/models/submission-cc-license-url.model'; import { SubmissionCcLicenseUrlDataService } from './submission/submission-cc-license-url-data.service'; +import { VocabularyEntry } from './submission/vocabularies/models/vocabulary-entry.model'; +import { Vocabulary } from './submission/vocabularies/models/vocabulary.model'; +import { VocabularyEntriesResponseParsingService } from './submission/vocabularies/vocabulary-entries-response-parsing.service'; +import { VocabularyEntryDetail } from './submission/vocabularies/models/vocabulary-entry-detail.model'; +import { VocabularyService } from './submission/vocabularies/vocabulary.service'; +import { VocabularyTreeviewService } from '../shared/vocabulary-treeview/vocabulary-treeview.service'; import { ConfigurationDataService } from './data/configuration-data.service'; import { ConfigurationProperty } from './shared/configuration-property.model'; +import { ReloadGuard } from './reload/reload.guard'; +import { EndUserAgreementCurrentUserGuard } from './end-user-agreement/end-user-agreement-current-user.guard'; +import { EndUserAgreementCookieGuard } from './end-user-agreement/end-user-agreement-cookie.guard'; +import { EndUserAgreementService } from './end-user-agreement/end-user-agreement.service'; +import { SiteRegisterGuard } from './data/feature-authorization/feature-authorization-guard/site-register.guard'; +import { UsageReport } from './statistics/models/usage-report.model'; /** * When not in production, endpoint responses can be mocked for testing purposes @@ -197,7 +207,7 @@ const PROVIDERS = [ SiteDataService, DSOResponseParsingService, { provide: MOCK_RESPONSE_MAP, useValue: mockResponseMap }, - { provide: DSpaceRESTv2Service, useFactory: restServiceFactory, deps: [MOCK_RESPONSE_MAP, HttpClient]}, + { provide: DSpaceRESTv2Service, useFactory: restServiceFactory, deps: [MOCK_RESPONSE_MAP, HttpClient] }, DynamicFormLayoutService, DynamicFormService, DynamicFormValidationService, @@ -239,8 +249,6 @@ const PROVIDERS = [ SubmissionResponseParsingService, SubmissionJsonPatchOperationsService, JsonPatchOperationsBuilder, - AuthorityService, - IntegrationResponseParsingService, UploaderService, UUIDService, NotificationsService, @@ -289,9 +297,14 @@ const PROVIDERS = [ FeatureDataService, AuthorizationDataService, SiteAdministratorGuard, + SiteRegisterGuard, MetadataSchemaDataService, MetadataFieldDataService, TokenResponseParsingService, + ReloadGuard, + EndUserAgreementCurrentUserGuard, + EndUserAgreementCookieGuard, + EndUserAgreementService, // register AuthInterceptor as HttpInterceptor { provide: HTTP_INTERCEPTORS, @@ -306,7 +319,10 @@ const PROVIDERS = [ }, NotificationsService, FilteredDiscoveryPageResponseParsingService, - { provide: NativeWindowService, useFactory: NativeWindowFactory } + { provide: NativeWindowService, useFactory: NativeWindowFactory }, + VocabularyService, + VocabularyEntriesResponseParsingService, + VocabularyTreeviewService ]; /** @@ -337,7 +353,6 @@ export const models = SubmissionSectionModel, SubmissionUploadsModel, AuthStatus, - AuthorityValue, BrowseEntry, BrowseDefinition, ClaimedTask, @@ -358,7 +373,11 @@ export const models = Feature, Authorization, Registration, - ConfigurationProperty + Vocabulary, + VocabularyEntry, + VocabularyEntryDetail, + ConfigurationProperty, + UsageReport, ]; @NgModule({ diff --git a/src/app/core/data/bitstream-data.service.ts b/src/app/core/data/bitstream-data.service.ts index 4c24f5d78b..ca0338116f 100644 --- a/src/app/core/data/bitstream-data.service.ts +++ b/src/app/core/data/bitstream-data.service.ts @@ -30,6 +30,8 @@ import { RestResponse } from '../cache/response.models'; import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service'; import { configureRequest, getResponseFromEntry } from '../shared/operators'; import { combineLatest as observableCombineLatest } from 'rxjs'; +import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; +import { PageInfo } from '../shared/page-info.model'; /** * A service to retrieve {@link Bitstream}s from the REST API @@ -165,8 +167,10 @@ export class BitstreamDataService extends DataService { public findAllByItemAndBundleName(item: Item, bundleName: string, options?: FindListOptions, ...linksToFollow: Array>): Observable>> { return this.bundleService.findByItemAndName(item, bundleName).pipe( switchMap((bundleRD: RemoteData) => { - if (hasValue(bundleRD.payload)) { + if (bundleRD.hasSucceeded && hasValue(bundleRD.payload)) { return this.findAllByBundle(bundleRD.payload, options, ...linksToFollow); + } else if (!bundleRD.hasSucceeded && bundleRD.error.statusCode === 404) { + return createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), [])) } else { return [bundleRD as any]; } diff --git a/src/app/core/data/browse-entries-response-parsing.service.ts b/src/app/core/data/browse-entries-response-parsing.service.ts index 98385f0237..1009a07bca 100644 --- a/src/app/core/data/browse-entries-response-parsing.service.ts +++ b/src/app/core/data/browse-entries-response-parsing.service.ts @@ -1,40 +1,22 @@ -import { Inject, Injectable } from '@angular/core'; -import { isNotEmpty } from '../../shared/empty.util'; +import { Injectable } from '@angular/core'; import { ObjectCacheService } from '../cache/object-cache.service'; -import { ErrorResponse, GenericSuccessResponse, RestResponse } from '../cache/response.models'; -import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; -import { DSpaceSerializer } from '../dspace-rest-v2/dspace.serializer'; import { BrowseEntry } from '../shared/browse-entry.model'; -import { BaseResponseParsingService } from './base-response-parsing.service'; -import { ResponseParsingService } from './parsing.service'; -import { RestRequest } from './request.models'; +import { EntriesResponseParsingService } from './entries-response-parsing.service'; +import { GenericConstructor } from '../shared/generic-constructor'; @Injectable() -export class BrowseEntriesResponseParsingService extends BaseResponseParsingService implements ResponseParsingService { +export class BrowseEntriesResponseParsingService extends EntriesResponseParsingService { protected toCache = false; constructor( protected objectCache: ObjectCacheService, - ) { super(); + ) { + super(objectCache); } - parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse { - if (isNotEmpty(data.payload)) { - let browseEntries = []; - if (isNotEmpty(data.payload._embedded) && Array.isArray(data.payload._embedded[Object.keys(data.payload._embedded)[0]])) { - const serializer = new DSpaceSerializer(BrowseEntry); - browseEntries = serializer.deserializeArray(data.payload._embedded[Object.keys(data.payload._embedded)[0]]); - } - return new GenericSuccessResponse(browseEntries, data.statusCode, data.statusText, this.processPageInfo(data.payload)); - } else { - return new ErrorResponse( - Object.assign( - new Error('Unexpected response from browse endpoint'), - { statusCode: data.statusCode, statusText: data.statusText } - ) - ); - } + getSerializerModel(): GenericConstructor { + return BrowseEntry; } } diff --git a/src/app/core/data/bundle-data.service.spec.ts b/src/app/core/data/bundle-data.service.spec.ts index 1e1bf0eb9c..6c63ca8978 100644 --- a/src/app/core/data/bundle-data.service.spec.ts +++ b/src/app/core/data/bundle-data.service.spec.ts @@ -1,21 +1,11 @@ import { HttpClient } from '@angular/common/http'; import { Store } from '@ngrx/store'; import { compare, Operation } from 'fast-json-patch'; -import { Observable, of as observableOf } from 'rxjs'; import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { followLink } from '../../shared/utils/follow-link-config.model'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import { SortDirection, SortOptions } from '../cache/models/sort-options.model'; -import { ObjectCacheService } from '../cache/object-cache.service'; import { CoreState } from '../core.reducers'; -import { DSpaceObject } from '../shared/dspace-object.model'; -import { HALEndpointService } from '../shared/hal-endpoint.service'; import { Item } from '../shared/item.model'; -import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; import { ChangeAnalyzer } from './change-analyzer'; -import { DataService } from './data.service'; -import { FindListOptions, PatchRequest } from './request.models'; -import { RequestService } from './request.service'; import { getMockRequestService } from '../../shared/mocks/request.service.mock'; import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service.stub'; import { BundleDataService } from './bundle-data.service'; diff --git a/src/app/core/data/bundle-data.service.ts b/src/app/core/data/bundle-data.service.ts index de0e8a4337..e651ed354f 100644 --- a/src/app/core/data/bundle-data.service.ts +++ b/src/app/core/data/bundle-data.service.ts @@ -22,6 +22,7 @@ import { FindListOptions, GetRequest } from './request.models'; import { RequestService } from './request.service'; import { PaginatedSearchOptions } from '../../shared/search/paginated-search-options.model'; import { Bitstream } from '../shared/bitstream.model'; +import { RemoteDataError } from './remote-data-error'; /** * A service to retrieve {@link Bundle}s from the REST API @@ -71,13 +72,17 @@ export class BundleDataService extends DataService { if (hasValue(rd.payload) && hasValue(rd.payload.page)) { const matchingBundle = rd.payload.page.find((bundle: Bundle) => bundle.name === bundleName); - return new RemoteData( - false, - false, - true, - undefined, - matchingBundle - ); + if (hasValue(matchingBundle)) { + return new RemoteData( + false, + false, + true, + undefined, + matchingBundle + ); + } else { + return new RemoteData(false, false, false, new RemoteDataError(404, 'Not found', `The bundle with name ${bundleName} was not found.` )) + } } else { return rd as any; } diff --git a/src/app/core/data/collection-data.service.spec.ts b/src/app/core/data/collection-data.service.spec.ts index 76aad4ad56..4b0dee7df7 100644 --- a/src/app/core/data/collection-data.service.spec.ts +++ b/src/app/core/data/collection-data.service.spec.ts @@ -6,18 +6,18 @@ import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-servic import { NotificationsServiceStub } from '../../shared/testing/notifications-service.stub'; import { getMockTranslateService } from '../../shared/mocks/translate.service.mock'; import { fakeAsync, tick } from '@angular/core/testing'; -import { ContentSourceRequest, GetRequest, RequestError, UpdateContentSourceRequest } from './request.models'; +import { ContentSourceRequest, GetRequest, UpdateContentSourceRequest } from './request.models'; import { ContentSource } from '../shared/content-source.model'; import { of as observableOf } from 'rxjs/internal/observable/of'; import { RequestEntry } from './request.reducer'; -import { ErrorResponse, RestResponse } from '../cache/response.models'; +import { ErrorResponse } from '../cache/response.models'; import { ObjectCacheService } from '../cache/object-cache.service'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { Collection } from '../shared/collection.model'; import { PageInfo } from '../shared/page-info.model'; import { PaginatedList } from './paginated-list'; import { createSuccessfulRemoteDataObject } from 'src/app/shared/remote-data.utils'; -import { hot, getTestScheduler, cold } from 'jasmine-marbles'; +import { cold, getTestScheduler, hot } from 'jasmine-marbles'; import { TestScheduler } from 'rxjs/testing'; const url = 'fake-url'; diff --git a/src/app/core/data/community-data.service.ts b/src/app/core/data/community-data.service.ts index 123c3eccd1..474bdef44a 100644 --- a/src/app/core/data/community-data.service.ts +++ b/src/app/core/data/community-data.service.ts @@ -24,7 +24,7 @@ import { RequestService } from './request.service'; @dataService(COMMUNITY) export class CommunityDataService extends ComColDataService { protected linkPath = 'communities'; - protected topLinkPath = 'communities/search/top'; + protected topLinkPath = 'search/top'; protected cds = this; constructor( diff --git a/src/app/core/data/data.service.spec.ts b/src/app/core/data/data.service.spec.ts index a99fc54269..31013c5132 100644 --- a/src/app/core/data/data.service.spec.ts +++ b/src/app/core/data/data.service.spec.ts @@ -18,6 +18,7 @@ import { FindListOptions, PatchRequest } from './request.models'; import { RequestService } from './request.service'; import { getMockRequestService } from '../../shared/mocks/request.service.mock'; import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service.stub'; +import { RequestParam } from '../cache/models/request-param.model'; const endpoint = 'https://rest.api/core'; @@ -150,7 +151,8 @@ describe('DataService', () => { currentPage: 6, elementsPerPage: 10, sort: sortOptions, - startsWith: 'ab' + startsWith: 'ab', + }; const expected = `${endpoint}?page=${options.currentPage - 1}&size=${options.elementsPerPage}` + `&sort=${sortOptions.field},${sortOptions.direction}&startsWith=${options.startsWith}`; @@ -160,6 +162,26 @@ describe('DataService', () => { }); }); + it('should include all searchParams in href if any provided in options', () => { + options = { searchParams: [ + new RequestParam('param1', 'test'), + new RequestParam('param2', 'test2'), + ] }; + const expected = `${endpoint}?param1=test¶m2=test2`; + + (service as any).getFindAllHref(options).subscribe((value) => { + expect(value).toBe(expected); + }); + }); + + it('should include linkPath in href if any provided', () => { + const expected = `${endpoint}/test/entries`; + + (service as any).getFindAllHref({}, 'test/entries').subscribe((value) => { + expect(value).toBe(expected); + }); + }); + it('should include single linksToFollow as embed', () => { const expected = `${endpoint}?embed=bundles`; diff --git a/src/app/core/data/data.service.ts b/src/app/core/data/data.service.ts index 0d818f2030..e3f367c8bf 100644 --- a/src/app/core/data/data.service.ts +++ b/src/app/core/data/data.service.ts @@ -3,7 +3,7 @@ import { Store } from '@ngrx/store'; import { Operation } from 'fast-json-patch'; import { Observable } from 'rxjs'; import { distinctUntilChanged, filter, find, first, map, mergeMap, switchMap, take } from 'rxjs/operators'; -import { hasValue, isNotEmpty, isNotEmptyOperator } from '../../shared/empty.util'; +import { hasValue, hasValueOperator, isNotEmpty, isNotEmptyOperator } from '../../shared/empty.util'; import { NotificationOptions } from '../../shared/notifications/models/notification-options.model'; import { NotificationsService } from '../../shared/notifications/notifications.service'; import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; @@ -71,13 +71,17 @@ export abstract class DataService implements UpdateDa * Return an observable that emits created HREF * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved */ - protected getFindAllHref(options: FindListOptions = {}, linkPath?: string, ...linksToFollow: Array>): Observable { - let result$: Observable; + public getFindAllHref(options: FindListOptions = {}, linkPath?: string, ...linksToFollow: Array>): Observable { + let endpoint$: Observable; const args = []; - result$ = this.getBrowseEndpoint(options, linkPath).pipe(distinctUntilChanged()); + endpoint$ = this.getBrowseEndpoint(options).pipe( + filter((href: string) => isNotEmpty(href)), + map((href: string) => isNotEmpty(linkPath) ? `${href}/${linkPath}` : href), + distinctUntilChanged() + ); - return result$.pipe(map((result: string) => this.buildHrefFromFindOptions(result, options, args, ...linksToFollow))); + return endpoint$.pipe(map((result: string) => this.buildHrefFromFindOptions(result, options, args, ...linksToFollow))); } /** @@ -89,18 +93,12 @@ export abstract class DataService implements UpdateDa * Return an observable that emits created HREF * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved */ - protected getSearchByHref(searchMethod: string, options: FindListOptions = {}, ...linksToFollow: Array>): Observable { + public getSearchByHref(searchMethod: string, options: FindListOptions = {}, ...linksToFollow: Array>): Observable { let result$: Observable; const args = []; result$ = this.getSearchEndpoint(searchMethod); - if (hasValue(options.searchParams)) { - options.searchParams.forEach((param: RequestParam) => { - args.push(`${param.fieldName}=${param.fieldValue}`); - }) - } - return result$.pipe(map((result: string) => this.buildHrefFromFindOptions(result, options, args, ...linksToFollow))); } @@ -114,7 +112,7 @@ export abstract class DataService implements UpdateDa * Return an observable that emits created HREF * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved */ - protected buildHrefFromFindOptions(href: string, options: FindListOptions, extraArgs: string[] = [], ...linksToFollow: Array>): string { + public buildHrefFromFindOptions(href: string, options: FindListOptions, extraArgs: string[] = [], ...linksToFollow: Array>): string { let args = [...extraArgs]; if (hasValue(options.currentPage) && typeof options.currentPage === 'number') { @@ -130,6 +128,11 @@ export abstract class DataService implements UpdateDa if (hasValue(options.startsWith)) { args = [...args, `startsWith=${options.startsWith}`]; } + if (hasValue(options.searchParams)) { + options.searchParams.forEach((param: RequestParam) => { + args = [...args, `${param.fieldName}=${param.fieldValue}`]; + }) + } args = this.addEmbedParams(args, ...linksToFollow); if (isNotEmpty(args)) { return new URLCombiner(href, `?${args.join('&')}`).toString(); @@ -373,11 +376,20 @@ export abstract class DataService implements UpdateDa ).subscribe(); return this.requestService.getByUUID(requestId).pipe( + hasValueOperator(), find((request: RequestEntry) => request.completed), map((request: RequestEntry) => request.response) ); } + createPatchFromCache(object: T): Observable { + const oldVersion$ = this.findByHref(object._links.self.href); + return oldVersion$.pipe( + getSucceededRemoteData(), + getRemoteDataPayload(), + map((oldVersion: T) => this.comparator.diff(oldVersion, object))); + } + /** * Send a PUT request for the specified object * @@ -406,18 +418,16 @@ export abstract class DataService implements UpdateDa * @param {DSpaceObject} object The given object */ update(object: T): Observable> { - const oldVersion$ = this.findByHref(object._links.self.href); - return oldVersion$.pipe( - getSucceededRemoteData(), - getRemoteDataPayload(), - mergeMap((oldVersion: T) => { - const operations = this.comparator.diff(oldVersion, object); - if (isNotEmpty(operations)) { - this.objectCache.addPatch(object._links.self.href, operations); + return this.createPatchFromCache(object) + .pipe( + mergeMap((operations: Operation[]) => { + if (isNotEmpty(operations)) { + this.objectCache.addPatch(object._links.self.href, operations); + } + return this.findByHref(object._links.self.href); } - return this.findByHref(object._links.self.href); - } - )); + ) + ); } /** diff --git a/src/app/core/data/entries-response-parsing.service.ts b/src/app/core/data/entries-response-parsing.service.ts new file mode 100644 index 0000000000..09ae8ae1c5 --- /dev/null +++ b/src/app/core/data/entries-response-parsing.service.ts @@ -0,0 +1,54 @@ +import { isNotEmpty } from '../../shared/empty.util'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { ErrorResponse, GenericSuccessResponse, RestResponse } from '../cache/response.models'; +import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; +import { DSpaceSerializer } from '../dspace-rest-v2/dspace.serializer'; +import { BaseResponseParsingService } from './base-response-parsing.service'; +import { ResponseParsingService } from './parsing.service'; +import { RestRequest } from './request.models'; +import { CacheableObject } from '../cache/object-cache.reducer'; +import { GenericConstructor } from '../shared/generic-constructor'; + +/** + * An abstract class to extend, responsible for parsing data for an entries response + */ +export abstract class EntriesResponseParsingService extends BaseResponseParsingService implements ResponseParsingService { + + protected toCache = false; + + constructor( + protected objectCache: ObjectCacheService, + ) { + super(); + } + + /** + * Abstract method to implement that must return the dspace serializer Constructor to use during parse + */ + abstract getSerializerModel(): GenericConstructor; + + /** + * Parse response + * + * @param request + * @param data + */ + parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse { + if (isNotEmpty(data.payload)) { + let entries = []; + if (isNotEmpty(data.payload._embedded) && Array.isArray(data.payload._embedded[Object.keys(data.payload._embedded)[0]])) { + const serializer = new DSpaceSerializer(this.getSerializerModel()); + entries = serializer.deserializeArray(data.payload._embedded[Object.keys(data.payload._embedded)[0]]); + } + return new GenericSuccessResponse(entries, data.statusCode, data.statusText, this.processPageInfo(data.payload)); + } else { + return new ErrorResponse( + Object.assign( + new Error('Unexpected response from browse endpoint'), + { statusCode: data.statusCode, statusText: data.statusText } + ) + ); + } + } + +} diff --git a/src/app/core/data/feature-authorization/authorization-data.service.spec.ts b/src/app/core/data/feature-authorization/authorization-data.service.spec.ts index 29db1a086b..7db7c27c29 100644 --- a/src/app/core/data/feature-authorization/authorization-data.service.spec.ts +++ b/src/app/core/data/feature-authorization/authorization-data.service.spec.ts @@ -63,33 +63,33 @@ describe('AuthorizationDataService', () => { return Object.assign(new FindListOptions(), { searchParams }); } - describe('when no arguments are provided and a user is authenticated', () => { + describe('when no arguments are provided', () => { beforeEach(() => { service.searchByObject().subscribe(); }); - it('should call searchBy with the site\'s url and authenticated user\'s uuid', () => { - expect(service.searchBy).toHaveBeenCalledWith('object', createExpected(site.self, ePerson.uuid)); + it('should call searchBy with the site\'s url', () => { + expect(service.searchBy).toHaveBeenCalledWith('object', createExpected(site.self)); }); }); - describe('when no arguments except for a feature are provided and a user is authenticated', () => { + describe('when no arguments except for a feature are provided', () => { beforeEach(() => { service.searchByObject(FeatureID.LoginOnBehalfOf).subscribe(); }); - it('should call searchBy with the site\'s url, authenticated user\'s uuid and the feature', () => { - expect(service.searchBy).toHaveBeenCalledWith('object', createExpected(site.self, ePerson.uuid, FeatureID.LoginOnBehalfOf)); + it('should call searchBy with the site\'s url and the feature', () => { + expect(service.searchBy).toHaveBeenCalledWith('object', createExpected(site.self, null, FeatureID.LoginOnBehalfOf)); }); }); - describe('when a feature and object url are provided, but no user uuid and a user is authenticated', () => { + describe('when a feature and object url are provided', () => { beforeEach(() => { service.searchByObject(FeatureID.LoginOnBehalfOf, objectUrl).subscribe(); }); - it('should call searchBy with the object\'s url, authenticated user\'s uuid and the feature', () => { - expect(service.searchBy).toHaveBeenCalledWith('object', createExpected(objectUrl, ePerson.uuid, FeatureID.LoginOnBehalfOf)); + it('should call searchBy with the object\'s url and the feature', () => { + expect(service.searchBy).toHaveBeenCalledWith('object', createExpected(objectUrl, null, FeatureID.LoginOnBehalfOf)); }); }); @@ -102,17 +102,6 @@ describe('AuthorizationDataService', () => { expect(service.searchBy).toHaveBeenCalledWith('object', createExpected(objectUrl, ePersonUuid, FeatureID.LoginOnBehalfOf)); }); }); - - describe('when no arguments are provided and no user is authenticated', () => { - beforeEach(() => { - spyOn(authService, 'isAuthenticated').and.returnValue(observableOf(false)); - service.searchByObject().subscribe(); - }); - - it('should call searchBy with the site\'s url', () => { - expect(service.searchBy).toHaveBeenCalledWith('object', createExpected(site.self)); - }); - }); }); describe('isAuthorized', () => { diff --git a/src/app/core/data/feature-authorization/authorization-data.service.ts b/src/app/core/data/feature-authorization/authorization-data.service.ts index 2d32b26efa..4dfa89cde6 100644 --- a/src/app/core/data/feature-authorization/authorization-data.service.ts +++ b/src/app/core/data/feature-authorization/authorization-data.service.ts @@ -25,7 +25,6 @@ import { hasValue, isNotEmpty } from '../../../shared/empty.util'; import { RequestParam } from '../../cache/models/request-param.model'; import { AuthorizationSearchParams } from './authorization-search-params'; import { - addAuthenticatedUserUuidIfEmpty, addSiteObjectUrlIfEmpty, oneAuthorizationMatchesFeature } from './authorization-utils'; @@ -90,7 +89,6 @@ export class AuthorizationDataService extends DataService { searchByObject(featureId?: FeatureID, objectUrl?: string, ePersonUuid?: string, options: FindListOptions = {}, ...linksToFollow: Array>): Observable>> { return observableOf(new AuthorizationSearchParams(objectUrl, ePersonUuid, featureId)).pipe( addSiteObjectUrlIfEmpty(this.siteService), - addAuthenticatedUserUuidIfEmpty(this.authService), switchMap((params: AuthorizationSearchParams) => { return this.searchBy(this.searchByObjectPath, this.createSearchOptions(params.objectUrl, options, params.ePersonUuid, params.featureId), ...linksToFollow); }) diff --git a/src/app/core/data/feature-authorization/feature-authorization-guard/dso-page-feature.guard.spec.ts b/src/app/core/data/feature-authorization/feature-authorization-guard/dso-page-feature.guard.spec.ts new file mode 100644 index 0000000000..1f5efd1329 --- /dev/null +++ b/src/app/core/data/feature-authorization/feature-authorization-guard/dso-page-feature.guard.spec.ts @@ -0,0 +1,63 @@ +import { AuthorizationDataService } from '../authorization-data.service'; +import { ActivatedRouteSnapshot, Resolve, Router, RouterStateSnapshot } from '@angular/router'; +import { RemoteData } from '../../remote-data'; +import { of as observableOf } from 'rxjs'; +import { createSuccessfulRemoteDataObject$ } from '../../../../shared/remote-data.utils'; +import { DSpaceObject } from '../../../shared/dspace-object.model'; +import { DsoPageFeatureGuard } from './dso-page-feature.guard'; +import { FeatureID } from '../feature-id'; +import { Observable } from 'rxjs/internal/Observable'; + +/** + * Test implementation of abstract class DsoPageAdministratorGuard + */ +class DsoPageFeatureGuardImpl extends DsoPageFeatureGuard { + constructor(protected resolver: Resolve>, + protected authorizationService: AuthorizationDataService, + protected router: Router, + protected featureID: FeatureID) { + super(resolver, authorizationService, router); + } + + getFeatureID(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { + return observableOf(this.featureID); + } +} + +describe('DsoPageAdministratorGuard', () => { + let guard: DsoPageFeatureGuard; + let authorizationService: AuthorizationDataService; + let router: Router; + let resolver: Resolve>; + let object: DSpaceObject; + + function init() { + object = { + self: 'test-selflink' + } as DSpaceObject; + + authorizationService = jasmine.createSpyObj('authorizationService', { + isAuthorized: observableOf(true) + }); + router = jasmine.createSpyObj('router', { + parseUrl: {} + }); + resolver = jasmine.createSpyObj('resolver', { + resolve: createSuccessfulRemoteDataObject$(object) + }); + guard = new DsoPageFeatureGuardImpl(resolver, authorizationService, router, undefined); + } + + beforeEach(() => { + init(); + }); + + describe('getObjectUrl', () => { + it('should return the resolved object\'s selflink', (done) => { + guard.getObjectUrl(undefined, undefined).subscribe((selflink) => { + expect(selflink).toEqual(object.self); + done(); + }); + }); + }); +}); diff --git a/src/app/core/data/feature-authorization/feature-authorization-guard/dso-page-feature.guard.ts b/src/app/core/data/feature-authorization/feature-authorization-guard/dso-page-feature.guard.ts new file mode 100644 index 0000000000..ed2590b521 --- /dev/null +++ b/src/app/core/data/feature-authorization/feature-authorization-guard/dso-page-feature.guard.ts @@ -0,0 +1,30 @@ +import { ActivatedRouteSnapshot, Resolve, Router, RouterStateSnapshot } from '@angular/router'; +import { RemoteData } from '../../remote-data'; +import { AuthorizationDataService } from '../authorization-data.service'; +import { Observable } from 'rxjs/internal/Observable'; +import { getAllSucceededRemoteDataPayload } from '../../../shared/operators'; +import { map } from 'rxjs/operators'; +import { DSpaceObject } from '../../../shared/dspace-object.model'; +import { FeatureAuthorizationGuard } from './feature-authorization.guard'; + +/** + * Abstract Guard for preventing unauthorized access to {@link DSpaceObject} pages that require rights for a specific feature + * This guard utilizes a resolver to retrieve the relevant object to check authorizations for + */ +export abstract class DsoPageFeatureGuard extends FeatureAuthorizationGuard { + constructor(protected resolver: Resolve>, + protected authorizationService: AuthorizationDataService, + protected router: Router) { + super(authorizationService, router); + } + + /** + * Check authorization rights for the object resolved using the provided resolver + */ + getObjectUrl(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { + return (this.resolver.resolve(route, state) as Observable>).pipe( + getAllSucceededRemoteDataPayload(), + map((dso) => dso.self) + ); + } +} diff --git a/src/app/core/data/feature-authorization/feature-authorization-guard/feature-authorization.guard.spec.ts b/src/app/core/data/feature-authorization/feature-authorization-guard/feature-authorization.guard.spec.ts index bfd161bad2..829a246dcc 100644 --- a/src/app/core/data/feature-authorization/feature-authorization-guard/feature-authorization.guard.spec.ts +++ b/src/app/core/data/feature-authorization/feature-authorization-guard/feature-authorization.guard.spec.ts @@ -2,7 +2,8 @@ import { FeatureAuthorizationGuard } from './feature-authorization.guard'; import { AuthorizationDataService } from '../authorization-data.service'; import { FeatureID } from '../feature-id'; import { of as observableOf } from 'rxjs'; -import { Router } from '@angular/router'; +import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from '@angular/router'; +import { Observable } from 'rxjs/internal/Observable'; /** * Test implementation of abstract class FeatureAuthorizationGuard @@ -17,16 +18,16 @@ class FeatureAuthorizationGuardImpl extends FeatureAuthorizationGuard { super(authorizationService, router); } - getFeatureID(): FeatureID { - return this.featureId; + getFeatureID(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { + return observableOf(this.featureId); } - getObjectUrl(): string { - return this.objectUrl; + getObjectUrl(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { + return observableOf(this.objectUrl); } - getEPersonUuid(): string { - return this.ePersonUuid; + getEPersonUuid(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { + return observableOf(this.ePersonUuid); } } diff --git a/src/app/core/data/feature-authorization/feature-authorization-guard/feature-authorization.guard.ts b/src/app/core/data/feature-authorization/feature-authorization-guard/feature-authorization.guard.ts index 7806d87b0c..d53e71e289 100644 --- a/src/app/core/data/feature-authorization/feature-authorization-guard/feature-authorization.guard.ts +++ b/src/app/core/data/feature-authorization/feature-authorization-guard/feature-authorization.guard.ts @@ -9,6 +9,8 @@ import { AuthorizationDataService } from '../authorization-data.service'; import { FeatureID } from '../feature-id'; import { Observable } from 'rxjs/internal/Observable'; import { returnUnauthorizedUrlTreeOnFalse } from '../../../shared/operators'; +import { combineLatest as observableCombineLatest, of as observableOf } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; /** * Abstract Guard for preventing unauthorized activating and loading of routes when a user @@ -24,29 +26,32 @@ export abstract class FeatureAuthorizationGuard implements CanActivate { * True when user has authorization rights for the feature and object provided * Redirect the user to the unauthorized page when he/she's not authorized for the given feature */ - canActivate(next: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { - return this.authorizationService.isAuthorized(this.getFeatureID(), this.getObjectUrl(), this.getEPersonUuid()).pipe(returnUnauthorizedUrlTreeOnFalse(this.router)); + canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { + return observableCombineLatest(this.getFeatureID(route, state), this.getObjectUrl(route, state), this.getEPersonUuid(route, state)).pipe( + switchMap(([featureID, objectUrl, ePersonUuid]) => this.authorizationService.isAuthorized(featureID, objectUrl, ePersonUuid)), + returnUnauthorizedUrlTreeOnFalse(this.router) + ); } /** * The type of feature to check authorization for * Override this method to define a feature */ - abstract getFeatureID(): FeatureID; + abstract getFeatureID(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable; /** * The URL of the object to check if the user has authorized rights for * Override this method to define an object URL. If not provided, the {@link Site}'s URL will be used */ - getObjectUrl(): string { - return undefined; + getObjectUrl(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { + return observableOf(undefined); } /** * The UUID of the user to check authorization rights for * Override this method to define an {@link EPerson} UUID. If not provided, the authenticated user's UUID will be used. */ - getEPersonUuid(): string { - return undefined; + getEPersonUuid(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { + return observableOf(undefined); } } diff --git a/src/app/core/data/feature-authorization/feature-authorization-guard/site-administrator.guard.ts b/src/app/core/data/feature-authorization/feature-authorization-guard/site-administrator.guard.ts index a64e40468d..a45049645a 100644 --- a/src/app/core/data/feature-authorization/feature-authorization-guard/site-administrator.guard.ts +++ b/src/app/core/data/feature-authorization/feature-authorization-guard/site-administrator.guard.ts @@ -2,7 +2,9 @@ import { Injectable } from '@angular/core'; import { FeatureAuthorizationGuard } from './feature-authorization.guard'; import { FeatureID } from '../feature-id'; import { AuthorizationDataService } from '../authorization-data.service'; -import { Router } from '@angular/router'; +import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from '@angular/router'; +import { of as observableOf } from 'rxjs'; +import { Observable } from 'rxjs/internal/Observable'; /** * Prevent unauthorized activating and loading of routes when the current authenticated user doesn't have administrator @@ -19,7 +21,7 @@ export class SiteAdministratorGuard extends FeatureAuthorizationGuard { /** * Check administrator authorization rights */ - getFeatureID(): FeatureID { - return FeatureID.AdministratorOf; + getFeatureID(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { + return observableOf(FeatureID.AdministratorOf); } } diff --git a/src/app/core/data/feature-authorization/feature-authorization-guard/site-register.guard.ts b/src/app/core/data/feature-authorization/feature-authorization-guard/site-register.guard.ts new file mode 100644 index 0000000000..18397cf71e --- /dev/null +++ b/src/app/core/data/feature-authorization/feature-authorization-guard/site-register.guard.ts @@ -0,0 +1,27 @@ +import { FeatureAuthorizationGuard } from './feature-authorization.guard'; +import { Injectable } from '@angular/core'; +import { AuthorizationDataService } from '../authorization-data.service'; +import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from '@angular/router'; +import { Observable } from 'rxjs/internal/Observable'; +import { FeatureID } from '../feature-id'; +import { of as observableOf } from 'rxjs'; + +/** + * Prevent unauthorized activating and loading of routes when the current authenticated user doesn't have registration + * rights to the {@link Site} + */ +@Injectable({ + providedIn: 'root' +}) +export class SiteRegisterGuard extends FeatureAuthorizationGuard { + constructor(protected authorizationService: AuthorizationDataService, protected router: Router) { + super(authorizationService, router); + } + + /** + * Check registration authorization rights + */ + getFeatureID(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { + return observableOf(FeatureID.EPersonRegistration); + } +} diff --git a/src/app/core/data/feature-authorization/feature-id.ts b/src/app/core/data/feature-authorization/feature-id.ts index 4731e92d6c..450d5057aa 100644 --- a/src/app/core/data/feature-authorization/feature-id.ts +++ b/src/app/core/data/feature-authorization/feature-id.ts @@ -3,5 +3,9 @@ */ export enum FeatureID { LoginOnBehalfOf = 'loginOnBehalfOf', - AdministratorOf = 'administratorOf' + AdministratorOf = 'administratorOf', + CanDelete = 'canDelete', + WithdrawItem = 'withdrawItem', + ReinstateItem = 'reinstateItem', + EPersonRegistration = 'epersonRegistration', } diff --git a/src/app/core/data/item-template-data.service.ts b/src/app/core/data/item-template-data.service.ts index a44d48e9bd..4f26f47eee 100644 --- a/src/app/core/data/item-template-data.service.ts +++ b/src/app/core/data/item-template-data.service.ts @@ -20,6 +20,7 @@ import { switchMap, map } from 'rxjs/operators'; import { BundleDataService } from './bundle-data.service'; import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; import { RestResponse } from '../cache/response.models'; +import { Operation } from 'fast-json-patch'; /* tslint:disable:max-classes-per-file */ /** @@ -165,6 +166,10 @@ export class ItemTemplateDataService implements UpdateDataService { return this.dataService.update(object); } + patch(dso: Item, operations: Operation[]): Observable { + return this.dataService.patch(dso, operations); + } + /** * Find an item template by collection ID * @param collectionID diff --git a/src/app/core/data/metadata-field-data.service.ts b/src/app/core/data/metadata-field-data.service.ts index 1f7a4b9089..af2ab7c45c 100644 --- a/src/app/core/data/metadata-field-data.service.ts +++ b/src/app/core/data/metadata-field-data.service.ts @@ -1,6 +1,9 @@ import { Injectable } from '@angular/core'; +import { hasValue } from '../../shared/empty.util'; import { dataService } from '../cache/builders/build-decorators'; import { DataService } from './data.service'; +import { PaginatedList } from './paginated-list'; +import { RemoteData } from './remote-data'; import { RequestService } from './request.service'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { Store } from '@ngrx/store'; @@ -27,6 +30,7 @@ import { RequestParam } from '../cache/models/request-param.model'; export class MetadataFieldDataService extends DataService { protected linkPath = 'metadatafields'; protected searchBySchemaLinkPath = 'bySchema'; + protected searchByFieldNameLinkPath = 'byFieldName'; constructor( protected requestService: RequestService, @@ -53,6 +57,43 @@ export class MetadataFieldDataService extends DataService { return this.searchBy(this.searchBySchemaLinkPath, optionsWithSchema, ...linksToFollow); } + /** + * Find metadata fields with either the partial metadata field name (e.g. "dc.ti") as query or an exact match to + * at least the schema, element or qualifier + * @param schema optional; an exact match of the prefix of the metadata schema (e.g. "dc", "dcterms", "eperson") + * @param element optional; an exact match of the field's element (e.g. "contributor", "title") + * @param qualifier optional; an exact match of the field's qualifier (e.g. "author", "alternative") + * @param query optional (if any of schema, element or qualifier used) - part of the fully qualified field, + * should start with the start of the schema, element or qualifier (e.g. “dc.ti”, “contributor”, “auth”, “contributor.ot”) + * @param exactName optional; the exact fully qualified field, should use the syntax schema.element.qualifier or + * schema.element if no qualifier exists (e.g. "dc.title", "dc.contributor.author"). It will only return one value + * if there's an exact match + * @param options The options info used to retrieve the fields + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved + */ + searchByFieldNameParams(schema: string, element: string, qualifier: string, query: string, exactName: string, options: FindListOptions = {}, ...linksToFollow: Array>): Observable>> { + const optionParams = Object.assign(new FindListOptions(), options, { + searchParams: [ + new RequestParam('schema', hasValue(schema) ? schema : ''), + new RequestParam('element', hasValue(element) ? element : ''), + new RequestParam('qualifier', hasValue(qualifier) ? qualifier : ''), + new RequestParam('query', hasValue(query) ? query : ''), + new RequestParam('exactName', hasValue(exactName) ? exactName : '') + ] + }); + return this.searchBy(this.searchByFieldNameLinkPath, optionParams, ...linksToFollow); + } + + /** + * Finds a specific metadata field by name. + * @param exactFieldName The exact fully qualified field, should use the syntax schema.element.qualifier or + * schema.element if no qualifier exists (e.g. "dc.title", "dc.contributor.author"). It will only return one value + * if there's an exact match, empty list if there is no exact match. + */ + findByExactFieldName(exactFieldName: string): Observable>> { + return this.searchByFieldNameParams(null, null, null, null, exactFieldName, null); + } + /** * Clear all metadata field requests * Used for refreshing lists after adding/updating/removing a metadata field from a metadata schema diff --git a/src/app/core/data/object-updates/object-updates.actions.ts b/src/app/core/data/object-updates/object-updates.actions.ts index f26be768b1..ff0babdd14 100644 --- a/src/app/core/data/object-updates/object-updates.actions.ts +++ b/src/app/core/data/object-updates/object-updates.actions.ts @@ -2,6 +2,8 @@ import {type} from '../../../shared/ngrx/type'; import {Action} from '@ngrx/store'; import {Identifiable} from './object-updates.reducer'; import {INotification} from '../../../shared/notifications/models/notification.model'; +import { InjectionToken } from '@angular/core'; +import { PatchOperationService } from './patch-operation-service/patch-operation.service'; /** * The list of ObjectUpdatesAction type definitions @@ -38,7 +40,8 @@ export class InitializeFieldsAction implements Action { payload: { url: string, fields: Identifiable[], - lastModified: Date + lastModified: Date, + patchOperationServiceToken?: InjectionToken }; /** @@ -48,16 +51,15 @@ export class InitializeFieldsAction implements Action { * the unique url of the page for which the fields are being initialized * @param fields The identifiable fields of which the updates are kept track of * @param lastModified The last modified date of the object that belongs to the page - * @param order A custom order to keep track of objects moving around - * @param pageSize The page size used to fill empty pages for the custom order - * @param page The first page to populate in the custom order + * @param patchOperationServiceToken An InjectionToken referring to the {@link PatchOperationService} used for creating a patch */ constructor( url: string, fields: Identifiable[], - lastModified: Date + lastModified: Date, + patchOperationServiceToken?: InjectionToken ) { - this.payload = { url, fields, lastModified }; + this.payload = { url, fields, lastModified, patchOperationServiceToken }; } } diff --git a/src/app/core/data/object-updates/object-updates.reducer.spec.ts b/src/app/core/data/object-updates/object-updates.reducer.spec.ts index cb7f44039c..4a14e2e874 100644 --- a/src/app/core/data/object-updates/object-updates.reducer.spec.ts +++ b/src/app/core/data/object-updates/object-updates.reducer.spec.ts @@ -231,7 +231,8 @@ describe('objectUpdatesReducer', () => { }, fieldUpdates: {}, virtualMetadataSources: {}, - lastModified: modDate + lastModified: modDate, + patchOperationServiceToken: undefined } }; const newState = objectUpdatesReducer(testState, action); diff --git a/src/app/core/data/object-updates/object-updates.reducer.ts b/src/app/core/data/object-updates/object-updates.reducer.ts index b1626a5ff5..94bb845aa8 100644 --- a/src/app/core/data/object-updates/object-updates.reducer.ts +++ b/src/app/core/data/object-updates/object-updates.reducer.ts @@ -14,6 +14,8 @@ import { } from './object-updates.actions'; import { hasNoValue, hasValue } from '../../../shared/empty.util'; import {Relationship} from '../../shared/item-relationships/relationship.model'; +import { InjectionToken } from '@angular/core'; +import { PatchOperationService } from './patch-operation-service/patch-operation.service'; /** * Path where discarded objects are saved @@ -48,7 +50,7 @@ export interface Identifiable { */ export interface FieldUpdate { field: Identifiable, - changeType: FieldChangeType + changeType: FieldChangeType, } /** @@ -89,6 +91,7 @@ export interface ObjectUpdatesEntry { fieldUpdates: FieldUpdates; virtualMetadataSources: VirtualMetadataSources; lastModified: Date; + patchOperationServiceToken?: InjectionToken; } /** @@ -163,6 +166,7 @@ function initializeFieldsUpdate(state: any, action: InitializeFieldsAction) { const url: string = action.payload.url; const fields: Identifiable[] = action.payload.fields; const lastModifiedServer: Date = action.payload.lastModified; + const patchOperationServiceToken: InjectionToken = action.payload.patchOperationServiceToken; const fieldStates = createInitialFieldStates(fields); const newPageState = Object.assign( {}, @@ -170,7 +174,8 @@ function initializeFieldsUpdate(state: any, action: InitializeFieldsAction) { { fieldStates: fieldStates }, { fieldUpdates: {} }, { virtualMetadataSources: {} }, - { lastModified: lastModifiedServer } + { lastModified: lastModifiedServer }, + { patchOperationServiceToken } ); return Object.assign({}, state, { [url]: newPageState }); } diff --git a/src/app/core/data/object-updates/object-updates.service.spec.ts b/src/app/core/data/object-updates/object-updates.service.spec.ts index 04018b8de2..ae73dc851f 100644 --- a/src/app/core/data/object-updates/object-updates.service.spec.ts +++ b/src/app/core/data/object-updates/object-updates.service.spec.ts @@ -12,6 +12,7 @@ import { Notification } from '../../../shared/notifications/models/notification. import { NotificationType } from '../../../shared/notifications/models/notification-type'; import { OBJECT_UPDATES_TRASH_PATH } from './object-updates.reducer'; import {Relationship} from '../../shared/item-relationships/relationship.model'; +import { Injector } from '@angular/core'; describe('ObjectUpdatesService', () => { let service: ObjectUpdatesService; @@ -31,6 +32,9 @@ describe('ObjectUpdatesService', () => { }; const modDate = new Date(2010, 2, 11); + const injectionToken = 'fake-injection-token'; + let patchOperationService; + let injector: Injector; beforeEach(() => { const fieldStates = { @@ -40,11 +44,17 @@ describe('ObjectUpdatesService', () => { }; const objectEntry = { - fieldStates, fieldUpdates, lastModified: modDate, virtualMetadataSources: {} + fieldStates, fieldUpdates, lastModified: modDate, virtualMetadataSources: {}, patchOperationServiceToken: injectionToken }; store = new Store(undefined, undefined, undefined); spyOn(store, 'dispatch'); - service = new ObjectUpdatesService(store); + patchOperationService = jasmine.createSpyObj('patchOperationService', { + fieldUpdatesToPatchOperations: [] + }); + injector = jasmine.createSpyObj('injector', { + get: patchOperationService + }); + service = new ObjectUpdatesService(store, injector); spyOn(service as any, 'getObjectEntry').and.returnValue(observableOf(objectEntry)); spyOn(service as any, 'getFieldState').and.callFake((uuid) => { @@ -277,4 +287,26 @@ describe('ObjectUpdatesService', () => { }); }); + describe('createPatch', () => { + let result$; + + beforeEach(() => { + result$ = service.createPatch(url); + }); + + it('should inject the service using the token stored in the entry', (done) => { + result$.subscribe(() => { + expect(injector.get).toHaveBeenCalledWith(injectionToken); + done(); + }); + }); + + it('should create a patch from the fieldUpdates using the injected service', (done) => { + result$.subscribe(() => { + expect(patchOperationService.fieldUpdatesToPatchOperations).toHaveBeenCalledWith(fieldUpdates); + done(); + }); + }); + }); + }); diff --git a/src/app/core/data/object-updates/object-updates.service.ts b/src/app/core/data/object-updates/object-updates.service.ts index 84f0f06035..8bd32e54e2 100644 --- a/src/app/core/data/object-updates/object-updates.service.ts +++ b/src/app/core/data/object-updates/object-updates.service.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@angular/core'; +import { Injectable, InjectionToken, Injector } from '@angular/core'; import { createSelector, MemoizedSelector, select, Store } from '@ngrx/store'; import { CoreState } from '../../core.reducers'; import { coreSelector } from '../../core.selectors'; @@ -26,6 +26,8 @@ import { import { distinctUntilChanged, filter, map, switchMap } from 'rxjs/operators'; import { hasNoValue, hasValue, isEmpty, isNotEmpty, isNotEmptyOperator } from '../../../shared/empty.util'; import { INotification } from '../../../shared/notifications/models/notification.model'; +import { Operation } from 'fast-json-patch'; +import { PatchOperationService } from './patch-operation-service/patch-operation.service'; function objectUpdatesStateSelector(): MemoizedSelector { return createSelector(coreSelector, (state: CoreState) => state['cache/object-updates']); @@ -48,7 +50,8 @@ function virtualMetadataSourceSelector(url: string, source: string): MemoizedSel */ @Injectable() export class ObjectUpdatesService { - constructor(private store: Store) { + constructor(private store: Store, + private injector: Injector) { } /** @@ -56,9 +59,10 @@ export class ObjectUpdatesService { * @param url The page's URL for which the changes are being mapped * @param fields The initial fields for the page's object * @param lastModified The date the object was last modified + * @param patchOperationServiceToken An InjectionToken referring to the {@link PatchOperationService} used for creating a patch */ - initialize(url, fields: Identifiable[], lastModified: Date): void { - this.store.dispatch(new InitializeFieldsAction(url, fields, lastModified)); + initialize(url, fields: Identifiable[], lastModified: Date, patchOperationServiceToken?: InjectionToken): void { + this.store.dispatch(new InitializeFieldsAction(url, fields, lastModified, patchOperationServiceToken)); } /** @@ -339,4 +343,22 @@ export class ObjectUpdatesService { getLastModified(url: string): Observable { return this.getObjectEntry(url).pipe(map((entry: ObjectUpdatesEntry) => entry.lastModified)); } + + /** + * Create a patch from the current object-updates state + * The {@link ObjectUpdatesEntry} should contain a patchOperationServiceToken, in order to define how a patch should + * be created. If it doesn't, an empty patch will be returned. + * @param url The URL of the page for which the patch should be created + */ + createPatch(url: string): Observable { + return this.getObjectEntry(url).pipe( + map((entry) => { + let patch = []; + if (hasValue(entry.patchOperationServiceToken)) { + patch = this.injector.get(entry.patchOperationServiceToken).fieldUpdatesToPatchOperations(entry.fieldUpdates); + } + return patch; + }) + ); + } } diff --git a/src/app/core/data/object-updates/patch-operation-service/metadata-patch-operation.service.spec.ts b/src/app/core/data/object-updates/patch-operation-service/metadata-patch-operation.service.spec.ts new file mode 100644 index 0000000000..f3578b1bde --- /dev/null +++ b/src/app/core/data/object-updates/patch-operation-service/metadata-patch-operation.service.spec.ts @@ -0,0 +1,252 @@ +import { MetadataPatchOperationService } from './metadata-patch-operation.service'; +import { FieldUpdates } from '../object-updates.reducer'; +import { Operation } from 'fast-json-patch'; +import { FieldChangeType } from '../object-updates.actions'; +import { MetadatumViewModel } from '../../../shared/metadata.models'; + +describe('MetadataPatchOperationService', () => { + let service: MetadataPatchOperationService; + + beforeEach(() => { + service = new MetadataPatchOperationService(); + }); + + describe('fieldUpdatesToPatchOperations', () => { + let fieldUpdates: FieldUpdates; + let expected: Operation[]; + let result: Operation[]; + + describe('when fieldUpdates contains a single remove', () => { + beforeEach(() => { + fieldUpdates = Object.assign({ + update1: { + field: Object.assign(new MetadatumViewModel(), { + key: 'dc.title', + value: 'Deleted title', + place: 0 + }), + changeType: FieldChangeType.REMOVE + } + }); + expected = [ + { op: 'remove', path: '/metadata/dc.title/0' } + ] as any[]; + result = service.fieldUpdatesToPatchOperations(fieldUpdates); + }); + + it('should contain a single remove operation with the correct path', () => { + expect(result).toEqual(expected); + }); + }); + + describe('when fieldUpdates contains a single add', () => { + beforeEach(() => { + fieldUpdates = Object.assign({ + update1: { + field: Object.assign(new MetadatumViewModel(), { + key: 'dc.title', + value: 'Added title', + place: 0 + }), + changeType: FieldChangeType.ADD + } + }); + expected = [ + { op: 'add', path: '/metadata/dc.title/-', value: [ { value: 'Added title', language: undefined } ] } + ] as any[]; + result = service.fieldUpdatesToPatchOperations(fieldUpdates); + }); + + it('should contain a single add operation with the correct path', () => { + expect(result).toEqual(expected); + }); + }); + + describe('when fieldUpdates contains a single update', () => { + beforeEach(() => { + fieldUpdates = Object.assign({ + update1: { + field: Object.assign(new MetadatumViewModel(), { + key: 'dc.title', + value: 'Changed title', + place: 0 + }), + changeType: FieldChangeType.UPDATE + } + }); + expected = [ + { op: 'replace', path: '/metadata/dc.title/0', value: { value: 'Changed title', language: undefined } } + ] as any[]; + result = service.fieldUpdatesToPatchOperations(fieldUpdates); + }); + + it('should contain a single replace operation with the correct path', () => { + expect(result).toEqual(expected); + }); + }); + + describe('when fieldUpdates contains multiple removes with incrementing indexes', () => { + beforeEach(() => { + fieldUpdates = Object.assign({ + update1: { + field: Object.assign(new MetadatumViewModel(), { + key: 'dc.title', + value: 'First deleted title', + place: 0 + }), + changeType: FieldChangeType.REMOVE + }, + update2: { + field: Object.assign(new MetadatumViewModel(), { + key: 'dc.title', + value: 'Second deleted title', + place: 1 + }), + changeType: FieldChangeType.REMOVE + }, + update3: { + field: Object.assign(new MetadatumViewModel(), { + key: 'dc.title', + value: 'Third deleted title', + place: 2 + }), + changeType: FieldChangeType.REMOVE + } + }); + expected = [ + { op: 'remove', path: '/metadata/dc.title/0' }, + { op: 'remove', path: '/metadata/dc.title/0' }, + { op: 'remove', path: '/metadata/dc.title/0' } + ] as any[]; + result = service.fieldUpdatesToPatchOperations(fieldUpdates); + }); + + it('should contain all the remove operations on the same index', () => { + expect(result).toEqual(expected); + }); + }); + + describe('when fieldUpdates contains multiple removes with decreasing indexes', () => { + beforeEach(() => { + fieldUpdates = Object.assign({ + update1: { + field: Object.assign(new MetadatumViewModel(), { + key: 'dc.title', + value: 'Third deleted title', + place: 2 + }), + changeType: FieldChangeType.REMOVE + }, + update2: { + field: Object.assign(new MetadatumViewModel(), { + key: 'dc.title', + value: 'Second deleted title', + place: 1 + }), + changeType: FieldChangeType.REMOVE + }, + update3: { + field: Object.assign(new MetadatumViewModel(), { + key: 'dc.title', + value: 'First deleted title', + place: 0 + }), + changeType: FieldChangeType.REMOVE + } + }); + expected = [ + { op: 'remove', path: '/metadata/dc.title/2' }, + { op: 'remove', path: '/metadata/dc.title/1' }, + { op: 'remove', path: '/metadata/dc.title/0' } + ] as any[]; + result = service.fieldUpdatesToPatchOperations(fieldUpdates); + }); + + it('should contain all the remove operations with their corresponding indexes', () => { + expect(result).toEqual(expected); + }); + }); + + describe('when fieldUpdates contains multiple removes with random indexes', () => { + beforeEach(() => { + fieldUpdates = Object.assign({ + update1: { + field: Object.assign(new MetadatumViewModel(), { + key: 'dc.title', + value: 'Second deleted title', + place: 1 + }), + changeType: FieldChangeType.REMOVE + }, + update2: { + field: Object.assign(new MetadatumViewModel(), { + key: 'dc.title', + value: 'Third deleted title', + place: 2 + }), + changeType: FieldChangeType.REMOVE + }, + update3: { + field: Object.assign(new MetadatumViewModel(), { + key: 'dc.title', + value: 'First deleted title', + place: 0 + }), + changeType: FieldChangeType.REMOVE + } + }); + expected = [ + { op: 'remove', path: '/metadata/dc.title/1' }, + { op: 'remove', path: '/metadata/dc.title/1' }, + { op: 'remove', path: '/metadata/dc.title/0' } + ] as any[]; + result = service.fieldUpdatesToPatchOperations(fieldUpdates); + }); + + it('should contain all the remove operations with the correct indexes taking previous operations into account', () => { + expect(result).toEqual(expected); + }); + }); + + describe('when fieldUpdates contains multiple removes and updates with random indexes', () => { + beforeEach(() => { + fieldUpdates = Object.assign({ + update1: { + field: Object.assign(new MetadatumViewModel(), { + key: 'dc.title', + value: 'Second deleted title', + place: 1 + }), + changeType: FieldChangeType.REMOVE + }, + update2: { + field: Object.assign(new MetadatumViewModel(), { + key: 'dc.title', + value: 'Third changed title', + place: 2 + }), + changeType: FieldChangeType.UPDATE + }, + update3: { + field: Object.assign(new MetadatumViewModel(), { + key: 'dc.title', + value: 'First deleted title', + place: 0 + }), + changeType: FieldChangeType.REMOVE + } + }); + expected = [ + { op: 'remove', path: '/metadata/dc.title/1' }, + { op: 'replace', path: '/metadata/dc.title/1', value: { value: 'Third changed title', language: undefined } }, + { op: 'remove', path: '/metadata/dc.title/0' } + ] as any[]; + result = service.fieldUpdatesToPatchOperations(fieldUpdates); + }); + + it('should contain all the remove and replace operations with the correct indexes taking previous remove operations into account', () => { + expect(result).toEqual(expected); + }); + }); + }); +}); diff --git a/src/app/core/data/object-updates/patch-operation-service/metadata-patch-operation.service.ts b/src/app/core/data/object-updates/patch-operation-service/metadata-patch-operation.service.ts new file mode 100644 index 0000000000..3b590cf58c --- /dev/null +++ b/src/app/core/data/object-updates/patch-operation-service/metadata-patch-operation.service.ts @@ -0,0 +1,106 @@ +import { PatchOperationService } from './patch-operation.service'; +import { MetadatumViewModel } from '../../../shared/metadata.models'; +import { FieldUpdates } from '../object-updates.reducer'; +import { Operation } from 'fast-json-patch'; +import { FieldChangeType } from '../object-updates.actions'; +import { InjectionToken } from '@angular/core'; +import { MetadataPatchOperation } from './operations/metadata/metadata-patch-operation.model'; +import { hasValue } from '../../../../shared/empty.util'; +import { MetadataPatchAddOperation } from './operations/metadata/metadata-patch-add-operation.model'; +import { MetadataPatchRemoveOperation } from './operations/metadata/metadata-patch-remove-operation.model'; +import { MetadataPatchReplaceOperation } from './operations/metadata/metadata-patch-replace-operation.model'; + +/** + * Token to use for injecting this service anywhere you want + * This token can used to store in the object-updates store + */ +export const METADATA_PATCH_OPERATION_SERVICE_TOKEN = new InjectionToken('MetadataPatchOperationService', { + providedIn: 'root', + factory: () => new MetadataPatchOperationService(), +}); + +/** + * Service transforming {@link FieldUpdates} into {@link Operation}s for metadata values + * This expects the fields within every {@link FieldUpdate} to be {@link MetadatumViewModel}s + */ +export class MetadataPatchOperationService implements PatchOperationService { + + /** + * Transform a {@link FieldUpdates} object into an array of fast-json-patch Operations for metadata values + * This method first creates an array of {@link MetadataPatchOperation} wrapper operations, which are then + * iterated over to create the actual patch operations. While iterating, it has the ability to check for previous + * operations that would modify the operation's position and act accordingly. + * @param fieldUpdates + */ + fieldUpdatesToPatchOperations(fieldUpdates: FieldUpdates): Operation[] { + const metadataPatch = this.fieldUpdatesToMetadataPatchOperations(fieldUpdates); + + // This map stores what metadata fields had a value deleted at which places + // This is used to modify the place of operations to match previous operations + const metadataRemoveMap = new Map(); + const patch = []; + metadataPatch.forEach((operation) => { + // If this operation is removing or editing an existing value, first check the map for previous operations + // If the map contains remove operations before this operation's place, lower the place by 1 for each + if ((operation.op === MetadataPatchRemoveOperation.operationType || operation.op === MetadataPatchReplaceOperation.operationType) && hasValue((operation as any).place)) { + if (metadataRemoveMap.has(operation.field)) { + metadataRemoveMap.get(operation.field).forEach((index) => { + if (index < (operation as any).place) { + (operation as any).place--; + } + }); + } + } + + // If this is a remove operation, add its (updated) place to the map, so we can adjust following operations accordingly + if (operation.op === MetadataPatchRemoveOperation.operationType && hasValue((operation as any).place)) { + if (!metadataRemoveMap.has(operation.field)) { + metadataRemoveMap.set(operation.field, []); + } + metadataRemoveMap.get(operation.field).push((operation as any).place); + } + + // Transform the updated operation into a fast-json-patch Operation and add it to the patch + patch.push(operation.toOperation()); + }); + + return patch; + } + + /** + * Transform a {@link FieldUpdates} object into an array of {@link MetadataPatchOperation} wrapper objects + * These wrapper objects contain detailed information about the patch operation that needs to be creates for each update + * This information can then be modified before creating the actual patch + * @param fieldUpdates + */ + fieldUpdatesToMetadataPatchOperations(fieldUpdates: FieldUpdates): MetadataPatchOperation[] { + const metadataPatch = []; + + Object.keys(fieldUpdates).forEach((uuid) => { + const update = fieldUpdates[uuid]; + const metadatum = update.field as MetadatumViewModel; + const val = { + value: metadatum.value, + language: metadatum.language + } + + let operation: MetadataPatchOperation; + switch (update.changeType) { + case FieldChangeType.ADD: + operation = new MetadataPatchAddOperation(metadatum.key, [ val ]); + break; + case FieldChangeType.REMOVE: + operation = new MetadataPatchRemoveOperation(metadatum.key, metadatum.place); + break; + case FieldChangeType.UPDATE: + operation = new MetadataPatchReplaceOperation(metadatum.key, metadatum.place, val); + break; + } + + metadataPatch.push(operation); + }); + + return metadataPatch; + } + +} diff --git a/src/app/core/data/object-updates/patch-operation-service/operations/metadata/metadata-patch-add-operation.model.ts b/src/app/core/data/object-updates/patch-operation-service/operations/metadata/metadata-patch-add-operation.model.ts new file mode 100644 index 0000000000..7f9b1d772f --- /dev/null +++ b/src/app/core/data/object-updates/patch-operation-service/operations/metadata/metadata-patch-add-operation.model.ts @@ -0,0 +1,27 @@ +import { MetadataPatchOperation } from './metadata-patch-operation.model'; +import { Operation } from 'fast-json-patch'; + +/** + * Wrapper object for a metadata patch add Operation + */ +export class MetadataPatchAddOperation extends MetadataPatchOperation { + static operationType = 'add'; + + /** + * The metadata value(s) to add to the field + */ + value: any; + + constructor(field: string, value: any) { + super(MetadataPatchAddOperation.operationType, field); + this.value = value; + } + + /** + * Transform the MetadataPatchOperation into a fast-json-patch Operation by constructing its path and other properties + * using the information provided. + */ + toOperation(): Operation { + return { op: this.op as any, path: `/metadata/${this.field}/-`, value: this.value }; + } +} diff --git a/src/app/core/data/object-updates/patch-operation-service/operations/metadata/metadata-patch-operation.model.ts b/src/app/core/data/object-updates/patch-operation-service/operations/metadata/metadata-patch-operation.model.ts new file mode 100644 index 0000000000..fb7c826fc9 --- /dev/null +++ b/src/app/core/data/object-updates/patch-operation-service/operations/metadata/metadata-patch-operation.model.ts @@ -0,0 +1,29 @@ +import { Operation } from 'fast-json-patch'; + +/** + * Wrapper object for metadata patch Operations + * It should contain at least the operation type and metadata field. An abstract method to transform this object + * into a fast-json-patch Operation is defined in each instance extending from this. + */ +export abstract class MetadataPatchOperation { + /** + * The operation to perform + */ + op: string; + + /** + * The metadata field this operation is intended for + */ + field: string; + + constructor(op: string, field: string) { + this.op = op; + this.field = field; + } + + /** + * Transform the MetadataPatchOperation into a fast-json-patch Operation by constructing its path and other properties + * using the information provided. + */ + abstract toOperation(): Operation; +} diff --git a/src/app/core/data/object-updates/patch-operation-service/operations/metadata/metadata-patch-remove-operation.model.ts b/src/app/core/data/object-updates/patch-operation-service/operations/metadata/metadata-patch-remove-operation.model.ts new file mode 100644 index 0000000000..61fbae1980 --- /dev/null +++ b/src/app/core/data/object-updates/patch-operation-service/operations/metadata/metadata-patch-remove-operation.model.ts @@ -0,0 +1,27 @@ +import { MetadataPatchOperation } from './metadata-patch-operation.model'; +import { Operation } from 'fast-json-patch'; + +/** + * Wrapper object for a metadata patch remove Operation + */ +export class MetadataPatchRemoveOperation extends MetadataPatchOperation { + static operationType = 'remove'; + + /** + * The place of the metadata value to remove within its field + */ + place: number; + + constructor(field: string, place: number) { + super(MetadataPatchRemoveOperation.operationType, field); + this.place = place; + } + + /** + * Transform the MetadataPatchOperation into a fast-json-patch Operation by constructing its path and other properties + * using the information provided. + */ + toOperation(): Operation { + return { op: this.op as any, path: `/metadata/${this.field}/${this.place}` }; + } +} diff --git a/src/app/core/data/object-updates/patch-operation-service/operations/metadata/metadata-patch-replace-operation.model.ts b/src/app/core/data/object-updates/patch-operation-service/operations/metadata/metadata-patch-replace-operation.model.ts new file mode 100644 index 0000000000..e889bede0b --- /dev/null +++ b/src/app/core/data/object-updates/patch-operation-service/operations/metadata/metadata-patch-replace-operation.model.ts @@ -0,0 +1,33 @@ +import { MetadataPatchOperation } from './metadata-patch-operation.model'; +import { Operation } from 'fast-json-patch'; + +/** + * Wrapper object for a metadata patch replace Operation + */ +export class MetadataPatchReplaceOperation extends MetadataPatchOperation { + static operationType = 'replace'; + + /** + * The place of the metadata value within its field to modify + */ + place: number; + + /** + * The new value to replace the metadata with + */ + value: any; + + constructor(field: string, place: number, value: any) { + super(MetadataPatchReplaceOperation.operationType, field); + this.place = place; + this.value = value; + } + + /** + * Transform the MetadataPatchOperation into a fast-json-patch Operation by constructing its path and other properties + * using the information provided. + */ + toOperation(): Operation { + return { op: this.op as any, path: `/metadata/${this.field}/${this.place}`, value: this.value }; + } +} diff --git a/src/app/core/data/object-updates/patch-operation-service/patch-operation.service.ts b/src/app/core/data/object-updates/patch-operation-service/patch-operation.service.ts new file mode 100644 index 0000000000..7c67f9a2e5 --- /dev/null +++ b/src/app/core/data/object-updates/patch-operation-service/patch-operation.service.ts @@ -0,0 +1,15 @@ +import { FieldUpdates } from '../object-updates.reducer'; +import { Operation } from 'fast-json-patch'; + +/** + * Interface for a service dealing with the transformations of patch operations from the object-updates store + * The implementations of this service know how to deal with the fields of a FieldUpdate and how to transform them + * into patch Operations. + */ +export interface PatchOperationService { + /** + * Transform a {@link FieldUpdates} object into an array of fast-json-patch Operations + * @param fieldUpdates + */ + fieldUpdatesToPatchOperations(fieldUpdates: FieldUpdates): Operation[]; +} diff --git a/src/app/core/data/request.models.ts b/src/app/core/data/request.models.ts index bd497d4ddb..6730487660 100644 --- a/src/app/core/data/request.models.ts +++ b/src/app/core/data/request.models.ts @@ -9,7 +9,6 @@ import { ConfigResponseParsingService } from '../config/config-response-parsing. import { AuthResponseParsingService } from '../auth/auth-response-parsing.service'; import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service'; import { SubmissionResponseParsingService } from '../submission/submission-response-parsing.service'; -import { IntegrationResponseParsingService } from '../integration/integration-response-parsing.service'; import { RestRequestMethod } from './rest-request-method'; import { RequestParam } from '../cache/models/request-param.model'; import { EpersonResponseParsingService } from '../eperson/eperson-response-parsing.service'; @@ -20,12 +19,13 @@ import { ContentSourceResponseParsingService } from './content-source-response-p import { MappedCollectionsReponseParsingService } from './mapped-collections-reponse-parsing.service'; import { ProcessFilesResponseParsingService } from './process-files-response-parsing.service'; import { TokenResponseParsingService } from '../auth/token-response-parsing.service'; +import { VocabularyEntriesResponseParsingService } from '../submission/vocabularies/vocabulary-entries-response-parsing.service'; /* tslint:disable:max-classes-per-file */ // uuid and handle requests have separate endpoints export enum IdentifierType { - UUID ='uuid', + UUID = 'uuid', HANDLE = 'handle' } @@ -60,7 +60,7 @@ export class GetRequest extends RestRequest { public href: string, public body?: any, public options?: HttpOptions - ) { + ) { super(uuid, href, RestRequestMethod.GET, body, options) } } @@ -71,7 +71,7 @@ export class PostRequest extends RestRequest { public href: string, public body?: any, public options?: HttpOptions - ) { + ) { super(uuid, href, RestRequestMethod.POST, body) } } @@ -97,7 +97,7 @@ export class PutRequest extends RestRequest { public href: string, public body?: any, public options?: HttpOptions - ) { + ) { super(uuid, href, RestRequestMethod.PUT, body) } } @@ -108,7 +108,7 @@ export class DeleteRequest extends RestRequest { public href: string, public body?: any, public options?: HttpOptions - ) { + ) { super(uuid, href, RestRequestMethod.DELETE, body) } } @@ -119,7 +119,7 @@ export class OptionsRequest extends RestRequest { public href: string, public body?: any, public options?: HttpOptions - ) { + ) { super(uuid, href, RestRequestMethod.OPTIONS, body) } } @@ -130,7 +130,7 @@ export class HeadRequest extends RestRequest { public href: string, public body?: any, public options?: HttpOptions - ) { + ) { super(uuid, href, RestRequestMethod.HEAD, body) } } @@ -143,7 +143,7 @@ export class PatchRequest extends RestRequest { public href: string, public body?: any, public options?: HttpOptions - ) { + ) { super(uuid, href, RestRequestMethod.PATCH, body) } } @@ -276,16 +276,6 @@ export class TokenPostRequest extends PostRequest { } } -export class IntegrationRequest extends GetRequest { - constructor(uuid: string, href: string) { - super(uuid, href); - } - - getResponseParser(): GenericConstructor { - return IntegrationResponseParsingService; - } -} - /** * Class representing a submission HTTP GET request object */ @@ -425,6 +415,15 @@ export class MyDSpaceRequest extends GetRequest { public responseMsToLive = 10 * 1000; } +/** + * Request to get vocabulary entries + */ +export class VocabularyEntriesRequest extends FindListRequest { + getResponseParser(): GenericConstructor { + return VocabularyEntriesResponseParsingService; + } +} + export class RequestError extends Error { statusCode: number; statusText: string; diff --git a/src/app/core/data/update-data.service.ts b/src/app/core/data/update-data.service.ts index 34835e14c1..f572c75982 100644 --- a/src/app/core/data/update-data.service.ts +++ b/src/app/core/data/update-data.service.ts @@ -1,11 +1,14 @@ import { Observable } from 'rxjs/internal/Observable'; import { RemoteData } from './remote-data'; import { RestRequestMethod } from './rest-request-method'; +import { Operation } from 'fast-json-patch'; +import { RestResponse } from '../cache/response.models'; /** * Represents a data service to update a given object */ export interface UpdateDataService { + patch(dso: T, operations: Operation[]): Observable; update(object: T): Observable>; commitUpdates(method?: RestRequestMethod); } diff --git a/src/app/core/dspace-rest-v2/dspace-rest-v2.service.ts b/src/app/core/dspace-rest-v2/dspace-rest-v2.service.ts index 8cb730c358..5394b6d83f 100644 --- a/src/app/core/dspace-rest-v2/dspace-rest-v2.service.ts +++ b/src/app/core/dspace-rest-v2/dspace-rest-v2.service.ts @@ -5,7 +5,7 @@ import { HttpClient, HttpHeaders, HttpParams, HttpResponse } from '@angular/comm import { DSpaceRESTV2Response } from './dspace-rest-v2-response.model'; import { RestRequestMethod } from '../data/rest-request-method'; -import { hasNoValue, isNotEmpty } from '../../shared/empty.util'; +import { hasNoValue, hasValue, isNotEmpty } from '../../shared/empty.util'; import { DSpaceObject } from '../shared/dspace-object.model'; export const DEFAULT_CONTENT_TYPE = 'application/json; charset=utf-8'; @@ -53,7 +53,7 @@ export class DSpaceRESTv2Service { return observableThrowError({ statusCode: err.status, statusText: err.statusText, - message: err.message + message: (hasValue(err.error) && isNotEmpty(err.error.message)) ? err.error.message : err.message }); })); } @@ -116,7 +116,7 @@ export class DSpaceRESTv2Service { return observableThrowError({ statusCode: err.status, statusText: err.statusText, - message: err.message + message: (hasValue(err.error) && isNotEmpty(err.error.message)) ? err.error.message : err.message }); })); } diff --git a/src/app/core/end-user-agreement/abstract-end-user-agreement.guard.ts b/src/app/core/end-user-agreement/abstract-end-user-agreement.guard.ts new file mode 100644 index 0000000000..ee07da004b --- /dev/null +++ b/src/app/core/end-user-agreement/abstract-end-user-agreement.guard.ts @@ -0,0 +1,32 @@ +import { ActivatedRouteSnapshot, CanActivate, Router, RouterStateSnapshot, UrlTree } from '@angular/router'; +import { Observable } from 'rxjs/internal/Observable'; +import { returnEndUserAgreementUrlTreeOnFalse } from '../shared/operators'; + +/** + * An abstract guard for redirecting users to the user agreement page if a certain condition is met + * That condition is defined by abstract method hasAccepted + */ +export abstract class AbstractEndUserAgreementGuard implements CanActivate { + + constructor(protected router: Router) { + } + + /** + * True when the user agreement has been accepted + * The user will be redirected to the End User Agreement page if they haven't accepted it before + * A redirect URL will be provided with the navigation so the component can redirect the user back to the blocked route + * when they're finished accepting the agreement + */ + canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { + return this.hasAccepted().pipe( + returnEndUserAgreementUrlTreeOnFalse(this.router, state.url) + ); + } + + /** + * This abstract method determines how the User Agreement has to be accepted before the user is allowed to visit + * the desired route + */ + abstract hasAccepted(): Observable; + +} diff --git a/src/app/core/end-user-agreement/end-user-agreement-cookie.guard.spec.ts b/src/app/core/end-user-agreement/end-user-agreement-cookie.guard.spec.ts new file mode 100644 index 0000000000..805c765832 --- /dev/null +++ b/src/app/core/end-user-agreement/end-user-agreement-cookie.guard.spec.ts @@ -0,0 +1,47 @@ +import { EndUserAgreementService } from './end-user-agreement.service'; +import { Router, UrlTree } from '@angular/router'; +import { EndUserAgreementCookieGuard } from './end-user-agreement-cookie.guard'; + +describe('EndUserAgreementCookieGuard', () => { + let guard: EndUserAgreementCookieGuard; + + let endUserAgreementService: EndUserAgreementService; + let router: Router; + + beforeEach(() => { + endUserAgreementService = jasmine.createSpyObj('endUserAgreementService', { + isCookieAccepted: true + }); + router = jasmine.createSpyObj('router', { + navigateByUrl: {}, + parseUrl: new UrlTree(), + createUrlTree: new UrlTree() + }); + + guard = new EndUserAgreementCookieGuard(endUserAgreementService, router); + }); + + describe('canActivate', () => { + describe('when the cookie has been accepted', () => { + it('should return true', (done) => { + guard.canActivate(undefined, { url: Object.assign({ url: 'redirect' }) } as any).subscribe((result) => { + expect(result).toEqual(true); + done(); + }); + }); + }); + + describe('when the cookie hasn\'t been accepted', () => { + beforeEach(() => { + (endUserAgreementService.isCookieAccepted as jasmine.Spy).and.returnValue(false); + }); + + it('should return a UrlTree', (done) => { + guard.canActivate(undefined, Object.assign({ url: 'redirect' })).subscribe((result) => { + expect(result).toEqual(jasmine.any(UrlTree)); + done(); + }); + }); + }); + }); +}); diff --git a/src/app/core/end-user-agreement/end-user-agreement-cookie.guard.ts b/src/app/core/end-user-agreement/end-user-agreement-cookie.guard.ts new file mode 100644 index 0000000000..e6461859f3 --- /dev/null +++ b/src/app/core/end-user-agreement/end-user-agreement-cookie.guard.ts @@ -0,0 +1,26 @@ +import { Injectable } from '@angular/core'; +import { AbstractEndUserAgreementGuard } from './abstract-end-user-agreement.guard'; +import { Observable } from 'rxjs/internal/Observable'; +import { of as observableOf } from 'rxjs'; +import { EndUserAgreementService } from './end-user-agreement.service'; +import { Router } from '@angular/router'; + +/** + * A guard redirecting users to the end agreement page when the user agreement cookie hasn't been accepted + */ +@Injectable() +export class EndUserAgreementCookieGuard extends AbstractEndUserAgreementGuard { + + constructor(protected endUserAgreementService: EndUserAgreementService, + protected router: Router) { + super(router); + } + + /** + * True when the user agreement cookie has been accepted + */ + hasAccepted(): Observable { + return observableOf(this.endUserAgreementService.isCookieAccepted()); + } + +} diff --git a/src/app/core/end-user-agreement/end-user-agreement-current-user.guard.spec.ts b/src/app/core/end-user-agreement/end-user-agreement-current-user.guard.spec.ts new file mode 100644 index 0000000000..1892509aef --- /dev/null +++ b/src/app/core/end-user-agreement/end-user-agreement-current-user.guard.spec.ts @@ -0,0 +1,49 @@ +import { EndUserAgreementCurrentUserGuard } from './end-user-agreement-current-user.guard'; +import { EndUserAgreementService } from './end-user-agreement.service'; +import { Router, UrlTree } from '@angular/router'; +import { of as observableOf } from 'rxjs'; +import { AuthService } from '../auth/auth.service'; + +describe('EndUserAgreementGuard', () => { + let guard: EndUserAgreementCurrentUserGuard; + + let endUserAgreementService: EndUserAgreementService; + let router: Router; + + beforeEach(() => { + endUserAgreementService = jasmine.createSpyObj('endUserAgreementService', { + hasCurrentUserAcceptedAgreement: observableOf(true) + }); + router = jasmine.createSpyObj('router', { + navigateByUrl: {}, + parseUrl: new UrlTree(), + createUrlTree: new UrlTree() + }); + + guard = new EndUserAgreementCurrentUserGuard(endUserAgreementService, router); + }); + + describe('canActivate', () => { + describe('when the user has accepted the agreement', () => { + it('should return true', (done) => { + guard.canActivate(undefined, Object.assign({ url: 'redirect' })).subscribe((result) => { + expect(result).toEqual(true); + done(); + }); + }); + }); + + describe('when the user hasn\'t accepted the agreement', () => { + beforeEach(() => { + (endUserAgreementService.hasCurrentUserAcceptedAgreement as jasmine.Spy).and.returnValue(observableOf(false)); + }); + + it('should return a UrlTree', (done) => { + guard.canActivate(undefined, Object.assign({ url: 'redirect' })).subscribe((result) => { + expect(result).toEqual(jasmine.any(UrlTree)); + done(); + }); + }); + }); + }); +}); diff --git a/src/app/core/end-user-agreement/end-user-agreement-current-user.guard.ts b/src/app/core/end-user-agreement/end-user-agreement-current-user.guard.ts new file mode 100644 index 0000000000..348a3285cc --- /dev/null +++ b/src/app/core/end-user-agreement/end-user-agreement-current-user.guard.ts @@ -0,0 +1,25 @@ +import { Injectable } from '@angular/core'; +import { Observable } from 'rxjs/internal/Observable'; +import { AbstractEndUserAgreementGuard } from './abstract-end-user-agreement.guard'; +import { EndUserAgreementService } from './end-user-agreement.service'; +import { Router } from '@angular/router'; + +/** + * A guard redirecting logged in users to the end agreement page when they haven't accepted the latest user agreement + */ +@Injectable() +export class EndUserAgreementCurrentUserGuard extends AbstractEndUserAgreementGuard { + + constructor(protected endUserAgreementService: EndUserAgreementService, + protected router: Router) { + super(router); + } + + /** + * True when the currently logged in user has accepted the agreements or when the user is not currently authenticated + */ + hasAccepted(): Observable { + return this.endUserAgreementService.hasCurrentUserAcceptedAgreement(true); + } + +} diff --git a/src/app/core/end-user-agreement/end-user-agreement.service.spec.ts b/src/app/core/end-user-agreement/end-user-agreement.service.spec.ts new file mode 100644 index 0000000000..d50c730d28 --- /dev/null +++ b/src/app/core/end-user-agreement/end-user-agreement.service.spec.ts @@ -0,0 +1,138 @@ +import { + END_USER_AGREEMENT_COOKIE, + END_USER_AGREEMENT_METADATA_FIELD, + EndUserAgreementService +} from './end-user-agreement.service'; +import { CookieServiceMock } from '../../shared/mocks/cookie.service.mock'; +import { of as observableOf } from 'rxjs'; +import { EPerson } from '../eperson/models/eperson.model'; +import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; +import { RestResponse } from '../cache/response.models'; + +describe('EndUserAgreementService', () => { + let service: EndUserAgreementService; + + let userWithMetadata: EPerson; + let userWithoutMetadata: EPerson; + + let cookie; + let authService; + let ePersonService; + + beforeEach(() => { + userWithMetadata = Object.assign(new EPerson(), { + metadata: { + [END_USER_AGREEMENT_METADATA_FIELD]: [ + { + value: 'true' + } + ] + } + }); + userWithoutMetadata = Object.assign(new EPerson()); + + cookie = new CookieServiceMock(); + authService = jasmine.createSpyObj('authService', { + isAuthenticated: observableOf(true), + getAuthenticatedUserFromStore: observableOf(userWithMetadata) + }); + ePersonService = jasmine.createSpyObj('ePersonService', { + update: createSuccessfulRemoteDataObject$(userWithMetadata), + patch: observableOf(new RestResponse(true, 200, 'OK')) + }); + + service = new EndUserAgreementService(cookie, authService, ePersonService); + }); + + describe('when the cookie is set to true', () => { + beforeEach(() => { + cookie.set(END_USER_AGREEMENT_COOKIE, true); + }); + + it('hasCurrentUserOrCookieAcceptedAgreement should return true', (done) => { + service.hasCurrentUserOrCookieAcceptedAgreement(false).subscribe((result) => { + expect(result).toEqual(true); + done(); + }); + }); + + it('isCookieAccepted should return true', () => { + expect(service.isCookieAccepted()).toEqual(true); + }); + + it('removeCookieAccepted should remove the cookie', () => { + service.removeCookieAccepted(); + expect(cookie.get(END_USER_AGREEMENT_COOKIE)).toBeUndefined(); + }); + }); + + describe('when the cookie isn\'t set', () => { + describe('and the user is authenticated', () => { + beforeEach(() => { + (authService.isAuthenticated as jasmine.Spy).and.returnValue(observableOf(true)); + }); + + describe('and the user contains agreement metadata', () => { + beforeEach(() => { + (authService.getAuthenticatedUserFromStore as jasmine.Spy).and.returnValue(observableOf(userWithMetadata)); + }); + + it('hasCurrentUserOrCookieAcceptedAgreement should return true', (done) => { + service.hasCurrentUserOrCookieAcceptedAgreement(false).subscribe((result) => { + expect(result).toEqual(true); + done(); + }); + }); + }); + + describe('and the user doesn\'t contain agreement metadata', () => { + beforeEach(() => { + (authService.getAuthenticatedUserFromStore as jasmine.Spy).and.returnValue(observableOf(userWithoutMetadata)); + }); + + it('hasCurrentUserOrCookieAcceptedAgreement should return false', (done) => { + service.hasCurrentUserOrCookieAcceptedAgreement(false).subscribe((result) => { + expect(result).toEqual(false); + done(); + }); + }); + }); + + it('setUserAcceptedAgreement should update the user with new metadata', (done) => { + service.setUserAcceptedAgreement(true).subscribe(() => { + expect(ePersonService.patch).toHaveBeenCalled(); + done(); + }); + }); + }); + + describe('and the user is not authenticated', () => { + beforeEach(() => { + (authService.isAuthenticated as jasmine.Spy).and.returnValue(observableOf(false)); + }); + + it('hasCurrentUserOrCookieAcceptedAgreement should return false', (done) => { + service.hasCurrentUserOrCookieAcceptedAgreement(false).subscribe((result) => { + expect(result).toEqual(false); + done(); + }); + }); + + it('setUserAcceptedAgreement should set the cookie to true', (done) => { + service.setUserAcceptedAgreement(true).subscribe(() => { + expect(cookie.get(END_USER_AGREEMENT_COOKIE)).toEqual(true); + done(); + }); + }); + }); + + it('isCookieAccepted should return false', () => { + expect(service.isCookieAccepted()).toEqual(false); + }); + + it('setCookieAccepted should set the cookie', () => { + service.setCookieAccepted(true); + expect(cookie.get(END_USER_AGREEMENT_COOKIE)).toEqual(true); + }); + }); +}); diff --git a/src/app/core/end-user-agreement/end-user-agreement.service.ts b/src/app/core/end-user-agreement/end-user-agreement.service.ts new file mode 100644 index 0000000000..23bda89169 --- /dev/null +++ b/src/app/core/end-user-agreement/end-user-agreement.service.ts @@ -0,0 +1,111 @@ +import { Injectable } from '@angular/core'; +import { AuthService } from '../auth/auth.service'; +import { CookieService } from '../services/cookie.service'; +import { Observable } from 'rxjs/internal/Observable'; +import { of as observableOf } from 'rxjs'; +import { map, switchMap, take } from 'rxjs/operators'; +import { hasValue } from '../../shared/empty.util'; +import { EPersonDataService } from '../eperson/eperson-data.service'; + +export const END_USER_AGREEMENT_COOKIE = 'hasAgreedEndUser'; +export const END_USER_AGREEMENT_METADATA_FIELD = 'dspace.agreements.end-user'; + +/** + * Service for checking and managing the status of the current end user agreement + */ +@Injectable() +export class EndUserAgreementService { + + constructor(protected cookie: CookieService, + protected authService: AuthService, + protected ePersonService: EPersonDataService) { + } + + /** + * Whether or not either the cookie was accepted or the current user has accepted the End User Agreement + * @param acceptedWhenAnonymous Whether or not the user agreement should be considered accepted if the user is + * currently not authenticated (anonymous) + */ + hasCurrentUserOrCookieAcceptedAgreement(acceptedWhenAnonymous: boolean): Observable { + if (this.isCookieAccepted()) { + return observableOf(true); + } else { + return this.hasCurrentUserAcceptedAgreement(acceptedWhenAnonymous); + } + } + + /** + * Whether or not the current user has accepted the End User Agreement + * @param acceptedWhenAnonymous Whether or not the user agreement should be considered accepted if the user is + * currently not authenticated (anonymous) + */ + hasCurrentUserAcceptedAgreement(acceptedWhenAnonymous: boolean): Observable { + return this.authService.isAuthenticated().pipe( + switchMap((authenticated) => { + if (authenticated) { + return this.authService.getAuthenticatedUserFromStore().pipe( + map((user) => hasValue(user) && user.hasMetadata(END_USER_AGREEMENT_METADATA_FIELD) && user.firstMetadata(END_USER_AGREEMENT_METADATA_FIELD).value === 'true') + ); + } else { + return observableOf(acceptedWhenAnonymous); + } + }) + ); + } + + /** + * Set the current user's accepted agreement status + * When a user is authenticated, set his/her metadata to the provided value + * When no user is authenticated, set the cookie to the provided value + * @param accepted + */ + setUserAcceptedAgreement(accepted: boolean): Observable { + return this.authService.isAuthenticated().pipe( + switchMap((authenticated) => { + if (authenticated) { + return this.authService.getAuthenticatedUserFromStore().pipe( + take(1), + switchMap((user) => { + const newValue = { value: String(accepted) }; + let operation; + if (user.hasMetadata(END_USER_AGREEMENT_METADATA_FIELD)) { + operation = { op: 'replace', path: `/metadata/${END_USER_AGREEMENT_METADATA_FIELD}/0`, value: newValue }; + } else { + operation = { op: 'add', path: `/metadata/${END_USER_AGREEMENT_METADATA_FIELD}`, value: [ newValue ] }; + } + return this.ePersonService.patch(user, [operation]); + }), + map((response) => response.isSuccessful) + ); + } else { + this.setCookieAccepted(accepted); + return observableOf(true); + } + }), + take(1) + ); + } + + /** + * Is the End User Agreement accepted in the cookie? + */ + isCookieAccepted(): boolean { + return this.cookie.get(END_USER_AGREEMENT_COOKIE) === true; + } + + /** + * Set the cookie's End User Agreement accepted state + * @param accepted + */ + setCookieAccepted(accepted: boolean) { + this.cookie.set(END_USER_AGREEMENT_COOKIE, accepted); + } + + /** + * Remove the End User Agreement cookie + */ + removeCookieAccepted() { + this.cookie.remove(END_USER_AGREEMENT_COOKIE); + } + +} diff --git a/src/app/core/eperson/eperson-data.service.ts b/src/app/core/eperson/eperson-data.service.ts index 415977a46f..a1428aee73 100644 --- a/src/app/core/eperson/eperson-data.service.ts +++ b/src/app/core/eperson/eperson-data.service.ts @@ -3,7 +3,7 @@ import { Injectable } from '@angular/core'; import { createSelector, select, Store } from '@ngrx/store'; import { Operation } from 'fast-json-patch/lib/core'; import { Observable } from 'rxjs'; -import { filter, find, map, skipWhile, switchMap, take, tap } from 'rxjs/operators'; +import { filter, find, map, take } from 'rxjs/operators'; import { EPeopleRegistryCancelEPersonAction, EPeopleRegistryEditEPersonAction @@ -223,8 +223,8 @@ export class EPersonDataService extends DataService { * Method to delete an EPerson * @param ePerson The EPerson to delete */ - public deleteEPerson(ePerson: EPerson): Observable { - return this.delete(ePerson.id).pipe(map((response: RestResponse) => response.isSuccessful)); + public deleteEPerson(ePerson: EPerson): Observable { + return this.delete(ePerson.id); } /** @@ -299,34 +299,4 @@ export class EPersonDataService extends DataService { map((request: RequestEntry) => request.response) ); } - - /** - * Make a new FindListRequest with given search method - * - * @param searchMethod The search method for the object - * @param options The [[FindListOptions]] object - * @param linksToFollow The array of [[FollowLinkConfig]] - * @return {Observable>} - * Return an observable that emits response from the server - */ - searchBy(searchMethod: string, options: FindListOptions = {}, ...linksToFollow: Array>): Observable>> { - const hrefObs = this.getSearchByHref(searchMethod, options, ...linksToFollow); - - return hrefObs.pipe( - find((href: string) => hasValue(href)), - tap((href: string) => { - this.requestService.removeByHrefSubstring(href); - const request = new FindListRequest(this.requestService.generateRequestId(), href, options); - - this.requestService.configure(request); - } - ), - switchMap((href) => this.requestService.getByHref(href)), - skipWhile((requestEntry) => hasValue(requestEntry) && requestEntry.completed), - switchMap((href) => - this.rdbService.buildList(hrefObs, ...linksToFollow) as Observable>> - ) - ); - } - } diff --git a/src/app/core/eperson/group-data.service.ts b/src/app/core/eperson/group-data.service.ts index c186bc8dcd..d42ba392f3 100644 --- a/src/app/core/eperson/group-data.service.ts +++ b/src/app/core/eperson/group-data.service.ts @@ -3,7 +3,7 @@ import { Injectable } from '@angular/core'; import { createSelector, select, Store } from '@ngrx/store'; import { Observable } from 'rxjs'; -import { distinctUntilChanged, filter, map, switchMap, take, tap } from 'rxjs/operators'; +import { filter, map, take, tap } from 'rxjs/operators'; import { GroupRegistryCancelGroupAction, GroupRegistryEditGroupAction @@ -21,18 +21,12 @@ import { DataService } from '../data/data.service'; import { DSOChangeAnalyzer } from '../data/dso-change-analyzer.service'; import { PaginatedList } from '../data/paginated-list'; import { RemoteData } from '../data/remote-data'; -import { - CreateRequest, - DeleteRequest, - FindListOptions, - FindListRequest, - PostRequest -} from '../data/request.models'; +import { CreateRequest, DeleteRequest, FindListOptions, FindListRequest, PostRequest } from '../data/request.models'; import { RequestService } from '../data/request.service'; import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service'; import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { configureRequest, getResponseFromEntry} from '../shared/operators'; +import { getResponseFromEntry } from '../shared/operators'; import { EPerson } from './models/eperson.model'; import { Group } from './models/group.model'; import { dataService } from '../cache/builders/build-decorators'; diff --git a/src/app/core/eperson/models/eperson-dto.model.ts b/src/app/core/eperson/models/eperson-dto.model.ts new file mode 100644 index 0000000000..f491f6f8be --- /dev/null +++ b/src/app/core/eperson/models/eperson-dto.model.ts @@ -0,0 +1,17 @@ +import { EPerson } from './eperson.model'; + +/** + * This class serves as a Data Transfer Model that contains the EPerson and whether or not it's able to be deleted + */ +export class EpersonDtoModel { + + /** + * The EPerson linked to this object + */ + public eperson: EPerson; + /** + * Whether or not the linked EPerson is able to be deleted + */ + public ableToDelete: boolean; + +} diff --git a/src/app/core/integration/authority.service.ts b/src/app/core/integration/authority.service.ts deleted file mode 100644 index f0a1759be6..0000000000 --- a/src/app/core/integration/authority.service.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { Injectable } from '@angular/core'; - -import { RequestService } from '../data/request.service'; -import { IntegrationService } from './integration.service'; -import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; - -@Injectable() -export class AuthorityService extends IntegrationService { - protected linkPath = 'authorities'; - protected entriesEndpoint = 'entries'; - protected entryValueEndpoint = 'entryValues'; - - constructor( - protected requestService: RequestService, - protected rdbService: RemoteDataBuildService, - protected halService: HALEndpointService) { - super(); - } - -} diff --git a/src/app/core/integration/integration-data.ts b/src/app/core/integration/integration-data.ts deleted file mode 100644 index b93ce36dad..0000000000 --- a/src/app/core/integration/integration-data.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { PageInfo } from '../shared/page-info.model'; -import { IntegrationModel } from './models/integration.model'; - -/** - * A class to represent the data retrieved by an Integration service - */ -export class IntegrationData { - constructor( - public pageInfo: PageInfo, - public payload: IntegrationModel[] - ) { } -} diff --git a/src/app/core/integration/integration-response-parsing.service.spec.ts b/src/app/core/integration/integration-response-parsing.service.spec.ts deleted file mode 100644 index b5cb8c4dc4..0000000000 --- a/src/app/core/integration/integration-response-parsing.service.spec.ts +++ /dev/null @@ -1,221 +0,0 @@ -import { Store } from '@ngrx/store'; - -import { ObjectCacheService } from '../cache/object-cache.service'; -import { ErrorResponse, IntegrationSuccessResponse } from '../cache/response.models'; -import { CoreState } from '../core.reducers'; -import { PaginatedList } from '../data/paginated-list'; -import { IntegrationRequest } from '../data/request.models'; -import { PageInfo } from '../shared/page-info.model'; -import { IntegrationResponseParsingService } from './integration-response-parsing.service'; -import { AuthorityValue } from './models/authority.value'; - -describe('IntegrationResponseParsingService', () => { - let service: IntegrationResponseParsingService; - - const store = {} as Store; - const objectCacheService = new ObjectCacheService(store, undefined); - const name = 'type'; - const metadata = 'dc.type'; - const query = ''; - const uuid = 'd9d30c0c-69b7-4369-8397-ca67c888974d'; - const integrationEndpoint = 'https://rest.api/integration/authorities'; - const entriesEndpoint = `${integrationEndpoint}/${name}/entries?query=${query}&metadata=${metadata}&uuid=${uuid}`; - let validRequest; - - let validResponse; - - let invalidResponse1; - let invalidResponse2; - let pageInfo; - let definitions; - - function initVars() { - pageInfo = Object.assign(new PageInfo(), { - elementsPerPage: 5, - totalElements: 5, - totalPages: 1, - currentPage: 1, - _links: { - self: { href: 'https://rest.api/integration/authorities/type/entries' } - } - }); - definitions = new PaginatedList(pageInfo, [ - Object.assign(new AuthorityValue(), { - type: 'authority', - display: 'One', - id: 'One', - otherInformation: undefined, - value: 'One' - }), - Object.assign(new AuthorityValue(), { - type: 'authority', - display: 'Two', - id: 'Two', - otherInformation: undefined, - value: 'Two' - }), - Object.assign(new AuthorityValue(), { - type: 'authority', - display: 'Three', - id: 'Three', - otherInformation: undefined, - value: 'Three' - }), - Object.assign(new AuthorityValue(), { - type: 'authority', - display: 'Four', - id: 'Four', - otherInformation: undefined, - value: 'Four' - }), - Object.assign(new AuthorityValue(), { - type: 'authority', - display: 'Five', - id: 'Five', - otherInformation: undefined, - value: 'Five' - }) - ]); - validRequest = new IntegrationRequest('69f375b5-19f4-4453-8c7a-7dc5c55aafbb', entriesEndpoint); - - validResponse = { - payload: { - page: { - number: 0, - size: 5, - totalElements: 5, - totalPages: 1 - }, - _embedded: { - authorityEntries: [ - { - display: 'One', - id: 'One', - otherInformation: {}, - type: 'authority', - value: 'One' - }, - { - display: 'Two', - id: 'Two', - otherInformation: {}, - type: 'authority', - value: 'Two' - }, - { - display: 'Three', - id: 'Three', - otherInformation: {}, - type: 'authority', - value: 'Three' - }, - { - display: 'Four', - id: 'Four', - otherInformation: {}, - type: 'authority', - value: 'Four' - }, - { - display: 'Five', - id: 'Five', - otherInformation: {}, - type: 'authority', - value: 'Five' - }, - ], - - }, - _links: { - self: { href: 'https://rest.api/integration/authorities/type/entries' } - } - }, - statusCode: 200, - statusText: 'OK' - }; - - invalidResponse1 = { - payload: {}, - statusCode: 400, - statusText: 'Bad Request' - }; - - invalidResponse2 = { - payload: { - page: { - number: 0, - size: 5, - totalElements: 5, - totalPages: 1 - }, - _embedded: { - authorityEntries: [ - { - display: 'One', - id: 'One', - otherInformation: {}, - type: 'authority', - value: 'One' - }, - { - display: 'Two', - id: 'Two', - otherInformation: {}, - type: 'authority', - value: 'Two' - }, - { - display: 'Three', - id: 'Three', - otherInformation: {}, - type: 'authority', - value: 'Three' - }, - { - display: 'Four', - id: 'Four', - otherInformation: {}, - type: 'authority', - value: 'Four' - }, - { - display: 'Five', - id: 'Five', - otherInformation: {}, - type: 'authority', - value: 'Five' - }, - ], - - }, - _links: {} - }, - statusCode: 500, - statusText: 'Internal Server Error' - }; - } - beforeEach(() => { - initVars(); - service = new IntegrationResponseParsingService(objectCacheService); - }); - - describe('parse', () => { - it('should return a IntegrationSuccessResponse if data contains a valid endpoint response', () => { - const response = service.parse(validRequest, validResponse); - expect(response.constructor).toBe(IntegrationSuccessResponse); - }); - - it('should return an ErrorResponse if data contains an invalid config endpoint response', () => { - const response1 = service.parse(validRequest, invalidResponse1); - const response2 = service.parse(validRequest, invalidResponse2); - expect(response1.constructor).toBe(ErrorResponse); - expect(response2.constructor).toBe(ErrorResponse); - }); - - it('should return a IntegrationSuccessResponse with data definition', () => { - const response = service.parse(validRequest, validResponse); - expect((response as any).dataDefinition).toEqual(definitions); - }); - - }); -}); diff --git a/src/app/core/integration/integration-response-parsing.service.ts b/src/app/core/integration/integration-response-parsing.service.ts deleted file mode 100644 index 2719669bae..0000000000 --- a/src/app/core/integration/integration-response-parsing.service.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { Inject, Injectable } from '@angular/core'; -import { RestRequest } from '../data/request.models'; -import { ResponseParsingService } from '../data/parsing.service'; -import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; -import { ErrorResponse, IntegrationSuccessResponse, RestResponse } from '../cache/response.models'; -import { isNotEmpty } from '../../shared/empty.util'; - -import { BaseResponseParsingService } from '../data/base-response-parsing.service'; -import { ObjectCacheService } from '../cache/object-cache.service'; -import { IntegrationModel } from './models/integration.model'; -import { AuthorityValue } from './models/authority.value'; -import { PaginatedList } from '../data/paginated-list'; - -@Injectable() -export class IntegrationResponseParsingService extends BaseResponseParsingService implements ResponseParsingService { - - protected toCache = true; - - constructor( - protected objectCache: ObjectCacheService, - ) { - super(); - } - - parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse { - if (isNotEmpty(data.payload) && isNotEmpty(data.payload._links)) { - const dataDefinition = this.process(data.payload, request); - return new IntegrationSuccessResponse(this.processResponse(dataDefinition), data.statusCode, data.statusText, this.processPageInfo(data.payload)); - } else { - return new ErrorResponse( - Object.assign( - new Error('Unexpected response from Integration endpoint'), - {statusCode: data.statusCode, statusText: data.statusText} - ) - ); - } - } - - protected processResponse(data: PaginatedList): any { - const returnList = Array.of(); - data.page.forEach((item, index) => { - if (item.type === AuthorityValue.type.value) { - data.page[index] = Object.assign(new AuthorityValue(), item); - } - }); - - return data; - } - -} diff --git a/src/app/core/integration/integration.service.spec.ts b/src/app/core/integration/integration.service.spec.ts deleted file mode 100644 index 148a5df7b8..0000000000 --- a/src/app/core/integration/integration.service.spec.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { cold, getTestScheduler } from 'jasmine-marbles'; -import { TestScheduler } from 'rxjs/testing'; -import { getMockRequestService } from '../../shared/mocks/request.service.mock'; - -import { RequestService } from '../data/request.service'; -import { IntegrationRequest } from '../data/request.models'; -import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service.stub'; -import { IntegrationService } from './integration.service'; -import { IntegrationSearchOptions } from './models/integration-options.model'; -import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import { getMockRemoteDataBuildService } from '../../shared/mocks/remote-data-build.service.mock'; - -const LINK_NAME = 'authorities'; -const ENTRIES = 'entries'; -const ENTRY_VALUE = 'entryValue'; - -class TestService extends IntegrationService { - protected linkPath = LINK_NAME; - protected entriesEndpoint = ENTRIES; - protected entryValueEndpoint = ENTRY_VALUE; - - constructor( - protected requestService: RequestService, - protected rdbService: RemoteDataBuildService, - protected halService: HALEndpointService) { - super(); - } -} - -describe('IntegrationService', () => { - let scheduler: TestScheduler; - let service: TestService; - let requestService: RequestService; - let rdbService: RemoteDataBuildService; - let halService: any; - let findOptions: IntegrationSearchOptions; - - const name = 'type'; - const metadata = 'dc.type'; - const query = ''; - const value = 'test'; - const uuid = 'd9d30c0c-69b7-4369-8397-ca67c888974d'; - const integrationEndpoint = 'https://rest.api/integration'; - const serviceEndpoint = `${integrationEndpoint}/${LINK_NAME}`; - const entriesEndpoint = `${serviceEndpoint}/${name}/entries?query=${query}&metadata=${metadata}&uuid=${uuid}`; - const entryValueEndpoint = `${serviceEndpoint}/${name}/entryValue/${value}?metadata=${metadata}`; - - findOptions = new IntegrationSearchOptions(uuid, name, metadata); - - function initTestService(): TestService { - return new TestService( - requestService, - rdbService, - halService - ); - } - - beforeEach(() => { - requestService = getMockRequestService(); - rdbService = getMockRemoteDataBuildService(); - scheduler = getTestScheduler(); - halService = new HALEndpointServiceStub(integrationEndpoint); - findOptions = new IntegrationSearchOptions(uuid, name, metadata, query); - service = initTestService(); - - }); - - describe('getEntriesByName', () => { - - it('should configure a new IntegrationRequest', () => { - const expected = new IntegrationRequest(requestService.generateRequestId(), entriesEndpoint); - scheduler.schedule(() => service.getEntriesByName(findOptions).subscribe()); - scheduler.flush(); - - expect(requestService.configure).toHaveBeenCalledWith(expected); - }); - }); - - describe('getEntryByValue', () => { - - it('should configure a new IntegrationRequest', () => { - findOptions = new IntegrationSearchOptions( - null, - name, - metadata, - value); - - const expected = new IntegrationRequest(requestService.generateRequestId(), entryValueEndpoint); - scheduler.schedule(() => service.getEntryByValue(findOptions).subscribe()); - scheduler.flush(); - - expect(requestService.configure).toHaveBeenCalledWith(expected); - }); - }); -}); diff --git a/src/app/core/integration/integration.service.ts b/src/app/core/integration/integration.service.ts deleted file mode 100644 index 5826f4646d..0000000000 --- a/src/app/core/integration/integration.service.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { Observable, of as observableOf, throwError as observableThrowError } from 'rxjs'; -import { distinctUntilChanged, filter, map, mergeMap, tap } from 'rxjs/operators'; -import { RequestService } from '../data/request.service'; -import { IntegrationSuccessResponse } from '../cache/response.models'; -import { GetRequest, IntegrationRequest } from '../data/request.models'; -import { hasValue, isNotEmpty } from '../../shared/empty.util'; -import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { IntegrationData } from './integration-data'; -import { IntegrationSearchOptions } from './models/integration-options.model'; -import { getResponseFromEntry } from '../shared/operators'; - -export abstract class IntegrationService { - protected request: IntegrationRequest; - protected abstract requestService: RequestService; - protected abstract linkPath: string; - protected abstract entriesEndpoint: string; - protected abstract entryValueEndpoint: string; - protected abstract halService: HALEndpointService; - - protected getData(request: GetRequest): Observable { - return this.requestService.getByHref(request.href).pipe( - getResponseFromEntry(), - mergeMap((response: IntegrationSuccessResponse) => { - if (response.isSuccessful && isNotEmpty(response)) { - return observableOf(new IntegrationData( - response.pageInfo, - (response.dataDefinition) ? response.dataDefinition.page : [] - )); - } else if (!response.isSuccessful) { - return observableThrowError(new Error(`Couldn't retrieve the integration data`)); - } - }), - distinctUntilChanged() - ); - } - - protected getEntriesHref(endpoint, options: IntegrationSearchOptions = new IntegrationSearchOptions()): string { - let result; - const args = []; - - if (hasValue(options.name)) { - result = `${endpoint}/${options.name}/${this.entriesEndpoint}`; - } else { - result = endpoint; - } - - if (hasValue(options.query)) { - args.push(`query=${options.query}`); - } - - if (hasValue(options.metadata)) { - args.push(`metadata=${options.metadata}`); - } - - if (hasValue(options.uuid)) { - args.push(`uuid=${options.uuid}`); - } - - if (hasValue(options.currentPage) && typeof options.currentPage === 'number') { - /* TODO: this is a temporary fix for the pagination start index (0 or 1) discrepancy between the rest and the frontend respectively */ - args.push(`page=${options.currentPage - 1}`); - } - - if (hasValue(options.elementsPerPage)) { - args.push(`size=${options.elementsPerPage}`); - } - - if (hasValue(options.sort)) { - args.push(`sort=${options.sort.field},${options.sort.direction}`); - } - - if (isNotEmpty(args)) { - result = `${result}?${args.join('&')}`; - } - return result; - } - - protected getEntryValueHref(endpoint, options: IntegrationSearchOptions = new IntegrationSearchOptions()): string { - let result; - const args = []; - - if (hasValue(options.name) && hasValue(options.query)) { - result = `${endpoint}/${options.name}/${this.entryValueEndpoint}/${options.query}`; - } else { - result = endpoint; - } - - if (hasValue(options.metadata)) { - args.push(`metadata=${options.metadata}`); - } - - if (isNotEmpty(args)) { - result = `${result}?${args.join('&')}`; - } - - return result; - } - - public getEntriesByName(options: IntegrationSearchOptions): Observable { - return this.halService.getEndpoint(this.linkPath).pipe( - map((endpoint: string) => this.getEntriesHref(endpoint, options)), - filter((href: string) => isNotEmpty(href)), - distinctUntilChanged(), - map((endpointURL: string) => new IntegrationRequest(this.requestService.generateRequestId(), endpointURL)), - tap((request: GetRequest) => this.requestService.configure(request)), - mergeMap((request: GetRequest) => this.getData(request)), - distinctUntilChanged()); - } - - public getEntryByValue(options: IntegrationSearchOptions): Observable { - return this.halService.getEndpoint(this.linkPath).pipe( - map((endpoint: string) => this.getEntryValueHref(endpoint, options)), - filter((href: string) => isNotEmpty(href)), - distinctUntilChanged(), - map((endpointURL: string) => new IntegrationRequest(this.requestService.generateRequestId(), endpointURL)), - tap((request: GetRequest) => this.requestService.configure(request)), - mergeMap((request: GetRequest) => this.getData(request)), - distinctUntilChanged()); - } - -} diff --git a/src/app/core/integration/models/authority-options.model.ts b/src/app/core/integration/models/authority-options.model.ts deleted file mode 100644 index 0b826f7f9c..0000000000 --- a/src/app/core/integration/models/authority-options.model.ts +++ /dev/null @@ -1,16 +0,0 @@ -export class AuthorityOptions { - name: string; - metadata: string; - scope: string; - closed: boolean; - - constructor(name: string, - metadata: string, - scope: string, - closed: boolean = false) { - this.name = name; - this.metadata = metadata; - this.scope = scope; - this.closed = closed; - } -} diff --git a/src/app/core/integration/models/authority.value.ts b/src/app/core/integration/models/authority.value.ts deleted file mode 100644 index 4af10034b2..0000000000 --- a/src/app/core/integration/models/authority.value.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { autoserialize, deserialize, inheritSerialization } from 'cerialize'; -import { isNotEmpty } from '../../../shared/empty.util'; -import { OtherInformation } from '../../../shared/form/builder/models/form-field-metadata-value.model'; -import { typedObject } from '../../cache/builders/build-decorators'; -import { HALLink } from '../../shared/hal-link.model'; -import { MetadataValueInterface } from '../../shared/metadata.models'; -import { AUTHORITY_VALUE } from './authority.resource-type'; -import { IntegrationModel } from './integration.model'; -import { PLACEHOLDER_PARENT_METADATA } from '../../../shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-constants'; - -/** - * Class representing an authority object - */ -@typedObject -@inheritSerialization(IntegrationModel) -export class AuthorityValue extends IntegrationModel implements MetadataValueInterface { - static type = AUTHORITY_VALUE; - - /** - * The identifier of this authority - */ - @autoserialize - id: string; - - /** - * The display value of this authority - */ - @autoserialize - display: string; - - /** - * The value of this authority - */ - @autoserialize - value: string; - - /** - * An object containing additional information related to this authority - */ - @autoserialize - otherInformation: OtherInformation; - - /** - * The language code of this authority value - */ - @autoserialize - language: string; - - /** - * The {@link HALLink}s for this AuthorityValue - */ - @deserialize - _links: { - self: HALLink, - }; - - /** - * This method checks if authority has an identifier value - * - * @return boolean - */ - hasAuthority(): boolean { - return isNotEmpty(this.id); - } - - /** - * This method checks if authority has a value - * - * @return boolean - */ - hasValue(): boolean { - return isNotEmpty(this.value); - } - - /** - * This method checks if authority has related information object - * - * @return boolean - */ - hasOtherInformation(): boolean { - return isNotEmpty(this.otherInformation); - } - - /** - * This method checks if authority has a placeholder as value - * - * @return boolean - */ - hasPlaceholder(): boolean { - return this.hasValue() && this.value === PLACEHOLDER_PARENT_METADATA; - } -} diff --git a/src/app/core/integration/models/integration-options.model.ts b/src/app/core/integration/models/integration-options.model.ts deleted file mode 100644 index 5f158bd47c..0000000000 --- a/src/app/core/integration/models/integration-options.model.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { SortOptions } from '../../cache/models/sort-options.model'; - -export class IntegrationSearchOptions { - - constructor(public uuid: string = '', - public name: string = '', - public metadata: string = '', - public query: string = '', - public elementsPerPage?: number, - public currentPage?: number, - public sort?: SortOptions) { - - } -} diff --git a/src/app/core/integration/models/integration.model.ts b/src/app/core/integration/models/integration.model.ts deleted file mode 100644 index d2f21a70c0..0000000000 --- a/src/app/core/integration/models/integration.model.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { autoserialize, deserialize } from 'cerialize'; -import { CacheableObject } from '../../cache/object-cache.reducer'; -import { HALLink } from '../../shared/hal-link.model'; - -export abstract class IntegrationModel implements CacheableObject { - - @autoserialize - self: string; - - @autoserialize - uuid: string; - - @autoserialize - public type: any; - - @deserialize - public _links: { - self: HALLink, - [name: string]: HALLink - } - -} diff --git a/src/app/core/json-patch/builder/json-patch-operations-builder.ts b/src/app/core/json-patch/builder/json-patch-operations-builder.ts index eb54265318..ced3750834 100644 --- a/src/app/core/json-patch/builder/json-patch-operations-builder.ts +++ b/src/app/core/json-patch/builder/json-patch-operations-builder.ts @@ -1,11 +1,16 @@ import { Store } from '@ngrx/store'; import { CoreState } from '../../core.reducers'; -import { NewPatchAddOperationAction, NewPatchMoveOperationAction, NewPatchRemoveOperationAction, NewPatchReplaceOperationAction } from '../json-patch-operations.actions'; +import { + NewPatchAddOperationAction, + NewPatchMoveOperationAction, + NewPatchRemoveOperationAction, + NewPatchReplaceOperationAction +} from '../json-patch-operations.actions'; import { JsonPatchOperationPathObject } from './json-patch-operation-path-combiner'; import { Injectable } from '@angular/core'; -import { hasNoValue, isEmpty, isNotEmpty } from '../../../shared/empty.util'; +import { hasNoValue, hasValue, isEmpty, isNotEmpty } from '../../../shared/empty.util'; import { dateToISOFormat } from '../../../shared/date.util'; -import { AuthorityValue } from '../../integration/models/authority.value'; +import { VocabularyEntry } from '../../submission/vocabularies/models/vocabulary-entry.model'; import { FormFieldMetadataValueObject } from '../../../shared/form/builder/models/form-field-metadata-value.model'; import { FormFieldLanguageValueObject } from '../../../shared/form/builder/models/form-field-language-value.model'; @@ -96,7 +101,7 @@ export class JsonPatchOperationsBuilder { protected prepareValue(value: any, plain: boolean, first: boolean) { let operationValue: any = null; - if (isNotEmpty(value)) { + if (hasValue(value)) { if (plain) { operationValue = value; } else { @@ -125,10 +130,12 @@ export class JsonPatchOperationsBuilder { operationValue = value; } else if (value instanceof Date) { operationValue = new FormFieldMetadataValueObject(dateToISOFormat(value)); - } else if (value instanceof AuthorityValue) { + } else if (value instanceof VocabularyEntry) { operationValue = this.prepareAuthorityValue(value); } else if (value instanceof FormFieldLanguageValueObject) { operationValue = new FormFieldMetadataValueObject(value.value, value.language); + } else if (value.hasOwnProperty('authority')) { + operationValue = new FormFieldMetadataValueObject(value.value, value.language, value.authority); } else if (value.hasOwnProperty('value')) { operationValue = new FormFieldMetadataValueObject(value.value); } else { @@ -144,10 +151,10 @@ export class JsonPatchOperationsBuilder { return operationValue; } - protected prepareAuthorityValue(value: any) { - let operationValue: any = null; - if (isNotEmpty(value.id)) { - operationValue = new FormFieldMetadataValueObject(value.value, value.language, value.id); + protected prepareAuthorityValue(value: any): FormFieldMetadataValueObject { + let operationValue: FormFieldMetadataValueObject; + if (isNotEmpty(value.authority)) { + operationValue = new FormFieldMetadataValueObject(value.value, value.language, value.authority); } else { operationValue = new FormFieldMetadataValueObject(value.value, value.language); } diff --git a/src/app/core/json-patch/json-patch-operations.service.spec.ts b/src/app/core/json-patch/json-patch-operations.service.spec.ts index fb9e641441..4ada78172e 100644 --- a/src/app/core/json-patch/json-patch-operations.service.spec.ts +++ b/src/app/core/json-patch/json-patch-operations.service.spec.ts @@ -1,9 +1,8 @@ -import { async, TestBed } from '@angular/core/testing'; - import { getTestScheduler } from 'jasmine-marbles'; import { TestScheduler } from 'rxjs/testing'; import { of as observableOf } from 'rxjs'; -import { Store, StoreModule } from '@ngrx/store'; +import { catchError } from 'rxjs/operators'; +import { Store } from '@ngrx/store'; import { getMockRequestService } from '../../shared/mocks/request.service.mock'; import { RequestService } from '../data/request.service'; @@ -22,7 +21,6 @@ import { StartTransactionPatchOperationsAction } from './json-patch-operations.actions'; import { RequestEntry } from '../data/request.reducer'; -import { catchError } from 'rxjs/operators'; class TestService extends JsonPatchOperationsService { protected linkPath = ''; diff --git a/src/app/core/locale/locale.service.ts b/src/app/core/locale/locale.service.ts index ade5c1f864..315fc02833 100644 --- a/src/app/core/locale/locale.service.ts +++ b/src/app/core/locale/locale.service.ts @@ -10,7 +10,7 @@ import { Observable, of as observableOf, combineLatest } from 'rxjs'; import { map, take, flatMap } from 'rxjs/operators'; import { NativeWindowService, NativeWindowRef } from '../services/window.service'; -export const LANG_COOKIE = 'language_cookie'; +export const LANG_COOKIE = 'dsLanguage'; /** * This enum defines the possible origin of the languages diff --git a/src/app/core/metadata/metadata-field.model.ts b/src/app/core/metadata/metadata-field.model.ts index 66171141c5..18840278c4 100644 --- a/src/app/core/metadata/metadata-field.model.ts +++ b/src/app/core/metadata/metadata-field.model.ts @@ -68,8 +68,8 @@ export class MetadataField extends ListableObject implements HALResource { schema?: Observable>; /** - * Method to print this metadata field as a string - * @param separator The separator between the schema, element and qualifier in the string + * Method to print this metadata field as a string without the schema + * @param separator The separator between element and qualifier in the string */ toString(separator: string = '.'): string { let key = this.element; diff --git a/src/app/core/metadata/metadata.service.spec.ts b/src/app/core/metadata/metadata.service.spec.ts index 74c4522bbd..28fe8e1acc 100644 --- a/src/app/core/metadata/metadata.service.spec.ts +++ b/src/app/core/metadata/metadata.service.spec.ts @@ -52,10 +52,13 @@ import { UUIDService } from '../shared/uuid.service'; import { MetadataService } from './metadata.service'; import { environment } from '../../../environments/environment'; import { storeModuleConfig } from '../../app.reducer'; +import { HardRedirectService } from '../services/hard-redirect.service'; +import { URLCombiner } from '../url-combiner/url-combiner'; /* tslint:disable:max-classes-per-file */ @Component({ - template: `` + template: ` + ` }) class TestComponent { constructor(private metadata: MetadataService) { @@ -170,6 +173,7 @@ describe('MetadataService', () => { Title, // tslint:disable-next-line:no-empty { provide: ItemDataService, useValue: { findById: () => {} } }, + { provide: HardRedirectService, useValue: { rewriteDownloadURL: (a) => a, getRequestOrigin: () => environment.ui.baseUrl }}, BrowseService, MetadataService ], @@ -208,7 +212,7 @@ describe('MetadataService', () => { tick(); expect(tagStore.get('citation_dissertation_name')[0].content).toEqual('Test PowerPoint Document'); expect(tagStore.get('citation_dissertation_institution')[0].content).toEqual('Mock Publisher'); - expect(tagStore.get('citation_abstract_html_url')[0].content).toEqual([environment.ui.baseUrl, router.url].join('')); + expect(tagStore.get('citation_abstract_html_url')[0].content).toEqual(new URLCombiner(environment.ui.baseUrl, router.url).toString()); expect(tagStore.get('citation_pdf_url')[0].content).toEqual('https://dspace7.4science.it/dspace-spring-rest/api/core/bitstreams/99b00f3c-1cc6-4689-8158-91965bee6b28/content'); })); diff --git a/src/app/core/metadata/metadata.service.ts b/src/app/core/metadata/metadata.service.ts index 02d2b0c86b..90171bac10 100644 --- a/src/app/core/metadata/metadata.service.ts +++ b/src/app/core/metadata/metadata.service.ts @@ -1,4 +1,4 @@ -import { Inject, Injectable } from '@angular/core'; +import { Injectable } from '@angular/core'; import { Meta, MetaDefinition, Title } from '@angular/platform-browser'; import { ActivatedRoute, NavigationEnd, Router } from '@angular/router'; @@ -20,7 +20,8 @@ import { Bitstream } from '../shared/bitstream.model'; import { DSpaceObject } from '../shared/dspace-object.model'; import { Item } from '../shared/item.model'; import { getFirstSucceededRemoteDataPayload, getFirstSucceededRemoteListPayload } from '../shared/operators'; -import { environment } from '../../../environments/environment'; +import { HardRedirectService } from '../services/hard-redirect.service'; +import { URLCombiner } from '../url-combiner/url-combiner'; @Injectable() export class MetadataService { @@ -39,6 +40,7 @@ export class MetadataService { private dsoNameService: DSONameService, private bitstreamDataService: BitstreamDataService, private bitstreamFormatDataService: BitstreamFormatDataService, + private redirectService: HardRedirectService ) { // TODO: determine what open graph meta tags are needed and whether // the differ per route. potentially add image based on DSpaceObject @@ -254,7 +256,7 @@ export class MetadataService { */ private setCitationAbstractUrlTag(): void { if (this.currentObject.value instanceof Item) { - const value = [environment.ui.baseUrl, this.router.url].join(''); + const value = new URLCombiner(this.redirectService.getRequestOrigin(), this.router.url).toString(); this.addMetaTag('citation_abstract_html_url', value); } } @@ -279,7 +281,8 @@ export class MetadataService { getFirstSucceededRemoteDataPayload() ).subscribe((format: BitstreamFormat) => { if (format.mimetype === 'application/pdf') { - this.addMetaTag('citation_pdf_url', bitstream._links.content.href); + const rewrittenURL= this.redirectService.rewriteDownloadURL(bitstream._links.content.href); + this.addMetaTag('citation_pdf_url', rewrittenURL); } }); } diff --git a/src/app/core/registry/registry.service.ts b/src/app/core/registry/registry.service.ts index 72de6ec793..f349cf428c 100644 --- a/src/app/core/registry/registry.service.ts +++ b/src/app/core/registry/registry.service.ts @@ -30,7 +30,6 @@ import { MetadataSchemaDataService } from '../data/metadata-schema-data.service' import { MetadataFieldDataService } from '../data/metadata-field-data.service'; import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; import { RequestParam } from '../cache/models/request-param.model'; -import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; const metadataRegistryStateSelector = (state: AppState) => state.metadataRegistry; const editMetadataSchemaSelector = createSelector(metadataRegistryStateSelector, (metadataState: MetadataRegistryState) => metadataState.editSchema); @@ -90,20 +89,6 @@ export class RegistryService { return this.metadataFieldService.findBySchema(schema, options, ...linksToFollow); } - /** - * Retrieve all existing metadata fields as a paginated list - * @param options Options to determine which page of metadata fields should be requested - * When no options are provided, all metadata fields are requested in one large page - * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved - * @returns an observable that emits a remote data object with a page of metadata fields - */ - // TODO this is temporarily disabled. The performance is too bad. - // It is used down the line for validation. That validation will have to be rewritten against a new rest endpoint. - // Not by downloading the list of all fields. - public getAllMetadataFields(options?: FindListOptions, ...linksToFollow: Array>): Observable>> { - return createSuccessfulRemoteDataObject$(new PaginatedList(null, [])); - } - public editMetadataSchema(schema: MetadataSchema) { this.store.dispatch(new MetadataRegistryEditSchemaAction(schema)); } @@ -151,6 +136,7 @@ export class RegistryService { public getSelectedMetadataSchemas(): Observable { return this.store.pipe(select(selectedMetadataSchemasSelector)); } + /** * Method to start editing a metadata field, dispatches an edit field action * @param field The field that's being edited @@ -165,12 +151,14 @@ export class RegistryService { public cancelEditMetadataField() { this.store.dispatch(new MetadataRegistryCancelFieldAction()); } + /** * Method to retrieve the metadata field that are currently being edited */ public getActiveMetadataField(): Observable { return this.store.pipe(select(editMetadataFieldSelector)); } + /** * Method to select a metadata field, dispatches a select field action * @param field The field that's being selected @@ -178,6 +166,7 @@ export class RegistryService { public selectMetadataField(field: MetadataField) { this.store.dispatch(new MetadataRegistrySelectFieldAction(field)); } + /** * Method to deselect a metadata field, dispatches a deselect field action * @param field The field that's it being deselected @@ -185,6 +174,7 @@ export class RegistryService { public deselectMetadataField(field: MetadataField) { this.store.dispatch(new MetadataRegistryDeselectFieldAction(field)); } + /** * Method to deselect all currently selected metadata fields, dispatches a deselect all field action */ @@ -213,7 +203,7 @@ export class RegistryService { getFirstSucceededRemoteDataPayload(), hasValueOperator(), tap(() => { - this.showNotifications(true, isUpdate, false, {prefix: schema.prefix}); + this.showNotifications(true, isUpdate, false, { prefix: schema.prefix }); }) ); } @@ -244,7 +234,7 @@ export class RegistryService { getFirstSucceededRemoteDataPayload(), hasValueOperator(), tap(() => { - this.showNotifications(true, false, true, {field: field.toString()}); + this.showNotifications(true, false, true, { field: field.toString() }); }) ); } @@ -259,7 +249,7 @@ export class RegistryService { getFirstSucceededRemoteDataPayload(), hasValueOperator(), tap(() => { - this.showNotifications(true, true, true, {field: field.toString()}); + this.showNotifications(true, true, true, { field: field.toString() }); }) ); } @@ -271,6 +261,7 @@ export class RegistryService { public deleteMetadataField(id: number): Observable { return this.metadataFieldService.delete(`${id}`); } + /** * Method that clears a cached metadata field request and returns its REST url */ @@ -297,13 +288,11 @@ export class RegistryService { /** * Retrieve a filtered paginated list of metadata fields - * @param query {string} The query to filter the field names by + * @param query {string} The query to use for the metadata field name, can be part of the fully qualified field, + * should start with the start of the schema, element or qualifier (e.g. “dc.ti”, “contributor”, “auth”, “contributor.ot”) * @returns an observable that emits a remote data object with a page of metadata fields that match the query */ - // TODO this is temporarily disabled. The performance is too bad. - // Querying metadatafields will need to be implemented as a search endpoint on the rest api, - // not by downloading everything and preforming the query client side. - queryMetadataFields(query: string): Observable>> { - return createSuccessfulRemoteDataObject$(new PaginatedList(null, [])); + queryMetadataFields(query: string, options: FindListOptions = {}, ...linksToFollow: Array>): Observable>> { + return this.metadataFieldService.searchByFieldNameParams(null, null, null, query, null, options, ...linksToFollow); } } diff --git a/src/app/core/reload/reload.guard.spec.ts b/src/app/core/reload/reload.guard.spec.ts new file mode 100644 index 0000000000..317245bafa --- /dev/null +++ b/src/app/core/reload/reload.guard.spec.ts @@ -0,0 +1,47 @@ +import { ReloadGuard } from './reload.guard'; +import { Router } from '@angular/router'; + +describe('ReloadGuard', () => { + let guard: ReloadGuard; + let router: Router; + + beforeEach(() => { + router = jasmine.createSpyObj('router', ['parseUrl', 'createUrlTree']); + guard = new ReloadGuard(router); + }); + + describe('canActivate', () => { + let route; + + describe('when the route\'s query params contain a redirect url', () => { + let redirectUrl; + + beforeEach(() => { + redirectUrl = '/redirect/url?param=extra'; + route = { + queryParams: { + redirect: redirectUrl + } + }; + }); + + it('should create a UrlTree with the redirect URL', () => { + guard.canActivate(route, undefined); + expect(router.parseUrl).toHaveBeenCalledWith(redirectUrl); + }); + }); + + describe('when the route\'s query params doesn\'t contain a redirect url', () => { + beforeEach(() => { + route = { + queryParams: {} + }; + }); + + it('should create a UrlTree to home', () => { + guard.canActivate(route, undefined); + expect(router.createUrlTree).toHaveBeenCalledWith(['home']); + }); + }); + }); +}); diff --git a/src/app/core/reload/reload.guard.ts b/src/app/core/reload/reload.guard.ts new file mode 100644 index 0000000000..78f9dcf642 --- /dev/null +++ b/src/app/core/reload/reload.guard.ts @@ -0,0 +1,26 @@ +import { ActivatedRouteSnapshot, CanActivate, Router, RouterStateSnapshot, UrlTree } from '@angular/router'; +import { Injectable } from '@angular/core'; +import { isNotEmpty } from '../../shared/empty.util'; + +/** + * A guard redirecting the user to the URL provided in the route's query params + * When no redirect url is found, the user is redirected to the homepage + */ +@Injectable() +export class ReloadGuard implements CanActivate { + constructor(private router: Router) { + } + + /** + * Get the UrlTree of the URL to redirect to + * @param route + * @param state + */ + canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): UrlTree { + if (isNotEmpty(route.queryParams.redirect)) { + return this.router.parseUrl(route.queryParams.redirect); + } else { + return this.router.createUrlTree(['home']); + } + } +} diff --git a/src/app/core/services/browser-hard-redirect.service.spec.ts b/src/app/core/services/browser-hard-redirect.service.spec.ts new file mode 100644 index 0000000000..64518579aa --- /dev/null +++ b/src/app/core/services/browser-hard-redirect.service.spec.ts @@ -0,0 +1,50 @@ +import {TestBed} from '@angular/core/testing'; +import {BrowserHardRedirectService} from './browser-hard-redirect.service'; + +describe('BrowserHardRedirectService', () => { + const origin = 'test origin'; + const mockLocation = { + href: undefined, + pathname: '/pathname', + search: '/search', + origin + } as Location; + + const service: BrowserHardRedirectService = new BrowserHardRedirectService(mockLocation); + + beforeEach(() => { + TestBed.configureTestingModule({}); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + describe('when performing a redirect', () => { + + const redirect = 'test redirect'; + + beforeEach(() => { + service.redirect(redirect); + }); + + it('should update the location', () => { + expect(mockLocation.href).toEqual(redirect); + }) + }); + + describe('when requesting the current route', () => { + + it('should return the location origin', () => { + expect(service.getCurrentRoute()).toEqual(mockLocation.pathname + mockLocation.search); + }); + }); + + describe('when requesting the origin', () => { + + it('should return the location origin', () => { + expect(service.getRequestOrigin()).toEqual(origin); + }); + }); + +}); diff --git a/src/app/core/services/browser-hard-redirect.service.ts b/src/app/core/services/browser-hard-redirect.service.ts new file mode 100644 index 0000000000..4b7424bee2 --- /dev/null +++ b/src/app/core/services/browser-hard-redirect.service.ts @@ -0,0 +1,43 @@ +import { Inject, Injectable, InjectionToken } from '@angular/core'; +import { HardRedirectService } from './hard-redirect.service'; + +export const LocationToken = new InjectionToken('Location'); + +export function locationProvider(): Location { + return window.location; +} + +/** + * Service for performing hard redirects within the browser app module + */ +@Injectable() +export class BrowserHardRedirectService extends HardRedirectService { + + constructor( + @Inject(LocationToken) protected location: Location, + ) { + super(); + } + + /** + * Perform a hard redirect to URL + * @param url + */ + redirect(url: string) { + this.location.href = url; + } + + /** + * Get the origin of a request + */ + getCurrentRoute() { + return this.location.pathname + this.location.search; + } + + /** + * Get the hostname of the request + */ + getRequestOrigin() { + return this.location.origin; + } +} diff --git a/src/app/core/services/hard-redirect.service.spec.ts b/src/app/core/services/hard-redirect.service.spec.ts new file mode 100644 index 0000000000..f7e2ba8955 --- /dev/null +++ b/src/app/core/services/hard-redirect.service.spec.ts @@ -0,0 +1,57 @@ +import { HardRedirectService } from './hard-redirect.service'; +import { environment } from '../../../environments/environment'; +import { TestBed } from '@angular/core/testing'; +import { Injectable } from '@angular/core'; + +const requestOrigin = 'http://dspace-angular-ui.dspace.com'; + +describe('HardRedirectService', () => { + let service: TestHardRedirectService; + + beforeEach(() => { + TestBed.configureTestingModule({ providers: [TestHardRedirectService] }); + service = TestBed.get(TestHardRedirectService); + }); + + describe('when calling rewriteDownloadURL', () => { + let originalValue; + const relativePath = '/test/url/path'; + const testURL = environment.rest.baseUrl + relativePath; + beforeEach(() => { + originalValue = environment.rewriteDownloadUrls; + }); + + it('it should return the same url when rewriteDownloadURL is false', () => { + environment.rewriteDownloadUrls = false; + expect(service.rewriteDownloadURL(testURL)).toEqual(testURL); + }); + + it('it should replace part of the url when rewriteDownloadURL is true', () => { + environment.rewriteDownloadUrls = true; + expect(service.rewriteDownloadURL(testURL)).toEqual(requestOrigin + environment.rest.nameSpace + relativePath); + }); + + afterEach(() => { + environment.rewriteDownloadUrls = originalValue; + }) + }); +}); + +@Injectable() +class TestHardRedirectService extends HardRedirectService { + constructor() { + super(); + } + + redirect(url: string) { + return undefined; + } + + getCurrentRoute() { + return undefined; + } + + getRequestOrigin() { + return requestOrigin; + } +} diff --git a/src/app/core/services/hard-redirect.service.ts b/src/app/core/services/hard-redirect.service.ts new file mode 100644 index 0000000000..a09521dae5 --- /dev/null +++ b/src/app/core/services/hard-redirect.service.ts @@ -0,0 +1,40 @@ +import { Injectable } from '@angular/core'; +import { environment } from '../../../environments/environment'; +import { URLCombiner } from '../url-combiner/url-combiner'; + +/** + * Service to take care of hard redirects + */ +@Injectable() +export abstract class HardRedirectService { + + /** + * Perform a hard redirect to a given location. + * + * @param url + * the page to redirect to + */ + abstract redirect(url: string); + + /** + * Get the current route, with query params included + * e.g. /search?page=1&query=open%20access&f.dateIssued.min=1980&f.dateIssued.max=2020 + */ + abstract getCurrentRoute(); + + /** + * Get the hostname of the request + */ + abstract getRequestOrigin(); + + public rewriteDownloadURL(originalUrl: string): string { + if (environment.rewriteDownloadUrls) { + const hostName = this.getRequestOrigin(); + const namespace = environment.rest.nameSpace; + const rewrittenUrl = new URLCombiner(hostName, namespace).toString(); + return originalUrl.replace(environment.rest.baseUrl, rewrittenUrl); + } else { + return originalUrl; + } + } +} diff --git a/src/app/core/services/server-hard-redirect.service.spec.ts b/src/app/core/services/server-hard-redirect.service.spec.ts new file mode 100644 index 0000000000..dc89517468 --- /dev/null +++ b/src/app/core/services/server-hard-redirect.service.spec.ts @@ -0,0 +1,56 @@ +import { TestBed } from '@angular/core/testing'; +import { ServerHardRedirectService } from './server-hard-redirect.service'; + +describe('ServerHardRedirectService', () => { + + const mockRequest = jasmine.createSpyObj(['get']); + const mockResponse = jasmine.createSpyObj(['redirect', 'end']); + + const service: ServerHardRedirectService = new ServerHardRedirectService(mockRequest, mockResponse); + const origin = 'test-host'; + + beforeEach(() => { + mockRequest.headers = { + host: 'test-host', + }; + + TestBed.configureTestingModule({}); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + describe('when performing a redirect', () => { + + const redirect = 'test redirect'; + + beforeEach(() => { + service.redirect(redirect); + }); + + it('should update the response object', () => { + expect(mockResponse.redirect).toHaveBeenCalledWith(302, redirect); + expect(mockResponse.end).toHaveBeenCalled(); + }) + }); + + describe('when requesting the current route', () => { + + beforeEach(() => { + mockRequest.originalUrl = 'original/url'; + }); + + it('should return the location origin', () => { + expect(service.getCurrentRoute()).toEqual(mockRequest.originalUrl); + }); + }); + + describe('when requesting the origin', () => { + + it('should return the location origin', () => { + expect(service.getRequestOrigin()).toEqual(origin); + }); + }); + +}); diff --git a/src/app/core/services/server-hard-redirect.service.ts b/src/app/core/services/server-hard-redirect.service.ts new file mode 100644 index 0000000000..65b404ca6c --- /dev/null +++ b/src/app/core/services/server-hard-redirect.service.ts @@ -0,0 +1,70 @@ +import { Inject, Injectable } from '@angular/core'; +import { Request, Response } from 'express'; +import { REQUEST, RESPONSE } from '@nguniversal/express-engine/tokens'; +import { HardRedirectService } from './hard-redirect.service'; + +/** + * Service for performing hard redirects within the server app module + */ +@Injectable() +export class ServerHardRedirectService extends HardRedirectService { + + constructor( + @Inject(REQUEST) protected req: Request, + @Inject(RESPONSE) protected res: Response, + ) { + super(); + } + + /** + * Perform a hard redirect to URL + * @param url + */ + redirect(url: string) { + + if (url === this.req.url) { + return; + } + + if (this.res.finished) { + const req: any = this.req; + req._r_count = (req._r_count || 0) + 1; + + console.warn('Attempted to redirect on a finished response. From', + this.req.url, 'to', url); + + if (req._r_count > 10) { + console.error('Detected a redirection loop. killing the nodejs process'); + process.exit(1); + } + } else { + // attempt to use the already set status + let status = this.res.statusCode || 0; + if (status < 300 || status >= 400) { + // temporary redirect + status = 302; + } + + console.log(`Redirecting from ${this.req.url} to ${url} with ${status}`); + this.res.redirect(status, url); + this.res.end(); + // I haven't found a way to correctly stop Angular rendering. + // So we just let it end its work, though we have already closed + // the response. + } + } + + /** + * Get the origin of a request + */ + getCurrentRoute() { + return this.req.originalUrl; + } + + /** + * Get the hostname of the request + */ + getRequestOrigin() { + return this.req.headers.host; + } +} diff --git a/src/app/core/shared/bitstream.model.ts b/src/app/core/shared/bitstream.model.ts index ab9d1548b7..314818b482 100644 --- a/src/app/core/shared/bitstream.model.ts +++ b/src/app/core/shared/bitstream.model.ts @@ -8,6 +8,8 @@ import { BITSTREAM } from './bitstream.resource-type'; import { DSpaceObject } from './dspace-object.model'; import { HALLink } from './hal-link.model'; import { HALResource } from './hal-resource.model'; +import {BUNDLE} from './bundle.resource-type'; +import {Bundle} from './bundle.model'; @typedObject @inheritSerialization(DSpaceObject) @@ -57,4 +59,10 @@ export class Bitstream extends DSpaceObject implements HALResource { @link(BITSTREAM_FORMAT, false, 'format') format?: Observable>; + /** + * The owning bundle for this Bitstream + * Will be undefined unless the bundle{@link HALLink} has been resolved. + */ + @link(BUNDLE) + bundle?: Observable>; } diff --git a/src/app/core/shared/bundle.model.ts b/src/app/core/shared/bundle.model.ts index 1e5c14d486..c84b1f691f 100644 --- a/src/app/core/shared/bundle.model.ts +++ b/src/app/core/shared/bundle.model.ts @@ -10,6 +10,8 @@ import { RemoteData } from '../data/remote-data'; import { PaginatedList } from '../data/paginated-list'; import { BITSTREAM } from './bitstream.resource-type'; import { Bitstream } from './bitstream.model'; +import {ITEM} from './item.resource-type'; +import {Item} from './item.model'; @typedObject @inheritSerialization(DSpaceObject) @@ -24,6 +26,7 @@ export class Bundle extends DSpaceObject { self: HALLink; primaryBitstream: HALLink; bitstreams: HALLink; + item: HALLink; }; /** @@ -39,4 +42,11 @@ export class Bundle extends DSpaceObject { */ @link(BITSTREAM, true) bitstreams?: Observable>>; + + /** + * The owning item for this Bundle + * Will be undefined unless the Item{@link HALLink} has been resolved. + */ + @link(ITEM) + item?: Observable>; } diff --git a/src/app/core/integration/models/confidence-type.ts b/src/app/core/shared/confidence-type.ts similarity index 100% rename from src/app/core/integration/models/confidence-type.ts rename to src/app/core/shared/confidence-type.ts diff --git a/src/app/core/shared/dspace-object.model.ts b/src/app/core/shared/dspace-object.model.ts index a9256fbb7f..3abb9bceed 100644 --- a/src/app/core/shared/dspace-object.model.ts +++ b/src/app/core/shared/dspace-object.model.ts @@ -184,4 +184,25 @@ export class DSpaceObject extends ListableObject implements CacheableObject { getRenderTypes(): Array> { return [this.constructor as GenericConstructor]; } + + setMetadata(key: string, language?: string, ...values: string[]) { + const mdValues: MetadataValue[] = values.map((value: string, index: number) => { + const md = new MetadataValue(); + md.value = value; + md.authority = null; + md.confidence = -1; + md.language = language || null; + md.place = index; + return md; + }); + if (hasNoValue(this.metadata)) { + this.metadata = Object.create({}); + } + this.metadata[key] = mdValues; + } + + removeMetadata(key: string) { + delete this.metadata[key]; + } + } diff --git a/src/app/core/shared/operators.spec.ts b/src/app/core/shared/operators.spec.ts index a19419259d..8acf5ea860 100644 --- a/src/app/core/shared/operators.spec.ts +++ b/src/app/core/shared/operators.spec.ts @@ -13,7 +13,8 @@ import { getRequestFromRequestUUID, getResourceLinksFromResponse, getResponseFromEntry, - getSucceededRemoteData, redirectToPageNotFoundOn404 + getSucceededRemoteData, + redirectOn404Or401 } from './operators'; import { RemoteData } from '../data/remote-data'; import { RemoteDataError } from '../data/remote-data-error'; @@ -199,7 +200,7 @@ describe('Core Module - RxJS Operators', () => { }); }); - describe('redirectToPageNotFoundOn404', () => { + describe('redirectOn404Or401', () => { let router; beforeEach(() => { router = jasmine.createSpyObj('router', ['navigateByUrl']); @@ -208,21 +209,28 @@ describe('Core Module - RxJS Operators', () => { it('should call navigateByUrl to a 404 page, when the remote data contains a 404 error', () => { const testRD = createFailedRemoteDataObject(undefined, new RemoteDataError(404, 'Not Found', 'Object was not found')); - observableOf(testRD).pipe(redirectToPageNotFoundOn404(router)).subscribe(); + observableOf(testRD).pipe(redirectOn404Or401(router)).subscribe(); expect(router.navigateByUrl).toHaveBeenCalledWith('/404', { skipLocationChange: true }); }); - it('should not call navigateByUrl to a 404 page, when the remote data contains another error than a 404', () => { + it('should call navigateByUrl to a 401 page, when the remote data contains a 401 error', () => { + const testRD = createFailedRemoteDataObject(undefined, new RemoteDataError(401, 'Unauthorized', 'The current user is unauthorized')); + + observableOf(testRD).pipe(redirectOn404Or401(router)).subscribe(); + expect(router.navigateByUrl).toHaveBeenCalledWith('/401', { skipLocationChange: true }); + }); + + it('should not call navigateByUrl to a 404 or 401 page, when the remote data contains another error than a 404 or 401', () => { const testRD = createFailedRemoteDataObject(undefined, new RemoteDataError(500, 'Server Error', 'Something went wrong')); - observableOf(testRD).pipe(redirectToPageNotFoundOn404(router)).subscribe(); + observableOf(testRD).pipe(redirectOn404Or401(router)).subscribe(); expect(router.navigateByUrl).not.toHaveBeenCalled(); }); - it('should not call navigateByUrl to a 404 page, when the remote data contains no error', () => { + it('should not call navigateByUrl to a 404 or 401 page, when the remote data contains no error', () => { const testRD = createSuccessfulRemoteDataObject(undefined); - observableOf(testRD).pipe(redirectToPageNotFoundOn404(router)).subscribe(); + observableOf(testRD).pipe(redirectOn404Or401(router)).subscribe(); expect(router.navigateByUrl).not.toHaveBeenCalled(); }); }); diff --git a/src/app/core/shared/operators.ts b/src/app/core/shared/operators.ts index b8120d4765..29e41907e1 100644 --- a/src/app/core/shared/operators.ts +++ b/src/app/core/shared/operators.ts @@ -1,6 +1,6 @@ import { Router, UrlTree } from '@angular/router'; -import { Observable } from 'rxjs'; -import { filter, find, flatMap, map, take, tap } from 'rxjs/operators'; +import { Observable, combineLatest as observableCombineLatest } from 'rxjs'; +import { filter, find, flatMap, map, switchMap, take, tap } from 'rxjs/operators'; import { hasValue, hasValueOperator, isNotEmpty } from '../../shared/empty.util'; import { SearchResult } from '../../shared/search/search-result.model'; import { DSOSuccessResponse, RestResponse } from '../cache/response.models'; @@ -9,9 +9,12 @@ import { RemoteData } from '../data/remote-data'; import { RestRequest } from '../data/request.models'; import { RequestEntry } from '../data/request.reducer'; import { RequestService } from '../data/request.service'; +import { MetadataField } from '../metadata/metadata-field.model'; +import { MetadataSchema } from '../metadata/metadata-schema.model'; import { BrowseDefinition } from './browse-definition.model'; import { DSpaceObject } from './dspace-object.model'; -import { getUnauthorizedRoute } from '../../app-routing-paths'; +import { getPageNotFoundRoute, getUnauthorizedRoute } from '../../app-routing-paths'; +import { getEndUserAgreementPath } from '../../info/info-routing-paths'; /** * This file contains custom RxJS operators that can be used in multiple places @@ -168,16 +171,20 @@ export const getAllSucceededRemoteListPayload = () => ); /** - * Operator that checks if a remote data object contains a page not found error - * When it does contain such an error, it will redirect the user to a page not found, without altering the current URL + * Operator that checks if a remote data object returned a 401 or 404 error + * When it does contain such an error, it will redirect the user to the related error page, without altering the current URL * @param router The router used to navigate to a new page */ -export const redirectToPageNotFoundOn404 = (router: Router) => +export const redirectOn404Or401 = (router: Router) => (source: Observable>): Observable> => source.pipe( tap((rd: RemoteData) => { - if (rd.hasFailed && rd.error.statusCode === 404) { - router.navigateByUrl('/404', { skipLocationChange: true }); + if (rd.hasFailed) { + if (rd.error.statusCode === 404) { + router.navigateByUrl(getPageNotFoundRoute(), {skipLocationChange: true}); + } else if (rd.error.statusCode === 401) { + router.navigateByUrl(getUnauthorizedRoute(), {skipLocationChange: true}); + } } })); @@ -192,6 +199,20 @@ export const returnUnauthorizedUrlTreeOnFalse = (router: Router) => return authorized ? authorized : router.parseUrl(getUnauthorizedRoute()) })); +/** + * Operator that returns a UrlTree to the unauthorized page when the boolean received is false + * @param router Router + * @param redirect Redirect URL to add to the UrlTree. This is used to redirect back to the original route after the + * user accepts the agreement. + */ +export const returnEndUserAgreementUrlTreeOnFalse = (router: Router, redirect: string) => + (source: Observable): Observable => + source.pipe( + map((hasAgreed: boolean) => { + const queryParams = { redirect: encodeURIComponent(redirect) }; + return hasAgreed ? hasAgreed : router.createUrlTree([getEndUserAgreementPath()], { queryParams }); + })); + export const getFinishedRemoteData = () => (source: Observable>): Observable> => source.pipe(find((rd: RemoteData) => !rd.isLoading)); @@ -250,3 +271,27 @@ export const paginatedListToArray = () => hasValueOperator(), map((objectRD: RemoteData>) => objectRD.payload.page.filter((object: T) => hasValue(object))) ); + +/** + * Operator for turning a list of metadata fields into an array of string representing their schema.element.qualifier string + */ +export const metadataFieldsToString = () => + (source: Observable>>): Observable => + source.pipe( + hasValueOperator(), + map((fieldRD: RemoteData>) => { + return fieldRD.payload.page.filter((object: MetadataField) => hasValue(object)) + }), + switchMap((fields: MetadataField[]) => { + const fieldSchemaArray = fields.map((field: MetadataField) => { + return field.schema.pipe( + getFirstSucceededRemoteDataPayload(), + map((schema: MetadataSchema) => ({ field, schema })) + ); + }); + return observableCombineLatest(fieldSchemaArray); + }), + map((fieldSchemaArray: Array<{ field: MetadataField, schema: MetadataSchema }>): string[] => { + return fieldSchemaArray.map((fieldSchema: { field: MetadataField, schema: MetadataSchema }) => fieldSchema.schema.prefix + '.' + fieldSchema.field.toString()) + }) + ); diff --git a/src/app/core/statistics/models/usage-report.model.ts b/src/app/core/statistics/models/usage-report.model.ts new file mode 100644 index 0000000000..4350bfd7d8 --- /dev/null +++ b/src/app/core/statistics/models/usage-report.model.ts @@ -0,0 +1,51 @@ +import { autoserialize, inheritSerialization } from 'cerialize'; +import { typedObject } from '../../cache/builders/build-decorators'; +import { excludeFromEquals } from '../../utilities/equals.decorators'; +import { ResourceType } from '../../shared/resource-type'; +import { HALResource } from '../../shared/hal-resource.model'; +import { USAGE_REPORT } from './usage-report.resource-type'; +import { HALLink } from '../../shared/hal-link.model'; +import { deserialize, autoserializeAs } from 'cerialize'; + +/** + * A usage report. + */ +@typedObject +@inheritSerialization(HALResource) +export class UsageReport extends HALResource { + + static type = USAGE_REPORT; + + /** + * The object type + */ + @excludeFromEquals + @autoserialize + type: ResourceType; + + @autoserialize + id: string; + + @autoserializeAs('report-type') + reportType: string; + + @autoserialize + points: Point[]; + + @deserialize + _links: { + self: HALLink; + }; +} + +/** + * A statistics data point. + */ +export interface Point { + id: string; + label: string; + type: string; + values: Array<{ + views: number; + }>; +} diff --git a/src/app/core/integration/models/authority.resource-type.ts b/src/app/core/statistics/models/usage-report.resource-type.ts similarity index 59% rename from src/app/core/integration/models/authority.resource-type.ts rename to src/app/core/statistics/models/usage-report.resource-type.ts index ec87ddc85f..650a51b3c3 100644 --- a/src/app/core/integration/models/authority.resource-type.ts +++ b/src/app/core/statistics/models/usage-report.resource-type.ts @@ -1,10 +1,9 @@ import { ResourceType } from '../../shared/resource-type'; /** - * The resource type for AuthorityValue + * The resource type for License * * Needs to be in a separate file to prevent circular * dependencies in webpack. */ - -export const AUTHORITY_VALUE = new ResourceType('authority'); +export const USAGE_REPORT = new ResourceType('usagereport'); diff --git a/src/app/core/statistics/usage-report-data.service.ts b/src/app/core/statistics/usage-report-data.service.ts new file mode 100644 index 0000000000..08dd111384 --- /dev/null +++ b/src/app/core/statistics/usage-report-data.service.ts @@ -0,0 +1,62 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { Store } from '@ngrx/store'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { dataService } from '../cache/builders/build-decorators'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { CoreState } from '../core.reducers'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { DataService } from '../data/data.service'; +import { RequestService } from '../data/request.service'; +import { DefaultChangeAnalyzer } from '../data/default-change-analyzer.service'; +import { USAGE_REPORT } from './models/usage-report.resource-type'; +import { UsageReport } from './models/usage-report.model'; +import { Observable } from 'rxjs'; +import { getRemoteDataPayload, getSucceededRemoteData } from '../shared/operators'; +import { map } from 'rxjs/operators'; + +/** + * A service to retrieve {@link UsageReport}s from the REST API + */ +@Injectable() +@dataService(USAGE_REPORT) +export class UsageReportService extends DataService { + + protected linkPath = 'statistics/usagereports'; + + constructor( + protected comparator: DefaultChangeAnalyzer, + protected halService: HALEndpointService, + protected http: HttpClient, + protected notificationsService: NotificationsService, + protected objectCache: ObjectCacheService, + protected rdbService: RemoteDataBuildService, + protected requestService: RequestService, + protected store: Store, + ) { + super(); + } + + getStatistic(scope: string, type: string): Observable { + return this.findById(`${scope}_${type}`).pipe( + getSucceededRemoteData(), + getRemoteDataPayload(), + ); + } + + searchStatistics(uri: string, page: number, size: number): Observable { + return this.searchBy('object', { + searchParams: [{ + fieldName: `uri`, + fieldValue: uri, + }], + currentPage: page, + elementsPerPage: size, + }).pipe( + getSucceededRemoteData(), + getRemoteDataPayload(), + map((list) => list.page), + ); + } +} diff --git a/src/app/core/submission/submission-object-data.service.spec.ts b/src/app/core/submission/submission-object-data.service.spec.ts index 931a7ae7d5..781036e950 100644 --- a/src/app/core/submission/submission-object-data.service.spec.ts +++ b/src/app/core/submission/submission-object-data.service.spec.ts @@ -1,8 +1,6 @@ -import { Observable } from 'rxjs'; import { SubmissionService } from '../../submission/submission.service'; import { RemoteData } from '../data/remote-data'; import { SubmissionObject } from './models/submission-object.model'; -import { WorkspaceItem } from './models/workspaceitem.model'; import { SubmissionObjectDataService } from './submission-object-data.service'; import { SubmissionScopeType } from './submission-scope-type'; import { WorkflowItemDataService } from './workflowitem-data.service'; diff --git a/src/app/core/submission/submission-response-parsing.service.ts b/src/app/core/submission/submission-response-parsing.service.ts index 4bbd93b18d..b588c919a1 100644 --- a/src/app/core/submission/submission-response-parsing.service.ts +++ b/src/app/core/submission/submission-response-parsing.service.ts @@ -1,4 +1,4 @@ -import { Inject, Injectable } from '@angular/core'; +import { Injectable } from '@angular/core'; import { deepClone } from 'fast-json-patch'; import { DSOResponseParsingService } from '../data/dso-response-parsing.service'; @@ -113,7 +113,7 @@ export class SubmissionResponseParsingService extends BaseResponseParsingService return new ErrorResponse( Object.assign( new Error('Unexpected response from server'), - {statusCode: data.statusCode, statusText: data.statusText} + { statusCode: data.statusCode, statusText: data.statusText } ) ); } @@ -133,7 +133,7 @@ export class SubmissionResponseParsingService extends BaseResponseParsingService processedList.forEach((item) => { - item = Object.assign({}, item); + // item = Object.assign({}, item); // In case data is an Instance of WorkspaceItem normalize field value of all the section of type form if (item instanceof WorkspaceItem || item instanceof WorkflowItem) { diff --git a/src/app/core/submission/vocabularies/models/vocabularies.resource-type.ts b/src/app/core/submission/vocabularies/models/vocabularies.resource-type.ts new file mode 100644 index 0000000000..5902fe4e17 --- /dev/null +++ b/src/app/core/submission/vocabularies/models/vocabularies.resource-type.ts @@ -0,0 +1,12 @@ +import { ResourceType } from '../../../shared/resource-type'; + +/** + * The resource type for vocabulary models + * + * Needs to be in a separate file to prevent circular + * dependencies in webpack. + */ + +export const VOCABULARY = new ResourceType('vocabulary'); +export const VOCABULARY_ENTRY = new ResourceType('vocabularyEntry'); +export const VOCABULARY_ENTRY_DETAIL = new ResourceType('vocabularyEntryDetail'); diff --git a/src/app/core/submission/vocabularies/models/vocabulary-entry-detail.model.ts b/src/app/core/submission/vocabularies/models/vocabulary-entry-detail.model.ts new file mode 100644 index 0000000000..2e066bae95 --- /dev/null +++ b/src/app/core/submission/vocabularies/models/vocabulary-entry-detail.model.ts @@ -0,0 +1,39 @@ +import { autoserialize, deserialize, inheritSerialization } from 'cerialize'; + +import { HALLink } from '../../../shared/hal-link.model'; +import { VOCABULARY_ENTRY_DETAIL } from './vocabularies.resource-type'; +import { typedObject } from '../../../cache/builders/build-decorators'; +import { VocabularyEntry } from './vocabulary-entry.model'; + +/** + * Model class for a VocabularyEntryDetail + */ +@typedObject +@inheritSerialization(VocabularyEntry) +export class VocabularyEntryDetail extends VocabularyEntry { + static type = VOCABULARY_ENTRY_DETAIL; + + /** + * The unique id of the entry + */ + @autoserialize + id: string; + + /** + * In an hierarchical vocabulary representing if entry is selectable as value + */ + @autoserialize + selectable: boolean; + + /** + * The {@link HALLink}s for this ExternalSourceEntry + */ + @deserialize + _links: { + self: HALLink; + vocabulary: HALLink; + parent: HALLink; + children + }; + +} diff --git a/src/app/core/submission/vocabularies/models/vocabulary-entry.model.ts b/src/app/core/submission/vocabularies/models/vocabulary-entry.model.ts new file mode 100644 index 0000000000..ca26c1b41e --- /dev/null +++ b/src/app/core/submission/vocabularies/models/vocabulary-entry.model.ts @@ -0,0 +1,103 @@ +import { autoserialize, deserialize } from 'cerialize'; + +import { HALLink } from '../../../shared/hal-link.model'; +import { VOCABULARY_ENTRY } from './vocabularies.resource-type'; +import { typedObject } from '../../../cache/builders/build-decorators'; +import { excludeFromEquals } from '../../../utilities/equals.decorators'; +import { PLACEHOLDER_PARENT_METADATA } from '../../../../shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-constants'; +import { OtherInformation } from '../../../../shared/form/builder/models/form-field-metadata-value.model'; +import { isNotEmpty } from '../../../../shared/empty.util'; +import { ListableObject } from '../../../../shared/object-collection/shared/listable-object.model'; +import { GenericConstructor } from '../../../shared/generic-constructor'; + +/** + * Model class for a VocabularyEntry + */ +@typedObject +export class VocabularyEntry extends ListableObject { + static type = VOCABULARY_ENTRY; + + /** + * The identifier of this vocabulary entry + */ + @autoserialize + authority: string; + + /** + * The display value of this vocabulary entry + */ + @autoserialize + display: string; + + /** + * The value of this vocabulary entry + */ + @autoserialize + value: string; + + /** + * An object containing additional information related to this vocabulary entry + */ + @autoserialize + otherInformation: OtherInformation; + + /** + * A string representing the kind of vocabulary entry + */ + @excludeFromEquals + @autoserialize + public type: any; + + /** + * The {@link HALLink}s for this ExternalSourceEntry + */ + @deserialize + _links: { + self: HALLink; + vocabularyEntryDetail?: HALLink; + }; + + /** + * This method checks if entry has an authority value + * + * @return boolean + */ + hasAuthority(): boolean { + return isNotEmpty(this.authority); + } + + /** + * This method checks if entry has a value + * + * @return boolean + */ + hasValue(): boolean { + return isNotEmpty(this.value); + } + + /** + * This method checks if entry has related information object + * + * @return boolean + */ + hasOtherInformation(): boolean { + return isNotEmpty(this.otherInformation); + } + + /** + * This method checks if entry has a placeholder as value + * + * @return boolean + */ + hasPlaceholder(): boolean { + return this.hasValue() && this.value === PLACEHOLDER_PARENT_METADATA; + } + + /** + * Method that returns as which type of object this object should be rendered + */ + getRenderTypes(): Array> { + return [this.constructor as GenericConstructor]; + } + +} diff --git a/src/app/core/submission/vocabularies/models/vocabulary-find-options.model.ts b/src/app/core/submission/vocabularies/models/vocabulary-find-options.model.ts new file mode 100644 index 0000000000..bd9bd55b95 --- /dev/null +++ b/src/app/core/submission/vocabularies/models/vocabulary-find-options.model.ts @@ -0,0 +1,37 @@ +import { SortOptions } from '../../../cache/models/sort-options.model'; +import { FindListOptions } from '../../../data/request.models'; +import { RequestParam } from '../../../cache/models/request-param.model'; +import { isNotEmpty } from '../../../../shared/empty.util'; + +/** + * Representing properties used to build a vocabulary find request + */ +export class VocabularyFindOptions extends FindListOptions { + + constructor(public query: string = '', + public filter?: string, + public exact?: boolean, + public entryID?: string, + public elementsPerPage?: number, + public currentPage?: number, + public sort?: SortOptions + ) { + super(); + + const searchParams = []; + + if (isNotEmpty(query)) { + searchParams.push(new RequestParam('query', query)) + } + if (isNotEmpty(filter)) { + searchParams.push(new RequestParam('filter', filter)) + } + if (isNotEmpty(exact)) { + searchParams.push(new RequestParam('exact', exact.toString())) + } + if (isNotEmpty(entryID)) { + searchParams.push(new RequestParam('entryID', entryID)) + } + this.searchParams = searchParams; + } +} diff --git a/src/app/core/submission/vocabularies/models/vocabulary-options.model.ts b/src/app/core/submission/vocabularies/models/vocabulary-options.model.ts new file mode 100644 index 0000000000..fd103718e1 --- /dev/null +++ b/src/app/core/submission/vocabularies/models/vocabulary-options.model.ts @@ -0,0 +1,21 @@ +/** + * Representing vocabulary properties + */ +export class VocabularyOptions { + + /** + * The name of the vocabulary + */ + name: string; + + /** + * A boolean representing if value is closely related to a vocabulary entry or not + */ + closed: boolean; + + constructor(name: string, + closed: boolean = false) { + this.name = name; + this.closed = closed; + } +} diff --git a/src/app/core/submission/vocabularies/models/vocabulary.model.ts b/src/app/core/submission/vocabularies/models/vocabulary.model.ts new file mode 100644 index 0000000000..8672d1c6ed --- /dev/null +++ b/src/app/core/submission/vocabularies/models/vocabulary.model.ts @@ -0,0 +1,61 @@ +import { autoserialize, deserialize } from 'cerialize'; + +import { HALLink } from '../../../shared/hal-link.model'; +import { VOCABULARY } from './vocabularies.resource-type'; +import { CacheableObject } from '../../../cache/object-cache.reducer'; +import { typedObject } from '../../../cache/builders/build-decorators'; +import { excludeFromEquals } from '../../../utilities/equals.decorators'; + +/** + * Model class for a Vocabulary + */ +@typedObject +export class Vocabulary implements CacheableObject { + static type = VOCABULARY; + /** + * The identifier of this Vocabulary + */ + @autoserialize + id: string; + + /** + * The name of this Vocabulary + */ + @autoserialize + name: string; + + /** + * True if it is possible to scroll all the entries in the vocabulary without providing a filter parameter + */ + @autoserialize + scrollable: boolean; + + /** + * True if the vocabulary exposes a tree structure where some entries are parent of others + */ + @autoserialize + hierarchical: boolean; + + /** + * For hierarchical vocabularies express the preference to preload the tree at a specific + * level of depth (0 only the top nodes are shown, 1 also their children are preloaded and so on) + */ + @autoserialize + preloadLevel: any; + + /** + * A string representing the kind of Vocabulary model + */ + @excludeFromEquals + @autoserialize + public type: any; + + /** + * The {@link HALLink}s for this Vocabulary + */ + @deserialize + _links: { + self: HALLink, + entries: HALLink + }; +} diff --git a/src/app/core/submission/vocabularies/vocabulary-entries-response-parsing.service.spec.ts b/src/app/core/submission/vocabularies/vocabulary-entries-response-parsing.service.spec.ts new file mode 100644 index 0000000000..8e3b63df74 --- /dev/null +++ b/src/app/core/submission/vocabularies/vocabulary-entries-response-parsing.service.spec.ts @@ -0,0 +1,111 @@ +import { getMockObjectCacheService } from '../../../shared/mocks/object-cache.service.mock'; +import { ErrorResponse, GenericSuccessResponse } from '../../cache/response.models'; +import { DSpaceRESTV2Response } from '../../dspace-rest-v2/dspace-rest-v2-response.model'; +import { VocabularyEntriesResponseParsingService } from './vocabulary-entries-response-parsing.service'; +import { VocabularyEntriesRequest } from '../../data/request.models'; + +describe('VocabularyEntriesResponseParsingService', () => { + let service: VocabularyEntriesResponseParsingService; + const metadata = 'dc.type'; + const collectionUUID = '8b39g7ya-5a4b-438b-851f-be1d5b4a1c5a'; + const entriesRequestURL = `https://rest.api/rest/api/submission/vocabularies/types/entries?metadata=${metadata}&collection=${collectionUUID}` + + beforeEach(() => { + service = new VocabularyEntriesResponseParsingService(getMockObjectCacheService()); + }); + + describe('parse', () => { + const request = new VocabularyEntriesRequest('client/f5b4ccb8-fbb0-4548-b558-f234d9fdfad6', entriesRequestURL); + + const validResponse = { + payload: { + _embedded: { + entries: [ + { + display: 'testValue1', + value: 'testValue1', + otherInformation: {}, + type: 'vocabularyEntry' + }, + { + display: 'testValue2', + value: 'testValue2', + otherInformation: {}, + type: 'vocabularyEntry' + }, + { + display: 'testValue3', + value: 'testValue3', + otherInformation: {}, + type: 'vocabularyEntry' + }, + { + authority: 'authorityId1', + display: 'testValue1', + value: 'testValue1', + otherInformation: { + id: 'VR131402', + parent: 'Research Subject Categories::SOCIAL SCIENCES::Social sciences::Social work', + hasChildren: 'false', + note: 'Familjeforskning' + }, + type: 'vocabularyEntry', + _links: { + vocabularyEntryDetail: { + href: 'https://rest.api/rest/api/submission/vocabularyEntryDetails/srsc:VR131402' + } + } + } + ] + }, + _links: { + first: { + href: 'https://rest.api/rest/api/submission/vocabularies/types/entries/first?page=0&size=5' + }, + self: { + href: 'https://rest.api/rest/api/submission/vocabularies/types/entries' + }, + next: { + href: 'https://rest.api/rest/api/submission/vocabularies/types/entries/next?page=1&size=5' + }, + last: { + href: 'https://rest.api/rest/api/submission/vocabularies/types/entries/last?page=9&size=5' + } + }, + page: { + size: 5, + totalElements: 50, + totalPages: 10, + number: 0 + } + }, + statusCode: 200, + statusText: 'OK' + } as DSpaceRESTV2Response; + + const invalidResponseNotAList = { + statusCode: 200, + statusText: 'OK' + } as DSpaceRESTV2Response; + + const invalidResponseStatusCode = { + payload: {}, statusCode: 500, statusText: 'Internal Server Error' + } as DSpaceRESTV2Response; + + it('should return a GenericSuccessResponse if data contains a valid browse entries response', () => { + const response = service.parse(request, validResponse); + expect(response.constructor).toBe(GenericSuccessResponse); + }); + + it('should return an ErrorResponse if data contains an invalid browse entries response', () => { + const response = service.parse(request, invalidResponseNotAList); + expect(response.constructor).toBe(ErrorResponse); + }); + + it('should return an ErrorResponse if data contains a statuscode other than 200', () => { + const response = service.parse(request, invalidResponseStatusCode); + expect(response.constructor).toBe(ErrorResponse); + }); + + }); +}); diff --git a/src/app/core/submission/vocabularies/vocabulary-entries-response-parsing.service.ts b/src/app/core/submission/vocabularies/vocabulary-entries-response-parsing.service.ts new file mode 100644 index 0000000000..f0c20fe7c5 --- /dev/null +++ b/src/app/core/submission/vocabularies/vocabulary-entries-response-parsing.service.ts @@ -0,0 +1,26 @@ +import { Injectable } from '@angular/core'; + +import { ObjectCacheService } from '../../cache/object-cache.service'; +import { VocabularyEntry } from './models/vocabulary-entry.model'; +import { EntriesResponseParsingService } from '../../data/entries-response-parsing.service'; +import { GenericConstructor } from '../../shared/generic-constructor'; + +/** + * A service responsible for parsing data for a vocabulary entries response + */ +@Injectable() +export class VocabularyEntriesResponseParsingService extends EntriesResponseParsingService { + + protected toCache = false; + + constructor( + protected objectCache: ObjectCacheService, + ) { + super(objectCache); + } + + getSerializerModel(): GenericConstructor { + return VocabularyEntry; + } + +} diff --git a/src/app/core/submission/vocabularies/vocabulary.service.spec.ts b/src/app/core/submission/vocabularies/vocabulary.service.spec.ts new file mode 100644 index 0000000000..1119d4f6e6 --- /dev/null +++ b/src/app/core/submission/vocabularies/vocabulary.service.spec.ts @@ -0,0 +1,569 @@ +import { HttpClient } from '@angular/common/http'; + +import { cold, getTestScheduler, hot } from 'jasmine-marbles'; +import { of as observableOf } from 'rxjs'; +import { TestScheduler } from 'rxjs/testing'; + +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; +import { ObjectCacheService } from '../../cache/object-cache.service'; +import { HALEndpointService } from '../../shared/hal-endpoint.service'; +import { RequestService } from '../../data/request.service'; +import { VocabularyEntriesRequest } from '../../data/request.models'; +import { RequestParam } from '../../cache/models/request-param.model'; +import { PageInfo } from '../../shared/page-info.model'; +import { PaginatedList } from '../../data/paginated-list'; +import { createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils'; +import { RequestEntry } from '../../data/request.reducer'; +import { RestResponse } from '../../cache/response.models'; +import { VocabularyService } from './vocabulary.service'; +import { getMockRequestService } from '../../../shared/mocks/request.service.mock'; +import { getMockRemoteDataBuildService } from '../../../shared/mocks/remote-data-build.service.mock'; +import { VocabularyOptions } from './models/vocabulary-options.model'; +import { VocabularyFindOptions } from './models/vocabulary-find-options.model'; + +describe('VocabularyService', () => { + let scheduler: TestScheduler; + let service: VocabularyService; + let requestService: RequestService; + let rdbService: RemoteDataBuildService; + let objectCache: ObjectCacheService; + let halService: HALEndpointService; + let responseCacheEntry: RequestEntry; + + const vocabulary: any = { + id: 'types', + name: 'types', + scrollable: true, + hierarchical: false, + preloadLevel: 1, + type: 'vocabulary', + uuid: 'vocabulary-types', + _links: { + self: { + href: 'https://rest.api/rest/api/submission/vocabularies/types' + }, + entries: { + href: 'https://rest.api/rest/api/submission/vocabularies/types/entries' + }, + } + }; + + const hierarchicalVocabulary: any = { + id: 'srsc', + name: 'srsc', + scrollable: false, + hierarchical: true, + preloadLevel: 2, + type: 'vocabulary', + uuid: 'vocabulary-srsc', + _links: { + self: { + href: 'https://rest.api/rest/api/submission/vocabularies/types' + }, + entries: { + href: 'https://rest.api/rest/api/submission/vocabularies/types/entries' + }, + } + }; + + const vocabularyEntry: any = { + display: 'testValue1', + value: 'testValue1', + otherInformation: {}, + type: 'vocabularyEntry' + }; + + const vocabularyEntry2: any = { + display: 'testValue2', + value: 'testValue2', + otherInformation: {}, + type: 'vocabularyEntry' + }; + + const vocabularyEntry3: any = { + display: 'testValue3', + value: 'testValue3', + otherInformation: {}, + type: 'vocabularyEntry' + }; + + const vocabularyEntryParentDetail: any = { + authority: 'authorityId2', + display: 'testParent', + value: 'testParent', + otherInformation: { + id: 'authorityId2', + hasChildren: 'true', + note: 'Familjeforskning' + }, + type: 'vocabularyEntryDetail', + _links: { + self: { + href: 'https://rest.api/rest/api/submission/vocabularyEntryDetails/srsc:VR131402' + }, + parent: { + href: 'https://rest.api/rest/api/submission/vocabularyEntryDetails/srsc:parent' + }, + children: { + href: 'https://rest.api/rest/api/submission/vocabularyEntryDetails/srsc:children' + } + } + }; + + const vocabularyEntryChildDetail: any = { + authority: 'authoritytestChild1', + display: 'testChild1', + value: 'testChild1', + otherInformation: { + id: 'authoritytestChild1', + hasChildren: 'true', + note: 'Familjeforskning' + }, + type: 'vocabularyEntryDetail', + _links: { + self: { + href: 'https://rest.api/rest/api/submission/vocabularyEntryDetails/srsc:authoritytestChild1' + }, + parent: { + href: 'https://rest.api/rest/api/submission/vocabularyEntryDetails/srsc:parent' + }, + children: { + href: 'https://rest.api/rest/api/submission/vocabularyEntryDetails/srsc:children' + } + } + }; + + const vocabularyEntryChild2Detail: any = { + authority: 'authoritytestChild2', + display: 'testChild2', + value: 'testChild2', + otherInformation: { + id: 'authoritytestChild2', + hasChildren: 'true', + note: 'Familjeforskning' + }, + type: 'vocabularyEntryDetail', + _links: { + self: { + href: 'https://rest.api/rest/api/submission/vocabularyEntryDetails/srsc:authoritytestChild2' + }, + parent: { + href: 'https://rest.api/rest/api/submission/vocabularyEntryDetails/srsc:parent' + }, + children: { + href: 'https://rest.api/rest/api/submission/vocabularyEntryDetails/srsc:children' + } + } + }; + + const endpointURL = `https://rest.api/rest/api/submission/vocabularies`; + const requestURL = `https://rest.api/rest/api/submission/vocabularies/${vocabulary.id}`; + const entryDetailEndpointURL = `https://rest.api/rest/api/submission/vocabularyEntryDetails`; + const entryDetailRequestURL = `https://rest.api/rest/api/submission/vocabularyEntryDetails/${hierarchicalVocabulary.id}:testValue`; + const entryDetailParentRequestURL = `https://rest.api/rest/api/submission/vocabularyEntryDetails/${hierarchicalVocabulary.id}:testValue/parent`; + const entryDetailChildrenRequestURL = `https://rest.api/rest/api/submission/vocabularyEntryDetails/${hierarchicalVocabulary.id}:testValue/children`; + const requestUUID = '8b3c613a-5a4b-438b-9686-be1d5b4a1c5a'; + const vocabularyId = 'types'; + const metadata = 'dc.type'; + const collectionUUID = '8b39g7ya-5a4b-438b-851f-be1d5b4a1c5a'; + const entryID = 'dsfsfsdf-5a4b-438b-851f-be1d5b4a1c5a'; + const searchRequestURL = `https://rest.api/rest/api/submission/vocabularies/search/byMetadataAndCollection?metadata=${metadata}&collection=${collectionUUID}`; + const entriesRequestURL = `https://rest.api/rest/api/submission/vocabularies/${vocabulary.id}/entries`; + const entriesByValueRequestURL = `https://rest.api/rest/api/submission/vocabularies/${vocabulary.id}/entries?filter=test&exact=false`; + const entryByValueRequestURL = `https://rest.api/rest/api/submission/vocabularies/${vocabulary.id}/entries?filter=test&exact=true`; + const entryByIDRequestURL = `https://rest.api/rest/api/submission/vocabularies/${vocabulary.id}/entries?entryID=${entryID}`; + const vocabularyOptions: VocabularyOptions = { + name: vocabularyId, + closed: false + } + const pageInfo = new PageInfo(); + const array = [vocabulary, hierarchicalVocabulary]; + const arrayEntries = [vocabularyEntry, vocabularyEntry2, vocabularyEntry3]; + const childrenEntries = [vocabularyEntryChildDetail, vocabularyEntryChild2Detail]; + const paginatedList = new PaginatedList(pageInfo, array); + const paginatedListEntries = new PaginatedList(pageInfo, arrayEntries); + const childrenPaginatedList = new PaginatedList(pageInfo, childrenEntries); + const vocabularyRD = createSuccessfulRemoteDataObject(vocabulary); + const vocabularyRD$ = createSuccessfulRemoteDataObject$(vocabulary); + const vocabularyEntriesRD = createSuccessfulRemoteDataObject$(paginatedListEntries); + const vocabularyEntryDetailParentRD = createSuccessfulRemoteDataObject(vocabularyEntryParentDetail); + const vocabularyEntryChildrenRD = createSuccessfulRemoteDataObject(childrenPaginatedList); + const paginatedListRD = createSuccessfulRemoteDataObject(paginatedList); + const getRequestEntries$ = (successful: boolean) => { + return observableOf({ + response: { isSuccessful: successful, payload: arrayEntries } as any + } as RequestEntry) + }; + objectCache = {} as ObjectCacheService; + const notificationsService = {} as NotificationsService; + const http = {} as HttpClient; + const comparator = {} as any; + const comparatorEntry = {} as any; + + function initTestService() { + return new VocabularyService( + requestService, + rdbService, + objectCache, + halService, + notificationsService, + http, + comparator, + comparatorEntry + ); + } + + describe('vocabularies endpoint', () => { + beforeEach(() => { + scheduler = getTestScheduler(); + + halService = jasmine.createSpyObj('halService', { + getEndpoint: cold('a', { a: endpointURL }) + }); + }); + + afterEach(() => { + service = null; + }); + + describe('', () => { + beforeEach(() => { + responseCacheEntry = new RequestEntry(); + responseCacheEntry.request = { href: 'https://rest.api/' } as any; + responseCacheEntry.completed = true; + responseCacheEntry.response = new RestResponse(true, 200, 'Success'); + + requestService = jasmine.createSpyObj('requestService', { + generateRequestId: requestUUID, + configure: true, + removeByHrefSubstring: {}, + getByHref: observableOf(responseCacheEntry), + getByUUID: observableOf(responseCacheEntry), + }); + rdbService = jasmine.createSpyObj('rdbService', { + buildSingle: hot('a|', { + a: vocabularyRD + }), + buildList: hot('a|', { + a: paginatedListRD + }), + }); + + service = initTestService(); + + spyOn((service as any).vocabularyDataService, 'findById').and.callThrough(); + spyOn((service as any).vocabularyDataService, 'findAll').and.callThrough(); + spyOn((service as any).vocabularyDataService, 'findByHref').and.callThrough(); + spyOn((service as any).vocabularyDataService, 'searchBy').and.callThrough(); + spyOn((service as any).vocabularyDataService, 'getSearchByHref').and.returnValue(observableOf(searchRequestURL)); + spyOn((service as any).vocabularyDataService, 'getFindAllHref').and.returnValue(observableOf(entriesRequestURL)); + }); + + afterEach(() => { + service = null; + }); + + describe('findVocabularyById', () => { + it('should proxy the call to vocabularyDataService.findVocabularyById', () => { + scheduler.schedule(() => service.findVocabularyById(vocabularyId)); + scheduler.flush(); + + expect((service as any).vocabularyDataService.findById).toHaveBeenCalledWith(vocabularyId); + }); + + it('should return a RemoteData for the object with the given id', () => { + const result = service.findVocabularyById(vocabularyId); + const expected = cold('a|', { + a: vocabularyRD + }); + expect(result).toBeObservable(expected); + }); + }); + + describe('findVocabularyByHref', () => { + it('should proxy the call to vocabularyDataService.findVocabularyByHref', () => { + scheduler.schedule(() => service.findVocabularyByHref(requestURL)); + scheduler.flush(); + + expect((service as any).vocabularyDataService.findByHref).toHaveBeenCalledWith(requestURL); + }); + + it('should return a RemoteData for the object with the given URL', () => { + const result = service.findVocabularyByHref(requestURL); + const expected = cold('a|', { + a: vocabularyRD + }); + expect(result).toBeObservable(expected); + }); + }); + + describe('findAllVocabularies', () => { + it('should proxy the call to vocabularyDataService.findAllVocabularies', () => { + scheduler.schedule(() => service.findAllVocabularies()); + scheduler.flush(); + + expect((service as any).vocabularyDataService.findAll).toHaveBeenCalled(); + }); + + it('should return a RemoteData>', () => { + const result = service.findAllVocabularies(); + const expected = cold('a|', { + a: paginatedListRD + }); + expect(result).toBeObservable(expected); + }); + }); + }); + + describe('', () => { + + beforeEach(() => { + requestService = getMockRequestService(getRequestEntries$(true)); + rdbService = getMockRemoteDataBuildService(undefined, vocabularyEntriesRD); + spyOn(rdbService, 'toRemoteDataObservable').and.callThrough(); + service = initTestService(); + spyOn(service, 'findVocabularyById').and.returnValue(vocabularyRD$); + }); + + describe('getVocabularyEntries', () => { + + it('should configure a new VocabularyEntriesRequest', () => { + const expected = new VocabularyEntriesRequest(requestService.generateRequestId(), entriesRequestURL); + + scheduler.schedule(() => service.getVocabularyEntries(vocabularyOptions, pageInfo).subscribe()); + scheduler.flush(); + + expect(requestService.configure).toHaveBeenCalledWith(expected); + }); + + it('should call RemoteDataBuildService to create the RemoteData Observable', () => { + scheduler.schedule(() => service.getVocabularyEntries(vocabularyOptions, pageInfo)); + scheduler.flush(); + + expect(rdbService.toRemoteDataObservable).toHaveBeenCalled(); + }); + }); + + describe('getVocabularyEntriesByValue', () => { + + it('should configure a new VocabularyEntriesRequest', () => { + const expected = new VocabularyEntriesRequest(requestService.generateRequestId(), entriesByValueRequestURL); + + scheduler.schedule(() => service.getVocabularyEntriesByValue('test', false, vocabularyOptions, pageInfo).subscribe()); + scheduler.flush(); + + expect(requestService.configure).toHaveBeenCalledWith(expected); + }); + + it('should call RemoteDataBuildService to create the RemoteData Observable', () => { + scheduler.schedule(() => service.getVocabularyEntriesByValue('test', false, vocabularyOptions, pageInfo)); + scheduler.flush(); + + expect(rdbService.toRemoteDataObservable).toHaveBeenCalled(); + + }); + }); + + describe('getVocabularyEntryByValue', () => { + + it('should configure a new VocabularyEntriesRequest', () => { + const expected = new VocabularyEntriesRequest(requestService.generateRequestId(), entryByValueRequestURL); + + scheduler.schedule(() => service.getVocabularyEntryByValue('test', vocabularyOptions).subscribe()); + scheduler.flush(); + + expect(requestService.configure).toHaveBeenCalledWith(expected); + }); + + it('should call RemoteDataBuildService to create the RemoteData Observable', () => { + scheduler.schedule(() => service.getVocabularyEntryByValue('test', vocabularyOptions)); + scheduler.flush(); + + expect(rdbService.toRemoteDataObservable).toHaveBeenCalled(); + + }); + }); + + describe('getVocabularyEntryByID', () => { + it('should configure a new VocabularyEntriesRequest', () => { + const expected = new VocabularyEntriesRequest(requestService.generateRequestId(), entryByIDRequestURL); + + scheduler.schedule(() => service.getVocabularyEntryByID(entryID, vocabularyOptions).subscribe()); + scheduler.flush(); + + expect(requestService.configure).toHaveBeenCalledWith(expected); + }); + + it('should call RemoteDataBuildService to create the RemoteData Observable', () => { + scheduler.schedule(() => service.getVocabularyEntryByID('test', vocabularyOptions)); + scheduler.flush(); + + expect(rdbService.toRemoteDataObservable).toHaveBeenCalled(); + + }); + }); + + }); + + }); + + describe('vocabularyEntryDetails endpoint', () => { + beforeEach(() => { + scheduler = getTestScheduler(); + + halService = jasmine.createSpyObj('halService', { + getEndpoint: cold('a', { a: entryDetailEndpointURL }) + }); + + responseCacheEntry = new RequestEntry(); + responseCacheEntry.request = { href: 'https://rest.api/' } as any; + responseCacheEntry.completed = true; + responseCacheEntry.response = new RestResponse(true, 200, 'Success'); + + requestService = jasmine.createSpyObj('requestService', { + generateRequestId: requestUUID, + configure: true, + removeByHrefSubstring: {}, + getByHref: observableOf(responseCacheEntry), + getByUUID: observableOf(responseCacheEntry), + }); + rdbService = jasmine.createSpyObj('rdbService', { + buildSingle: hot('a|', { + a: vocabularyEntryDetailParentRD + }), + buildList: hot('a|', { + a: vocabularyEntryChildrenRD + }), + }); + + service = initTestService(); + + spyOn((service as any).vocabularyEntryDetailDataService, 'findById').and.callThrough(); + spyOn((service as any).vocabularyEntryDetailDataService, 'findAll').and.callThrough(); + spyOn((service as any).vocabularyEntryDetailDataService, 'findByHref').and.callThrough(); + spyOn((service as any).vocabularyEntryDetailDataService, 'findAllByHref').and.callThrough(); + spyOn((service as any).vocabularyEntryDetailDataService, 'searchBy').and.callThrough(); + spyOn((service as any).vocabularyEntryDetailDataService, 'getSearchByHref').and.returnValue(observableOf(searchRequestURL)); + spyOn((service as any).vocabularyEntryDetailDataService, 'getFindAllHref').and.returnValue(observableOf(entryDetailChildrenRequestURL)); + spyOn((service as any).vocabularyEntryDetailDataService, 'getBrowseEndpoint').and.returnValue(observableOf(entryDetailEndpointURL)); + }); + + afterEach(() => { + service = null; + }); + + describe('findEntryDetailByHref', () => { + it('should proxy the call to vocabularyDataService.findEntryDetailByHref', () => { + scheduler.schedule(() => service.findEntryDetailByHref(entryDetailRequestURL)); + scheduler.flush(); + + expect((service as any).vocabularyEntryDetailDataService.findByHref).toHaveBeenCalledWith(entryDetailRequestURL); + }); + + it('should return a RemoteData for the object with the given URL', () => { + const result = service.findEntryDetailByHref(entryDetailRequestURL); + const expected = cold('a|', { + a: vocabularyEntryDetailParentRD + }); + expect(result).toBeObservable(expected); + }); + }); + + describe('findEntryDetailById', () => { + it('should proxy the call to vocabularyDataService.findVocabularyById', () => { + scheduler.schedule(() => service.findEntryDetailById('testValue', hierarchicalVocabulary.id)); + scheduler.flush(); + const expectedId = `${hierarchicalVocabulary.id}:testValue` + expect((service as any).vocabularyEntryDetailDataService.findById).toHaveBeenCalledWith(expectedId); + }); + + it('should return a RemoteData for the object with the given id', () => { + const result = service.findEntryDetailById('testValue', hierarchicalVocabulary.id); + const expected = cold('a|', { + a: vocabularyEntryDetailParentRD + }); + expect(result).toBeObservable(expected); + }); + }); + + describe('getEntryDetailParent', () => { + it('should proxy the call to vocabularyDataService.getEntryDetailParent', () => { + scheduler.schedule(() => service.getEntryDetailParent('testValue', hierarchicalVocabulary.id).subscribe()); + scheduler.flush(); + + expect((service as any).vocabularyEntryDetailDataService.findByHref).toHaveBeenCalledWith(entryDetailParentRequestURL); + }); + + it('should return a RemoteData for the object with the given URL', () => { + const result = service.getEntryDetailParent('testValue', hierarchicalVocabulary.id); + const expected = cold('a|', { + a: vocabularyEntryDetailParentRD + }); + expect(result).toBeObservable(expected); + }); + }); + + describe('getEntryDetailChildren', () => { + it('should proxy the call to vocabularyDataService.getEntryDetailChildren', () => { + const options: VocabularyFindOptions = new VocabularyFindOptions( + null, + null, + null, + null, + pageInfo.elementsPerPage, + pageInfo.currentPage + ); + scheduler.schedule(() => service.getEntryDetailChildren('testValue', hierarchicalVocabulary.id, pageInfo).subscribe()); + scheduler.flush(); + + expect((service as any).vocabularyEntryDetailDataService.findAllByHref).toHaveBeenCalledWith(entryDetailChildrenRequestURL, options); + }); + + it('should return a RemoteData> for the object with the given URL', () => { + const result = service.getEntryDetailChildren('testValue', hierarchicalVocabulary.id, new PageInfo()); + const expected = cold('a|', { + a: vocabularyEntryChildrenRD + }); + expect(result).toBeObservable(expected); + }); + }); + + describe('searchByTop', () => { + it('should proxy the call to vocabularyEntryDetailDataService.searchBy', () => { + const options: VocabularyFindOptions = new VocabularyFindOptions( + null, + null, + null, + null, + pageInfo.elementsPerPage, + pageInfo.currentPage + ); + options.searchParams = [new RequestParam('vocabulary', 'srsc')]; + scheduler.schedule(() => service.searchTopEntries('srsc', pageInfo)); + scheduler.flush(); + + expect((service as any).vocabularyEntryDetailDataService.searchBy).toHaveBeenCalledWith((service as any).searchTopMethod, options); + }); + + it('should return a RemoteData> for the search', () => { + const result = service.searchTopEntries('srsc', pageInfo); + const expected = cold('a|', { + a: vocabularyEntryChildrenRD + }); + expect(result).toBeObservable(expected); + }); + + }); + + describe('clearSearchTopRequests', () => { + it('should remove requests on the data service\'s endpoint', (done) => { + service.clearSearchTopRequests(); + + expect(requestService.removeByHrefSubstring).toHaveBeenCalledWith(`search/${(service as any).searchTopMethod}`); + done(); + }); + }); + + }); +}); diff --git a/src/app/core/submission/vocabularies/vocabulary.service.ts b/src/app/core/submission/vocabularies/vocabulary.service.ts new file mode 100644 index 0000000000..595edfc861 --- /dev/null +++ b/src/app/core/submission/vocabularies/vocabulary.service.ts @@ -0,0 +1,389 @@ +import { Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; + +import { Store } from '@ngrx/store'; +import { Observable } from 'rxjs'; +import { distinctUntilChanged, first, flatMap, map } from 'rxjs/operators'; + +import { FollowLinkConfig } from '../../../shared/utils/follow-link-config.model'; +import { dataService } from '../../cache/builders/build-decorators'; +import { DataService } from '../../data/data.service'; +import { RequestService } from '../../data/request.service'; +import { FindListOptions, RestRequest, VocabularyEntriesRequest } from '../../data/request.models'; +import { HALEndpointService } from '../../shared/hal-endpoint.service'; +import { RemoteData } from '../../data/remote-data'; +import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; +import { CoreState } from '../../core.reducers'; +import { ObjectCacheService } from '../../cache/object-cache.service'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { ChangeAnalyzer } from '../../data/change-analyzer'; +import { DefaultChangeAnalyzer } from '../../data/default-change-analyzer.service'; +import { PaginatedList } from '../../data/paginated-list'; +import { Vocabulary } from './models/vocabulary.model'; +import { VOCABULARY } from './models/vocabularies.resource-type'; +import { VocabularyEntry } from './models/vocabulary-entry.model'; +import { hasValue, isNotEmpty, isNotEmptyOperator } from '../../../shared/empty.util'; +import { + configureRequest, + filterSuccessfulResponses, + getFirstSucceededRemoteDataPayload, + getFirstSucceededRemoteListPayload, + getRequestFromRequestHref +} from '../../shared/operators'; +import { GenericSuccessResponse } from '../../cache/response.models'; +import { VocabularyFindOptions } from './models/vocabulary-find-options.model'; +import { VocabularyEntryDetail } from './models/vocabulary-entry-detail.model'; +import { RequestParam } from '../../cache/models/request-param.model'; +import { VocabularyOptions } from './models/vocabulary-options.model'; +import { PageInfo } from '../../shared/page-info.model'; + +/* tslint:disable:max-classes-per-file */ + +/** + * A private DataService implementation to delegate specific methods to. + */ +class VocabularyDataServiceImpl extends DataService { + protected linkPath = 'vocabularies'; + + constructor( + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected store: Store, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected notificationsService: NotificationsService, + protected http: HttpClient, + protected comparator: ChangeAnalyzer) { + super(); + } + +} + +/** + * A private DataService implementation to delegate specific methods to. + */ +class VocabularyEntryDetailDataServiceImpl extends DataService { + protected linkPath = 'vocabularyEntryDetails'; + + constructor( + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected store: Store, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected notificationsService: NotificationsService, + protected http: HttpClient, + protected comparator: ChangeAnalyzer) { + super(); + } + +} + +/** + * A service responsible for fetching/sending data from/to the REST API on the vocabularies endpoint + */ +@Injectable() +@dataService(VOCABULARY) +export class VocabularyService { + protected searchByMetadataAndCollectionMethod = 'byMetadataAndCollection'; + protected searchTopMethod = 'top'; + private vocabularyDataService: VocabularyDataServiceImpl; + private vocabularyEntryDetailDataService: VocabularyEntryDetailDataServiceImpl; + + constructor( + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected notificationsService: NotificationsService, + protected http: HttpClient, + protected comparatorVocabulary: DefaultChangeAnalyzer, + protected comparatorEntry: DefaultChangeAnalyzer) { + this.vocabularyDataService = new VocabularyDataServiceImpl(requestService, rdbService, null, objectCache, halService, notificationsService, http, comparatorVocabulary); + this.vocabularyEntryDetailDataService = new VocabularyEntryDetailDataServiceImpl(requestService, rdbService, null, objectCache, halService, notificationsService, http, comparatorEntry); + } + + /** + * Returns an observable of {@link RemoteData} of a {@link Vocabulary}, based on an href, with a list of {@link FollowLinkConfig}, + * to automatically resolve {@link HALLink}s of the {@link Vocabulary} + * @param href The url of {@link Vocabulary} we want to retrieve + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved + * @return {Observable>} + * Return an observable that emits vocabulary object + */ + findVocabularyByHref(href: string, ...linksToFollow: Array>): Observable> { + return this.vocabularyDataService.findByHref(href, ...linksToFollow); + } + + /** + * Returns an observable of {@link RemoteData} of a {@link Vocabulary}, based on its ID, with a list of {@link FollowLinkConfig}, + * to automatically resolve {@link HALLink}s of the object + * @param name The name of {@link Vocabulary} we want to retrieve + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved + * @return {Observable>} + * Return an observable that emits vocabulary object + */ + findVocabularyById(name: string, ...linksToFollow: Array>): Observable> { + return this.vocabularyDataService.findById(name, ...linksToFollow); + } + + /** + * Returns {@link RemoteData} of all object with a list of {@link FollowLinkConfig}, to indicate which embedded + * info should be added to the objects + * + * @param options Find list options object + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved + * @return {Observable>>} + * Return an observable that emits object list + */ + findAllVocabularies(options: FindListOptions = {}, ...linksToFollow: Array>): Observable>> { + return this.vocabularyDataService.findAll(options, ...linksToFollow); + } + + /** + * Return the {@link VocabularyEntry} list for a given {@link Vocabulary} + * + * @param vocabularyOptions The {@link VocabularyOptions} for the request to which the entries belong + * @param pageInfo The {@link PageInfo} for the request + * @return {Observable>>} + * Return an observable that emits object list + */ + getVocabularyEntries(vocabularyOptions: VocabularyOptions, pageInfo: PageInfo): Observable>> { + + const options: VocabularyFindOptions = new VocabularyFindOptions( + null, + null, + null, + null, + pageInfo.elementsPerPage, + pageInfo.currentPage + ); + + return this.findVocabularyById(vocabularyOptions.name).pipe( + getFirstSucceededRemoteDataPayload(), + map((vocabulary: Vocabulary) => this.vocabularyDataService.buildHrefFromFindOptions(vocabulary._links.entries.href, options)), + isNotEmptyOperator(), + distinctUntilChanged(), + getVocabularyEntriesFor(this.requestService, this.rdbService) + ) + } + + /** + * Return the {@link VocabularyEntry} list for a given value + * + * @param value The entry value to retrieve + * @param exact If true force the vocabulary to provide only entries that match exactly with the value + * @param vocabularyOptions The {@link VocabularyOptions} for the request to which the entries belong + * @param pageInfo The {@link PageInfo} for the request + * @return {Observable>>} + * Return an observable that emits object list + */ + getVocabularyEntriesByValue(value: string, exact: boolean, vocabularyOptions: VocabularyOptions, pageInfo: PageInfo): Observable>> { + const options: VocabularyFindOptions = new VocabularyFindOptions( + null, + value, + exact, + null, + pageInfo.elementsPerPage, + pageInfo.currentPage + ); + + return this.findVocabularyById(vocabularyOptions.name).pipe( + getFirstSucceededRemoteDataPayload(), + map((vocabulary: Vocabulary) => this.vocabularyDataService.buildHrefFromFindOptions(vocabulary._links.entries.href, options)), + isNotEmptyOperator(), + distinctUntilChanged(), + getVocabularyEntriesFor(this.requestService, this.rdbService) + ) + } + + /** + * Return the {@link VocabularyEntry} list for a given value + * + * @param value The entry value to retrieve + * @param vocabularyOptions The {@link VocabularyOptions} for the request to which the entry belongs + * @return {Observable>>} + * Return an observable that emits {@link VocabularyEntry} object + */ + getVocabularyEntryByValue(value: string, vocabularyOptions: VocabularyOptions): Observable { + + return this.getVocabularyEntriesByValue(value, true, vocabularyOptions, new PageInfo()).pipe( + getFirstSucceededRemoteListPayload(), + map((list: VocabularyEntry[]) => { + if (isNotEmpty(list)) { + return list[0] + } else { + return null; + } + }) + ); + } + + /** + * Return the {@link VocabularyEntry} list for a given ID + * + * @param ID The entry ID to retrieve + * @param vocabularyOptions The {@link VocabularyOptions} for the request to which the entry belongs + * @return {Observable>>} + * Return an observable that emits {@link VocabularyEntry} object + */ + getVocabularyEntryByID(ID: string, vocabularyOptions: VocabularyOptions): Observable { + const pageInfo = new PageInfo() + const options: VocabularyFindOptions = new VocabularyFindOptions( + null, + null, + null, + ID, + pageInfo.elementsPerPage, + pageInfo.currentPage + ); + + return this.findVocabularyById(vocabularyOptions.name).pipe( + getFirstSucceededRemoteDataPayload(), + map((vocabulary: Vocabulary) => this.vocabularyDataService.buildHrefFromFindOptions(vocabulary._links.entries.href, options)), + isNotEmptyOperator(), + distinctUntilChanged(), + getVocabularyEntriesFor(this.requestService, this.rdbService), + getFirstSucceededRemoteListPayload(), + map((list: VocabularyEntry[]) => { + if (isNotEmpty(list)) { + return list[0] + } else { + return null; + } + }) + ); + } + + /** + * Returns an observable of {@link RemoteData} of a {@link VocabularyEntryDetail}, based on an href, with a list of {@link FollowLinkConfig}, + * to automatically resolve {@link HALLink}s of the {@link VocabularyEntryDetail} + * @param href The url of {@link VocabularyEntryDetail} we want to retrieve + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved + * @return {Observable>} + * Return an observable that emits vocabulary object + */ + findEntryDetailByHref(href: string, ...linksToFollow: Array>): Observable> { + return this.vocabularyEntryDetailDataService.findByHref(href, ...linksToFollow); + } + + /** + * Returns an observable of {@link RemoteData} of a {@link VocabularyEntryDetail}, based on its ID, with a list of {@link FollowLinkConfig}, + * to automatically resolve {@link HALLink}s of the object + * @param id The entry id for which to provide detailed information. + * @param name The name of {@link Vocabulary} to which the entry belongs + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved + * @return {Observable>} + * Return an observable that emits VocabularyEntryDetail object + */ + findEntryDetailById(id: string, name: string, ...linksToFollow: Array>): Observable> { + const findId = `${name}:${id}`; + return this.vocabularyEntryDetailDataService.findById(findId, ...linksToFollow); + } + + /** + * Returns the parent detail entry for a given detail entry, with a list of {@link FollowLinkConfig}, + * to automatically resolve {@link HALLink}s of the object + * @param value The entry value for which to provide parent. + * @param name The name of {@link Vocabulary} to which the entry belongs + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved + * @return {Observable>>} + * Return an observable that emits a PaginatedList of VocabularyEntryDetail + */ + getEntryDetailParent(value: string, name: string, ...linksToFollow: Array>): Observable> { + const linkPath = `${name}:${value}/parent`; + + return this.vocabularyEntryDetailDataService.getBrowseEndpoint().pipe( + map((href: string) => `${href}/${linkPath}`), + flatMap((href) => this.vocabularyEntryDetailDataService.findByHref(href, ...linksToFollow)) + ); + } + + /** + * Returns the list of children detail entries for a given detail entry, with a list of {@link FollowLinkConfig}, + * to automatically resolve {@link HALLink}s of the object + * @param value The entry value for which to provide children list. + * @param name The name of {@link Vocabulary} to which the entry belongs + * @param pageInfo The {@link PageInfo} for the request + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved + * @return {Observable>>} + * Return an observable that emits a PaginatedList of VocabularyEntryDetail + */ + getEntryDetailChildren(value: string, name: string, pageInfo: PageInfo, ...linksToFollow: Array>): Observable>> { + const linkPath = `${name}:${value}/children`; + const options: VocabularyFindOptions = new VocabularyFindOptions( + null, + null, + null, + null, + pageInfo.elementsPerPage, + pageInfo.currentPage + ); + return this.vocabularyEntryDetailDataService.getFindAllHref(options, linkPath).pipe( + flatMap((href) => this.vocabularyEntryDetailDataService.findAllByHref(href, options, ...linksToFollow)) + ); + } + + /** + * Return the top level {@link VocabularyEntryDetail} list for a given hierarchical vocabulary + * + * @param name The name of hierarchical {@link Vocabulary} to which the entries belongs + * @param pageInfo The {@link PageInfo} for the request + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved + */ + searchTopEntries(name: string, pageInfo: PageInfo, ...linksToFollow: Array>): Observable>> { + const options: VocabularyFindOptions = new VocabularyFindOptions( + null, + null, + null, + null, + pageInfo.elementsPerPage, + pageInfo.currentPage + ); + options.searchParams = [new RequestParam('vocabulary', name)]; + return this.vocabularyEntryDetailDataService.searchBy(this.searchTopMethod, options, ...linksToFollow) + } + + /** + * Clear all search Top Requests + */ + clearSearchTopRequests(): void { + this.requestService.removeByHrefSubstring(`search/${this.searchTopMethod}`); + } +} + +/** + * Operator for turning a href into a PaginatedList of VocabularyEntry + * @param requestService + * @param rdb + */ +export const getVocabularyEntriesFor = (requestService: RequestService, rdb: RemoteDataBuildService) => + (source: Observable): Observable>> => + source.pipe( + map((href: string) => new VocabularyEntriesRequest(requestService.generateRequestId(), href)), + configureRequest(requestService), + toRDPaginatedVocabularyEntries(requestService, rdb) + ); + +/** + * Operator for turning a RestRequest into a PaginatedList of VocabularyEntry + * @param requestService + * @param rdb + */ +export const toRDPaginatedVocabularyEntries = (requestService: RequestService, rdb: RemoteDataBuildService) => + (source: Observable): Observable>> => { + const href$ = source.pipe(map((request: RestRequest) => request.href)); + + const requestEntry$ = href$.pipe(getRequestFromRequestHref(requestService)); + + const payload$ = requestEntry$.pipe( + filterSuccessfulResponses(), + map((response: GenericSuccessResponse) => new PaginatedList(response.pageInfo, response.payload)), + map((list: PaginatedList) => Object.assign(list, { + page: list.page ? list.page.map((entry: VocabularyEntry) => Object.assign(new VocabularyEntry(), entry)) : list.page + })), + distinctUntilChanged() + ); + + return rdb.toRemoteDataObservable(requestEntry$, payload$); + }; diff --git a/src/app/core/submission/workflowitem-data.service.ts b/src/app/core/submission/workflowitem-data.service.ts index 9b7555808d..7cd745fd7f 100644 --- a/src/app/core/submission/workflowitem-data.service.ts +++ b/src/app/core/submission/workflowitem-data.service.ts @@ -9,7 +9,7 @@ import { DataService } from '../data/data.service'; import { RequestService } from '../data/request.service'; import { WorkflowItem } from './models/workflowitem.model'; import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { DeleteByIDRequest, FindListOptions } from '../data/request.models'; +import { DeleteByIDRequest } from '../data/request.models'; import { NotificationsService } from '../../shared/notifications/notifications.service'; import { ObjectCacheService } from '../cache/object-cache.service'; import { DSOChangeAnalyzer } from '../data/dso-change-analyzer.service'; diff --git a/src/app/core/submission/workspaceitem-data.service.ts b/src/app/core/submission/workspaceitem-data.service.ts index 224bb64706..2fc95bdd00 100644 --- a/src/app/core/submission/workspaceitem-data.service.ts +++ b/src/app/core/submission/workspaceitem-data.service.ts @@ -8,7 +8,6 @@ import { CoreState } from '../core.reducers'; import { DataService } from '../data/data.service'; import { RequestService } from '../data/request.service'; import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { FindListOptions } from '../data/request.models'; import { NotificationsService } from '../../shared/notifications/notifications.service'; import { ObjectCacheService } from '../cache/object-cache.service'; import { DSOChangeAnalyzer } from '../data/dso-change-analyzer.service'; diff --git a/src/app/entity-groups/research-entities/submission/item-list-elements/external-source-entry/external-source-entry-list-submission-element.component.ts b/src/app/entity-groups/research-entities/submission/item-list-elements/external-source-entry/external-source-entry-list-submission-element.component.ts index 7d39d4d314..272331aaf6 100644 --- a/src/app/entity-groups/research-entities/submission/item-list-elements/external-source-entry/external-source-entry-list-submission-element.component.ts +++ b/src/app/entity-groups/research-entities/submission/item-list-elements/external-source-entry/external-source-entry-list-submission-element.component.ts @@ -3,7 +3,7 @@ import { ExternalSourceEntry } from '../../../../../core/shared/external-source- import { listableObjectComponent } from '../../../../../shared/object-collection/shared/listable-object/listable-object.decorator'; import { ViewMode } from '../../../../../core/shared/view-mode.model'; import { Context } from '../../../../../core/shared/context.model'; -import { Component, Inject, OnInit } from '@angular/core'; +import { Component, OnInit } from '@angular/core'; import { Metadata } from '../../../../../core/shared/metadata.utils'; import { MetadataValue } from '../../../../../core/shared/metadata.models'; diff --git a/src/app/footer/footer.component.html b/src/app/footer/footer.component.html index fec75b2fd3..6d30302f80 100644 --- a/src/app/footer/footer.component.html +++ b/src/app/footer/footer.component.html @@ -1,10 +1,21 @@ diff --git a/src/app/footer/footer.component.scss b/src/app/footer/footer.component.scss index 51201774d5..fb51fc258d 100644 --- a/src/app/footer/footer.component.scss +++ b/src/app/footer/footer.component.scss @@ -4,15 +4,37 @@ $footer-padding: $spacer * 1.5; $footer-logo-height: 55px; .footer { - background-color: $footer-bg; - border-top: $footer-border; - text-align: center; - padding: $footer-padding; + background-color: $footer-bg; + border-top: $footer-border; + text-align: center; + padding: $footer-padding; + padding-bottom: $spacer; - p { - margin: 0; - } - img { - height: $footer-logo-height; - } + p { + margin: 0; + } + + img { + height: $footer-logo-height; + } + + ul { + padding-top: $spacer * 0.5; + + li { + display: inline-flex; + a { + padding: 0 $spacer/2; + color: inherit + } + + &:not(:last-child) { + &:after { + content: ''; + border-right: 1px map-get($theme-colors, secondary) solid; + } + + } + } + } } diff --git a/src/app/footer/footer.component.ts b/src/app/footer/footer.component.ts index 94b239d204..6ece2cf08b 100644 --- a/src/app/footer/footer.component.ts +++ b/src/app/footer/footer.component.ts @@ -1,4 +1,6 @@ -import { Component } from '@angular/core'; +import { Component, Optional } from '@angular/core'; +import { hasValue } from '../shared/empty.util'; +import { KlaroService } from '../shared/cookies/klaro.service'; @Component({ selector: 'ds-footer', @@ -6,7 +8,15 @@ import { Component } from '@angular/core'; templateUrl: 'footer.component.html' }) export class FooterComponent { - dateObj: number = Date.now(); + constructor(@Optional() private cookies: KlaroService) { + } + + showCookieSettings() { + if (hasValue(this.cookies)) { + this.cookies.showSettings(); + } + return false; + } } diff --git a/src/app/info/end-user-agreement/end-user-agreement-content/end-user-agreement-content.component.html b/src/app/info/end-user-agreement/end-user-agreement-content/end-user-agreement-content.component.html new file mode 100644 index 0000000000..1ee8712444 --- /dev/null +++ b/src/app/info/end-user-agreement/end-user-agreement-content/end-user-agreement-content.component.html @@ -0,0 +1,37 @@ +

{{ 'info.end-user-agreement.head' | translate }}

+

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Nunc sed velit dignissim sodales ut eu. In ante metus dictum at tempor. Diam phasellus vestibulum lorem sed risus. Sed cras ornare arcu dui vivamus. Sit amet consectetur adipiscing elit pellentesque. Id velit ut tortor pretium viverra suspendisse potenti. Sed euismod nisi porta lorem mollis aliquam ut. Justo laoreet sit amet cursus sit amet dictum sit. Ullamcorper morbi tincidunt ornare massa eget egestas. +

+

+ In iaculis nunc sed augue lacus. Curabitur vitae nunc sed velit dignissim sodales ut eu sem. Tellus id interdum velit laoreet id donec ultrices tincidunt arcu. Quis vel eros donec ac odio tempor. Viverra accumsan in nisl nisi scelerisque eu ultrices vitae. Varius quam quisque id diam vel quam. Nisl tincidunt eget nullam non nisi est sit. Nunc aliquet bibendum enim facilisis. Aenean sed adipiscing diam donec adipiscing. Convallis tellus id interdum velit laoreet. Massa placerat duis ultricies lacus sed turpis tincidunt. Sed cras ornare arcu dui vivamus arcu. Egestas integer eget aliquet nibh praesent tristique. Sit amet purus gravida quis blandit turpis cursus in hac. Porta non pulvinar neque laoreet suspendisse. Quis risus sed vulputate odio ut. Dignissim enim sit amet venenatis urna cursus. +

+

+ Interdum velit laoreet id donec ultrices tincidunt arcu non sodales. Massa sapien faucibus et molestie. Dictumst vestibulum rhoncus est pellentesque elit ullamcorper. Metus dictum at tempor commodo ullamcorper. Tincidunt lobortis feugiat vivamus at augue eget. Non diam phasellus vestibulum lorem sed risus ultricies. Neque aliquam vestibulum morbi blandit cursus risus at ultrices mi. Euismod lacinia at quis risus sed. Lorem mollis aliquam ut porttitor leo a diam. Ipsum dolor sit amet consectetur. Ante in nibh mauris cursus mattis molestie a iaculis at. Commodo ullamcorper a lacus vestibulum. Pellentesque elit eget gravida cum sociis. Sit amet commodo nulla facilisi nullam vehicula. Vehicula ipsum a arcu cursus vitae congue mauris rhoncus aenean. +

+

+ Ac turpis egestas maecenas pharetra convallis. Lacus sed viverra tellus in. Nullam eget felis eget nunc lobortis mattis aliquam faucibus purus. Id aliquet risus feugiat in ante metus dictum at. Quis enim lobortis scelerisque fermentum dui faucibus. Eu volutpat odio facilisis mauris sit amet massa vitae tortor. Tellus elementum sagittis vitae et leo. Cras sed felis eget velit aliquet sagittis. Proin fermentum leo vel orci porta non pulvinar neque laoreet. Dui sapien eget mi proin sed libero enim. Ultrices mi tempus imperdiet nulla malesuada. Mattis molestie a iaculis at. Turpis massa sed elementum tempus egestas. +

+

+ Dui faucibus in ornare quam viverra orci sagittis eu volutpat. Cras adipiscing enim eu turpis. Ac felis donec et odio pellentesque. Iaculis nunc sed augue lacus viverra vitae congue eu consequat. Posuere lorem ipsum dolor sit amet consectetur adipiscing elit duis. Elit eget gravida cum sociis natoque penatibus. Id faucibus nisl tincidunt eget nullam non. Sagittis aliquam malesuada bibendum arcu vitae. Fermentum leo vel orci porta. Aliquam ultrices sagittis orci a scelerisque purus semper. Diam maecenas sed enim ut sem viverra aliquet eget sit. Et ultrices neque ornare aenean euismod. Eu mi bibendum neque egestas congue quisque egestas diam. Eget lorem dolor sed viverra. Ut lectus arcu bibendum at. Rutrum tellus pellentesque eu tincidunt tortor. Vitae congue eu consequat ac. Elit ullamcorper dignissim cras tincidunt. Sit amet volutpat consequat mauris nunc congue nisi. +

+

+ Cursus in hac habitasse platea dictumst quisque sagittis purus. Placerat duis ultricies lacus sed turpis tincidunt. In egestas erat imperdiet sed euismod nisi porta lorem mollis. Non nisi est sit amet facilisis magna. In massa tempor nec feugiat nisl pretium fusce. Pulvinar neque laoreet suspendisse interdum consectetur. Ullamcorper morbi tincidunt ornare massa eget egestas purus viverra accumsan. Fringilla urna porttitor rhoncus dolor purus non enim. Mauris nunc congue nisi vitae suscipit. Commodo elit at imperdiet dui accumsan sit amet nulla. Tempor id eu nisl nunc mi ipsum faucibus. Porta non pulvinar neque laoreet suspendisse. Nec nam aliquam sem et tortor consequat. +

+

+ Eget nunc lobortis mattis aliquam faucibus purus. Odio tempor orci dapibus ultrices. Sed nisi lacus sed viverra tellus. Elit ullamcorper dignissim cras tincidunt. Porttitor rhoncus dolor purus non enim praesent elementum facilisis. Viverra orci sagittis eu volutpat odio. Pharetra massa massa ultricies mi quis. Lectus vestibulum mattis ullamcorper velit sed ullamcorper. Pulvinar neque laoreet suspendisse interdum consectetur. Vitae auctor eu augue ut. Arcu dictum varius duis at consectetur lorem donec. Massa sed elementum tempus egestas sed sed. Risus viverra adipiscing at in tellus integer. Vulputate enim nulla aliquet porttitor lacus luctus accumsan. Pharetra massa massa ultricies mi. Elementum eu facilisis sed odio morbi quis commodo odio. Tincidunt lobortis feugiat vivamus at. Felis donec et odio pellentesque diam volutpat commodo sed. Risus feugiat in ante metus dictum at tempor commodo ullamcorper. Fringilla phasellus faucibus scelerisque eleifend donec pretium vulputate. +

+

+ Lectus proin nibh nisl condimentum id venenatis a condimentum. Id consectetur purus ut faucibus pulvinar elementum integer enim. Non pulvinar neque laoreet suspendisse interdum consectetur. Est pellentesque elit ullamcorper dignissim cras tincidunt lobortis feugiat vivamus. Suscipit tellus mauris a diam maecenas sed enim ut sem. Dolor purus non enim praesent elementum facilisis. Non enim praesent elementum facilisis leo vel. Ultricies leo integer malesuada nunc vel risus commodo viverra maecenas. Nulla porttitor massa id neque aliquam vestibulum. Erat velit scelerisque in dictum non consectetur. Amet cursus sit amet dictum. Nec tincidunt praesent semper feugiat nibh. Rutrum quisque non tellus orci ac auctor. Sagittis aliquam malesuada bibendum arcu vitae elementum. Massa tincidunt dui ut ornare lectus sit amet est. Aliquet porttitor lacus luctus accumsan tortor posuere ac. Quis hendrerit dolor magna eget est lorem ipsum dolor sit. Lectus mauris ultrices eros in. +

+

+ Massa massa ultricies mi quis hendrerit dolor magna. Est ullamcorper eget nulla facilisi etiam dignissim diam. Vulputate sapien nec sagittis aliquam malesuada. Nisi porta lorem mollis aliquam ut porttitor leo a diam. Tempus quam pellentesque nec nam. Faucibus vitae aliquet nec ullamcorper sit amet risus nullam eget. Gravida arcu ac tortor dignissim convallis aenean et tortor. A scelerisque purus semper eget duis at tellus at. Viverra ipsum nunc aliquet bibendum enim. Semper feugiat nibh sed pulvinar proin gravida hendrerit. Et ultrices neque ornare aenean euismod. Consequat semper viverra nam libero justo laoreet. Nunc mattis enim ut tellus elementum sagittis. Consectetur lorem donec massa sapien faucibus et. Vel risus commodo viverra maecenas accumsan lacus vel facilisis. Diam sollicitudin tempor id eu nisl nunc. Dolor magna eget est lorem ipsum dolor. Adipiscing elit pellentesque habitant morbi tristique. +

+

+ Nec sagittis aliquam malesuada bibendum arcu vitae elementum curabitur. Egestas fringilla phasellus faucibus scelerisque eleifend donec pretium vulputate sapien. Porttitor leo a diam sollicitudin tempor. Pellentesque dignissim enim sit amet venenatis urna cursus eget nunc. Posuere sollicitudin aliquam ultrices sagittis orci a scelerisque. Vehicula ipsum a arcu cursus vitae congue mauris rhoncus. Leo urna molestie at elementum. Duis tristique sollicitudin nibh sit amet commodo nulla facilisi. Libero id faucibus nisl tincidunt eget nullam. Tellus elementum sagittis vitae et leo duis ut diam. Sodales ut etiam sit amet nisl purus in mollis. Ipsum faucibus vitae aliquet nec ullamcorper sit amet risus. Lacus laoreet non curabitur gravida arcu ac tortor dignissim convallis. Aliquam malesuada bibendum arcu vitae elementum. Leo vel orci porta non pulvinar neque laoreet. Ipsum suspendisse ultrices gravida dictum fusce. +

+

+ Egestas erat imperdiet sed euismod nisi porta lorem. Venenatis a condimentum vitae sapien pellentesque habitant. Sit amet luctus venenatis lectus magna fringilla urna porttitor. Orci sagittis eu volutpat odio facilisis mauris sit amet massa. Ut enim blandit volutpat maecenas volutpat blandit aliquam. Libero volutpat sed cras ornare. Molestie ac feugiat sed lectus vestibulum mattis ullamcorper velit sed. Diam quis enim lobortis scelerisque fermentum dui. Pellentesque habitant morbi tristique senectus et netus. Auctor urna nunc id cursus metus aliquam eleifend. Elit scelerisque mauris pellentesque pulvinar pellentesque habitant morbi tristique. Sed risus ultricies tristique nulla aliquet enim tortor. Tincidunt arcu non sodales neque sodales ut. Sed lectus vestibulum mattis ullamcorper velit sed ullamcorper morbi tincidunt. +

+

+ Pulvinar etiam non quam lacus suspendisse faucibus. Eu mi bibendum neque egestas congue. Egestas purus viverra accumsan in nisl nisi scelerisque eu. Vulputate enim nulla aliquet porttitor lacus luctus accumsan. Eu non diam phasellus vestibulum. Semper feugiat nibh sed pulvinar. Ante in nibh mauris cursus mattis molestie a. Maecenas accumsan lacus vel facilisis volutpat. Non quam lacus suspendisse faucibus. Quis commodo odio aenean sed adipiscing. Vel elit scelerisque mauris pellentesque pulvinar pellentesque habitant morbi. Sed cras ornare arcu dui vivamus arcu felis. Tortor vitae purus faucibus ornare suspendisse sed. Morbi tincidunt ornare massa eget egestas purus viverra. Nibh cras pulvinar mattis nunc. Luctus venenatis lectus magna fringilla urna porttitor. Enim blandit volutpat maecenas volutpat blandit aliquam etiam erat. Malesuada pellentesque elit eget gravida cum sociis natoque penatibus et. Felis eget nunc lobortis mattis aliquam faucibus purus in. Vivamus arcu felis bibendum ut. +

diff --git a/src/app/info/end-user-agreement/end-user-agreement-content/end-user-agreement-content.component.scss b/src/app/info/end-user-agreement/end-user-agreement-content/end-user-agreement-content.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/info/end-user-agreement/end-user-agreement-content/end-user-agreement-content.component.spec.ts b/src/app/info/end-user-agreement/end-user-agreement-content/end-user-agreement-content.component.spec.ts new file mode 100644 index 0000000000..d2290cd01c --- /dev/null +++ b/src/app/info/end-user-agreement/end-user-agreement-content/end-user-agreement-content.component.spec.ts @@ -0,0 +1,27 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { EndUserAgreementContentComponent } from './end-user-agreement-content.component'; +import { TranslateModule } from '@ngx-translate/core'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; + +describe('EndUserAgreementContentComponent', () => { + let component: EndUserAgreementContentComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ TranslateModule.forRoot() ], + declarations: [ EndUserAgreementContentComponent ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(EndUserAgreementContentComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/info/end-user-agreement/end-user-agreement-content/end-user-agreement-content.component.ts b/src/app/info/end-user-agreement/end-user-agreement-content/end-user-agreement-content.component.ts new file mode 100644 index 0000000000..faa7d5a78f --- /dev/null +++ b/src/app/info/end-user-agreement/end-user-agreement-content/end-user-agreement-content.component.ts @@ -0,0 +1,12 @@ +import { Component } from '@angular/core'; + +@Component({ + selector: 'ds-end-user-agreement-content', + templateUrl: './end-user-agreement-content.component.html', + styleUrls: ['./end-user-agreement-content.component.scss'] +}) +/** + * Component displaying the contents of the End User Agreement + */ +export class EndUserAgreementContentComponent { +} diff --git a/src/app/info/end-user-agreement/end-user-agreement.component.html b/src/app/info/end-user-agreement/end-user-agreement.component.html new file mode 100644 index 0000000000..2ab0005c69 --- /dev/null +++ b/src/app/info/end-user-agreement/end-user-agreement.component.html @@ -0,0 +1,13 @@ +
+ + +
+ + + +
+ + +
+
+
diff --git a/src/app/info/end-user-agreement/end-user-agreement.component.scss b/src/app/info/end-user-agreement/end-user-agreement.component.scss new file mode 100644 index 0000000000..2960a0fac1 --- /dev/null +++ b/src/app/info/end-user-agreement/end-user-agreement.component.scss @@ -0,0 +1,8 @@ +input#user-agreement-accept { + /* Large-sized Checkboxes */ + -ms-transform: scale(1.6); /* IE */ + -moz-transform: scale(1.6); /* FF */ + -webkit-transform: scale(1.6); /* Safari and Chrome */ + -o-transform: scale(1.6); /* Opera */ + padding: 10px; +} diff --git a/src/app/info/end-user-agreement/end-user-agreement.component.spec.ts b/src/app/info/end-user-agreement/end-user-agreement.component.spec.ts new file mode 100644 index 0000000000..c0957fa7ba --- /dev/null +++ b/src/app/info/end-user-agreement/end-user-agreement.component.spec.ts @@ -0,0 +1,156 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { EndUserAgreementComponent } from './end-user-agreement.component'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { EndUserAgreementService } from '../../core/end-user-agreement/end-user-agreement.service'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { TranslateModule } from '@ngx-translate/core'; +import { AuthService } from '../../core/auth/auth.service'; +import { ActivatedRoute, Router } from '@angular/router'; +import { of as observableOf } from 'rxjs'; +import { Store } from '@ngrx/store'; +import { By } from '@angular/platform-browser'; +import { LogOutAction } from '../../core/auth/auth.actions'; +import { ActivatedRouteStub } from '../../shared/testing/active-router.stub'; + +describe('EndUserAgreementComponent', () => { + let component: EndUserAgreementComponent; + let fixture: ComponentFixture; + + let endUserAgreementService: EndUserAgreementService; + let notificationsService: NotificationsService; + let authService: AuthService; + let store; + let router: Router; + let route: ActivatedRoute; + + let redirectUrl; + + function init() { + redirectUrl = 'redirect/url'; + + endUserAgreementService = jasmine.createSpyObj('endUserAgreementService', { + hasCurrentUserOrCookieAcceptedAgreement : observableOf(false), + setUserAcceptedAgreement: observableOf(true) + }); + notificationsService = jasmine.createSpyObj('notificationsService', ['success', 'error']); + authService = jasmine.createSpyObj('authService', { + isAuthenticated: observableOf(true) + }); + store = jasmine.createSpyObj('store', ['dispatch']); + router = jasmine.createSpyObj('router', ['navigate', 'navigateByUrl']); + route = Object.assign(new ActivatedRouteStub(), { + queryParams: observableOf({ + redirect: redirectUrl + }) + }) as any; + } + + beforeEach(async(() => { + init(); + TestBed.configureTestingModule({ + imports: [ TranslateModule.forRoot() ], + declarations: [ EndUserAgreementComponent ], + providers: [ + { provide: EndUserAgreementService, useValue: endUserAgreementService }, + { provide: NotificationsService, useValue: notificationsService }, + { provide: AuthService, useValue: authService }, + { provide: Store, useValue: store }, + { provide: Router, useValue: router }, + { provide: ActivatedRoute, useValue: route } + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(EndUserAgreementComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + describe('when the user hasn\'t accepted the agreement', () => { + beforeEach(() => { + (endUserAgreementService.hasCurrentUserOrCookieAcceptedAgreement as jasmine.Spy).and.returnValue(observableOf(false)); + component.ngOnInit(); + fixture.detectChanges(); + }); + + it('should initialize the accepted property', () => { + expect(component.accepted).toEqual(false); + }); + + it('should disable the save button', () => { + const button = fixture.debugElement.query(By.css('#button-save')).nativeElement; + expect(button.disabled).toBeTruthy(); + }); + }); + + describe('when the user has accepted the agreement', () => { + beforeEach(() => { + (endUserAgreementService.hasCurrentUserOrCookieAcceptedAgreement as jasmine.Spy).and.returnValue(observableOf(true)); + component.ngOnInit(); + fixture.detectChanges(); + }); + + it('should initialize the accepted property', () => { + expect(component.accepted).toEqual(true); + }); + + it('should enable the save button', () => { + const button = fixture.debugElement.query(By.css('#button-save')).nativeElement; + expect(button.disabled).toBeFalsy(); + }); + + describe('submit', () => { + describe('when accepting the agreement was successful', () => { + beforeEach(() => { + (endUserAgreementService.setUserAcceptedAgreement as jasmine.Spy).and.returnValue(observableOf(true)); + component.submit(); + }); + + it('should display a success notification', () => { + expect(notificationsService.success).toHaveBeenCalled(); + }); + + it('should navigate the user to the redirect url', () => { + expect(router.navigateByUrl).toHaveBeenCalledWith(redirectUrl); + }); + }); + + describe('when accepting the agreement was unsuccessful', () => { + beforeEach(() => { + (endUserAgreementService.setUserAcceptedAgreement as jasmine.Spy).and.returnValue(observableOf(false)); + component.submit(); + }); + + it('should display an error notification', () => { + expect(notificationsService.error).toHaveBeenCalled(); + }); + }); + }); + }); + + describe('cancel', () => { + describe('when the user is authenticated', () => { + beforeEach(() => { + (authService.isAuthenticated as jasmine.Spy).and.returnValue(observableOf(true)); + component.cancel(); + }); + + it('should logout the user', () => { + expect(store.dispatch).toHaveBeenCalledWith(new LogOutAction()); + }); + }); + + describe('when the user is not authenticated', () => { + beforeEach(() => { + (authService.isAuthenticated as jasmine.Spy).and.returnValue(observableOf(false)); + component.cancel(); + }); + + it('should navigate the user to the homepage', () => { + expect(router.navigate).toHaveBeenCalledWith(['home']); + }); + }); + }); +}); diff --git a/src/app/info/end-user-agreement/end-user-agreement.component.ts b/src/app/info/end-user-agreement/end-user-agreement.component.ts new file mode 100644 index 0000000000..a3350319ba --- /dev/null +++ b/src/app/info/end-user-agreement/end-user-agreement.component.ts @@ -0,0 +1,92 @@ +import { Component, OnInit } from '@angular/core'; +import { AuthService } from '../../core/auth/auth.service'; +import { map, switchMap, take } from 'rxjs/operators'; +import { ActivatedRoute, Router } from '@angular/router'; +import { Store } from '@ngrx/store'; +import { AppState } from '../../app.reducer'; +import { LogOutAction } from '../../core/auth/auth.actions'; +import { EndUserAgreementService } from '../../core/end-user-agreement/end-user-agreement.service'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { TranslateService } from '@ngx-translate/core'; +import { of as observableOf } from 'rxjs'; +import { isNotEmpty } from '../../shared/empty.util'; + +@Component({ + selector: 'ds-end-user-agreement', + templateUrl: './end-user-agreement.component.html', + styleUrls: ['./end-user-agreement.component.scss'] +}) +/** + * Component displaying the End User Agreement and an option to accept it + */ +export class EndUserAgreementComponent implements OnInit { + + /** + * Whether or not the user agreement has been accepted + */ + accepted = false; + + constructor(protected endUserAgreementService: EndUserAgreementService, + protected notificationsService: NotificationsService, + protected translate: TranslateService, + protected authService: AuthService, + protected store: Store, + protected router: Router, + protected route: ActivatedRoute) { + } + + /** + * Initialize the component + */ + ngOnInit(): void { + this.initAccepted(); + } + + /** + * Initialize the "accepted" property of this component by checking if the current user has accepted it before + */ + initAccepted() { + this.endUserAgreementService.hasCurrentUserOrCookieAcceptedAgreement(false).subscribe((accepted) => { + this.accepted = accepted; + }); + } + + /** + * Submit the form + * Set the End User Agreement, display a notification and (optionally) redirect the user back to their original destination + */ + submit() { + this.endUserAgreementService.setUserAcceptedAgreement(this.accepted).pipe( + switchMap((success) => { + if (success) { + this.notificationsService.success(this.translate.instant('info.end-user-agreement.accept.success')); + return this.route.queryParams.pipe(map((params) => params.redirect)); + } else { + this.notificationsService.error(this.translate.instant('info.end-user-agreement.accept.error')); + return observableOf(undefined); + } + }), + take(1) + ).subscribe((redirectUrl) => { + if (isNotEmpty(redirectUrl)) { + this.router.navigateByUrl(decodeURIComponent(redirectUrl)); + } + }); + } + + /** + * Cancel the agreement + * If the user is logged in, this will log him/her out + * If the user is not logged in, they will be redirected to the homepage + */ + cancel() { + this.authService.isAuthenticated().pipe(take(1)).subscribe((authenticated) => { + if (authenticated) { + this.store.dispatch(new LogOutAction()); + } else { + this.router.navigate(['home']); + } + }); + } + +} 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 new file mode 100644 index 0000000000..799572f9b9 --- /dev/null +++ b/src/app/info/info-routing.module.ts @@ -0,0 +1,32 @@ +import { NgModule } from '@angular/core'; +import { RouterModule } from '@angular/router'; +import { EndUserAgreementComponent } from './end-user-agreement/end-user-agreement.component'; +import { I18nBreadcrumbResolver } from '../core/breadcrumbs/i18n-breadcrumb.resolver'; +import { PrivacyComponent } from './privacy/privacy.component'; +import { PRIVACY_PATH, END_USER_AGREEMENT_PATH } from './info-routing-paths'; + +@NgModule({ + imports: [ + RouterModule.forChild([ + { + path: END_USER_AGREEMENT_PATH, + component: EndUserAgreementComponent, + resolve: { breadcrumb: I18nBreadcrumbResolver }, + data: { title: 'info.end-user-agreement.title', breadcrumbKey: 'info.end-user-agreement' } + } + ]), + RouterModule.forChild([ + { + path: PRIVACY_PATH, + component: PrivacyComponent, + resolve: { breadcrumb: I18nBreadcrumbResolver }, + data: { title: 'info.privacy.title', breadcrumbKey: 'info.privacy' } + } + ]) + ] +}) +/** + * Module for navigating to components within the info module + */ +export class InfoRoutingModule { +} diff --git a/src/app/info/info.module.ts b/src/app/info/info.module.ts new file mode 100644 index 0000000000..ae8ef89b27 --- /dev/null +++ b/src/app/info/info.module.ts @@ -0,0 +1,24 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { SharedModule } from '../shared/shared.module'; +import { EndUserAgreementComponent } from './end-user-agreement/end-user-agreement.component'; +import { InfoRoutingModule } from './info-routing.module'; +import { EndUserAgreementContentComponent } from './end-user-agreement/end-user-agreement-content/end-user-agreement-content.component'; +import { PrivacyComponent } from './privacy/privacy.component'; +import { PrivacyContentComponent } from './privacy/privacy-content/privacy-content.component'; + +@NgModule({ + imports: [ + CommonModule, + SharedModule, + InfoRoutingModule + ], + declarations: [ + EndUserAgreementComponent, + EndUserAgreementContentComponent, + PrivacyComponent, + PrivacyContentComponent + ] +}) +export class InfoModule { +} diff --git a/src/app/info/privacy/privacy-content/privacy-content.component.html b/src/app/info/privacy/privacy-content/privacy-content.component.html new file mode 100644 index 0000000000..a5bbb3fe10 --- /dev/null +++ b/src/app/info/privacy/privacy-content/privacy-content.component.html @@ -0,0 +1,37 @@ +

{{ 'info.privacy.head' | translate }}

+

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Nunc sed velit dignissim sodales ut eu. In ante metus dictum at tempor. Diam phasellus vestibulum lorem sed risus. Sed cras ornare arcu dui vivamus. Sit amet consectetur adipiscing elit pellentesque. Id velit ut tortor pretium viverra suspendisse potenti. Sed euismod nisi porta lorem mollis aliquam ut. Justo laoreet sit amet cursus sit amet dictum sit. Ullamcorper morbi tincidunt ornare massa eget egestas. +

+

+ In iaculis nunc sed augue lacus. Curabitur vitae nunc sed velit dignissim sodales ut eu sem. Tellus id interdum velit laoreet id donec ultrices tincidunt arcu. Quis vel eros donec ac odio tempor. Viverra accumsan in nisl nisi scelerisque eu ultrices vitae. Varius quam quisque id diam vel quam. Nisl tincidunt eget nullam non nisi est sit. Nunc aliquet bibendum enim facilisis. Aenean sed adipiscing diam donec adipiscing. Convallis tellus id interdum velit laoreet. Massa placerat duis ultricies lacus sed turpis tincidunt. Sed cras ornare arcu dui vivamus arcu. Egestas integer eget aliquet nibh praesent tristique. Sit amet purus gravida quis blandit turpis cursus in hac. Porta non pulvinar neque laoreet suspendisse. Quis risus sed vulputate odio ut. Dignissim enim sit amet venenatis urna cursus. +

+

+ Interdum velit laoreet id donec ultrices tincidunt arcu non sodales. Massa sapien faucibus et molestie. Dictumst vestibulum rhoncus est pellentesque elit ullamcorper. Metus dictum at tempor commodo ullamcorper. Tincidunt lobortis feugiat vivamus at augue eget. Non diam phasellus vestibulum lorem sed risus ultricies. Neque aliquam vestibulum morbi blandit cursus risus at ultrices mi. Euismod lacinia at quis risus sed. Lorem mollis aliquam ut porttitor leo a diam. Ipsum dolor sit amet consectetur. Ante in nibh mauris cursus mattis molestie a iaculis at. Commodo ullamcorper a lacus vestibulum. Pellentesque elit eget gravida cum sociis. Sit amet commodo nulla facilisi nullam vehicula. Vehicula ipsum a arcu cursus vitae congue mauris rhoncus aenean. +

+

+ Ac turpis egestas maecenas pharetra convallis. Lacus sed viverra tellus in. Nullam eget felis eget nunc lobortis mattis aliquam faucibus purus. Id aliquet risus feugiat in ante metus dictum at. Quis enim lobortis scelerisque fermentum dui faucibus. Eu volutpat odio facilisis mauris sit amet massa vitae tortor. Tellus elementum sagittis vitae et leo. Cras sed felis eget velit aliquet sagittis. Proin fermentum leo vel orci porta non pulvinar neque laoreet. Dui sapien eget mi proin sed libero enim. Ultrices mi tempus imperdiet nulla malesuada. Mattis molestie a iaculis at. Turpis massa sed elementum tempus egestas. +

+

+ Dui faucibus in ornare quam viverra orci sagittis eu volutpat. Cras adipiscing enim eu turpis. Ac felis donec et odio pellentesque. Iaculis nunc sed augue lacus viverra vitae congue eu consequat. Posuere lorem ipsum dolor sit amet consectetur adipiscing elit duis. Elit eget gravida cum sociis natoque penatibus. Id faucibus nisl tincidunt eget nullam non. Sagittis aliquam malesuada bibendum arcu vitae. Fermentum leo vel orci porta. Aliquam ultrices sagittis orci a scelerisque purus semper. Diam maecenas sed enim ut sem viverra aliquet eget sit. Et ultrices neque ornare aenean euismod. Eu mi bibendum neque egestas congue quisque egestas diam. Eget lorem dolor sed viverra. Ut lectus arcu bibendum at. Rutrum tellus pellentesque eu tincidunt tortor. Vitae congue eu consequat ac. Elit ullamcorper dignissim cras tincidunt. Sit amet volutpat consequat mauris nunc congue nisi. +

+

+ Cursus in hac habitasse platea dictumst quisque sagittis purus. Placerat duis ultricies lacus sed turpis tincidunt. In egestas erat imperdiet sed euismod nisi porta lorem mollis. Non nisi est sit amet facilisis magna. In massa tempor nec feugiat nisl pretium fusce. Pulvinar neque laoreet suspendisse interdum consectetur. Ullamcorper morbi tincidunt ornare massa eget egestas purus viverra accumsan. Fringilla urna porttitor rhoncus dolor purus non enim. Mauris nunc congue nisi vitae suscipit. Commodo elit at imperdiet dui accumsan sit amet nulla. Tempor id eu nisl nunc mi ipsum faucibus. Porta non pulvinar neque laoreet suspendisse. Nec nam aliquam sem et tortor consequat. +

+

+ Eget nunc lobortis mattis aliquam faucibus purus. Odio tempor orci dapibus ultrices. Sed nisi lacus sed viverra tellus. Elit ullamcorper dignissim cras tincidunt. Porttitor rhoncus dolor purus non enim praesent elementum facilisis. Viverra orci sagittis eu volutpat odio. Pharetra massa massa ultricies mi quis. Lectus vestibulum mattis ullamcorper velit sed ullamcorper. Pulvinar neque laoreet suspendisse interdum consectetur. Vitae auctor eu augue ut. Arcu dictum varius duis at consectetur lorem donec. Massa sed elementum tempus egestas sed sed. Risus viverra adipiscing at in tellus integer. Vulputate enim nulla aliquet porttitor lacus luctus accumsan. Pharetra massa massa ultricies mi. Elementum eu facilisis sed odio morbi quis commodo odio. Tincidunt lobortis feugiat vivamus at. Felis donec et odio pellentesque diam volutpat commodo sed. Risus feugiat in ante metus dictum at tempor commodo ullamcorper. Fringilla phasellus faucibus scelerisque eleifend donec pretium vulputate. +

+

+ Lectus proin nibh nisl condimentum id venenatis a condimentum. Id consectetur purus ut faucibus pulvinar elementum integer enim. Non pulvinar neque laoreet suspendisse interdum consectetur. Est pellentesque elit ullamcorper dignissim cras tincidunt lobortis feugiat vivamus. Suscipit tellus mauris a diam maecenas sed enim ut sem. Dolor purus non enim praesent elementum facilisis. Non enim praesent elementum facilisis leo vel. Ultricies leo integer malesuada nunc vel risus commodo viverra maecenas. Nulla porttitor massa id neque aliquam vestibulum. Erat velit scelerisque in dictum non consectetur. Amet cursus sit amet dictum. Nec tincidunt praesent semper feugiat nibh. Rutrum quisque non tellus orci ac auctor. Sagittis aliquam malesuada bibendum arcu vitae elementum. Massa tincidunt dui ut ornare lectus sit amet est. Aliquet porttitor lacus luctus accumsan tortor posuere ac. Quis hendrerit dolor magna eget est lorem ipsum dolor sit. Lectus mauris ultrices eros in. +

+

+ Massa massa ultricies mi quis hendrerit dolor magna. Est ullamcorper eget nulla facilisi etiam dignissim diam. Vulputate sapien nec sagittis aliquam malesuada. Nisi porta lorem mollis aliquam ut porttitor leo a diam. Tempus quam pellentesque nec nam. Faucibus vitae aliquet nec ullamcorper sit amet risus nullam eget. Gravida arcu ac tortor dignissim convallis aenean et tortor. A scelerisque purus semper eget duis at tellus at. Viverra ipsum nunc aliquet bibendum enim. Semper feugiat nibh sed pulvinar proin gravida hendrerit. Et ultrices neque ornare aenean euismod. Consequat semper viverra nam libero justo laoreet. Nunc mattis enim ut tellus elementum sagittis. Consectetur lorem donec massa sapien faucibus et. Vel risus commodo viverra maecenas accumsan lacus vel facilisis. Diam sollicitudin tempor id eu nisl nunc. Dolor magna eget est lorem ipsum dolor. Adipiscing elit pellentesque habitant morbi tristique. +

+

+ Nec sagittis aliquam malesuada bibendum arcu vitae elementum curabitur. Egestas fringilla phasellus faucibus scelerisque eleifend donec pretium vulputate sapien. Porttitor leo a diam sollicitudin tempor. Pellentesque dignissim enim sit amet venenatis urna cursus eget nunc. Posuere sollicitudin aliquam ultrices sagittis orci a scelerisque. Vehicula ipsum a arcu cursus vitae congue mauris rhoncus. Leo urna molestie at elementum. Duis tristique sollicitudin nibh sit amet commodo nulla facilisi. Libero id faucibus nisl tincidunt eget nullam. Tellus elementum sagittis vitae et leo duis ut diam. Sodales ut etiam sit amet nisl purus in mollis. Ipsum faucibus vitae aliquet nec ullamcorper sit amet risus. Lacus laoreet non curabitur gravida arcu ac tortor dignissim convallis. Aliquam malesuada bibendum arcu vitae elementum. Leo vel orci porta non pulvinar neque laoreet. Ipsum suspendisse ultrices gravida dictum fusce. +

+

+ Egestas erat imperdiet sed euismod nisi porta lorem. Venenatis a condimentum vitae sapien pellentesque habitant. Sit amet luctus venenatis lectus magna fringilla urna porttitor. Orci sagittis eu volutpat odio facilisis mauris sit amet massa. Ut enim blandit volutpat maecenas volutpat blandit aliquam. Libero volutpat sed cras ornare. Molestie ac feugiat sed lectus vestibulum mattis ullamcorper velit sed. Diam quis enim lobortis scelerisque fermentum dui. Pellentesque habitant morbi tristique senectus et netus. Auctor urna nunc id cursus metus aliquam eleifend. Elit scelerisque mauris pellentesque pulvinar pellentesque habitant morbi tristique. Sed risus ultricies tristique nulla aliquet enim tortor. Tincidunt arcu non sodales neque sodales ut. Sed lectus vestibulum mattis ullamcorper velit sed ullamcorper morbi tincidunt. +

+

+ Pulvinar etiam non quam lacus suspendisse faucibus. Eu mi bibendum neque egestas congue. Egestas purus viverra accumsan in nisl nisi scelerisque eu. Vulputate enim nulla aliquet porttitor lacus luctus accumsan. Eu non diam phasellus vestibulum. Semper feugiat nibh sed pulvinar. Ante in nibh mauris cursus mattis molestie a. Maecenas accumsan lacus vel facilisis volutpat. Non quam lacus suspendisse faucibus. Quis commodo odio aenean sed adipiscing. Vel elit scelerisque mauris pellentesque pulvinar pellentesque habitant morbi. Sed cras ornare arcu dui vivamus arcu felis. Tortor vitae purus faucibus ornare suspendisse sed. Morbi tincidunt ornare massa eget egestas purus viverra. Nibh cras pulvinar mattis nunc. Luctus venenatis lectus magna fringilla urna porttitor. Enim blandit volutpat maecenas volutpat blandit aliquam etiam erat. Malesuada pellentesque elit eget gravida cum sociis natoque penatibus et. Felis eget nunc lobortis mattis aliquam faucibus purus in. Vivamus arcu felis bibendum ut. +

diff --git a/src/app/info/privacy/privacy-content/privacy-content.component.scss b/src/app/info/privacy/privacy-content/privacy-content.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/info/privacy/privacy-content/privacy-content.component.spec.ts b/src/app/info/privacy/privacy-content/privacy-content.component.spec.ts new file mode 100644 index 0000000000..a77e809dc3 --- /dev/null +++ b/src/app/info/privacy/privacy-content/privacy-content.component.spec.ts @@ -0,0 +1,27 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { PrivacyContentComponent } from './privacy-content.component'; +import { TranslateModule } from '@ngx-translate/core'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; + +describe('PrivacyContentComponent', () => { + let component: PrivacyContentComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ TranslateModule.forRoot() ], + declarations: [ PrivacyContentComponent ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(PrivacyContentComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/info/privacy/privacy-content/privacy-content.component.ts b/src/app/info/privacy/privacy-content/privacy-content.component.ts new file mode 100644 index 0000000000..6a7b394cf4 --- /dev/null +++ b/src/app/info/privacy/privacy-content/privacy-content.component.ts @@ -0,0 +1,12 @@ +import { Component } from '@angular/core'; + +@Component({ + selector: 'ds-privacy-content', + templateUrl: './privacy-content.component.html', + styleUrls: ['./privacy-content.component.scss'] +}) +/** + * Component displaying the contents of the Privacy Statement + */ +export class PrivacyContentComponent { +} diff --git a/src/app/info/privacy/privacy.component.html b/src/app/info/privacy/privacy.component.html new file mode 100644 index 0000000000..c6772e98f2 --- /dev/null +++ b/src/app/info/privacy/privacy.component.html @@ -0,0 +1,3 @@ +
+ +
diff --git a/src/app/info/privacy/privacy.component.scss b/src/app/info/privacy/privacy.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/info/privacy/privacy.component.spec.ts b/src/app/info/privacy/privacy.component.spec.ts new file mode 100644 index 0000000000..a3d47e82f9 --- /dev/null +++ b/src/app/info/privacy/privacy.component.spec.ts @@ -0,0 +1,27 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { PrivacyComponent } from './privacy.component'; +import { TranslateModule } from '@ngx-translate/core'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; + +describe('PrivacyComponent', () => { + let component: PrivacyComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ TranslateModule.forRoot() ], + declarations: [ PrivacyComponent ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(PrivacyComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/info/privacy/privacy.component.ts b/src/app/info/privacy/privacy.component.ts new file mode 100644 index 0000000000..dc9d3d69dc --- /dev/null +++ b/src/app/info/privacy/privacy.component.ts @@ -0,0 +1,12 @@ +import { Component } from '@angular/core'; + +@Component({ + selector: 'ds-privacy', + templateUrl: './privacy.component.html', + styleUrls: ['./privacy.component.scss'] +}) +/** + * Component displaying the Privacy Statement + */ +export class PrivacyComponent { +} 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/process-page/detail/process-detail.component.ts b/src/app/process-page/detail/process-detail.component.ts index f6b628f0f8..13f9814923 100644 --- a/src/app/process-page/detail/process-detail.component.ts +++ b/src/app/process-page/detail/process-detail.component.ts @@ -8,7 +8,7 @@ import { DSpaceObject } from '../../core/shared/dspace-object.model'; import { ProcessOutput } from '../processes/process-output.model'; import { Process } from '../processes/process.model'; import { finalize, map, switchMap, take } from 'rxjs/operators'; -import { getFirstSucceededRemoteDataPayload, redirectToPageNotFoundOn404 } from '../../core/shared/operators'; +import { getFirstSucceededRemoteDataPayload, redirectOn404Or401 } from '../../core/shared/operators'; import { AlertType } from '../../shared/alert/aletr-type'; import { ProcessDataService } from '../../core/data/processes/process-data.service'; import { PaginatedList } from '../../core/data/paginated-list'; @@ -69,7 +69,7 @@ export class ProcessDetailComponent implements OnInit { ngOnInit(): void { this.processRD$ = this.route.data.pipe( map((data) => data.process as RemoteData), - redirectToPageNotFoundOn404(this.router) + redirectOn404Or401(this.router) ); this.filesRD$ = this.processRD$.pipe( diff --git a/src/app/register-page/create-profile/create-profile.component.spec.ts b/src/app/register-page/create-profile/create-profile.component.spec.ts index a435e1143e..00c2eef99d 100644 --- a/src/app/register-page/create-profile/create-profile.component.spec.ts +++ b/src/app/register-page/create-profile/create-profile.component.spec.ts @@ -18,6 +18,10 @@ import { EPerson } from '../../core/eperson/models/eperson.model'; import { AuthenticateAction } from '../../core/auth/auth.actions'; import { RouterStub } from '../../shared/testing/router.stub'; import { NotificationsServiceStub } from '../../shared/testing/notifications-service.stub'; +import { + END_USER_AGREEMENT_METADATA_FIELD, + EndUserAgreementService +} from '../../core/end-user-agreement/end-user-agreement.service'; describe('CreateProfileComponent', () => { let comp: CreateProfileComponent; @@ -28,40 +32,80 @@ describe('CreateProfileComponent', () => { let ePersonDataService: EPersonDataService; let notificationsService; let store: Store; + let endUserAgreementService: EndUserAgreementService; const registration = Object.assign(new Registration(), {email: 'test@email.org', token: 'test-token'}); - const values = { - metadata: { - 'eperson.firstname': [ - { - value: 'First' - } - ], - 'eperson.lastname': [ - { - value: 'Last' - }, - ], - 'eperson.phone': [ - { - value: 'Phone' - } - ], - 'eperson.language': [ - { - value: 'en' - } - ] - }, - email: 'test@email.org', - password: 'password', - canLogIn: true, - requireCertificate: false - }; - const eperson = Object.assign(new EPerson(), values); + let values; + let eperson: EPerson; + let valuesWithAgreement; + let epersonWithAgreement: EPerson; beforeEach(async(() => { + values = { + metadata: { + 'eperson.firstname': [ + { + value: 'First' + } + ], + 'eperson.lastname': [ + { + value: 'Last' + }, + ], + 'eperson.phone': [ + { + value: 'Phone' + } + ], + 'eperson.language': [ + { + value: 'en' + } + ] + }, + email: 'test@email.org', + password: 'password', + canLogIn: true, + requireCertificate: false + }; + eperson = Object.assign(new EPerson(), values); + valuesWithAgreement = { + metadata: { + 'eperson.firstname': [ + { + value: 'First' + } + ], + 'eperson.lastname': [ + { + value: 'Last' + }, + ], + 'eperson.phone': [ + { + value: 'Phone' + } + ], + 'eperson.language': [ + { + value: 'en' + } + ], + [END_USER_AGREEMENT_METADATA_FIELD]: [ + { + value: 'true' + } + ] + }, + email: 'test@email.org', + password: 'password', + canLogIn: true, + requireCertificate: false + }; + epersonWithAgreement = Object.assign(new EPerson(), valuesWithAgreement); + route = {data: observableOf({registration: registration})}; router = new RouterStub(); notificationsService = new NotificationsServiceStub(); @@ -74,6 +118,11 @@ describe('CreateProfileComponent', () => { dispatch: {}, }); + endUserAgreementService = jasmine.createSpyObj('endUserAgreementService', { + isCookieAccepted: false, + removeCookieAccepted: {} + }); + TestBed.configureTestingModule({ imports: [CommonModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), ReactiveFormsModule], declarations: [CreateProfileComponent], @@ -84,6 +133,7 @@ describe('CreateProfileComponent', () => { {provide: EPersonDataService, useValue: ePersonDataService}, {provide: FormBuilder, useValue: new FormBuilder()}, {provide: NotificationsService, useValue: notificationsService}, + {provide: EndUserAgreementService, useValue: endUserAgreementService}, ], schemas: [CUSTOM_ELEMENTS_SCHEMA] }).compileComponents(); @@ -131,6 +181,41 @@ describe('CreateProfileComponent', () => { expect(notificationsService.success).toHaveBeenCalled(); }); + describe('when the end-user-agreement cookie is accepted', () => { + beforeEach(() => { + (endUserAgreementService.isCookieAccepted as jasmine.Spy).and.returnValue(true); + }); + + it('should submit an eperson with agreement metadata for creation and log in on success', () => { + comp.firstName.patchValue('First'); + comp.lastName.patchValue('Last'); + comp.contactPhone.patchValue('Phone'); + comp.language.patchValue('en'); + comp.password = 'password'; + comp.isInValidPassword = false; + + comp.submitEperson(); + + expect(ePersonDataService.createEPersonForToken).toHaveBeenCalledWith(epersonWithAgreement, 'test-token'); + expect(store.dispatch).toHaveBeenCalledWith(new AuthenticateAction('test@email.org', 'password')); + expect(router.navigate).toHaveBeenCalledWith(['/home']); + expect(notificationsService.success).toHaveBeenCalled(); + }); + + it('should remove the cookie', () => { + comp.firstName.patchValue('First'); + comp.lastName.patchValue('Last'); + comp.contactPhone.patchValue('Phone'); + comp.language.patchValue('en'); + comp.password = 'password'; + comp.isInValidPassword = false; + + comp.submitEperson(); + + expect(endUserAgreementService.removeCookieAccepted).toHaveBeenCalled(); + }); + }); + it('should submit an eperson for creation and stay on page on error', () => { (ePersonDataService.createEPersonForToken as jasmine.Spy).and.returnValue(observableOf(new RestResponse(false, 500, 'Error'))); diff --git a/src/app/register-page/create-profile/create-profile.component.ts b/src/app/register-page/create-profile/create-profile.component.ts index 2755a17739..589e2d741e 100644 --- a/src/app/register-page/create-profile/create-profile.component.ts +++ b/src/app/register-page/create-profile/create-profile.component.ts @@ -14,6 +14,10 @@ import { AuthenticateAction } from '../../core/auth/auth.actions'; import { NotificationsService } from '../../shared/notifications/notifications.service'; import { environment } from '../../../environments/environment'; import { isEmpty } from '../../shared/empty.util'; +import { + END_USER_AGREEMENT_METADATA_FIELD, + EndUserAgreementService +} from '../../core/end-user-agreement/end-user-agreement.service'; /** * Component that renders the create profile page to be used by a user registering through a token @@ -41,7 +45,8 @@ export class CreateProfileComponent implements OnInit { private router: Router, private route: ActivatedRoute, private formBuilder: FormBuilder, - private notificationsService: NotificationsService + private notificationsService: NotificationsService, + private endUserAgreementService: EndUserAgreementService ) { } @@ -137,6 +142,16 @@ export class CreateProfileComponent implements OnInit { requireCertificate: false }; + // If the End User Agreement cookie is accepted, add end-user agreement metadata to the user + if (this.endUserAgreementService.isCookieAccepted()) { + values.metadata[END_USER_AGREEMENT_METADATA_FIELD] = [ + { + value: String(true) + } + ]; + this.endUserAgreementService.removeCookieAccepted(); + } + const eperson = Object.assign(new EPerson(), values); this.ePersonDataService.createEPersonForToken(eperson, this.token).subscribe((response) => { if (response.isSuccessful) { diff --git a/src/app/register-page/register-page-routing.module.ts b/src/app/register-page/register-page-routing.module.ts index c7cceeaaf4..7954d7963a 100644 --- a/src/app/register-page/register-page-routing.module.ts +++ b/src/app/register-page/register-page-routing.module.ts @@ -4,6 +4,7 @@ import { RegisterEmailComponent } from './register-email/register-email.componen import { CreateProfileComponent } from './create-profile/create-profile.component'; import { ItemPageResolver } from '../+item-page/item-page.resolver'; import { RegistrationResolver } from '../register-email-form/registration.resolver'; +import { EndUserAgreementCookieGuard } from '../core/end-user-agreement/end-user-agreement-cookie.guard'; @NgModule({ imports: [ @@ -16,7 +17,8 @@ import { RegistrationResolver } from '../register-email-form/registration.resolv { path: ':token', component: CreateProfileComponent, - resolve: {registration: RegistrationResolver} + resolve: {registration: RegistrationResolver}, + canActivate: [EndUserAgreementCookieGuard] } ]) ], diff --git a/src/app/shared/auth-nav-menu/auth-nav-menu.component.html b/src/app/shared/auth-nav-menu/auth-nav-menu.component.html index a05381fee8..fa92939e0f 100644 --- a/src/app/shared/auth-nav-menu/auth-nav-menu.component.html +++ b/src/app/shared/auth-nav-menu/auth-nav-menu.component.html @@ -1,7 +1,7 @@ -
+
Sorry, suggestions could not be loaded.
+ + diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/typeahead/dynamic-typeahead.component.scss b/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.scss similarity index 87% rename from src/app/shared/form/builder/ds-dynamic-form-ui/models/typeahead/dynamic-typeahead.component.scss rename to src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.scss index 3857d96e78..d6ce88eed9 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/typeahead/dynamic-typeahead.component.scss +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.scss @@ -16,3 +16,8 @@ color: $dropdown-link-hover-color !important; background-color: $dropdown-link-hover-bg !important; } + +.treeview .modal-body { + max-height: 85vh !important; + overflow-y: auto; +} diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.spec.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.spec.ts new file mode 100644 index 0000000000..7a18bcc6e4 --- /dev/null +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.spec.ts @@ -0,0 +1,462 @@ +// Load the implementations that should be tested +import { ChangeDetectorRef, Component, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; +import { FormControl, FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { ComponentFixture, fakeAsync, inject, TestBed, tick, } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { CdkTreeModule } from '@angular/cdk/tree'; + +import { TestScheduler } from 'rxjs/testing'; +import { getTestScheduler } from 'jasmine-marbles'; +import { of as observableOf } from 'rxjs'; +import { NgbModal, NgbModule } from '@ng-bootstrap/ng-bootstrap'; +import { DynamicFormLayoutService, DynamicFormsCoreModule, DynamicFormValidationService } from '@ng-dynamic-forms/core'; +import { DynamicFormsNGBootstrapUIModule } from '@ng-dynamic-forms/ui-ng-bootstrap'; +import { TranslateModule } from '@ngx-translate/core'; + +import { VocabularyOptions } from '../../../../../../core/submission/vocabularies/models/vocabulary-options.model'; +import { VocabularyService } from '../../../../../../core/submission/vocabularies/vocabulary.service'; +import { VocabularyServiceStub } from '../../../../../testing/vocabulary-service.stub'; +import { DsDynamicOneboxComponent } from './dynamic-onebox.component'; +import { DynamicOneboxModel } from './dynamic-onebox.model'; +import { FormFieldMetadataValueObject } from '../../../models/form-field-metadata-value.model'; +import { createTestComponent } from '../../../../../testing/utils.test'; +import { AuthorityConfidenceStateDirective } from '../../../../../authority-confidence/authority-confidence-state.directive'; +import { ObjNgFor } from '../../../../../utils/object-ngfor.pipe'; +import { VocabularyEntry } from '../../../../../../core/submission/vocabularies/models/vocabulary-entry.model'; +import { createSuccessfulRemoteDataObject$ } from '../../../../../remote-data.utils'; +import { VocabularyTreeviewComponent } from '../../../../../vocabulary-treeview/vocabulary-treeview.component'; + +export let ONEBOX_TEST_GROUP; + +export let ONEBOX_TEST_MODEL_CONFIG; + +/* tslint:disable:max-classes-per-file */ + +// Mock class for NgbModalRef +export class MockNgbModalRef { + componentInstance = { + vocabularyOptions: undefined, + preloadLevel: undefined, + selectedItem: undefined + }; + result: Promise = new Promise((resolve, reject) => resolve(true)); +} + +function init() { + ONEBOX_TEST_GROUP = new FormGroup({ + onebox: new FormControl(), + }); + + ONEBOX_TEST_MODEL_CONFIG = { + vocabularyOptions: { + closed: false, + name: 'vocabulary' + } as VocabularyOptions, + disabled: false, + id: 'onebox', + label: 'Conference', + minChars: 3, + name: 'onebox', + placeholder: 'Conference', + readOnly: false, + required: false, + repeatable: false, + value: undefined + }; +} + +describe('DsDynamicOneboxComponent test suite', () => { + + let scheduler: TestScheduler; + let testComp: TestComponent; + let oneboxComponent: DsDynamicOneboxComponent; + let testFixture: ComponentFixture; + let oneboxCompFixture: ComponentFixture; + let vocabularyServiceStub: any; + let modalService: any; + let html; + let modal; + const vocabulary = { + id: 'vocabulary', + name: 'vocabulary', + scrollable: true, + hierarchical: false, + preloadLevel: 0, + type: 'vocabulary', + _links: { + self: { + url: 'self' + }, + entries: { + url: 'entries' + } + } + } + + const hierarchicalVocabulary = { + id: 'hierarchicalVocabulary', + name: 'hierarchicalVocabulary', + scrollable: true, + hierarchical: true, + preloadLevel: 2, + type: 'vocabulary', + _links: { + self: { + url: 'self' + }, + entries: { + url: 'entries' + } + } + } + + // async beforeEach + beforeEach(() => { + vocabularyServiceStub = new VocabularyServiceStub(); + + modal = jasmine.createSpyObj('modal', + { + open: jasmine.createSpy('open'), + close: jasmine.createSpy('close'), + dismiss: jasmine.createSpy('dismiss'), + } + ); + init(); + TestBed.configureTestingModule({ + imports: [ + DynamicFormsCoreModule, + DynamicFormsNGBootstrapUIModule, + FormsModule, + NgbModule, + ReactiveFormsModule, + TranslateModule.forRoot(), + CdkTreeModule + ], + declarations: [ + DsDynamicOneboxComponent, + TestComponent, + AuthorityConfidenceStateDirective, + ObjNgFor, + VocabularyTreeviewComponent + ], // declare the test component + providers: [ + ChangeDetectorRef, + DsDynamicOneboxComponent, + { provide: VocabularyService, useValue: vocabularyServiceStub }, + { provide: DynamicFormLayoutService, useValue: {} }, + { provide: DynamicFormValidationService, useValue: {} }, + { provide: NgbModal, useValue: modal } + ], + schemas: [CUSTOM_ELEMENTS_SCHEMA] + }).compileComponents(); + + }); + + describe('', () => { + // synchronous beforeEach + beforeEach(() => { + html = ` + `; + + spyOn(vocabularyServiceStub, 'findVocabularyById').and.returnValue(createSuccessfulRemoteDataObject$(vocabulary)); + testFixture = createTestComponent(html, TestComponent) as ComponentFixture; + testComp = testFixture.componentInstance; + }); + + afterEach(() => { + testFixture.destroy(); + }); + it('should create DsDynamicOneboxComponent', inject([DsDynamicOneboxComponent], (app: DsDynamicOneboxComponent) => { + expect(app).toBeDefined(); + })); + }); + + describe('Has not hierarchical vocabulary', () => { + beforeEach(() => { + spyOn(vocabularyServiceStub, 'findVocabularyById').and.returnValue(createSuccessfulRemoteDataObject$(vocabulary)); + }); + + describe('when init model value is empty', () => { + beforeEach(() => { + + oneboxCompFixture = TestBed.createComponent(DsDynamicOneboxComponent); + oneboxComponent = oneboxCompFixture.componentInstance; // FormComponent test instance + oneboxComponent.group = ONEBOX_TEST_GROUP; + oneboxComponent.model = new DynamicOneboxModel(ONEBOX_TEST_MODEL_CONFIG); + oneboxCompFixture.detectChanges(); + }); + + afterEach(() => { + oneboxCompFixture.destroy(); + oneboxComponent = null; + }); + + it('should init component properly', () => { + expect(oneboxComponent.currentValue).not.toBeDefined(); + }); + + it('should search when 3+ characters typed', fakeAsync(() => { + + spyOn((oneboxComponent as any).vocabularyService, 'getVocabularyEntriesByValue').and.callThrough(); + + oneboxComponent.search(observableOf('test')).subscribe(); + + tick(300); + oneboxCompFixture.detectChanges(); + + expect((oneboxComponent as any).vocabularyService.getVocabularyEntriesByValue).toHaveBeenCalled(); + })); + + it('should set model.value on input type when VocabularyOptions.closed is false', () => { + const inputDe = oneboxCompFixture.debugElement.query(By.css('input.form-control')); + const inputElement = inputDe.nativeElement; + + inputElement.value = 'test value'; + inputElement.dispatchEvent(new Event('input')); + + expect(oneboxComponent.inputValue).toEqual(new FormFieldMetadataValueObject('test value')) + + }); + + it('should not set model.value on input type when VocabularyOptions.closed is true', () => { + oneboxComponent.model.vocabularyOptions.closed = true; + oneboxCompFixture.detectChanges(); + const inputDe = oneboxCompFixture.debugElement.query(By.css('input.form-control')); + const inputElement = inputDe.nativeElement; + + inputElement.value = 'test value'; + inputElement.dispatchEvent(new Event('input')); + + expect(oneboxComponent.model.value).not.toBeDefined(); + + }); + + it('should emit blur Event onBlur when popup is closed', () => { + spyOn(oneboxComponent.blur, 'emit'); + spyOn(oneboxComponent.instance, 'isPopupOpen').and.returnValue(false); + oneboxComponent.onBlur(new Event('blur')); + expect(oneboxComponent.blur.emit).toHaveBeenCalled(); + }); + + it('should not emit blur Event onBlur when popup is opened', () => { + spyOn(oneboxComponent.blur, 'emit'); + spyOn(oneboxComponent.instance, 'isPopupOpen').and.returnValue(true); + const input = oneboxCompFixture.debugElement.query(By.css('input')); + + input.nativeElement.blur(); + expect(oneboxComponent.blur.emit).not.toHaveBeenCalled(); + }); + + it('should emit change Event onBlur when VocabularyOptions.closed is false and inputValue is changed', () => { + oneboxComponent.inputValue = 'test value'; + oneboxCompFixture.detectChanges(); + spyOn(oneboxComponent.blur, 'emit'); + spyOn(oneboxComponent.change, 'emit'); + spyOn(oneboxComponent.instance, 'isPopupOpen').and.returnValue(false); + oneboxComponent.onBlur(new Event('blur',)); + expect(oneboxComponent.change.emit).toHaveBeenCalled(); + expect(oneboxComponent.blur.emit).toHaveBeenCalled(); + }); + + it('should not emit change Event onBlur when VocabularyOptions.closed is false and inputValue is not changed', () => { + oneboxComponent.inputValue = 'test value'; + oneboxComponent.model = new DynamicOneboxModel(ONEBOX_TEST_MODEL_CONFIG); + (oneboxComponent.model as any).value = 'test value'; + oneboxCompFixture.detectChanges(); + spyOn(oneboxComponent.blur, 'emit'); + spyOn(oneboxComponent.change, 'emit'); + spyOn(oneboxComponent.instance, 'isPopupOpen').and.returnValue(false); + oneboxComponent.onBlur(new Event('blur',)); + expect(oneboxComponent.change.emit).not.toHaveBeenCalled(); + expect(oneboxComponent.blur.emit).toHaveBeenCalled(); + }); + + it('should not emit change Event onBlur when VocabularyOptions.closed is false and inputValue is null', () => { + oneboxComponent.inputValue = null; + oneboxComponent.model = new DynamicOneboxModel(ONEBOX_TEST_MODEL_CONFIG); + (oneboxComponent.model as any).value = 'test value'; + oneboxCompFixture.detectChanges(); + spyOn(oneboxComponent.blur, 'emit'); + spyOn(oneboxComponent.change, 'emit'); + spyOn(oneboxComponent.instance, 'isPopupOpen').and.returnValue(false); + oneboxComponent.onBlur(new Event('blur',)); + expect(oneboxComponent.change.emit).not.toHaveBeenCalled(); + expect(oneboxComponent.blur.emit).toHaveBeenCalled(); + }); + + it('should emit focus Event onFocus', () => { + spyOn(oneboxComponent.focus, 'emit'); + oneboxComponent.onFocus(new Event('focus')); + expect(oneboxComponent.focus.emit).toHaveBeenCalled(); + }); + + }); + + describe('when init model value is not empty', () => { + beforeEach(() => { + oneboxCompFixture = TestBed.createComponent(DsDynamicOneboxComponent); + oneboxComponent = oneboxCompFixture.componentInstance; // FormComponent test instance + oneboxComponent.group = ONEBOX_TEST_GROUP; + oneboxComponent.model = new DynamicOneboxModel(ONEBOX_TEST_MODEL_CONFIG); + const entry = observableOf(Object.assign(new VocabularyEntry(), { + authority: null, + value: 'test', + display: 'testDisplay' + })); + spyOn((oneboxComponent as any).vocabularyService, 'getVocabularyEntryByValue').and.returnValue(entry); + spyOn((oneboxComponent as any).vocabularyService, 'getVocabularyEntryByID').and.returnValue(entry); + (oneboxComponent.model as any).value = new FormFieldMetadataValueObject('test', null, null, 'testDisplay'); + oneboxCompFixture.detectChanges(); + }); + + afterEach(() => { + oneboxCompFixture.destroy(); + oneboxComponent = null; + }); + + it('should init component properly', fakeAsync(() => { + tick(); + expect(oneboxComponent.currentValue).toEqual(new FormFieldMetadataValueObject('test', null, null, 'testDisplay')); + expect((oneboxComponent as any).vocabularyService.getVocabularyEntryByValue).toHaveBeenCalled(); + })); + + it('should emit change Event onChange and currentValue is empty', () => { + oneboxComponent.currentValue = null; + spyOn(oneboxComponent.change, 'emit'); + oneboxComponent.onChange(new Event('change')); + expect(oneboxComponent.change.emit).toHaveBeenCalled(); + expect(oneboxComponent.model.value).toBeNull(); + }); + }); + + describe('when init model value is not empty and has authority', () => { + beforeEach(() => { + oneboxCompFixture = TestBed.createComponent(DsDynamicOneboxComponent); + oneboxComponent = oneboxCompFixture.componentInstance; // FormComponent test instance + oneboxComponent.group = ONEBOX_TEST_GROUP; + oneboxComponent.model = new DynamicOneboxModel(ONEBOX_TEST_MODEL_CONFIG); + const entry = observableOf(Object.assign(new VocabularyEntry(), { + authority: 'test001', + value: 'test001', + display: 'test' + })); + spyOn((oneboxComponent as any).vocabularyService, 'getVocabularyEntryByValue').and.returnValue(entry); + spyOn((oneboxComponent as any).vocabularyService, 'getVocabularyEntryByID').and.returnValue(entry); + (oneboxComponent.model as any).value = new FormFieldMetadataValueObject('test', null, 'test001'); + oneboxCompFixture.detectChanges(); + }); + + afterEach(() => { + oneboxCompFixture.destroy(); + oneboxComponent = null; + }); + + it('should init component properly', fakeAsync(() => { + tick(); + expect(oneboxComponent.currentValue).toEqual(new FormFieldMetadataValueObject('test001', null, 'test001', 'test')); + expect((oneboxComponent as any).vocabularyService.getVocabularyEntryByID).toHaveBeenCalled(); + })); + + it('should emit change Event onChange and currentValue is empty', () => { + oneboxComponent.currentValue = null; + spyOn(oneboxComponent.change, 'emit'); + oneboxComponent.onChange(new Event('change')); + expect(oneboxComponent.change.emit).toHaveBeenCalled(); + expect(oneboxComponent.model.value).toBeNull(); + }); + }); + }); + + describe('Has hierarchical vocabulary', () => { + beforeEach(() => { + scheduler = getTestScheduler(); + spyOn(vocabularyServiceStub, 'findVocabularyById').and.returnValue(createSuccessfulRemoteDataObject$(hierarchicalVocabulary)); + oneboxCompFixture = TestBed.createComponent(DsDynamicOneboxComponent); + oneboxComponent = oneboxCompFixture.componentInstance; // FormComponent test instance + modalService = TestBed.get(NgbModal); + modalService.open.and.returnValue(new MockNgbModalRef()); + }); + + describe('when init model value is empty', () => { + beforeEach(() => { + oneboxComponent.group = ONEBOX_TEST_GROUP; + oneboxComponent.model = new DynamicOneboxModel(ONEBOX_TEST_MODEL_CONFIG); + oneboxCompFixture.detectChanges(); + }); + + afterEach(() => { + oneboxCompFixture.destroy(); + oneboxComponent = null; + }); + + it('should init component properly', fakeAsync(() => { + tick(); + expect(oneboxComponent.currentValue).not.toBeDefined(); + })); + + it('should open tree properly', (done) => { + scheduler.schedule(() => oneboxComponent.openTree(new Event('click'))); + scheduler.flush(); + + expect((oneboxComponent as any).modalService.open).toHaveBeenCalled(); + done(); + }); + }); + + describe('when init model value is not empty', () => { + beforeEach(() => { + oneboxComponent.group = ONEBOX_TEST_GROUP; + oneboxComponent.model = new DynamicOneboxModel(ONEBOX_TEST_MODEL_CONFIG); + const entry = observableOf(Object.assign(new VocabularyEntry(), { + authority: null, + value: 'test', + display: 'testDisplay' + })); + spyOn((oneboxComponent as any).vocabularyService, 'getVocabularyEntryByValue').and.returnValue(entry); + spyOn((oneboxComponent as any).vocabularyService, 'getVocabularyEntryByID').and.returnValue(entry); + (oneboxComponent.model as any).value = new FormFieldMetadataValueObject('test', null, null, 'testDisplay'); + oneboxCompFixture.detectChanges(); + }); + + afterEach(() => { + oneboxCompFixture.destroy(); + oneboxComponent = null; + }); + + it('should init component properly', fakeAsync(() => { + tick(); + expect(oneboxComponent.currentValue).toEqual(new FormFieldMetadataValueObject('test', null, null, 'testDisplay')); + expect((oneboxComponent as any).vocabularyService.getVocabularyEntryByValue).toHaveBeenCalled(); + })); + + it('should open tree properly', (done) => { + scheduler.schedule(() => oneboxComponent.openTree(new Event('click'))); + scheduler.flush(); + + expect((oneboxComponent as any).modalService.open).toHaveBeenCalled(); + done(); + }); + }); + + }); +}); + +// declare a test component +@Component({ + selector: 'ds-test-cmp', + template: `` +}) +class TestComponent { + + group: FormGroup = ONEBOX_TEST_GROUP; + + model = new DynamicOneboxModel(ONEBOX_TEST_MODEL_CONFIG); + +} + +/* tslint:enable:max-classes-per-file */ diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.ts new file mode 100644 index 0000000000..43ea03228d --- /dev/null +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.component.ts @@ -0,0 +1,278 @@ +import { ChangeDetectorRef, Component, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core'; +import { FormGroup } from '@angular/forms'; + +import { DynamicFormLayoutService, DynamicFormValidationService } from '@ng-dynamic-forms/core'; +import { + catchError, + debounceTime, + distinctUntilChanged, + filter, + map, + merge, + switchMap, + take, + tap +} from 'rxjs/operators'; +import { Observable, of as observableOf, Subject, Subscription } from 'rxjs'; +import { NgbModal, NgbModalRef, NgbTypeahead, NgbTypeaheadSelectItemEvent } from '@ng-bootstrap/ng-bootstrap'; + +import { VocabularyService } from '../../../../../../core/submission/vocabularies/vocabulary.service'; +import { DynamicOneboxModel } from './dynamic-onebox.model'; +import { hasValue, isEmpty, isNotEmpty, isNotNull } from '../../../../../empty.util'; +import { FormFieldMetadataValueObject } from '../../../models/form-field-metadata-value.model'; +import { ConfidenceType } from '../../../../../../core/shared/confidence-type'; +import { getFirstSucceededRemoteDataPayload } from '../../../../../../core/shared/operators'; +import { PaginatedList } from '../../../../../../core/data/paginated-list'; +import { VocabularyEntry } from '../../../../../../core/submission/vocabularies/models/vocabulary-entry.model'; +import { PageInfo } from '../../../../../../core/shared/page-info.model'; +import { DsDynamicVocabularyComponent } from '../dynamic-vocabulary.component'; +import { Vocabulary } from '../../../../../../core/submission/vocabularies/models/vocabulary.model'; +import { VocabularyTreeviewComponent } from '../../../../../vocabulary-treeview/vocabulary-treeview.component'; +import { VocabularyEntryDetail } from '../../../../../../core/submission/vocabularies/models/vocabulary-entry-detail.model'; + +/** + * Component representing a onebox input field. + * If field has a Hierarchical Vocabulary configured, it's rendered with vocabulary tree + */ +@Component({ + selector: 'ds-dynamic-onebox', + styleUrls: ['./dynamic-onebox.component.scss'], + templateUrl: './dynamic-onebox.component.html' +}) +export class DsDynamicOneboxComponent extends DsDynamicVocabularyComponent implements OnInit { + @Input() bindId = true; + @Input() group: FormGroup; + @Input() model: DynamicOneboxModel; + + @Output() blur: EventEmitter = new EventEmitter(); + @Output() change: EventEmitter = new EventEmitter(); + @Output() focus: EventEmitter = new EventEmitter(); + + @ViewChild('instance', { static: false }) instance: NgbTypeahead; + + pageInfo: PageInfo = new PageInfo(); + searching = false; + searchFailed = false; + hideSearchingWhenUnsubscribed$ = new Observable(() => () => this.changeSearchingStatus(false)); + click$ = new Subject(); + currentValue: any; + inputValue: any; + preloadLevel: number; + + private vocabulary$: Observable; + private isHierarchicalVocabulary$: Observable; + private subs: Subscription[] = []; + + constructor(protected vocabularyService: VocabularyService, + protected cdr: ChangeDetectorRef, + protected layoutService: DynamicFormLayoutService, + protected modalService: NgbModal, + protected validationService: DynamicFormValidationService + ) { + super(vocabularyService, layoutService, validationService); + } + + /** + * Converts an item from the result list to a `string` to display in the `` field. + */ + formatter = (x: { display: string }) => { + return (typeof x === 'object') ? x.display : x + }; + + /** + * Converts a stream of text values from the `` element to the stream of the array of items + * to display in the onebox popup. + */ + search = (text$: Observable) => { + return text$.pipe( + merge(this.click$), + debounceTime(300), + distinctUntilChanged(), + tap(() => this.changeSearchingStatus(true)), + switchMap((term) => { + if (term === '' || term.length < this.model.minChars) { + return observableOf({ list: [] }); + } else { + return this.vocabularyService.getVocabularyEntriesByValue( + term, + false, + this.model.vocabularyOptions, + this.pageInfo).pipe( + getFirstSucceededRemoteDataPayload(), + tap(() => this.searchFailed = false), + catchError(() => { + this.searchFailed = true; + return observableOf(new PaginatedList( + new PageInfo(), + [] + )); + })); + } + }), + map((list: PaginatedList) => list.page), + tap(() => this.changeSearchingStatus(false)), + merge(this.hideSearchingWhenUnsubscribed$) + ) + }; + + /** + * Initialize the component, setting up the init form value + */ + ngOnInit() { + if (this.model.value) { + this.setCurrentValue(this.model.value, true); + } + + this.vocabulary$ = this.vocabularyService.findVocabularyById(this.model.vocabularyOptions.name).pipe( + getFirstSucceededRemoteDataPayload(), + distinctUntilChanged() + ); + + this.isHierarchicalVocabulary$ = this.vocabulary$.pipe( + map((result: Vocabulary) => result.hierarchical) + ); + + this.subs.push(this.group.get(this.model.id).valueChanges.pipe( + filter((value) => this.currentValue !== value)) + .subscribe((value) => { + this.setCurrentValue(this.model.value); + })); + } + + /** + * Changes the searching status + * @param status + */ + changeSearchingStatus(status: boolean) { + this.searching = status; + this.cdr.detectChanges(); + } + + /** + * Checks if configured vocabulary is Hierarchical or not + */ + isHierarchicalVocabulary(): Observable { + return this.isHierarchicalVocabulary$; + } + + /** + * Update the input value with a FormFieldMetadataValueObject + * @param event + */ + onInput(event) { + if (!this.model.vocabularyOptions.closed && isNotEmpty(event.target.value)) { + this.inputValue = new FormFieldMetadataValueObject(event.target.value); + } + } + + /** + * Emits a blur event containing a given value. + * @param event The value to emit. + */ + onBlur(event: Event) { + if (!this.instance.isPopupOpen()) { + if (!this.model.vocabularyOptions.closed && isNotEmpty(this.inputValue)) { + if (isNotNull(this.inputValue) && this.model.value !== this.inputValue) { + this.dispatchUpdate(this.inputValue); + } + this.inputValue = null; + } + this.blur.emit(event); + } else { + // prevent on blur propagation if typeahed suggestions are showed + event.preventDefault(); + event.stopImmediatePropagation(); + // set focus on input again, this is to avoid to lose changes when no suggestion is selected + (event.target as HTMLInputElement).focus(); + } + } + + /** + * Updates model value with the current value + * @param event The change event. + */ + onChange(event: Event) { + event.stopPropagation(); + if (isEmpty(this.currentValue)) { + this.dispatchUpdate(null); + } + } + + /** + * Updates current value and model value with the selected value. + * @param event The value to set. + */ + onSelectItem(event: NgbTypeaheadSelectItemEvent) { + this.inputValue = null; + this.setCurrentValue(event.item); + this.dispatchUpdate(event.item); + } + + /** + * Open modal to show tree for hierarchical vocabulary + * @param event The click event fired + */ + openTree(event) { + event.preventDefault(); + event.stopImmediatePropagation(); + this.subs.push(this.vocabulary$.pipe( + map((vocabulary: Vocabulary) => vocabulary.preloadLevel), + take(1) + ).subscribe((preloadLevel) => { + const modalRef: NgbModalRef = this.modalService.open(VocabularyTreeviewComponent, { size: 'lg', windowClass: 'treeview' }); + modalRef.componentInstance.vocabularyOptions = this.model.vocabularyOptions; + modalRef.componentInstance.preloadLevel = preloadLevel; + modalRef.componentInstance.selectedItem = this.currentValue ? this.currentValue : ''; + modalRef.result.then((result: VocabularyEntryDetail) => { + if (result) { + this.currentValue = result; + this.dispatchUpdate(result); + } + }, () => { + return; + }); + })) + } + + /** + * Callback functions for whenClickOnConfidenceNotAccepted event + */ + public whenClickOnConfidenceNotAccepted(confidence: ConfidenceType) { + if (!this.model.readOnly) { + this.click$.next(this.formatter(this.currentValue)); + } + } + + /** + * Sets the current value with the given value. + * @param value The value to set. + * @param init Representing if is init value or not. + */ + setCurrentValue(value: any, init = false): void { + let result: string; + if (init) { + this.getInitValueFromModel() + .subscribe((formValue: FormFieldMetadataValueObject) => { + this.currentValue = formValue; + this.cdr.detectChanges(); + }); + } else { + if (isEmpty(value)) { + result = ''; + } else { + result = value.value; + } + + this.currentValue = result; + this.cdr.detectChanges(); + } + + } + + ngOnDestroy(): void { + this.subs + .filter((sub) => hasValue(sub)) + .forEach((sub) => sub.unsubscribe()); + } + +} diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/typeahead/dynamic-typeahead.model.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.model.ts similarity index 58% rename from src/app/shared/form/builder/ds-dynamic-form-ui/models/typeahead/dynamic-typeahead.model.ts rename to src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.model.ts index 866055ed04..4b973e3058 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/typeahead/dynamic-typeahead.model.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/onebox/dynamic-onebox.model.ts @@ -1,19 +1,19 @@ import { AUTOCOMPLETE_OFF, DynamicFormControlLayout, serializable } from '@ng-dynamic-forms/core'; import { DsDynamicInputModel, DsDynamicInputModelConfig } from '../ds-dynamic-input.model'; -export const DYNAMIC_FORM_CONTROL_TYPE_TYPEAHEAD = 'TYPEAHEAD'; +export const DYNAMIC_FORM_CONTROL_TYPE_ONEBOX = 'ONEBOX'; -export interface DsDynamicTypeaheadModelConfig extends DsDynamicInputModelConfig { +export interface DsDynamicOneboxModelConfig extends DsDynamicInputModelConfig { minChars?: number; value?: any; } -export class DynamicTypeaheadModel extends DsDynamicInputModel { +export class DynamicOneboxModel extends DsDynamicInputModel { @serializable() minChars: number; - @serializable() readonly type: string = DYNAMIC_FORM_CONTROL_TYPE_TYPEAHEAD; + @serializable() readonly type: string = DYNAMIC_FORM_CONTROL_TYPE_ONEBOX; - constructor(config: DsDynamicTypeaheadModelConfig, layout?: DynamicFormControlLayout) { + constructor(config: DsDynamicOneboxModelConfig, layout?: DynamicFormControlLayout) { super(config, layout); diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/relation-group/dynamic-relation-group.component.spec.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/relation-group/dynamic-relation-group.component.spec.ts index b5cb153db2..8fc579fb1b 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/relation-group/dynamic-relation-group.component.spec.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/relation-group/dynamic-relation-group.component.spec.ts @@ -2,9 +2,12 @@ import { ChangeDetectorRef, Component, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; import { async, ComponentFixture, inject, TestBed, } from '@angular/core/testing'; import { FormControl, FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { Store, StoreModule } from '@ngrx/store'; import { TranslateModule } from '@ngx-translate/core'; import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; +import { DynamicFormLayoutService, DynamicFormValidationService } from '@ng-dynamic-forms/core'; import { DsDynamicRelationGroupComponent } from './dynamic-relation-group.components'; import { DynamicRelationGroupModel, DynamicRelationGroupModelConfig } from './dynamic-relation-group.model'; @@ -13,18 +16,14 @@ import { FormFieldModel } from '../../../models/form-field.model'; import { FormBuilderService } from '../../../form-builder.service'; import { FormService } from '../../../../form.service'; import { FormComponent } from '../../../../form.component'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { Chips } from '../../../../../chips/models/chips.model'; import { FormFieldMetadataValueObject } from '../../../models/form-field-metadata-value.model'; import { DsDynamicInputModel } from '../ds-dynamic-input.model'; import { createTestComponent } from '../../../../../testing/utils.test'; -import { DynamicFormLayoutService, DynamicFormValidationService } from '@ng-dynamic-forms/core'; -import { AuthorityService } from '../../../../../../core/integration/authority.service'; -import { AuthorityServiceStub } from '../../../../../testing/authority-service.stub'; -import { Store, StoreModule } from '@ngrx/store'; +import { VocabularyService } from '../../../../../../core/submission/vocabularies/vocabulary.service'; +import { VocabularyServiceStub } from '../../../../../testing/vocabulary-service.stub'; import { StoreMock } from '../../../../../testing/store.mock'; import { FormRowModel } from '../../../../../../core/config/models/config-submission-form.model'; -import { GlobalConfig } from '../../../../../../../config/global-config.interface'; import { storeModuleConfig } from '../../../../../../app.reducer'; export let FORM_GROUP_TEST_MODEL_CONFIG; @@ -47,7 +46,7 @@ function init() { mandatoryMessage: 'Required field!', repeatable: false, selectableMetadata: [{ - authority: 'RPAuthority', + controlledVocabulary: 'RPAuthority', closed: false, metadata: 'dc.contributor.author' }], @@ -61,7 +60,7 @@ function init() { mandatory: 'false', repeatable: false, selectableMetadata: [{ - authority: 'OUAuthority', + controlledVocabulary: 'OUAuthority', closed: false, metadata: 'local.contributor.affiliation' }] @@ -129,7 +128,7 @@ describe('DsDynamicRelationGroupComponent test suite', () => { FormBuilderService, FormComponent, FormService, - { provide: AuthorityService, useValue: new AuthorityServiceStub() }, + { provide: VocabularyService, useValue: new VocabularyServiceStub() }, { provide: Store, useClass: StoreMock } ], schemas: [CUSTOM_ELEMENTS_SCHEMA] diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/relation-group/dynamic-relation-group.components.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/relation-group/dynamic-relation-group.components.ts index e7a519e2b4..4f6c776497 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/relation-group/dynamic-relation-group.components.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/relation-group/dynamic-relation-group.components.ts @@ -1,14 +1,4 @@ -import { - ChangeDetectorRef, - Component, - EventEmitter, - Inject, - Input, - OnDestroy, - OnInit, - Output, - ViewChild -} from '@angular/core'; +import { ChangeDetectorRef, Component, EventEmitter, Input, OnDestroy, OnInit, Output, ViewChild } from '@angular/core'; import { FormGroup } from '@angular/forms'; import { combineLatest, Observable, of as observableOf, Subscription } from 'rxjs'; @@ -33,14 +23,16 @@ import { hasValue, isEmpty, isNotEmpty, isNotNull } from '../../../../../empty.u import { shrinkInOut } from '../../../../../animations/shrink'; import { ChipsItem } from '../../../../../chips/models/chips-item.model'; import { hasOnlyEmptyProperties } from '../../../../../object.util'; -import { IntegrationSearchOptions } from '../../../../../../core/integration/models/integration-options.model'; -import { AuthorityService } from '../../../../../../core/integration/authority.service'; -import { IntegrationData } from '../../../../../../core/integration/integration-data'; +import { VocabularyService } from '../../../../../../core/submission/vocabularies/vocabulary.service'; import { FormFieldMetadataValueObject } from '../../../models/form-field-metadata-value.model'; -import { AuthorityValue } from '../../../../../../core/integration/models/authority.value'; import { environment } from '../../../../../../../environments/environment'; import { PLACEHOLDER_PARENT_METADATA } from '../../ds-dynamic-form-constants'; +import { getFirstSucceededRemoteDataPayload } from '../../../../../../core/shared/operators'; +import { VocabularyEntryDetail } from '../../../../../../core/submission/vocabularies/models/vocabulary-entry-detail.model'; +/** + * Component representing a group input field + */ @Component({ selector: 'ds-dynamic-relation-group', styleUrls: ['./dynamic-relation-group.component.scss'], @@ -65,9 +57,9 @@ export class DsDynamicRelationGroupComponent extends DynamicFormControlComponent private selectedChipItem: ChipsItem; private subs: Subscription[] = []; - @ViewChild('formRef', {static: false}) private formRef: FormComponent; + @ViewChild('formRef', { static: false }) private formRef: FormComponent; - constructor(private authorityService: AuthorityService, + constructor(private vocabularyService: VocabularyService, private formBuilderService: FormBuilderService, private formService: FormService, private cdr: ChangeDetectorRef, @@ -178,6 +170,12 @@ export class DsDynamicRelationGroupComponent extends DynamicFormControlComponent this.clear(); } + ngOnDestroy(): void { + this.subs + .filter((sub) => hasValue(sub)) + .forEach((sub) => sub.unsubscribe()); + } + private addToChips() { if (!this.formRef.formGroup.valid) { this.formService.validateAllFormFields(this.formRef.formGroup); @@ -236,20 +234,16 @@ export class DsDynamicRelationGroupComponent extends DynamicFormControlComponent if (isObject(valueObj[fieldName]) && valueObj[fieldName].hasAuthority() && isNotEmpty(valueObj[fieldName].authority)) { const fieldId = fieldName.replace(/\./g, '_'); const model = this.formBuilderService.findById(fieldId, this.formModel); - const searchOptions: IntegrationSearchOptions = new IntegrationSearchOptions( - (model as any).authorityOptions.scope, - (model as any).authorityOptions.name, - (model as any).authorityOptions.metadata, + return$ = this.vocabularyService.findEntryDetailById( valueObj[fieldName].authority, - (model as any).maxOptions, - 1); - - return$ = this.authorityService.getEntryByValue(searchOptions).pipe( - map((result: IntegrationData) => Object.assign( + (model as any).vocabularyOptions.name + ).pipe( + getFirstSucceededRemoteDataPayload(), + map((entryDetail: VocabularyEntryDetail) => Object.assign( new FormFieldMetadataValueObject(), valueObj[fieldName], { - otherInformation: (result.payload[0] as AuthorityValue).otherInformation + otherInformation: entryDetail.otherInformation }) )); } else { @@ -316,10 +310,4 @@ export class DsDynamicRelationGroupComponent extends DynamicFormControlComponent } } - ngOnDestroy(): void { - this.subs - .filter((sub) => hasValue(sub)) - .forEach((sub) => sub.unsubscribe()); - } - } diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component.html b/src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component.html index 8cb44bc733..f40d58bb0e 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component.html +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component.html @@ -1,7 +1,8 @@ -
- + -
-

{{ 'submission.sections.describe.relationship-lookup.selection-tab.title.' + routeData.sourceId | translate}}

+

{{ 'submission.sections.describe.relationship-lookup.selection-tab.title.' + routeData.sourceId | translate}}

+ (importObject)="import($event)" + (pageChange)="paginationChange();"> diff --git a/src/app/submission/import-external/submission-import-external.component.spec.ts b/src/app/submission/import-external/submission-import-external.component.spec.ts index 4cbe204a91..10370d5e77 100644 --- a/src/app/submission/import-external/submission-import-external.component.spec.ts +++ b/src/app/submission/import-external/submission-import-external.component.spec.ts @@ -1,21 +1,25 @@ import { Component, NO_ERRORS_SCHEMA } from '@angular/core'; -import { async, TestBed, ComponentFixture, inject } from '@angular/core/testing'; +import { async, ComponentFixture, inject, TestBed } from '@angular/core/testing'; + +import { getTestScheduler } from 'jasmine-marbles'; import { TranslateModule } from '@ngx-translate/core'; import { Router } from '@angular/router'; import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; -import { of as observableOf, of } from 'rxjs/internal/observable/of'; +import { of as observableOf } from 'rxjs'; +import { TestScheduler } from 'rxjs/testing'; + import { SubmissionImportExternalComponent } from './submission-import-external.component'; import { ExternalSourceService } from '../../core/data/external-source.service'; import { getMockExternalSourceService } from '../../shared/mocks/external-source.service.mock'; import { SearchConfigurationService } from '../../core/shared/search/search-configuration.service'; import { RouteService } from '../../core/services/route.service'; -import { createTestComponent, createPaginatedList } from '../../shared/testing/utils.test'; +import { createPaginatedList, createTestComponent } from '../../shared/testing/utils.test'; import { RouterStub } from '../../shared/testing/router.stub'; import { VarDirective } from '../../shared/utils/var.directive'; import { routeServiceStub } from '../../shared/testing/route-service.stub'; import { PaginatedSearchOptions } from '../../shared/search/paginated-search-options.model'; import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model'; -import { createSuccessfulRemoteDataObject } from '../../shared/remote-data.utils'; +import { createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; import { ExternalSourceEntry } from '../../core/shared/external-source-entry.model'; import { SubmissionImportExternalPreviewComponent } from './import-external-preview/submission-import-external-preview.component'; @@ -23,16 +27,19 @@ describe('SubmissionImportExternalComponent test suite', () => { let comp: SubmissionImportExternalComponent; let compAsAny: any; let fixture: ComponentFixture; + let scheduler: TestScheduler; const ngbModal = jasmine.createSpyObj('modal', ['open']); - const mockSearchOptions = of(new PaginatedSearchOptions({ + const mockSearchOptions = observableOf(new PaginatedSearchOptions({ pagination: Object.assign(new PaginationComponentOptions(), { pageSize: 10, currentPage: 0 - }) + }), + query: 'test' })); const searchConfigServiceStub = { paginatedSearchOptions: mockSearchOptions }; + const mockExternalSourceService: any = getMockExternalSourceService(); beforeEach(async (() => { TestBed.configureTestingModule({ @@ -45,7 +52,7 @@ describe('SubmissionImportExternalComponent test suite', () => { VarDirective ], providers: [ - { provide: ExternalSourceService, useClass: getMockExternalSourceService }, + { provide: ExternalSourceService, useValue: mockExternalSourceService }, { provide: SearchConfigurationService, useValue: searchConfigServiceStub }, { provide: RouteService, useValue: routeServiceStub }, { provide: Router, useValue: new RouterStub() }, @@ -83,6 +90,8 @@ describe('SubmissionImportExternalComponent test suite', () => { fixture = TestBed.createComponent(SubmissionImportExternalComponent); comp = fixture.componentInstance; compAsAny = comp; + scheduler = getTestScheduler(); + mockExternalSourceService.getExternalSourceEntries.and.returnValue(createSuccessfulRemoteDataObject$(createPaginatedList([]))) }); afterEach(() => { @@ -102,25 +111,31 @@ describe('SubmissionImportExternalComponent test suite', () => { }); it('Should init component properly (with route data)', () => { - const expectedEntries = createSuccessfulRemoteDataObject(createPaginatedList([])); - const searchOptions = new PaginatedSearchOptions({ - pagination: Object.assign(new PaginationComponentOptions(), { - pageSize: 10, - currentPage: 0 - }) - }); - spyOn(compAsAny.routeService, 'getQueryParameterValue').and.returnValue(observableOf('dummy')); + spyOn(compAsAny, 'retrieveExternalSources'); + spyOn(compAsAny.routeService, 'getQueryParameterValue').and.returnValues(observableOf('source'), observableOf('dummy')); fixture.detectChanges(); - expect(comp.routeData).toEqual({ sourceId: 'dummy', query: 'dummy' }); - expect(comp.isLoading$.value).toBe(true); - expect(comp.entriesRD$.value).toEqual(expectedEntries); - expect(compAsAny.externalService.getExternalSourceEntries).toHaveBeenCalledWith('dummy', searchOptions); + expect(compAsAny.retrieveExternalSources).toHaveBeenCalledWith('source', 'dummy'); + }); + + it('Should call \'getExternalSourceEntries\' properly', () => { + comp.routeData = { sourceId: '', query: '' }; + scheduler.schedule(() => compAsAny.retrieveExternalSources('orcidV2', 'test')); + scheduler.flush(); + + expect(comp.routeData).toEqual({ sourceId: 'orcidV2', query: 'test' }); + expect(comp.isLoading$.value).toBe(false); + expect(compAsAny.externalService.getExternalSourceEntries).toHaveBeenCalled(); }); it('Should call \'router.navigate\'', () => { + comp.routeData = { sourceId: '', query: '' }; + spyOn(compAsAny, 'retrieveExternalSources').and.callFake(() => null); + compAsAny.router.navigate.and.returnValue( new Promise(() => {return;})) const event = { sourceId: 'orcidV2', query: 'dummy' }; - comp.getExternalsourceData(event); + + scheduler.schedule(() => comp.getExternalSourceData(event)); + scheduler.flush(); expect(compAsAny.router.navigate).toHaveBeenCalledWith([], { queryParams: { source: event.sourceId, query: event.query }, replaceUrl: true }); }); diff --git a/src/app/submission/import-external/submission-import-external.component.ts b/src/app/submission/import-external/submission-import-external.component.ts index a369863a74..d28186a703 100644 --- a/src/app/submission/import-external/submission-import-external.component.ts +++ b/src/app/submission/import-external/submission-import-external.component.ts @@ -1,22 +1,25 @@ -import { Component, OnInit } from '@angular/core'; +import { Component, OnDestroy, OnInit } from '@angular/core'; import { Router } from '@angular/router'; -import { combineLatest, BehaviorSubject } from 'rxjs'; + +import { BehaviorSubject, combineLatest, Subscription } from 'rxjs'; +import { filter, flatMap, take } from 'rxjs/operators'; +import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'; + import { ExternalSourceService } from '../../core/data/external-source.service'; import { ExternalSourceData } from './import-external-searchbar/submission-import-external-searchbar.component'; import { RemoteData } from '../../core/data/remote-data'; import { PaginatedList } from '../../core/data/paginated-list'; import { ExternalSourceEntry } from '../../core/shared/external-source-entry.model'; import { SearchConfigurationService } from '../../core/shared/search/search-configuration.service'; -import { switchMap, filter, take } from 'rxjs/operators'; -import { PaginatedSearchOptions } from '../../shared/search/paginated-search-options.model'; import { Context } from '../../core/shared/context.model'; import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model'; import { RouteService } from '../../core/services/route.service'; import { createSuccessfulRemoteDataObject } from '../../shared/remote-data.utils'; -import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'; import { SubmissionImportExternalPreviewComponent } from './import-external-preview/submission-import-external-preview.component'; import { fadeIn } from '../../shared/animations/fade'; import { PageInfo } from '../../core/shared/page-info.model'; +import { hasValue, isNotEmpty } from '../../shared/empty.util'; +import { getFinishedRemoteData } from '../../core/shared/operators'; /** * This component allows to submit a new workspaceitem importing the data from an external source. @@ -25,9 +28,10 @@ import { PageInfo } from '../../core/shared/page-info.model'; selector: 'ds-submission-import-external', styleUrls: ['./submission-import-external.component.scss'], templateUrl: './submission-import-external.component.html', - animations: [ fadeIn ] + animations: [fadeIn] }) -export class SubmissionImportExternalComponent implements OnInit { +export class SubmissionImportExternalComponent implements OnInit, OnDestroy { + /** * The external source search data from the routing service. */ @@ -35,11 +39,11 @@ export class SubmissionImportExternalComponent implements OnInit { /** * The displayed list of entries */ - public entriesRD$: BehaviorSubject>>; + public entriesRD$: BehaviorSubject>> = new BehaviorSubject>>(null); /** * TRUE if the REST service is called to retrieve the external source items */ - public isLoading$: BehaviorSubject; + public isLoading$: BehaviorSubject = new BehaviorSubject(false); /** * Configuration to use for the import buttons */ @@ -61,7 +65,7 @@ export class SubmissionImportExternalComponent implements OnInit { */ public initialPagination = Object.assign(new PaginationComponentOptions(), { id: 'submission-external-source-relation-list', - pageSize: 5 + pageSize: 10 }); /** * The context to displaying lists for @@ -72,6 +76,11 @@ export class SubmissionImportExternalComponent implements OnInit { */ public modalRef: NgbModalRef; + /** + * The subscription to unsubscribe + */ + protected subs: Subscription[] = []; + /** * Initialize the component variables. * @param {SearchConfigurationService} searchConfigService @@ -86,57 +95,45 @@ export class SubmissionImportExternalComponent implements OnInit { private routeService: RouteService, private router: Router, private modalService: NgbModal, - ) { } + ) { + } /** * Get the entries for the selected external source and set initial configuration. */ ngOnInit(): void { - this.label = 'Journal'; - this.listId = 'list-submission-external-sources'; - this.context = Context.EntitySearchModalWithNameVariants; + this.label = 'Journal'; + this.listId = 'list-submission-external-sources'; + this.context = Context.EntitySearchModalWithNameVariants; this.repeatable = false; - this.routeData = { sourceId: '', query: '' }; + this.routeData = { sourceId: '', query: '' }; this.importConfig = { buttonLabel: 'submission.sections.describe.relationship-lookup.external-source.import-button-title.' + this.label }; this.entriesRD$ = new BehaviorSubject(createSuccessfulRemoteDataObject(new PaginatedList(new PageInfo(), []))); this.isLoading$ = new BehaviorSubject(false); - combineLatest( + this.subs.push(combineLatest( [ this.routeService.getQueryParameterValue('source'), this.routeService.getQueryParameterValue('query') ]).pipe( - filter(([source, query]) => source && query && source !== '' && query !== ''), - filter(([source, query]) => source !== this.routeData.sourceId || query !== this.routeData.query), - switchMap(([source, query]) => { - this.routeData.sourceId = source; - this.routeData.query = query; - this.isLoading$.next(true); - return this.searchConfigService.paginatedSearchOptions.pipe( - switchMap((searchOptions: PaginatedSearchOptions) => { - return this.externalService.getExternalSourceEntries(this.routeData.sourceId, searchOptions); - }), - take(1) - ) - }), - ).subscribe((rdData) => { - this.entriesRD$.next(rdData); - this.isLoading$.next(false); - }); + take(1) + ).subscribe(([source, query]: [string, string]) => { + this.retrieveExternalSources(source, query); + })); } /** * Get the data from the searchbar and changes the router data. */ - public getExternalsourceData(event: ExternalSourceData): void { + public getExternalSourceData(event: ExternalSourceData): void { this.router.navigate( [], { queryParams: { source: event.sourceId, query: event.query }, replaceUrl: true } - ); + ).then(() => this.retrieveExternalSources(event.sourceId, event.query)); } /** @@ -150,4 +147,49 @@ export class SubmissionImportExternalComponent implements OnInit { const modalComp = this.modalRef.componentInstance; modalComp.externalSourceEntry = entry; } + + /** + * Retrieve external sources on pagination change + */ + paginationChange() { + this.retrieveExternalSources(this.routeData.sourceId, this.routeData.query); + } + + /** + * Unsubscribe from all subscriptions + */ + ngOnDestroy(): void { + this.subs + .filter((sub) => hasValue(sub)) + .forEach((sub) => sub.unsubscribe()); + } + + /** + * Retrieve external source entries + * + * @param source The source tupe + * @param query The query string to search + */ + private retrieveExternalSources(source: string, query: string): void { + if (isNotEmpty(source) && isNotEmpty(query)) { + this.routeData.sourceId = source; + this.routeData.query = query; + this.isLoading$.next(true); + this.subs.push( + this.searchConfigService.paginatedSearchOptions.pipe( + filter((searchOptions) => searchOptions.query === query), + take(1), + flatMap((searchOptions) => this.externalService.getExternalSourceEntries(this.routeData.sourceId, searchOptions).pipe( + getFinishedRemoteData(), + take(1) + )), + take(1) + ).subscribe((rdData) => { + this.entriesRD$.next(rdData); + this.isLoading$.next(false); + }) + ); + } + } + } diff --git a/src/app/submission/objects/submission-objects.actions.ts b/src/app/submission/objects/submission-objects.actions.ts index 1e3e44aba9..73c070846c 100644 --- a/src/app/submission/objects/submission-objects.actions.ts +++ b/src/app/submission/objects/submission-objects.actions.ts @@ -42,7 +42,8 @@ export const SubmissionObjectActionTypes = { DISABLE_SECTION: type('dspace/submission/DISABLE_SECTION'), SECTION_STATUS_CHANGE: type('dspace/submission/SECTION_STATUS_CHANGE'), SECTION_LOADING_STATUS_CHANGE: type('dspace/submission/SECTION_LOADING_STATUS_CHANGE'), - UPLOAD_SECTION_DATA: type('dspace/submission/UPLOAD_SECTION_DATA'), + UPDATE_SECTION_DATA: type('dspace/submission/UPDATE_SECTION_DATA'), + UPDATE_SECTION_DATA_SUCCESS: type('dspace/submission/UPDATE_SECTION_DATA_SUCCESS'), SAVE_AND_DEPOSIT_SUBMISSION: type('dspace/submission/SAVE_AND_DEPOSIT_SUBMISSION'), DEPOSIT_SUBMISSION: type('dspace/submission/DEPOSIT_SUBMISSION'), DEPOSIT_SUBMISSION_SUCCESS: type('dspace/submission/DEPOSIT_SUBMISSION_SUCCESS'), @@ -199,7 +200,7 @@ export class DisableSectionAction implements Action { } export class UpdateSectionDataAction implements Action { - type = SubmissionObjectActionTypes.UPLOAD_SECTION_DATA; + type = SubmissionObjectActionTypes.UPDATE_SECTION_DATA; payload: { submissionId: string; sectionId: string; @@ -227,6 +228,10 @@ export class UpdateSectionDataAction implements Action { } } +export class UpdateSectionDataSuccessAction implements Action { + type = SubmissionObjectActionTypes.UPDATE_SECTION_DATA_SUCCESS; +} + export class RemoveSectionErrorsAction implements Action { type = SubmissionObjectActionTypes.REMOVE_SECTION_ERRORS; payload: { diff --git a/src/app/submission/objects/submission-objects.effects.spec.ts b/src/app/submission/objects/submission-objects.effects.spec.ts index 6c2e9eefc6..c35968c0a0 100644 --- a/src/app/submission/objects/submission-objects.effects.spec.ts +++ b/src/app/submission/objects/submission-objects.effects.spec.ts @@ -276,7 +276,7 @@ describe('SubmissionObjectEffects test suite', () => { describe('saveSubmissionSuccess$', () => { - it('should return a UPLOAD_SECTION_DATA action for each updated section', () => { + it('should return a UPDATE_SECTION_DATA action for each updated section', () => { store.nextState({ submission: { objects: submissionState diff --git a/src/app/submission/objects/submission-objects.effects.ts b/src/app/submission/objects/submission-objects.effects.ts index 2dfed9ee47..2a69a61a8c 100644 --- a/src/app/submission/objects/submission-objects.effects.ts +++ b/src/app/submission/objects/submission-objects.effects.ts @@ -5,7 +5,16 @@ import { TranslateService } from '@ngx-translate/core'; import { isEqual, union } from 'lodash'; import { from as observableFrom, Observable, of as observableOf } from 'rxjs'; -import { catchError, filter, map, mergeMap, switchMap, take, tap, withLatestFrom } from 'rxjs/operators'; +import { + catchError, + filter, + map, + mergeMap, + switchMap, + take, + tap, + withLatestFrom +} from 'rxjs/operators'; import { SubmissionObject } from '../../core/submission/models/submission-object.model'; import { WorkflowItem } from '../../core/submission/models/workflowitem.model'; import { WorkspaceitemSectionUploadObject } from '../../core/submission/models/workspaceitem-section-upload.model'; @@ -40,7 +49,8 @@ import { SaveSubmissionSectionFormSuccessAction, SubmissionObjectAction, SubmissionObjectActionTypes, - UpdateSectionDataAction + UpdateSectionDataAction, + UpdateSectionDataSuccessAction } from './submission-objects.actions'; import { SubmissionObjectEntry, SubmissionSectionObject } from './submission-objects.reducer'; import { Item } from '../../core/shared/item.model'; @@ -246,28 +256,32 @@ export class SubmissionObjectEffects { * Adds all metadata an item to the SubmissionForm sections of the submission */ @Effect() addAllMetadataToSectionData = this.actions$.pipe( - ofType(SubmissionObjectActionTypes.UPLOAD_SECTION_DATA), + ofType(SubmissionObjectActionTypes.UPDATE_SECTION_DATA), switchMap((action: UpdateSectionDataAction) => { - return this.sectionService.getSectionState(action.payload.submissionId, action.payload.sectionId) + return this.sectionService.getSectionState(action.payload.submissionId, action.payload.sectionId, SectionsType.Upload) .pipe(map((section: SubmissionSectionObject) => [action, section]), take(1)); }), filter(([action, section]: [UpdateSectionDataAction, SubmissionSectionObject]) => section.sectionType === SectionsType.SubmissionForm), switchMap(([action, section]: [UpdateSectionDataAction, SubmissionSectionObject]) => { - const submissionObject$ = this.submissionObjectService - .findById(action.payload.submissionId, followLink('item')).pipe( - getFirstSucceededRemoteDataPayload() + if (section.sectionType === SectionsType.SubmissionForm) { + const submissionObject$ = this.submissionObjectService + .findById(action.payload.submissionId, followLink('item')).pipe( + getFirstSucceededRemoteDataPayload() + ); + + const item$ = submissionObject$.pipe( + switchMap((submissionObject: SubmissionObject) => (submissionObject.item as Observable>).pipe( + getFirstSucceededRemoteDataPayload(), + ))); + + return item$.pipe( + map((item: Item) => item.metadata), + filter((metadata) => !isEqual(action.payload.data, metadata)), + map((metadata: any) => new UpdateSectionDataAction(action.payload.submissionId, action.payload.sectionId, metadata, action.payload.errors)) ); - - const item$ = submissionObject$.pipe( - switchMap((submissionObject: SubmissionObject) => (submissionObject.item as Observable>).pipe( - getFirstSucceededRemoteDataPayload(), - ))); - - return item$.pipe( - map((item: Item) => item.metadata), - filter((metadata) => !isEqual(action.payload.data, metadata)), - map((metadata: any) => new UpdateSectionDataAction(action.payload.submissionId, action.payload.sectionId, metadata, action.payload.errors)) - ); + } else { + return observableOf(new UpdateSectionDataSuccessAction()); + } }), ); @@ -381,5 +395,4 @@ export class SubmissionObjectEffects { } return mappedActions; } - } diff --git a/src/app/submission/objects/submission-objects.reducer.ts b/src/app/submission/objects/submission-objects.reducer.ts index e0aeefd7b6..098160c737 100644 --- a/src/app/submission/objects/submission-objects.reducer.ts +++ b/src/app/submission/objects/submission-objects.reducer.ts @@ -262,7 +262,7 @@ export function submissionObjectReducer(state = initialState, action: Submission return changeSectionState(state, action as EnableSectionAction, true); } - case SubmissionObjectActionTypes.UPLOAD_SECTION_DATA: { + case SubmissionObjectActionTypes.UPDATE_SECTION_DATA: { return updateSectionData(state, action as UpdateSectionDataAction); } diff --git a/src/app/submission/sections/cc-license/submission-section-cc-licenses.component.ts b/src/app/submission/sections/cc-license/submission-section-cc-licenses.component.ts index d455bd5e22..9e0cfa9efa 100644 --- a/src/app/submission/sections/cc-license/submission-section-cc-licenses.component.ts +++ b/src/app/submission/sections/cc-license/submission-section-cc-licenses.component.ts @@ -1,6 +1,10 @@ import { Component, Inject } from '@angular/core'; import { Observable, of as observableOf, Subscription } from 'rxjs'; -import { Field, Option, SubmissionCcLicence } from '../../../core/submission/models/submission-cc-license.model'; +import { + Field, + Option, + SubmissionCcLicence +} from '../../../core/submission/models/submission-cc-license.model'; import { getRemoteDataPayload, getSucceededRemoteData } from '../../../core/shared/operators'; import { distinctUntilChanged, filter, map, take } from 'rxjs/operators'; import { SubmissionCcLicenseDataService } from '../../../core/submission/submission-cc-license-data.service'; @@ -228,7 +232,7 @@ export class SubmissionSectionCcLicensesComponent extends SectionModelComponent onSectionInit(): void { this.pathCombiner = new JsonPatchOperationPathCombiner('sections', this.sectionData.id); this.subscriptions.push( - this.sectionService.getSectionState(this.submissionId, this.sectionData.id).pipe( + this.sectionService.getSectionState(this.submissionId, this.sectionData.id, SectionsType.CcLicense).pipe( filter((sectionState) => { return isNotEmpty(sectionState) && (isNotEmpty(sectionState.data) || isNotEmpty(sectionState.errors)) }), diff --git a/src/app/submission/sections/container/section-container.component.html b/src/app/submission/sections/container/section-container.component.html index fb29f606e6..8262d51f6f 100644 --- a/src/app/submission/sections/container/section-container.component.html +++ b/src/app/submission/sections/container/section-container.component.html @@ -3,6 +3,7 @@ [ngClass]="{ 'section-focus' : sectionRef.isSectionActive() }" [mandatory]="sectionData.mandatory" [submissionId]="submissionId" + [sectionType]="sectionData.sectionType" [sectionId]="sectionData.id"> { let formBuilderService: any; @@ -365,7 +365,7 @@ describe('SectionFormOperationsService test suite', () => { event = Object.assign({}, dynamicFormControlChangeEvent, { model: mockInputWithLanguageAndAuthorityModel }); - expectedValue = Object.assign(new AuthorityValue(), mockInputWithLanguageAndAuthorityModel.value, {language: mockInputWithLanguageAndAuthorityModel.language}); + expectedValue = Object.assign(new VocabularyEntry(), mockInputWithLanguageAndAuthorityModel.value, {language: mockInputWithLanguageAndAuthorityModel.language}); expect(service.getFieldValueFromChangeEvent(event)).toEqual(expectedValue); @@ -373,7 +373,7 @@ describe('SectionFormOperationsService test suite', () => { model: mockInputWithLanguageAndAuthorityArrayModel }); expectedValue = [ - Object.assign(new AuthorityValue(), mockInputWithLanguageAndAuthorityArrayModel.value[0], + Object.assign(new VocabularyEntry(), mockInputWithLanguageAndAuthorityArrayModel.value[0], { language: mockInputWithLanguageAndAuthorityArrayModel.language } ) ]; diff --git a/src/app/submission/sections/form/section-form-operations.service.ts b/src/app/submission/sections/form/section-form-operations.service.ts index 28bf71b210..5c242f2b83 100644 --- a/src/app/submission/sections/form/section-form-operations.service.ts +++ b/src/app/submission/sections/form/section-form-operations.service.ts @@ -23,11 +23,12 @@ import { FormFieldPreviousValueObject } from '../../../shared/form/builder/model import { JsonPatchOperationsBuilder } from '../../../core/json-patch/builder/json-patch-operations-builder'; import { FormFieldLanguageValueObject } from '../../../shared/form/builder/models/form-field-language-value.model'; import { DsDynamicInputModel } from '../../../shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-input.model'; -import { AuthorityValue } from '../../../core/integration/models/authority.value'; +import { VocabularyEntry } from '../../../core/submission/vocabularies/models/vocabulary-entry.model'; import { FormBuilderService } from '../../../shared/form/builder/form-builder.service'; import { FormFieldMetadataValueObject } from '../../../shared/form/builder/models/form-field-metadata-value.model'; import { DynamicQualdropModel } from '../../../shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-qualdrop.model'; import { DynamicRelationGroupModel } from '../../../shared/form/builder/ds-dynamic-form-ui/models/relation-group/dynamic-relation-group.model'; +import { VocabularyEntryDetail } from '../../../core/submission/vocabularies/models/vocabulary-entry-detail.model'; import { deepClone } from 'fast-json-patch'; /** @@ -233,18 +234,19 @@ export class SectionFormOperationsService { if ((event.model as DsDynamicInputModel).hasAuthority) { if (Array.isArray(value)) { value.forEach((authority, index) => { - authority = Object.assign(new AuthorityValue(), authority, { language }); + authority = Object.assign(new VocabularyEntry(), authority, { language }); value[index] = authority; }); fieldValue = value; } else { - fieldValue = Object.assign(new AuthorityValue(), value, { language }); + fieldValue = Object.assign(new VocabularyEntry(), value, { language }); } } else { // Language without Authority (input, textArea) fieldValue = new FormFieldMetadataValueObject(value, language); } - } else if (value instanceof FormFieldLanguageValueObject || value instanceof AuthorityValue || isObject(value)) { + } else if (value instanceof FormFieldLanguageValueObject || value instanceof VocabularyEntry + || value instanceof VocabularyEntryDetail || isObject(value)) { fieldValue = value; } else { fieldValue = new FormFieldMetadataValueObject(value); diff --git a/src/app/submission/sections/form/section-form.component.spec.ts b/src/app/submission/sections/form/section-form.component.spec.ts index b2bbd4e63f..7602ad8090 100644 --- a/src/app/submission/sections/form/section-form.component.spec.ts +++ b/src/app/submission/sections/form/section-form.component.spec.ts @@ -41,7 +41,6 @@ import { SubmissionSectionError } from '../../objects/submission-objects.reducer import { DynamicFormControlEvent, DynamicFormControlEventType } from '@ng-dynamic-forms/core'; import { JsonPatchOperationPathCombiner } from '../../../core/json-patch/builder/json-patch-operation-path-combiner'; import { FormRowModel } from '../../../core/config/models/config-submission-form.model'; -import { WorkspaceitemDataService } from '../../../core/submission/workspaceitem-data.service'; import { RemoteData } from '../../../core/data/remote-data'; import { WorkspaceItem } from '../../../core/submission/models/workspaceitem.model'; import { SubmissionObjectDataService } from '../../../core/submission/submission-object-data.service'; diff --git a/src/app/submission/sections/form/section-form.component.ts b/src/app/submission/sections/form/section-form.component.ts index f4a64c5976..ab3b660ba7 100644 --- a/src/app/submission/sections/form/section-form.component.ts +++ b/src/app/submission/sections/form/section-form.component.ts @@ -17,7 +17,6 @@ import { JsonPatchOperationPathCombiner } from '../../../core/json-patch/builder import { SubmissionFormsModel } from '../../../core/config/models/config-submission-forms.model'; import { SubmissionSectionError, SubmissionSectionObject } from '../../objects/submission-objects.reducer'; import { FormFieldPreviousValueObject } from '../../../shared/form/builder/models/form-field-previous-value-object'; -import { GlobalConfig } from '../../../../config/global-config.interface'; import { SectionDataObject } from '../models/section-data.model'; import { renderSectionFor } from '../sections-decorator'; import { SectionsType } from '../sections-type'; @@ -161,7 +160,7 @@ export class SubmissionSectionformComponent extends SectionModelComponent { tap((config: SubmissionFormsModel) => this.formConfig = config), flatMap(() => observableCombineLatest( - this.sectionService.getSectionData(this.submissionId, this.sectionData.id), + this.sectionService.getSectionData(this.submissionId, this.sectionData.id, this.sectionData.sectionType), this.submissionObjectService.getHrefByID(this.submissionId).pipe(take(1)).pipe( switchMap((href: string) => { this.objectCache.remove(href); @@ -319,7 +318,7 @@ export class SubmissionSectionformComponent extends SectionModelComponent { /** * Subscribe to section state */ - this.sectionService.getSectionState(this.submissionId, this.sectionData.id).pipe( + this.sectionService.getSectionState(this.submissionId, this.sectionData.id, this.sectionData.sectionType).pipe( filter((sectionState: SubmissionSectionObject) => { return isNotEmpty(sectionState) && (isNotEmpty(sectionState.data) || isNotEmpty(sectionState.errors)) }), diff --git a/src/app/submission/sections/sections.directive.ts b/src/app/submission/sections/sections.directive.ts index 0efb7225aa..1b0a6bfbc6 100644 --- a/src/app/submission/sections/sections.directive.ts +++ b/src/app/submission/sections/sections.directive.ts @@ -9,6 +9,7 @@ import { hasValue, isNotEmpty, isNotNull } from '../../shared/empty.util'; import { SubmissionSectionError, SubmissionSectionObject } from '../objects/submission-objects.reducer'; import parseSectionErrorPaths, { SectionErrorPath } from '../utils/parseSectionErrorPaths'; import { SubmissionService } from '../submission.service'; +import { SectionsType } from './sections-type'; /** * Directive for handling generic section functionality @@ -31,6 +32,12 @@ export class SectionsDirective implements OnDestroy, OnInit { */ @Input() sectionId: string; + /** + * The section type + * @type {SectionsType} + */ + @Input() sectionType: SectionsType; + /** * The submission id * @type {string} @@ -104,7 +111,7 @@ export class SectionsDirective implements OnDestroy, OnInit { })); this.subs.push( - this.sectionService.getSectionState(this.submissionId, this.sectionId).pipe( + this.sectionService.getSectionState(this.submissionId, this.sectionId, this.sectionType).pipe( map((state: SubmissionSectionObject) => state.errors)) .subscribe((errors: SubmissionSectionError[]) => { if (isNotEmpty(errors)) { diff --git a/src/app/submission/sections/sections.service.spec.ts b/src/app/submission/sections/sections.service.spec.ts index e5cb3ddc09..5c7bff13ce 100644 --- a/src/app/submission/sections/sections.service.spec.ts +++ b/src/app/submission/sections/sections.service.spec.ts @@ -14,7 +14,11 @@ import { NotificationsServiceStub } from '../../shared/testing/notifications-ser import { SubmissionServiceStub } from '../../shared/testing/submission-service.stub'; import { getMockTranslateService } from '../../shared/mocks/translate.service.mock'; import { SectionsService } from './sections.service'; -import { mockSectionsData, mockSectionsErrors, mockSubmissionState } from '../../shared/mocks/submission.mock'; +import { + mockSectionsData, + mockSectionsErrors, + mockSubmissionState +} from '../../shared/mocks/submission.mock'; import { DisableSectionAction, EnableSectionAction, @@ -23,12 +27,17 @@ import { SectionStatusChangeAction, UpdateSectionDataAction } from '../objects/submission-objects.actions'; -import { FormAddError, FormClearErrorsAction, FormRemoveErrorAction } from '../../shared/form/form.actions'; +import { + FormAddError, + FormClearErrorsAction, + FormRemoveErrorAction +} from '../../shared/form/form.actions'; import parseSectionErrors from '../utils/parseSectionErrors'; import { SubmissionScopeType } from '../../core/submission/submission-scope-type'; import { SubmissionSectionError } from '../objects/submission-objects.reducer'; import { getMockScrollToService } from '../../shared/mocks/scroll-to-service.mock'; import { storeModuleConfig } from '../../app.reducer'; +import { SectionsType } from './sections-type'; describe('SectionsService test suite', () => { let notificationsServiceStub: NotificationsServiceStub; @@ -151,7 +160,7 @@ describe('SectionsService test suite', () => { b: sectionData[sectionId] }); - expect(service.getSectionData(submissionId, sectionId)).toBeObservable(expected); + expect(service.getSectionData(submissionId, sectionId, SectionsType.SubmissionForm)).toBeObservable(expected); }); }); @@ -175,7 +184,7 @@ describe('SectionsService test suite', () => { b: sectionState }); - expect(service.getSectionState(submissionId, sectionId)).toBeObservable(expected); + expect(service.getSectionState(submissionId, sectionId, SectionsType.SubmissionForm)).toBeObservable(expected); }); }); diff --git a/src/app/submission/sections/sections.service.ts b/src/app/submission/sections/sections.service.ts index 52ae941893..5aa3c1d3ea 100644 --- a/src/app/submission/sections/sections.service.ts +++ b/src/app/submission/sections/sections.service.ts @@ -17,14 +17,25 @@ import { SectionStatusChangeAction, UpdateSectionDataAction } from '../objects/submission-objects.actions'; -import { SubmissionObjectEntry, SubmissionSectionError, SubmissionSectionObject } from '../objects/submission-objects.reducer'; -import { submissionObjectFromIdSelector, submissionSectionDataFromIdSelector, submissionSectionErrorsFromIdSelector, submissionSectionFromIdSelector } from '../selectors'; +import { + SubmissionObjectEntry, + SubmissionSectionError, + SubmissionSectionObject +} from '../objects/submission-objects.reducer'; +import { + submissionObjectFromIdSelector, + submissionSectionDataFromIdSelector, + submissionSectionErrorsFromIdSelector, + submissionSectionFromIdSelector +} from '../selectors'; import { SubmissionScopeType } from '../../core/submission/submission-scope-type'; import parseSectionErrorPaths, { SectionErrorPath } from '../utils/parseSectionErrorPaths'; import { FormAddError, FormClearErrorsAction, FormRemoveErrorAction } from '../../shared/form/form.actions'; import { NotificationsService } from '../../shared/notifications/notifications.service'; import { SubmissionService } from '../submission.service'; import { WorkspaceitemSectionDataType } from '../../core/submission/models/workspaceitem-sections.model'; +import { SectionsType } from './sections-type'; +import { normalizeSectionData } from '../../core/submission/submission-response-parsing.service'; /** * A service that provides methods used in submission process. @@ -129,12 +140,22 @@ export class SectionsService { * The submission id * @param sectionId * The section id + * @param sectionType + * The type of section to retrieve * @return Observable * observable of [WorkspaceitemSectionDataType] */ - public getSectionData(submissionId: string, sectionId: string): Observable { + public getSectionData(submissionId: string, sectionId: string, sectionType: SectionsType): Observable { return this.store.select(submissionSectionDataFromIdSelector(submissionId, sectionId)).pipe( - distinctUntilChanged()); + map((sectionData: WorkspaceitemSectionDataType) => { + if (sectionType === SectionsType.SubmissionForm) { + return normalizeSectionData(sectionData) + } else { + return sectionData; + } + }), + distinctUntilChanged(), + ); } /** @@ -159,14 +180,25 @@ export class SectionsService { * The submission id * @param sectionId * The section id + * @param sectionType + * The type of section to retrieve * @return Observable * observable of [SubmissionSectionObject] */ - public getSectionState(submissionId: string, sectionId: string): Observable { + public getSectionState(submissionId: string, sectionId: string, sectionType: SectionsType): Observable { return this.store.select(submissionSectionFromIdSelector(submissionId, sectionId)).pipe( filter((sectionObj: SubmissionSectionObject) => hasValue(sectionObj)), map((sectionObj: SubmissionSectionObject) => sectionObj), - distinctUntilChanged(), + map((sectionState: SubmissionSectionObject) => { + if (hasValue(sectionState.data) && sectionType === SectionsType.SubmissionForm) { + return Object.assign({}, sectionState, { + data: normalizeSectionData(sectionState.data) + }) + } else { + return sectionState; + } + }), + distinctUntilChanged() ); } diff --git a/src/app/submission/submission.service.ts b/src/app/submission/submission.service.ts index deec04ee83..280b411480 100644 --- a/src/app/submission/submission.service.ts +++ b/src/app/submission/submission.service.ts @@ -1,4 +1,4 @@ -import { Inject, Injectable } from '@angular/core'; +import { Injectable } from '@angular/core'; import { HttpHeaders } from '@angular/common/http'; import { Router } from '@angular/router'; @@ -28,7 +28,6 @@ import { SubmissionSectionObject } from './objects/submission-objects.reducer'; import { submissionObjectFromIdSelector } from './selectors'; -import { GlobalConfig } from '../../config/global-config.interface'; import { HttpOptions } from '../core/dspace-rest-v2/dspace-rest-v2.service'; import { SubmissionRestService } from '../core/submission/submission-rest.service'; import { SectionDataObject } from './sections/models/section-data.model'; @@ -68,7 +67,6 @@ export class SubmissionService { private workflowLinkPath = 'workflowitems'; /** * Initialize service variables - * @param {GlobalConfig} EnvConfig * @param {NotificationsService} notificationsService * @param {SubmissionRestService} restService * @param {Router} router diff --git a/src/app/submission/submit/submission-submit.component.html b/src/app/submission/submit/submission-submit.component.html index 2ceaf5a6de..a7680f07c2 100644 --- a/src/app/submission/submit/submission-submit.component.html +++ b/src/app/submission/submit/submission-submit.component.html @@ -1,6 +1,7 @@
{ expect(comp.submissionId.toString()).toEqual(submissionId); expect(comp.collectionId).toBe(submissionObject.collection.id); expect(comp.selfUrl).toBe(submissionObject._links.self.href); + expect(comp.sections).toBe(submissionObject.sections); expect(comp.submissionDefinition).toBe(submissionObject.submissionDefinition); })); diff --git a/src/app/submission/submit/submission-submit.component.ts b/src/app/submission/submit/submission-submit.component.ts index deced3ef26..3c7e0ce6a7 100644 --- a/src/app/submission/submit/submission-submit.component.ts +++ b/src/app/submission/submit/submission-submit.component.ts @@ -11,6 +11,7 @@ import { SubmissionService } from '../submission.service'; import { SubmissionObject } from '../../core/submission/models/submission-object.model'; import { Collection } from '../../core/shared/collection.model'; import { Item } from '../../core/shared/item.model'; +import { WorkspaceitemSectionsObject } from '../../core/submission/models/workspaceitem-sections.model'; /** * This component allows to submit a new workspaceitem. @@ -35,6 +36,12 @@ export class SubmissionSubmitComponent implements OnDestroy, OnInit { */ public collectionParam: string; + /** + * The list of submission's sections + * @type {WorkspaceitemSectionsObject} + */ + public sections: WorkspaceitemSectionsObject; + /** * The submission self url * @type {string} @@ -97,6 +104,7 @@ export class SubmissionSubmitComponent implements OnDestroy, OnInit { this.router.navigate(['/mydspace']); } else { this.collectionId = (submissionObject.collection as Collection).id; + this.sections = submissionObject.sections; this.selfUrl = submissionObject._links.self.href; this.submissionDefinition = (submissionObject.submissionDefinition as SubmissionDefinitionsModel); this.submissionId = submissionObject.id; diff --git a/src/assets/i18n/en.json5 b/src/assets/i18n/en.json5 index b8a2c32314..930c59cee5 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", @@ -937,6 +941,68 @@ + "cookies.consent.accept-all": "Accept all", + + "cookies.consent.accept-selected": "Accept selected", + + "cookies.consent.app.opt-out.description": "This app is loaded by default (but you can opt out)", + + "cookies.consent.app.opt-out.title": "(opt-out)", + + "cookies.consent.app.purpose": "purpose", + + "cookies.consent.app.required.description": "This application is always required", + + "cookies.consent.app.required.title": "(always required)", + + "cookies.consent.update": "There were changes since your last visit, please update your consent.", + + "cookies.consent.close": "Close", + + "cookies.consent.decline": "Decline", + + "cookies.consent.content-notice.description": "We collect and process your personal information for the following purposes: Authentication, Preferences, Acknowledgement and Statistics.
To learn more, please read our {privacyPolicy}.", + + "cookies.consent.content-notice.learnMore": "Customize", + + "cookies.consent.content-modal.description": "Here you can see and customize the information that we collect about you.", + + "cookies.consent.content-modal.privacy-policy.name": "privacy policy", + + "cookies.consent.content-modal.privacy-policy.text": "To learn more, please read our {privacyPolicy}.", + + "cookies.consent.content-modal.title": "Information that we collect", + + + + "cookies.consent.app.title.authentication": "Authentication", + + "cookies.consent.app.description.authentication": "Required for signing you in", + + + "cookies.consent.app.title.preferences": "Preferences", + + "cookies.consent.app.description.preferences": "Required for saving your preferences", + + + + "cookies.consent.app.title.acknowledgement": "Acknowledgement", + + "cookies.consent.app.description.acknowledgement": "Required for saving your acknowledgements and consents", + + + + "cookies.consent.app.title.google-analytics": "Google Analytics", + + "cookies.consent.app.description.google-analytics": "Allows us to track statistical data", + + + + "cookies.consent.purpose.functional": "Functional", + + "cookies.consent.purpose.statistical": "Statistical", + + "curation-task.task.checklinks.label": "Check Links in Metadata", "curation-task.task.noop.label": "NOOP", @@ -1003,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", @@ -1045,12 +1118,23 @@ + "file-section.error.header": "Error obtaining files for this item", + + + "footer.copyright": "copyright © 2002-{{ year }}", "footer.link.dspace": "DSpace software", "footer.link.duraspace": "DuraSpace", + "footer.link.cookies": "Cookie settings", + + "footer.link.privacy-policy": "Privacy policy", + + "footer.link.end-user-agreement":"End User Agreement", + + "forgot-email.form.header": "Forgot Password", @@ -1170,6 +1254,30 @@ + "info.end-user-agreement.accept": "I have read and I agree to the End User Agreement", + + "info.end-user-agreement.accept.error": "An error occurred accepting the End User Agreement", + + "info.end-user-agreement.accept.success": "Successfully updated the End User Agreement", + + "info.end-user-agreement.breadcrumbs": "End User Agreement", + + "info.end-user-agreement.buttons.cancel": "Cancel", + + "info.end-user-agreement.buttons.save": "Save", + + "info.end-user-agreement.head": "End User Agreement", + + "info.end-user-agreement.title": "End User Agreement", + + "info.privacy.breadcrumbs": "Privacy Statement", + + "info.privacy.head": "Privacy Statement", + + "info.privacy.title": "Privacy Statement", + + + "item.edit.authorizations.heading": "With this editor you can view and alter the policies of an item, plus alter policies of individual item components: bundles and bitstreams. Briefly, an item is a container of bundles, and bundles are containers of bitstreams. Containers usually have ADD/REMOVE/READ/WRITE policies, while bitstreams only have READ/WRITE policies.", "item.edit.authorizations.title": "Edit item's Policies", @@ -1344,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", @@ -2757,6 +2867,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.", @@ -2794,6 +2928,8 @@ "submission.import-external.search.source.hint": "Pick an external source", + "submission.import-external.source.arxiv": "arXiv", + "submission.import-external.source.loading": "Loading ...", "submission.import-external.source.sherpaJournal": "SHERPA Journals", @@ -3145,6 +3281,24 @@ "title": "DSpace", + + + "vocabulary-treeview.header": "Hierarchical tree view", + + "vocabulary-treeview.load-more": "Load more", + + "vocabulary-treeview.search.form.reset": "Reset", + + "vocabulary-treeview.search.form.search": "Search", + + "vocabulary-treeview.search.no-result": "There were no items to show", + + "vocabulary-treeview.tree.description.nsi": "The Norwegian Science Index", + + "vocabulary-treeview.tree.description.srsc": "Research Subject Categories", + + + "administrativeView.search.results.head": "Administrative Search", "menu.section.admin_search": "Admin Search", 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', * } */ }; diff --git a/src/modules/app/browser-app.module.ts b/src/modules/app/browser-app.module.ts index 73a49b0211..295e78b932 100644 --- a/src/modules/app/browser-app.module.ts +++ b/src/modules/app/browser-app.module.ts @@ -21,6 +21,13 @@ import { AuthService } from '../../app/core/auth/auth.service'; import { Angulartics2RouterlessModule } from 'angulartics2/routerlessmodule'; import { SubmissionService } from '../../app/submission/submission.service'; import { StatisticsModule } from '../../app/statistics/statistics.module'; +import { BrowserKlaroService } from '../../app/shared/cookies/browser-klaro.service'; +import { KlaroService } from '../../app/shared/cookies/klaro.service'; +import { HardRedirectService } from '../../app/core/services/hard-redirect.service'; +import { + BrowserHardRedirectService, + LocationToken, locationProvider +} from '../../app/core/services/browser-hard-redirect.service'; export const REQ_KEY = makeStateKey('req'); @@ -75,10 +82,22 @@ export function getRequest(transferState: TransferState): any { provide: CookieService, useClass: ClientCookieService }, + { + provide: KlaroService, + useClass: BrowserKlaroService + }, { provide: SubmissionService, useClass: SubmissionService - } + }, + { + provide: HardRedirectService, + useClass: BrowserHardRedirectService, + }, + { + provide: LocationToken, + useFactory: locationProvider, + }, ] }) export class BrowserAppModule { diff --git a/src/modules/app/server-app.module.ts b/src/modules/app/server-app.module.ts index 0ba09182cc..c8ea81bdec 100644 --- a/src/modules/app/server-app.module.ts +++ b/src/modules/app/server-app.module.ts @@ -29,6 +29,8 @@ import { ServerLocaleService } from 'src/app/core/locale/server-locale.service'; import { LocaleService } from 'src/app/core/locale/locale.service'; import { HTTP_INTERCEPTORS } from '@angular/common/http'; import { ForwardClientIpInterceptor } from '../../app/core/forward-client-ip/forward-client-ip.interceptor'; +import { HardRedirectService } from '../../app/core/services/hard-redirect.service'; +import { ServerHardRedirectService } from '../../app/core/services/server-hard-redirect.service'; export function createTranslateLoader() { return new TranslateJson5UniversalLoader('dist/server/assets/i18n/', '.json5'); @@ -88,6 +90,10 @@ export function createTranslateLoader() { useClass: ForwardClientIpInterceptor, multi: true }, + { + provide: HardRedirectService, + useClass: ServerHardRedirectService, + }, ] }) export class ServerAppModule { diff --git a/webpack.server.config.js b/webpack.server.config.js index 264ae71939..7df6d6b83c 100644 --- a/webpack.server.config.js +++ b/webpack.server.config.js @@ -25,7 +25,8 @@ module.exports = { module: { noParse: /polyfills-.*\.js/, rules: [ - { test: /\.ts$/, loader: 'ts-loader', + { + test: /\.ts$/, loader: 'ts-loader', options: { configFile: "tsconfig.server.json" } }, diff --git a/yarn.lock b/yarn.lock index 0b02259f38..d44182cc51 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1096,6 +1096,11 @@ semver "6.3.0" semver-intersect "1.4.0" +"@tootallnate/once@1": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-1.1.2.tgz#ccb91445360179a04e7fe6aff78c00ffc1eeaf82" + integrity sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw== + "@types/body-parser@*": version "1.19.0" resolved "https://registry.yarnpkg.com/@types/body-parser/-/body-parser-1.19.0.tgz#0685b3c47eb3006ffed117cdd55164b61f80538f" @@ -1467,6 +1472,18 @@ agent-base@4, agent-base@^4.3.0: dependencies: es6-promisify "^5.0.0" +agent-base@5: + version "5.1.1" + resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-5.1.1.tgz#e8fb3f242959db44d63be665db7a8e739537a32c" + integrity sha512-TMeqbNl2fMW0nMjTEPOwe3J/PRFP4vqeoNuQMG0HlMrtm5QxKqdvAkZ1pRBQ/ulIyDD5Yq0nJ7YbdD8ey0TO3g== + +agent-base@6: + version "6.0.1" + resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-6.0.1.tgz#808007e4e5867decb0ab6ab2f928fbdb5a596db4" + integrity sha512-01q25QQDwLSsyfhrKbn8yuur+JNw0H+0Y4JiGIKd3z9aYk/w/2kxD/Upc+t2ZBBSUNff50VjPsSW2YxM8QYKVg== + dependencies: + debug "4" + agent-base@~4.2.1: version "4.2.1" resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-4.2.1.tgz#d89e5999f797875674c07d87f260fc41e83e8ca9" @@ -1667,6 +1684,11 @@ argparse@^1.0.7: dependencies: sprintf-js "~1.0.2" +argv@0.0.2: + version "0.0.2" + resolved "https://registry.yarnpkg.com/argv/-/argv-0.0.2.tgz#ecbd16f8949b157183711b1bda334f37840185ab" + integrity sha1-7L0W+JSbFXGDcRsb2jNPN4QBhas= + aria-query@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/aria-query/-/aria-query-3.0.0.tgz#65b3fcc1ca1155a8c9ae64d6eee297f15d5133cc" @@ -2701,6 +2723,17 @@ code-point-at@^1.0.0: resolved "https://registry.yarnpkg.com/code-point-at/-/code-point-at-1.1.0.tgz#0d070b4d043a5bea33a2f1a40e2edb3d9a4ccf77" integrity sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c= +codecov@^3.7.2: + version "3.7.2" + resolved "https://registry.yarnpkg.com/codecov/-/codecov-3.7.2.tgz#998e68c8c1ef4b55cfcf11cd456866d35e13d693" + integrity sha512-fmCjAkTese29DUX3GMIi4EaKGflHa4K51EoMc29g8fBHawdk/+KEq5CWOeXLdd9+AT7o1wO4DIpp/Z1KCqCz1g== + dependencies: + argv "0.0.2" + ignore-walk "3.0.3" + js-yaml "3.13.1" + teeny-request "6.0.1" + urlgrey "0.4.4" + codelyzer@^5.0.0: version "5.2.1" resolved "https://registry.yarnpkg.com/codelyzer/-/codelyzer-5.2.1.tgz#44fd431e128009f38c761828c33ebacba9549d32" @@ -3068,17 +3101,6 @@ coverage-istanbul-loader@2.0.3: merge-source-map "^1.1.0" schema-utils "^2.6.1" -coveralls@^3.0.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/coveralls/-/coveralls-3.1.0.tgz#13c754d5e7a2dd8b44fe5269e21ca394fb4d615b" - integrity sha512-sHxOu2ELzW8/NC1UP5XVLbZDzO4S3VxfFye3XYCznopHy02YjNkHcj5bKaVw2O7hVaBdBjEdQGpie4II1mWhuQ== - dependencies: - js-yaml "^3.13.1" - lcov-parse "^1.0.0" - log-driver "^1.2.7" - minimist "^1.2.5" - request "^2.88.2" - create-ecdh@^4.0.0: version "4.0.3" resolved "https://registry.yarnpkg.com/create-ecdh/-/create-ecdh-4.0.3.tgz#c9111b6f33045c4697f144787f9254cdc77c45ff" @@ -3419,6 +3441,13 @@ debug@3.1.0, debug@~3.1.0: dependencies: ms "2.0.0" +debug@4: + version "4.2.0" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.2.0.tgz#7f150f93920e94c58f5574c2fd01a3110effe7f1" + integrity sha512-IX2ncY78vDTjZMFUdmsvIRFY2Cf4FnD0wRs+nQwJU8Lu99/tPFdb0VybiiMTPe3I6rQmwsqQqRBvxU+bZ/I8sg== + dependencies: + ms "2.1.2" + debug@^3.0.0, debug@^3.1.0, debug@^3.1.1, debug@^3.2.5, debug@^3.2.6: version "3.2.6" resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.6.tgz#e83d17de16d8a7efb7717edbe5fb10135eee629b" @@ -5128,6 +5157,15 @@ http-proxy-agent@^2.1.0: agent-base "4" debug "3.1.0" +http-proxy-agent@^4.0.0: + version "4.0.1" + resolved "https://registry.yarnpkg.com/http-proxy-agent/-/http-proxy-agent-4.0.1.tgz#8a8c8ef7f5932ccf953c296ca8291b95aa74aa3a" + integrity sha512-k0zdNgqWTGA6aeIRVpvfVob4fL52dTfaehylg0Y4UvSySvOq/Y+BOyPrgpUrA7HylqvU8vIZGsRuXmspskV0Tg== + dependencies: + "@tootallnate/once" "1" + agent-base "6" + debug "4" + http-proxy-middleware@0.19.1: version "0.19.1" resolved "https://registry.yarnpkg.com/http-proxy-middleware/-/http-proxy-middleware-0.19.1.tgz#183c7dc4aa1479150306498c210cdaf96080a43a" @@ -5189,6 +5227,14 @@ https-proxy-agent@^2.2.1, https-proxy-agent@^2.2.3: agent-base "^4.3.0" debug "^3.1.0" +https-proxy-agent@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-4.0.0.tgz#702b71fb5520a132a66de1f67541d9e62154d82b" + integrity sha512-zoDhWrkR3of1l9QAL8/scJZyLu8j/gBkcwcaQOZh7Gyh/+uJQzGVETdgT30akuwkpL8HTRfssqI3BZuV18teDg== + dependencies: + agent-base "5" + debug "4" + https@1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/https/-/https-1.0.0.tgz#3c37c7ae1a8eeb966904a2ad1e975a194b7ed3a4" @@ -5235,7 +5281,7 @@ ignore-by-default@^1.0.1: resolved "https://registry.yarnpkg.com/ignore-by-default/-/ignore-by-default-1.0.1.tgz#48ca6d72f6c6a3af00a9ad4ae6876be3889e2b09" integrity sha1-SMptcvbGo68Aqa1K5odr44ieKwk= -ignore-walk@^3.0.1: +ignore-walk@3.0.3, ignore-walk@^3.0.1: version "3.0.3" resolved "https://registry.yarnpkg.com/ignore-walk/-/ignore-walk-3.0.3.tgz#017e2447184bfeade7c238e4aefdd1e8f95b1e37" integrity sha512-m7o6xuOaT1aqheYHKf8W6J5pYH85ZI9w077erOzLje3JsB1gkafkAhHHY19dqjulgIZHFm32Cp5uNZgcQqdJKw== @@ -5946,7 +5992,7 @@ js-tokens@^3.0.2: resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-3.0.2.tgz#9866df395102130e38f7f996bceb65443209c25b" integrity sha1-mGbfOVECEw449/mWvOtlRDIJwls= -js-yaml@^3.13.0, js-yaml@^3.13.1: +js-yaml@3.13.1, js-yaml@^3.13.0, js-yaml@^3.13.1: version "3.13.1" resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.13.1.tgz#aff151b30bfdfa8e49e05da22e7415e9dfa37847" integrity sha512-YfbcO7jXDdyj0DGxYVSlSeQNHbD7XPWvrVWeVUujrQEoZzWJIRrCPoyk6kL6IAjAG2IolMK4T0hNUe0HOUs5Jw== @@ -6165,6 +6211,11 @@ kind-of@^6.0.0, kind-of@^6.0.2: resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-6.0.3.tgz#07c05034a6c349fa06e24fa35aa76db4580ce4dd" integrity sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw== +klaro@^0.6.3: + version "0.6.3" + resolved "https://registry.yarnpkg.com/klaro/-/klaro-0.6.3.tgz#b2aaa810d17f073c9a1b5eab618a9f13d5f40fa3" + integrity sha512-rRP37FaJaHHSScHIe3YUdMZJ1asxOF5+C/RMrFB2RzhAUfGVMM5/GiucECM3Si1lhW2LL0xGVymE8JhYZl2Bjg== + last-call-webpack-plugin@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/last-call-webpack-plugin/-/last-call-webpack-plugin-3.0.0.tgz#9742df0e10e3cf46e5c0381c2de90d3a7a2d7555" @@ -6187,11 +6238,6 @@ lcid@^2.0.0: dependencies: invert-kv "^2.0.0" -lcov-parse@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/lcov-parse/-/lcov-parse-1.0.0.tgz#eb0d46b54111ebc561acb4c408ef9363bdc8f7e0" - integrity sha1-6w1GtUER68VhrLTECO+TY73I9+A= - less-loader@5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/less-loader/-/less-loader-5.0.0.tgz#498dde3a6c6c4f887458ee9ed3f086a12ad1b466" @@ -6347,11 +6393,6 @@ lodash@^4.17.19: resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.19.tgz#e48ddedbe30b3321783c5b4301fbd353bc1e4a4b" integrity sha512-JNvd8XER9GQX0v2qJgsaN/mzFCNA5BRe/j8JN9d+tWyGLSodKQHKFicdwNYzWwI3wjRnaKPsGj1XkBjx/F96DQ== -log-driver@^1.2.7: - version "1.2.7" - resolved "https://registry.yarnpkg.com/log-driver/-/log-driver-1.2.7.tgz#63b95021f0702fedfa2c9bb0a24e7797d71871d8" - integrity sha512-U7KCmLdqsGHBLeWqYlFA0V0Sl6P08EE1ZrmA9cxjUE0WVqT9qnyVDPz1kzpFEP0jdJuFnasWIfSd7fsaNXkpbg== - log-symbols@^2.1.0, log-symbols@^2.2.0: version "2.2.0" resolved "https://registry.yarnpkg.com/log-symbols/-/log-symbols-2.2.0.tgz#5740e1c5d6f0dfda4ad9323b5332107ef6b4c40a" @@ -6805,7 +6846,7 @@ ms@2.1.1: resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.1.tgz#30a5864eb3ebb0a66f2ebe6d727af06a09d86e0a" integrity sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg== -ms@^2.0.0, ms@^2.1.1: +ms@2.1.2, ms@^2.0.0, ms@^2.1.1: version "2.1.2" resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== @@ -6942,6 +6983,11 @@ node-fetch@1.6.3: encoding "^0.1.11" is-stream "^1.0.1" +node-fetch@^2.2.0: + version "2.6.1" + resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.1.tgz#045bd323631f76ed2e2b55573394416b639a0052" + integrity sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw== + node-forge@0.9.0: version "0.9.0" resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-0.9.0.tgz#d624050edbb44874adca12bb9a52ec63cb782579" @@ -8954,7 +9000,7 @@ repeat-string@^1.6.1: resolved "https://registry.yarnpkg.com/repeat-string/-/repeat-string-1.6.1.tgz#8dcae470e1c88abc2d600fff4a776286da75e637" integrity sha1-jcrkcOHIirwtYA//Sndihtp15jc= -request@^2.83.0, request@^2.87.0, request@^2.88.0, request@^2.88.2: +request@^2.83.0, request@^2.87.0, request@^2.88.0: version "2.88.2" resolved "https://registry.yarnpkg.com/request/-/request-2.88.2.tgz#d73c918731cb5a87da047e207234146f664d12b3" integrity sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw== @@ -9882,6 +9928,13 @@ stream-each@^1.1.0: end-of-stream "^1.1.0" stream-shift "^1.0.0" +stream-events@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/stream-events/-/stream-events-1.0.5.tgz#bbc898ec4df33a4902d892333d47da9bf1c406d5" + integrity sha512-E1GUzBSgvct8Jsb3v2X15pjzN1tYebtbLaMg+eBOUOAxgbLoSbT2NS91ckc5lJD1KfLjId+jXJRgo0qnV5Nerg== + dependencies: + stubs "^3.0.0" + stream-http@^2.7.2: version "2.8.3" resolved "https://registry.yarnpkg.com/stream-http/-/stream-http-2.8.3.tgz#b2d242469288a5a27ec4fe8933acf623de6514fc" @@ -10036,6 +10089,11 @@ strip-json-comments@~2.0.1: resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a" integrity sha1-PFMZQukIwml8DsNEhYwobHygpgo= +stubs@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/stubs/-/stubs-3.0.0.tgz#e8d2ba1fa9c90570303c030b6900f7d5f89abe5b" + integrity sha1-6NK6H6nJBXAwPAMLaQD31fiavls= + style-loader@1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/style-loader/-/style-loader-1.0.0.tgz#1d5296f9165e8e2c85d24eee0b7caf9ec8ca1f82" @@ -10142,6 +10200,17 @@ tar@^4.4.8: safe-buffer "^5.1.2" yallist "^3.0.3" +teeny-request@6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/teeny-request/-/teeny-request-6.0.1.tgz#9b1f512cef152945827ba7e34f62523a4ce2c5b0" + integrity sha512-TAK0c9a00ELOqLrZ49cFxvPVogMUFaWY8dUsQc/0CuQPGF+BOxOQzXfE413BAk2kLomwNplvdtMpeaeGWmoc2g== + dependencies: + http-proxy-agent "^4.0.0" + https-proxy-agent "^4.0.0" + node-fetch "^2.2.0" + stream-events "^1.0.5" + uuid "^3.3.2" + term-size@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/term-size/-/term-size-1.2.0.tgz#458b83887f288fc56d6fffbfad262e26638efa69" @@ -10644,6 +10713,11 @@ url@^0.11.0: punycode "1.3.2" querystring "0.2.0" +urlgrey@0.4.4: + version "0.4.4" + resolved "https://registry.yarnpkg.com/urlgrey/-/urlgrey-0.4.4.tgz#892fe95960805e85519f1cd4389f2cb4cbb7652f" + integrity sha1-iS/pWWCAXoVRnxzUOJ8stMu3ZS8= + use@^3.1.0: version "3.1.1" resolved "https://registry.yarnpkg.com/use/-/use-3.1.1.tgz#d50c8cac79a19fbc20f2911f56eb973f4e10070f"