diff --git a/.codecov.yml b/.codecov.yml new file mode 100644 index 0000000000..43a06c0eb3 --- /dev/null +++ b/.codecov.yml @@ -0,0 +1,21 @@ +# 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: + 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% + +# 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/.travis.yml b/.travis.yml index 13a159bfd0..0ef2e7b74e 100644 --- a/.travis.yml +++ b/.travis.yml @@ -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/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/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/+collection-page/collection-page-administrator.guard.ts b/src/app/+collection-page/collection-page-administrator.guard.ts new file mode 100644 index 0000000000..4d2f246689 --- /dev/null +++ b/src/app/+collection-page/collection-page-administrator.guard.ts @@ -0,0 +1,30 @@ +import { Injectable } from '@angular/core'; +import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from '@angular/router'; +import { Collection } from '../core/shared/collection.model'; +import { CollectionPageResolver } from './collection-page.resolver'; +import { AuthorizationDataService } from '../core/data/feature-authorization/authorization-data.service'; +import { of as observableOf } from 'rxjs'; +import { DsoPageFeatureGuard } from '../core/data/feature-authorization/feature-authorization-guard/dso-page-feature.guard'; +import { Observable } from 'rxjs/internal/Observable'; +import { FeatureID } from '../core/data/feature-authorization/feature-id'; + +@Injectable({ + providedIn: 'root' +}) +/** + * Guard for preventing unauthorized access to certain {@link Collection} pages requiring administrator rights + */ +export class CollectionPageAdministratorGuard extends DsoPageFeatureGuard { + constructor(protected resolver: CollectionPageResolver, + protected authorizationService: AuthorizationDataService, + protected router: Router) { + super(resolver, authorizationService, router); + } + + /** + * Check administrator authorization rights + */ + getFeatureID(next: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { + return observableOf(FeatureID.AdministratorOf); + } +} diff --git a/src/app/+collection-page/collection-page-routing.module.ts b/src/app/+collection-page/collection-page-routing.module.ts index 479a136140..a03f2d0b5f 100644 --- a/src/app/+collection-page/collection-page-routing.module.ts +++ b/src/app/+collection-page/collection-page-routing.module.ts @@ -19,6 +19,7 @@ import { COLLECTION_EDIT_PATH, COLLECTION_CREATE_PATH } from './collection-page-routing-paths'; +import { CollectionPageAdministratorGuard } from './collection-page-administrator.guard'; @NgModule({ imports: [ @@ -39,7 +40,7 @@ import { { path: COLLECTION_EDIT_PATH, loadChildren: './edit-collection-page/edit-collection-page.module#EditCollectionPageModule', - canActivate: [AuthenticatedGuard] + canActivate: [CollectionPageAdministratorGuard] }, { path: 'delete', @@ -78,7 +79,8 @@ import { CollectionBreadcrumbResolver, DSOBreadcrumbsService, LinkService, - CreateCollectionPageGuard + CreateCollectionPageGuard, + CollectionPageAdministratorGuard ] }) export class CollectionPageRoutingModule { diff --git a/src/app/+community-page/community-page-administrator.guard.ts b/src/app/+community-page/community-page-administrator.guard.ts new file mode 100644 index 0000000000..c5e58ddb1a --- /dev/null +++ b/src/app/+community-page/community-page-administrator.guard.ts @@ -0,0 +1,30 @@ +import { Injectable } from '@angular/core'; +import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from '@angular/router'; +import { Community } from '../core/shared/community.model'; +import { CommunityPageResolver } from './community-page.resolver'; +import { AuthorizationDataService } from '../core/data/feature-authorization/authorization-data.service'; +import { of as observableOf } from 'rxjs'; +import { DsoPageFeatureGuard } from '../core/data/feature-authorization/feature-authorization-guard/dso-page-feature.guard'; +import { Observable } from 'rxjs/internal/Observable'; +import { FeatureID } from '../core/data/feature-authorization/feature-id'; + +@Injectable({ + providedIn: 'root' +}) +/** + * Guard for preventing unauthorized access to certain {@link Community} pages requiring administrator rights + */ +export class CommunityPageAdministratorGuard extends DsoPageFeatureGuard { + constructor(protected resolver: CommunityPageResolver, + protected authorizationService: AuthorizationDataService, + protected router: Router) { + super(resolver, authorizationService, router); + } + + /** + * Check administrator authorization rights + */ + getFeatureID(next: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { + return observableOf(FeatureID.AdministratorOf); + } +} diff --git a/src/app/+community-page/community-page-routing.module.ts b/src/app/+community-page/community-page-routing.module.ts index 08520ab8d4..f266bd7df9 100644 --- a/src/app/+community-page/community-page-routing.module.ts +++ b/src/app/+community-page/community-page-routing.module.ts @@ -11,6 +11,7 @@ import { CommunityBreadcrumbResolver } from '../core/breadcrumbs/community-bread import { DSOBreadcrumbsService } from '../core/breadcrumbs/dso-breadcrumbs.service'; import { LinkService } from '../core/cache/builders/link.service'; import { COMMUNITY_EDIT_PATH, COMMUNITY_CREATE_PATH } from './community-page-routing-paths'; +import { CommunityPageAdministratorGuard } from './community-page-administrator.guard'; @NgModule({ imports: [ @@ -31,7 +32,7 @@ import { COMMUNITY_EDIT_PATH, COMMUNITY_CREATE_PATH } from './community-page-rou { path: COMMUNITY_EDIT_PATH, loadChildren: './edit-community-page/edit-community-page.module#EditCommunityPageModule', - canActivate: [AuthenticatedGuard] + canActivate: [CommunityPageAdministratorGuard] }, { path: 'delete', @@ -53,7 +54,8 @@ import { COMMUNITY_EDIT_PATH, COMMUNITY_CREATE_PATH } from './community-page-rou CommunityBreadcrumbResolver, DSOBreadcrumbsService, LinkService, - CreateCommunityPageGuard + CreateCommunityPageGuard, + CommunityPageAdministratorGuard ] }) export class CommunityPageRoutingModule { diff --git a/src/app/+item-page/edit-item-page/abstract-item-update/abstract-item-update.component.ts b/src/app/+item-page/edit-item-page/abstract-item-update/abstract-item-update.component.ts index 6f97ec3057..bde2b5a1b0 100644 --- a/src/app/+item-page/edit-item-page/abstract-item-update/abstract-item-update.component.ts +++ b/src/app/+item-page/edit-item-page/abstract-item-update/abstract-item-update.component.ts @@ -123,7 +123,7 @@ export class AbstractItemUpdateComponent extends AbstractTrackableComponent impl /** * Check if the current page is entirely valid */ - protected isValid() { + public isValid() { return this.objectUpdatesService.isValidPage(this.url); } diff --git a/src/app/+item-page/edit-item-page/edit-item-page.routing.module.ts b/src/app/+item-page/edit-item-page/edit-item-page.routing.module.ts index 7006c5dc89..3acbd77c40 100644 --- a/src/app/+item-page/edit-item-page/edit-item-page.routing.module.ts +++ b/src/app/+item-page/edit-item-page/edit-item-page.routing.module.ts @@ -29,6 +29,8 @@ import { ITEM_EDIT_REINSTATE_PATH, ITEM_EDIT_WITHDRAW_PATH } from './edit-item-page.routing-paths'; +import { ItemPageReinstateGuard } from './item-page-reinstate.guard'; +import { ItemPageWithdrawGuard } from './item-page-withdraw.guard'; /** * Routing module that handles the routing for the Edit Item page administrator functionality @@ -98,10 +100,12 @@ import { { path: ITEM_EDIT_WITHDRAW_PATH, component: ItemWithdrawComponent, + canActivate: [ItemPageWithdrawGuard] }, { path: ITEM_EDIT_REINSTATE_PATH, component: ItemReinstateComponent, + canActivate: [ItemPageReinstateGuard] }, { path: ITEM_EDIT_PRIVATE_PATH, @@ -154,7 +158,9 @@ import { I18nBreadcrumbResolver, I18nBreadcrumbsService, ResourcePolicyResolver, - ResourcePolicyTargetResolver + ResourcePolicyTargetResolver, + ItemPageReinstateGuard, + ItemPageWithdrawGuard ] }) export class EditItemPageRoutingModule { diff --git a/src/app/+item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.html b/src/app/+item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.html index 80c78941c8..8f0776e4d3 100644 --- a/src/app/+item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.html +++ b/src/app/+item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.html @@ -5,14 +5,15 @@
- - -
-
- +
+
diff --git a/src/app/+item-page/edit-item-page/item-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/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..66dbcbb10d 100644 --- a/src/app/+item-page/item-page-routing.module.ts +++ b/src/app/+item-page/item-page-routing.module.ts @@ -10,6 +10,7 @@ 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'; @NgModule({ imports: [ @@ -34,7 +35,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, @@ -49,7 +50,8 @@ import { UPLOAD_BITSTREAM_PATH, ITEM_EDIT_PATH } from './item-page-routing-paths ItemPageResolver, ItemBreadcrumbResolver, DSOBreadcrumbsService, - LinkService + LinkService, + ItemPageAdministratorGuard ] }) diff --git a/src/app/app-routing-paths.ts b/src/app/app-routing-paths.ts index 5d529edeb7..4e64a4a552 100644 --- a/src/app/app-routing-paths.ts +++ b/src/app/app-routing-paths.ts @@ -60,3 +60,8 @@ export const UNAUTHORIZED_PATH = 'unauthorized'; export function getUnauthorizedRoute() { return `/${UNAUTHORIZED_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..50e2f6b532 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,65 @@ 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: '**', 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/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 5aa462d5e0..63fd8119b4 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'; @@ -15,8 +16,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'; @@ -80,9 +81,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'; @@ -160,8 +158,19 @@ 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'; /** * When not in production, endpoint responses can be mocked for testing purposes @@ -195,7 +204,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, @@ -237,8 +246,6 @@ const PROVIDERS = [ SubmissionResponseParsingService, SubmissionJsonPatchOperationsService, JsonPatchOperationsBuilder, - AuthorityService, - IntegrationResponseParsingService, UploaderService, UUIDService, NotificationsService, @@ -286,9 +293,14 @@ const PROVIDERS = [ FeatureDataService, AuthorizationDataService, SiteAdministratorGuard, + SiteRegisterGuard, MetadataSchemaDataService, MetadataFieldDataService, TokenResponseParsingService, + ReloadGuard, + EndUserAgreementCurrentUserGuard, + EndUserAgreementCookieGuard, + EndUserAgreementService, // register AuthInterceptor as HttpInterceptor { provide: HTTP_INTERCEPTORS, @@ -303,7 +315,10 @@ const PROVIDERS = [ }, NotificationsService, FilteredDiscoveryPageResponseParsingService, - { provide: NativeWindowService, useFactory: NativeWindowFactory } + { provide: NativeWindowService, useFactory: NativeWindowFactory }, + VocabularyService, + VocabularyEntriesResponseParsingService, + VocabularyTreeviewService ]; /** @@ -334,7 +349,6 @@ export const models = SubmissionSectionModel, SubmissionUploadsModel, AuthStatus, - AuthorityValue, BrowseEntry, BrowseDefinition, ClaimedTask, @@ -354,6 +368,9 @@ export const models = Feature, Authorization, Registration, + Vocabulary, + VocabularyEntry, + VocabularyEntryDetail, ConfigurationProperty ]; 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/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..27d6618e44 100644 --- a/src/app/core/data/feature-authorization/feature-id.ts +++ b/src/app/core/data/feature-authorization/feature-id.ts @@ -3,5 +3,8 @@ */ export enum FeatureID { LoginOnBehalfOf = 'loginOnBehalfOf', - AdministratorOf = 'administratorOf' + AdministratorOf = 'administratorOf', + WithdrawItem = 'withdrawItem', + ReinstateItem = 'reinstateItem', + EPersonRegistration = 'epersonRegistration', } 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/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/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/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/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.resource-type.ts b/src/app/core/integration/models/authority.resource-type.ts deleted file mode 100644 index ec87ddc85f..0000000000 --- a/src/app/core/integration/models/authority.resource-type.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { ResourceType } from '../../shared/resource-type'; - -/** - * The resource type for AuthorityValue - * - * Needs to be in a separate file to prevent circular - * dependencies in webpack. - */ - -export const AUTHORITY_VALUE = new ResourceType('authority'); 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/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..9d4c5df9a2 --- /dev/null +++ b/src/app/core/services/browser-hard-redirect.service.spec.ts @@ -0,0 +1,41 @@ +import {TestBed} from '@angular/core/testing'; +import {BrowserHardRedirectService} from './browser-hard-redirect.service'; + +describe('BrowserHardRedirectService', () => { + + const mockLocation = { + href: undefined, + pathname: '/pathname', + search: '/search', + } 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); + }); + }); +}); 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..0d14b6b834 --- /dev/null +++ b/src/app/core/services/browser-hard-redirect.service.ts @@ -0,0 +1,35 @@ +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 implements HardRedirectService { + + constructor( + @Inject(LocationToken) protected location: Location, + ) { + } + + /** + * 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; + } +} 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..09757a1250 --- /dev/null +++ b/src/app/core/services/hard-redirect.service.ts @@ -0,0 +1,22 @@ +import { Injectable } from '@angular/core'; + +/** + * 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(); +} 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..2d09c21eb9 --- /dev/null +++ b/src/app/core/services/server-hard-redirect.service.spec.ts @@ -0,0 +1,43 @@ +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); + + 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 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); + }); + }); +}); 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..79755d2dc9 --- /dev/null +++ b/src/app/core/services/server-hard-redirect.service.ts @@ -0,0 +1,62 @@ +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 implements HardRedirectService { + + constructor( + @Inject(REQUEST) protected req: Request, + @Inject(RESPONSE) protected res: Response, + ) { + } + + /** + * 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; + } +} 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.ts b/src/app/core/shared/operators.ts index b8120d4765..17823c0447 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 { getEndUserAgreementPath } from '../../info/info-routing.module'; /** * This file contains custom RxJS operators that can be used in multiple places @@ -192,6 +195,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 +267,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/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.module.ts b/src/app/info/info-routing.module.ts new file mode 100644 index 0000000000..86ff7fb334 --- /dev/null +++ b/src/app/info/info-routing.module.ts @@ -0,0 +1,47 @@ +import { NgModule } from '@angular/core'; +import { RouterModule } from '@angular/router'; +import { EndUserAgreementComponent } from './end-user-agreement/end-user-agreement.component'; +import { getInfoModulePath } from '../app-routing-paths'; +import { I18nBreadcrumbResolver } from '../core/breadcrumbs/i18n-breadcrumb.resolver'; +import { PrivacyComponent } from './privacy/privacy.component'; + +const END_USER_AGREEMENT_PATH = 'end-user-agreement'; +const PRIVACY_PATH = 'privacy'; + +export function getEndUserAgreementPath() { + return getSubPath(END_USER_AGREEMENT_PATH); +} + +export function getPrivacyPath() { + return getSubPath(PRIVACY_PATH); +} + +function getSubPath(path: string) { + return `${getInfoModulePath()}/${path}`; +} + +@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/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 @@ -
- + -