diff --git a/angular.json b/angular.json new file mode 100644 index 0000000000..336738fd6e --- /dev/null +++ b/angular.json @@ -0,0 +1,13 @@ +{ + "$schema": "./node_modules/@angular/cli/lib/config/schema.json", + "version": 1, + "cli": { + "defaultCollection": "@ngrx/schematics" + }, + "projects": { + "core": { + "root": "", + "projectType": "application" + } + } +} \ No newline at end of file diff --git a/config/environment.default.js b/config/environment.default.js index a6ef738f41..527e12936e 100644 --- a/config/environment.default.js +++ b/config/environment.default.js @@ -18,9 +18,17 @@ module.exports = { // Caching settings cache: { // NOTE: how long should objects be cached for by default - msToLive: 15 * 60 * 1000, // 15 minutes + msToLive: { + default: 15 * 60 * 1000, // 15 minutes + exportToZip: 5 * 1000 // 5 seconds + }, // msToLive: 1000, // 15 minutes - control: 'max-age=60' // revalidate browser + control: 'max-age=60', // revalidate browser + autoSync: { + defaultTime: 0, + maxBufferSize: 100, + timePerMethod: {'PATCH': 3} //time in seconds + } }, // Form settings form: { diff --git a/package.json b/package.json index 874f4a6019..8e2d86e9ed 100644 --- a/package.json +++ b/package.json @@ -23,9 +23,9 @@ "prebuild": "yarn run clean:dist", "prebuild:aot": "yarn run prebuild", "prebuild:prod": "yarn run prebuild", - "build": "webpack --progress", - "build:aot": "webpack --env.aot --env.server && webpack --env.aot --env.client", - "build:prod": "webpack --env.aot --env.server -p && webpack --env.aot --env.client -p", + "build": "webpack --progress --mode development", + "build:aot": "webpack --env.aot --env.server --mode development && webpack --env.aot --env.client --mode development", + "build:prod": "webpack --env.aot --env.server --env.production && webpack --env.aot --env.client --env.production", "postbuild:prod": "yarn run rollup", "rollup": "rollup -c rollup.config.js", "prestart": "yarn run build:prod", @@ -40,21 +40,15 @@ "server": "node dist/server.js", "server:watch": "nodemon dist/server.js", "server:watch:debug": "nodemon --debug dist/server.js", - "webpack:watch": "webpack -w", - "webpack:watch:aot": "webpack -w --env.aot --env.server && webpack -w --env.aot --env.client", - "webpack:watch:prod": "webpack -w --env.aot --env.server -p && webpack -w --env.aot --env.client -p", + "webpack:watch": "webpack -w --mode development", "watch": "yarn run build && npm-run-all -p webpack:watch server:watch", - "watch:aot": "yarn run build:aot && npm-run-all -p webpack:watch:aot server:watch", - "watch:prod": "yarn run build:prod && npm-run-all -p webpack:watch:prod server:watch", "watch:debug": "yarn run build && npm-run-all -p webpack:watch server:watch:debug", - "watch:debug:aot": "yarn run build:aot && npm-run-all -p webpack:watch:aot server:watch:debug", - "watch:debug:prod": "yarn run build:prod && npm-run-all -p webpack:watch:prod server:watch:debug", "predebug": "yarn run build", "predebug:server": "yarn run build", "debug": "node --debug-brk dist/server.js", "debug:server": "node-nightly --inspect --debug-brk dist/server.js", - "debug:build": "node-nightly --inspect --debug-brk node_modules/webpack/bin/webpack.js", - "debug:build:prod": "node-nightly --inspect --debug-brk node_modules/webpack/bin/webpack.js --env.aot --env.client --env.server -p", + "debug:build": "node-nightly --inspect --debug-brk node_modules/webpack/bin/webpack.js --mode development", + "debug:build:prod": "node-nightly --inspect --debug-brk node_modules/webpack/bin/webpack.js --env.aot --env.client --env.server --mode production", "ci": "yarn run lint && yarn run build:aot && yarn run test:headless && npm-run-all -p -r server e2e", "protractor": "node node_modules/protractor/bin/protractor", "pree2e": "yarn run webdriver:update", @@ -69,31 +63,31 @@ "coverage": "http-server -c-1 -o -p 9875 ./coverage" }, "dependencies": { - "@angular/animations": "^5.2.5", - "@angular/common": "^5.2.5", - "@angular/core": "^5.2.5", - "@angular/forms": "^5.2.5", - "@angular/http": "^5.2.5", - "@angular/platform-browser": "^5.2.5", - "@angular/platform-browser-dynamic": "^5.2.5", - "@angular/platform-server": "^5.2.5", - "@angular/router": "^5.2.5", + "@angular/animations": "^6.1.4", + "@angular/cli": "^6.1.5", + "@angular/common": "^6.1.4", + "@angular/core": "^6.1.4", + "@angular/forms": "^6.1.4", + "@angular/http": "^6.1.4", + "@angular/platform-browser": "^6.1.4", + "@angular/platform-browser-dynamic": "^6.1.4", + "@angular/platform-server": "^6.1.4", + "@angular/router": "^6.1.4", "@angularclass/bootloader": "1.0.1", - "@ng-bootstrap/ng-bootstrap": "^1.0.0", - "@ng-dynamic-forms/core": "5.4.7", - "@ng-dynamic-forms/ui-ng-bootstrap": "5.4.7", - "@ngrx/effects": "^5.1.0", - "@ngrx/router-store": "^5.0.1", - "@ngrx/store": "^5.1.0", - "@nguniversal/express-engine": "5.0.0", - "@ngx-translate/core": "9.1.1", - "@ngx-translate/http-loader": "2.0.1", - "@nicky-lenaers/ngx-scroll-to": "^0.6.0", - "angular-idle-preload": "2.0.4", + "@ng-bootstrap/ng-bootstrap": "^2.0.0", + "@ng-dynamic-forms/core": "6.0.9", + "@ng-dynamic-forms/ui-ng-bootstrap": "6.0.9", + "@ngrx/effects": "^6.1.0", + "@ngrx/router-store": "^6.1.0", + "@ngrx/store": "^6.1.0", + "@nguniversal/express-engine": "6.1.0", + "@ngx-translate/core": "10.0.2", + "@ngx-translate/http-loader": "3.0.1", + "@nicky-lenaers/ngx-scroll-to": "^1.0.0", + "angular-idle-preload": "3.0.0", "angular-sortablejs": "^2.5.0", - "angular2-moment": "^1.9.0", - "angular2-text-mask": "8.0.4", - "angulartics2": "^5.2.0", + "angular2-text-mask": "9.0.0", + "angulartics2": "^6.2.0", "body-parser": "1.18.2", "bootstrap": "4.1.3", "cerialize": "0.1.18", @@ -102,7 +96,9 @@ "core-js": "^2.5.7", "express": "4.16.2", "express-session": "1.15.6", + "fast-json-patch": "^2.0.7", "font-awesome": "4.7.0", + "fork-ts-checker-webpack-plugin": "^0.4.10", "http-server": "0.11.1", "https": "1.0.0", "js-cookie": "2.2.0", @@ -112,110 +108,117 @@ "methods": "1.1.2", "moment": "^2.22.1", "morgan": "1.9.0", + "ng-mocks": "^6.2.1", "ng2-file-upload": "1.2.1", "ng2-nouislider": "^1.7.11", - "ngx-infinite-scroll": "0.8.2", + "ngx-bootstrap": "^3.0.1", + "ngx-infinite-scroll": "6.0.1", + "ngx-moment": "^3.1.0", "ngx-pagination": "3.0.3", "nouislider": "^11.0.0", "pem": "1.12.3", "reflect-metadata": "0.1.12", - "rxjs": "5.5.6", + "rxjs": "6.2.2", "sortablejs": "1.7.0", "text-mask-core": "5.0.1", + "ts-loader": "^5.2.1", "ts-md5": "^1.2.4", "uuid": "^3.2.1", "webfontloader": "1.6.28", - "zone.js": "0.8.20" + "webpack-cli": "^3.1.0", + "zone.js": "^0.8.26" }, "devDependencies": { - "@angular/compiler": "^5.2.5", - "@angular/compiler-cli": "^5.2.5", - "@ngrx/store-devtools": "^5.1.0", - "@ngtools/webpack": "^1.10.0", + "@angular/compiler": "^6.1.4", + "@angular/compiler-cli": "^6.1.4", + "@ngrx/entity": "^6.1.0", + "@ngrx/schematics": "^6.1.0", + "@ngrx/store-devtools": "^6.1.0", + "@ngtools/webpack": "^6.1.5", + "@schematics/angular": "^0.7.5", "@types/acorn": "^4.0.3", "@types/cookie-parser": "1.4.1", "@types/deep-freeze": "0.1.1", "@types/express": "^4.11.1", - "@types/express-serve-static-core": "4.11.1", + "@types/express-serve-static-core": "4.16.0", "@types/hammerjs": "2.0.35", "@types/jasmine": "^2.8.6", "@types/js-cookie": "2.1.0", + "@types/lodash": "^4.14.110", "@types/memory-cache": "0.2.0", "@types/mime": "2.0.0", - "@types/node": "^9.4.6", - "@types/serve-static": "1.13.1", + "@types/node": "^10.9.4", + "@types/serve-static": "1.13.2", "@types/uuid": "^3.4.3", "@types/webfontloader": "1.6.29", "ajv": "^6.1.1", "ajv-keywords": "^3.1.0", "angular2-template-loader": "0.6.2", - "autoprefixer": "^8.0.0", - "awesome-typescript-loader": "3.4.1", + "autoprefixer": "^9.1.3", "caniuse-lite": "^1.0.30000697", - "codelyzer": "^4.1.0", + "codelyzer": "^4.4.4", "compression-webpack-plugin": "^1.1.6", "copy-webpack-plugin": "^4.4.1", "coveralls": "3.0.0", - "css-loader": "0.28.9", + "css-loader": "1.0.0", "deep-freeze": "0.0.1", "exports-loader": "^0.7.0", - "html-webpack-plugin": "2.30.1", - "imports-loader": "0.7.1", - "istanbul-instrumenter-loader": "3.0.0", - "jasmine-core": "^2.99.1", - "jasmine-marbles": "0.2.0", + "html-webpack-plugin": "^4.0.0-alpha", + "imports-loader": "0.8.0", + "istanbul-instrumenter-loader": "3.0.1", + "jasmine-core": "^3.2.1", + "jasmine-marbles": "0.3.1", "jasmine-spec-reporter": "4.2.1", - "json-loader": "0.5.7", - "karma": "2.0.0", + "karma": "3.0.0", "karma-chrome-launcher": "2.2.0", "karma-cli": "1.0.1", - "karma-coverage": "1.1.1", + "karma-coverage": "1.1.2", "karma-istanbul-preprocessor": "0.0.2", - "karma-jasmine": "1.1.1", + "karma-jasmine": "1.1.2", "karma-mocha-reporter": "2.2.5", "karma-phantomjs-launcher": "1.0.4", "karma-remap-coverage": "^0.1.5", "karma-remap-istanbul": "0.6.0", "karma-sourcemap-loader": "0.3.7", "karma-webdriver-launcher": "1.0.5", - "karma-webpack": "2.0.9", - "ngrx-store-freeze": "^0.2.1", + "karma-webpack": "3.0.0", + "ngrx-store-freeze": "^0.2.4", "node-sass": "^4.7.2", "nodemon": "^1.15.0", - "npm-run-all": "4.1.2", - "postcss": "^6.0.18", - "postcss-apply": "0.8.0", - "postcss-cli": "^5.0.0", + "npm-run-all": "4.1.3", + "postcss": "^7.0.2", + "postcss-apply": "0.11.0", + "postcss-cli": "^6.0.0", "postcss-cssnext": "3.1.0", - "postcss-loader": "^2.1.0", + "postcss-loader": "^3.0.0", "postcss-responsive-type": "1.0.0", "postcss-smart-import": "0.7.6", "protractor": "^5.3.0", "protractor-istanbul-plugin": "2.0.0", "raw-loader": "0.5.1", - "resolve-url-loader": "2.2.1", + "resolve-url-loader": "^2.3.0", "rimraf": "2.6.2", - "rollup": "^0.56.0", - "rollup-plugin-commonjs": "^8.3.0", - "rollup-plugin-node-globals": "1.1.0", + "rollup": "^0.65.0", + "rollup-plugin-commonjs": "^9.1.6", + "rollup-plugin-node-globals": "1.2.1", "rollup-plugin-node-resolve": "^3.0.3", - "rollup-plugin-uglify": "3.0.0", - "sass-loader": "6.0.6", - "script-ext-html-webpack-plugin": "1.8.8", - "source-map": "0.6.1", - "source-map-loader": "0.2.3", - "string-replace-loader": "1.3.0", + "rollup-plugin-terser": "^2.0.2", + "sass-loader": "7.1.0", + "script-ext-html-webpack-plugin": "2.0.1", + "source-map": "0.7.3", + "source-map-loader": "0.2.4", + "string-replace-loader": "2.1.1", "to-string-loader": "1.1.5", "ts-helpers": "1.1.2", "ts-node": "4.1.0", - "tslint": "5.9.1", + "tslint": "5.11.0", "typedoc": "^0.9.0", - "typescript": "2.6.2", - "webpack": "^3.11.0", - "webpack-bundle-analyzer": "^2.10.0", - "webpack-dev-middleware": "^2.0.5", - "webpack-dev-server": "2.11.1", - "webpack-merge": "4.1.1", - "webpack-node-externals": "1.6.0" + "typescript": "^2.9.1", + "webpack": "^4.17.1", + "webpack-bundle-analyzer": "^2.13.1", + "webpack-dev-middleware": "3.2.0", + "webpack-dev-server": "^3.1.5", + "webpack-merge": "4.1.4", + "webpack-node-externals": "1.7.2" } } diff --git a/resources/i18n/en.json b/resources/i18n/en.json index a73bf14dac..7223cb739b 100644 --- a/resources/i18n/en.json +++ b/resources/i18n/en.json @@ -229,7 +229,8 @@ }, "results": { "head": "Search Results", - "no-results": "There were no results for this search" + "no-results": "Your search returned no results. Having trouble finding what you're looking for? Try putting", + "no-results-link": "quotes around it" }, "sidebar": { "close": "Back to results", diff --git a/rollup.config.js b/rollup.config.js index 8c8700d387..33e3ec3346 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -1,6 +1,6 @@ import nodeResolve from 'rollup-plugin-node-resolve' import commonjs from 'rollup-plugin-commonjs'; -import uglify from 'rollup-plugin-uglify' +import terser from 'rollup-plugin-terser' export default { input: 'dist/client.js', @@ -8,7 +8,6 @@ export default { file: 'dist/client.js', format: 'iife', }, - sourcemap: false, plugins: [ nodeResolve({ jsnext: true, @@ -17,6 +16,6 @@ export default { commonjs({ include: 'node_modules/rxjs/**' }), - uglify() + terser.terser() ] } diff --git a/spec-bundle.js b/spec-bundle.js index b9df9bec5e..aa46c35d14 100644 --- a/spec-bundle.js +++ b/spec-bundle.js @@ -28,7 +28,7 @@ require('zone.js/dist/async-test'); require('zone.js/dist/fake-async-test'); // RxJS -require('rxjs/Rx'); +require('rxjs'); var testing = require('@angular/core/testing'); var browser = require('@angular/platform-browser-dynamic/testing'); diff --git a/src/app/+admin/admin-registries/bitstream-formats/bitstream-formats.component.spec.ts b/src/app/+admin/admin-registries/bitstream-formats/bitstream-formats.component.spec.ts index f720c336e5..b6e3b7e989 100644 --- a/src/app/+admin/admin-registries/bitstream-formats/bitstream-formats.component.spec.ts +++ b/src/app/+admin/admin-registries/bitstream-formats/bitstream-formats.component.spec.ts @@ -1,14 +1,13 @@ import { BitstreamFormatsComponent } from './bitstream-formats.component'; import { async, ComponentFixture, TestBed } from '@angular/core/testing'; import { RegistryService } from '../../../core/registry/registry.service'; -import { Observable } from 'rxjs/Observable'; +import { of as observableOf } from 'rxjs'; import { RemoteData } from '../../../core/data/remote-data'; import { PaginatedList } from '../../../core/data/paginated-list'; import { CommonModule } from '@angular/common'; import { RouterTestingModule } from '@angular/router/testing'; import { TranslateModule } from '@ngx-translate/core'; import { By } from '@angular/platform-browser'; -import { SharedModule } from '../../../shared/shared.module'; import { PaginationComponent } from '../../../shared/pagination/pagination.component'; import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; import { EnumKeysPipe } from '../../../shared/utils/enum-keys-pipe'; @@ -53,7 +52,7 @@ describe('BitstreamFormatsComponent', () => { extensions: null } ]; - const mockFormats = Observable.of(new RemoteData(false, false, true, undefined, new PaginatedList(null, mockFormatsList))); + const mockFormats = observableOf(new RemoteData(false, false, true, undefined, new PaginatedList(null, mockFormatsList))); const registryServiceStub = { getBitstreamFormats: () => mockFormats }; diff --git a/src/app/+admin/admin-registries/bitstream-formats/bitstream-formats.component.ts b/src/app/+admin/admin-registries/bitstream-formats/bitstream-formats.component.ts index d6c84ac858..6ba4e8146b 100644 --- a/src/app/+admin/admin-registries/bitstream-formats/bitstream-formats.component.ts +++ b/src/app/+admin/admin-registries/bitstream-formats/bitstream-formats.component.ts @@ -1,6 +1,6 @@ import { Component } from '@angular/core'; import { RegistryService } from '../../../core/registry/registry.service'; -import { Observable } from 'rxjs/Observable'; +import { Observable } from 'rxjs'; import { RemoteData } from '../../../core/data/remote-data'; import { PaginatedList } from '../../../core/data/paginated-list'; import { BitstreamFormat } from '../../../core/registry/mock-bitstream-format.model'; diff --git a/src/app/+admin/admin-registries/metadata-registry/metadata-registry.component.spec.ts b/src/app/+admin/admin-registries/metadata-registry/metadata-registry.component.spec.ts index e3b2e1f2c1..8b72afa083 100644 --- a/src/app/+admin/admin-registries/metadata-registry/metadata-registry.component.spec.ts +++ b/src/app/+admin/admin-registries/metadata-registry/metadata-registry.component.spec.ts @@ -1,6 +1,6 @@ import { MetadataRegistryComponent } from './metadata-registry.component'; import { async, ComponentFixture, TestBed } from '@angular/core/testing'; -import { Observable } from 'rxjs/Observable'; +import { of as observableOf } from 'rxjs'; import { RemoteData } from '../../../core/data/remote-data'; import { PaginatedList } from '../../../core/data/paginated-list'; import { TranslateModule } from '@ngx-translate/core'; @@ -8,7 +8,6 @@ import { By } from '@angular/platform-browser'; import { CommonModule } from '@angular/common'; import { RouterTestingModule } from '@angular/router/testing'; import { RegistryService } from '../../../core/registry/registry.service'; -import { SharedModule } from '../../../shared/shared.module'; import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; import { EnumKeysPipe } from '../../../shared/utils/enum-keys-pipe'; import { PaginationComponent } from '../../../shared/pagination/pagination.component'; @@ -33,7 +32,7 @@ describe('MetadataRegistryComponent', () => { namespace: 'http://dspace.org/mockschema' } ]; - const mockSchemas = Observable.of(new RemoteData(false, false, true, undefined, new PaginatedList(null, mockSchemasList))); + const mockSchemas = observableOf(new RemoteData(false, false, true, undefined, new PaginatedList(null, mockSchemasList))); const registryServiceStub = { getMetadataSchemas: () => mockSchemas }; @@ -68,5 +67,4 @@ describe('MetadataRegistryComponent', () => { const mockName: HTMLElement = fixture.debugElement.query(By.css('#metadata-schemas tr:nth-child(2) td:nth-child(3)')).nativeElement; expect(mockName.textContent).toBe('mock'); }); - }); diff --git a/src/app/+admin/admin-registries/metadata-registry/metadata-registry.component.ts b/src/app/+admin/admin-registries/metadata-registry/metadata-registry.component.ts index 15dc6b0d80..c2f70eaa9e 100644 --- a/src/app/+admin/admin-registries/metadata-registry/metadata-registry.component.ts +++ b/src/app/+admin/admin-registries/metadata-registry/metadata-registry.component.ts @@ -1,6 +1,6 @@ import { Component } from '@angular/core'; import { RegistryService } from '../../../core/registry/registry.service'; -import { Observable } from 'rxjs/Observable'; +import { Observable } from 'rxjs'; import { RemoteData } from '../../../core/data/remote-data'; import { PaginatedList } from '../../../core/data/paginated-list'; import { MetadataSchema } from '../../../core/metadata/metadataschema.model'; diff --git a/src/app/+admin/admin-registries/metadata-schema/metadata-schema.component.spec.ts b/src/app/+admin/admin-registries/metadata-schema/metadata-schema.component.spec.ts index 7e6064ddff..96777116f4 100644 --- a/src/app/+admin/admin-registries/metadata-schema/metadata-schema.component.spec.ts +++ b/src/app/+admin/admin-registries/metadata-schema/metadata-schema.component.spec.ts @@ -1,16 +1,14 @@ import { MetadataSchemaComponent } from './metadata-schema.component'; import { async, ComponentFixture, TestBed } from '@angular/core/testing'; -import { Observable } from 'rxjs/Observable'; +import { of as observableOf } from 'rxjs'; import { RemoteData } from '../../../core/data/remote-data'; import { PaginatedList } from '../../../core/data/paginated-list'; import { MetadataSchema } from '../../../core/metadata/metadataschema.model'; -import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; +import { TranslateModule } from '@ngx-translate/core'; import { CommonModule } from '@angular/common'; import { ActivatedRoute, Router } from '@angular/router'; import { By } from '@angular/platform-browser'; -import { MockTranslateLoader } from '../../../shared/testing/mock-translate-loader'; import { RegistryService } from '../../../core/registry/registry.service'; -import { SharedModule } from '../../../shared/shared.module'; import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; import { EnumKeysPipe } from '../../../shared/utils/enum-keys-pipe'; import { PaginationComponent } from '../../../shared/pagination/pagination.component'; @@ -68,15 +66,15 @@ describe('MetadataSchemaComponent', () => { schema: mockSchemasList[1] } ]; - const mockSchemas = Observable.of(new RemoteData(false, false, true, undefined, new PaginatedList(null, mockSchemasList))); + const mockSchemas = observableOf(new RemoteData(false, false, true, undefined, new PaginatedList(null, mockSchemasList))); const registryServiceStub = { getMetadataSchemas: () => mockSchemas, - getMetadataFieldsBySchema: (schema: MetadataSchema) => Observable.of(new RemoteData(false, false, true, undefined, new PaginatedList(null, mockFieldsList.filter((value) => value.schema === schema)))), - getMetadataSchemaByName: (schemaName: string) => Observable.of(new RemoteData(false, false, true, undefined, mockSchemasList.filter((value) => value.prefix === schemaName)[0])) + getMetadataFieldsBySchema: (schema: MetadataSchema) => observableOf(new RemoteData(false, false, true, undefined, new PaginatedList(null, mockFieldsList.filter((value) => value.schema === schema)))), + getMetadataSchemaByName: (schemaName: string) => observableOf(new RemoteData(false, false, true, undefined, mockSchemasList.filter((value) => value.prefix === schemaName)[0])) }; const schemaNameParam = 'mock'; const activatedRouteStub = Object.assign(new ActivatedRouteStub(), { - params: Observable.of({ + params: observableOf({ schemaName: schemaNameParam }) }); diff --git a/src/app/+admin/admin-registries/metadata-schema/metadata-schema.component.ts b/src/app/+admin/admin-registries/metadata-schema/metadata-schema.component.ts index 2f0bfdeddb..b2cc5129ce 100644 --- a/src/app/+admin/admin-registries/metadata-schema/metadata-schema.component.ts +++ b/src/app/+admin/admin-registries/metadata-schema/metadata-schema.component.ts @@ -1,7 +1,7 @@ import { Component, OnInit } from '@angular/core'; import { RegistryService } from '../../../core/registry/registry.service'; import { ActivatedRoute } from '@angular/router'; -import { Observable } from 'rxjs/Observable'; +import { Observable } from 'rxjs'; import { RemoteData } from '../../../core/data/remote-data'; import { PaginatedList } from '../../../core/data/paginated-list'; import { MetadataField } from '../../../core/metadata/metadatafield.model'; diff --git a/src/app/+browse-by/+browse-by-author-page/browse-by-author-page.component.ts b/src/app/+browse-by/+browse-by-author-page/browse-by-author-page.component.ts index 1553889741..813ee8a32f 100644 --- a/src/app/+browse-by/+browse-by-author-page/browse-by-author-page.component.ts +++ b/src/app/+browse-by/+browse-by-author-page/browse-by-author-page.component.ts @@ -1,14 +1,13 @@ + +import {combineLatest as observableCombineLatest, Observable, Subscription } from 'rxjs'; import { Component, OnInit } from '@angular/core'; import { RemoteData } from '../../core/data/remote-data'; import { PaginatedList } from '../../core/data/paginated-list'; import { ItemDataService } from '../../core/data/item-data.service'; -import { Observable } from 'rxjs/Observable'; import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model'; import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model'; -import { Subscription } from 'rxjs/Subscription'; import { ActivatedRoute } from '@angular/router'; import { hasValue, isNotEmpty } from '../../shared/empty.util'; -import { Metadatum } from '../../core/shared/metadatum.model'; import { BrowseService } from '../../core/browse/browse.service'; import { BrowseEntry } from '../../core/shared/browse-entry.model'; import { Item } from '../../core/shared/item.model'; @@ -47,7 +46,7 @@ export class BrowseByAuthorPageComponent implements OnInit { sort: this.sortConfig }); this.subs.push( - Observable.combineLatest( + observableCombineLatest( this.route.params, this.route.queryParams, (params, queryParams, ) => { diff --git a/src/app/+browse-by/+browse-by-title-page/browse-by-title-page.component.ts b/src/app/+browse-by/+browse-by-title-page/browse-by-title-page.component.ts index 1759264e2a..e9127dbbab 100644 --- a/src/app/+browse-by/+browse-by-title-page/browse-by-title-page.component.ts +++ b/src/app/+browse-by/+browse-by-title-page/browse-by-title-page.component.ts @@ -1,13 +1,13 @@ + +import {combineLatest as observableCombineLatest, Observable , Subscription } from 'rxjs'; import { Component, OnInit } from '@angular/core'; import { RemoteData } from '../../core/data/remote-data'; import { DSpaceObject } from '../../core/shared/dspace-object.model'; import { PaginatedList } from '../../core/data/paginated-list'; import { ItemDataService } from '../../core/data/item-data.service'; -import { Observable } from 'rxjs/Observable'; import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model'; import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model'; import { Item } from '../../core/shared/item.model'; -import { Subscription } from 'rxjs/Subscription'; import { ActivatedRoute, PRIMARY_OUTLET, UrlSegmentGroup } from '@angular/router'; import { hasValue } from '../../shared/empty.util'; import { Collection } from '../../core/shared/collection.model'; @@ -45,7 +45,7 @@ export class BrowseByTitlePageComponent implements OnInit { sort: this.sortConfig }); this.subs.push( - Observable.combineLatest( + observableCombineLatest( this.route.params, this.route.queryParams, (params, queryParams, ) => { diff --git a/src/app/+collection-page/collection-page.component.ts b/src/app/+collection-page/collection-page.component.ts index 89567c4a54..b76c0a7520 100644 --- a/src/app/+collection-page/collection-page.component.ts +++ b/src/app/+collection-page/collection-page.component.ts @@ -1,8 +1,6 @@ import { ChangeDetectionStrategy, Component, OnDestroy, OnInit } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; -import { Observable } from 'rxjs/Observable'; - -import { Subscription } from 'rxjs/Subscription'; +import { Observable, Subscription } from 'rxjs'; import { SortDirection, SortOptions } from '../core/cache/models/sort-options.model'; import { CollectionDataService } from '../core/data/collection-data.service'; import { PaginatedList } from '../core/data/paginated-list'; @@ -17,7 +15,7 @@ import { Item } from '../core/shared/item.model'; import { fadeIn, fadeInOut } from '../shared/animations/fade'; import { hasValue, isNotEmpty } from '../shared/empty.util'; import { PaginationComponentOptions } from '../shared/pagination/pagination-component-options.model'; -import { filter, flatMap, map } from 'rxjs/operators'; +import { filter, flatMap, map, tap } from 'rxjs/operators'; import { SearchService } from '../+search-page/search-service/search.service'; import { PaginatedSearchOptions } from '../+search-page/paginated-search-options.model'; import { toDSpaceObjectListRD } from '../core/shared/operators'; @@ -56,7 +54,9 @@ export class CollectionPageComponent implements OnInit, OnDestroy { } ngOnInit(): void { - this.collectionRD$ = this.route.data.map((data) => data.collection); + this.collectionRD$ = this.route.data.pipe( + map((data) => data.collection) + ); this.logoRD$ = this.collectionRD$.pipe( map((rd: RemoteData) => rd.payload), filter((collection: Collection) => hasValue(collection)), diff --git a/src/app/+collection-page/collection-page.resolver.ts b/src/app/+collection-page/collection-page.resolver.ts index c049901bf2..d4835e2e14 100644 --- a/src/app/+collection-page/collection-page.resolver.ts +++ b/src/app/+collection-page/collection-page.resolver.ts @@ -1,7 +1,7 @@ import { Injectable } from '@angular/core'; import { ActivatedRouteSnapshot, Resolve, RouterStateSnapshot } from '@angular/router'; import { Collection } from '../core/shared/collection.model'; -import { Observable } from 'rxjs/Observable'; +import { Observable } from 'rxjs'; import { CollectionDataService } from '../core/data/collection-data.service'; import { RemoteData } from '../core/data/remote-data'; import { getSucceededRemoteData } from '../core/shared/operators'; diff --git a/src/app/+community-page/community-page.component.html b/src/app/+community-page/community-page.component.html index 637e37af0c..1bf322a688 100644 --- a/src/app/+community-page/community-page.component.html +++ b/src/app/+community-page/community-page.component.html @@ -24,9 +24,11 @@ [content]="communityPayload.copyrightText" [hasInnerHtml]="true"> - + - + diff --git a/src/app/+community-page/community-page.component.ts b/src/app/+community-page/community-page.component.ts index 5fea9b01c9..ce260aefc0 100644 --- a/src/app/+community-page/community-page.component.ts +++ b/src/app/+community-page/community-page.component.ts @@ -1,7 +1,8 @@ +import { mergeMap, filter, map, first, tap } from 'rxjs/operators'; import { ChangeDetectionStrategy, Component, OnDestroy, OnInit } from '@angular/core'; -import { ActivatedRoute, Params } from '@angular/router'; +import { ActivatedRoute } from '@angular/router'; -import { Subscription } from 'rxjs/Subscription'; +import { Subscription, Observable } from 'rxjs'; import { CommunityDataService } from '../core/data/community-data.service'; import { RemoteData } from '../core/data/remote-data'; import { Bitstream } from '../core/shared/bitstream.model'; @@ -12,7 +13,6 @@ import { MetadataService } from '../core/metadata/metadata.service'; import { fadeInOut } from '../shared/animations/fade'; import { hasValue } from '../shared/empty.util'; -import { Observable } from 'rxjs/Observable'; @Component({ selector: 'ds-community-page', @@ -24,6 +24,8 @@ import { Observable } from 'rxjs/Observable'; export class CommunityPageComponent implements OnInit, OnDestroy { communityRD$: Observable>; logoRD$: Observable>; + + private subs: Subscription[] = []; constructor( @@ -35,15 +37,19 @@ export class CommunityPageComponent implements OnInit, OnDestroy { } ngOnInit(): void { - this.communityRD$ = this.route.data.map((data) => data.community); - this.logoRD$ = this.communityRD$ - .map((rd: RemoteData) => rd.payload) - .filter((community: Community) => hasValue(community)) - .flatMap((community: Community) => community.logo); + this.communityRD$ = this.route.data.pipe(map((data) => data.community)); + this.logoRD$ = this.communityRD$.pipe( + map((rd: RemoteData) => rd.payload), + filter((community: Community) => hasValue(community)), + mergeMap((community: Community) => community.logo)); + + } ngOnDestroy(): void { this.subs.filter((sub) => hasValue(sub)).forEach((sub) => sub.unsubscribe()); } + + } diff --git a/src/app/+community-page/community-page.resolver.ts b/src/app/+community-page/community-page.resolver.ts index 917f37a821..a32fe78bc5 100644 --- a/src/app/+community-page/community-page.resolver.ts +++ b/src/app/+community-page/community-page.resolver.ts @@ -1,6 +1,6 @@ import { Injectable } from '@angular/core'; import { ActivatedRouteSnapshot, Resolve, RouterStateSnapshot } from '@angular/router'; -import { Observable } from 'rxjs/Observable'; +import { Observable } from 'rxjs'; import { RemoteData } from '../core/data/remote-data'; import { getSucceededRemoteData } from '../core/shared/operators'; import { Community } from '../core/shared/community.model'; diff --git a/src/app/+community-page/sub-collection-list/community-page-sub-collection-list.component.ts b/src/app/+community-page/sub-collection-list/community-page-sub-collection-list.component.ts index aed2b69a30..b8a5d60002 100644 --- a/src/app/+community-page/sub-collection-list/community-page-sub-collection-list.component.ts +++ b/src/app/+community-page/sub-collection-list/community-page-sub-collection-list.component.ts @@ -1,5 +1,5 @@ import { Component, Input, OnInit } from '@angular/core'; -import { Observable } from 'rxjs/Observable'; +import { Observable } from 'rxjs'; import { RemoteData } from '../../core/data/remote-data'; import { Collection } from '../../core/shared/collection.model'; diff --git a/src/app/+home-page/top-level-community-list/top-level-community-list.component.ts b/src/app/+home-page/top-level-community-list/top-level-community-list.component.ts index 1915a8ce64..3fdb7e48a2 100644 --- a/src/app/+home-page/top-level-community-list/top-level-community-list.component.ts +++ b/src/app/+home-page/top-level-community-list/top-level-community-list.component.ts @@ -1,5 +1,5 @@ import { ChangeDetectionStrategy, Component } from '@angular/core'; -import { Observable } from 'rxjs/Observable'; +import { Observable } from 'rxjs'; import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model'; import { CommunityDataService } from '../../core/data/community-data.service'; import { PaginatedList } from '../../core/data/paginated-list'; @@ -17,6 +17,7 @@ import { PaginationComponentOptions } from '../../shared/pagination/pagination-c changeDetection: ChangeDetectionStrategy.OnPush, animations: [fadeInOut] }) + export class TopLevelCommunityListComponent { communitiesRDObs: Observable>>; config: PaginationComponentOptions; diff --git a/src/app/+item-page/field-components/collections/collections.component.spec.ts b/src/app/+item-page/field-components/collections/collections.component.spec.ts index 871018a9d8..865ce78a39 100644 --- a/src/app/+item-page/field-components/collections/collections.component.spec.ts +++ b/src/app/+item-page/field-components/collections/collections.component.spec.ts @@ -6,7 +6,7 @@ import { Collection } from '../../../core/shared/collection.model'; import { RemoteDataBuildService } from '../../../core/cache/builders/remote-data-build.service'; import { getMockRemoteDataBuildService } from '../../../shared/mocks/mock-remote-data-build.service'; import { Item } from '../../../core/shared/item.model'; -import { Observable } from 'rxjs/Observable'; +import { of as observableOf } from 'rxjs'; import { RemoteData } from '../../../core/data/remote-data'; import { TranslateModule } from '@ngx-translate/core'; @@ -22,8 +22,8 @@ const mockCollection1: Collection = Object.assign(new Collection(), { }] }); -const succeededMockItem: Item = Object.assign(new Item(), {owningCollection: Observable.of(new RemoteData(false, false, true, null, mockCollection1))}); -const failedMockItem: Item = Object.assign(new Item(), {owningCollection: Observable.of(new RemoteData(false, false, false, null, mockCollection1))}); +const succeededMockItem: Item = Object.assign(new Item(), {owningCollection: observableOf(new RemoteData(false, false, true, null, mockCollection1))}); +const failedMockItem: Item = Object.assign(new Item(), {owningCollection: observableOf(new RemoteData(false, false, false, null, mockCollection1))}); describe('CollectionsComponent', () => { beforeEach(async(() => { diff --git a/src/app/+item-page/field-components/collections/collections.component.ts b/src/app/+item-page/field-components/collections/collections.component.ts index 83bb0d464d..b33c5fd41b 100644 --- a/src/app/+item-page/field-components/collections/collections.component.ts +++ b/src/app/+item-page/field-components/collections/collections.component.ts @@ -1,5 +1,7 @@ + +import {map} from 'rxjs/operators'; import { Component, Input, OnInit } from '@angular/core'; -import { Observable } from 'rxjs/Observable'; +import { Observable } from 'rxjs'; import { Collection } from '../../../core/shared/collection.model'; import { Item } from '../../../core/shared/item.model'; @@ -35,11 +37,11 @@ export class CollectionsComponent implements OnInit { // TODO: this should use parents, but the collections // for an Item aren't returned by the REST API yet, // only the owning collection - this.collections = this.item.owner.map((rd: RemoteData) => [rd.payload]); + this.collections = this.item.owner.pipe(map((rd: RemoteData) => [rd.payload])); } hasSucceeded() { - return this.item.owner.map((rd: RemoteData) => rd.hasSucceeded); + return this.item.owner.pipe(map((rd: RemoteData) => rd.hasSucceeded)); } } diff --git a/src/app/+item-page/field-components/metadata-field-wrapper/metadata-field-wrapper.component.html b/src/app/+item-page/field-components/metadata-field-wrapper/metadata-field-wrapper.component.html index d926b5330e..084232edf4 100644 --- a/src/app/+item-page/field-components/metadata-field-wrapper/metadata-field-wrapper.component.html +++ b/src/app/+item-page/field-components/metadata-field-wrapper/metadata-field-wrapper.component.html @@ -1,6 +1,8 @@
+
{{ label }}
-
- -
+
+
+ +
diff --git a/src/app/+item-page/field-components/metadata-field-wrapper/metadata-field-wrapper.component.spec.ts b/src/app/+item-page/field-components/metadata-field-wrapper/metadata-field-wrapper.component.spec.ts new file mode 100644 index 0000000000..47e7d6c34e --- /dev/null +++ b/src/app/+item-page/field-components/metadata-field-wrapper/metadata-field-wrapper.component.spec.ts @@ -0,0 +1,54 @@ +import { ComponentFixture, TestBed, async } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { Component, DebugElement } from '@angular/core'; + +import { MetadataFieldWrapperComponent } from './metadata-field-wrapper.component'; + +@Component({ + selector: 'ds-component-with-content', + template: '\n' + + '
\n' + + '
\n' + + '
' +}) +class ContentComponent {} + +describe('MetadataFieldWrapperComponent', () => { + let component: MetadataFieldWrapperComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [MetadataFieldWrapperComponent, ContentComponent] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(MetadataFieldWrapperComponent); + component = fixture.componentInstance; + }); + + const wrapperSelector = '.simple-view-element'; + const labelSelector = '.simple-view-element-header'; + + it('should create', () => { + expect(component).toBeDefined(); + }); + + it('should not show a label when there is no content', () => { + component.label = 'test label'; + fixture.detectChanges(); + const debugLabel = fixture.debugElement.query(By.css(labelSelector)); + expect(debugLabel).toBeNull(); + }); + + it('should show a label when there is content', () => { + const parentFixture = TestBed.createComponent(ContentComponent); + parentFixture.detectChanges(); + const parentComponent = parentFixture.componentInstance; + const parentNative = parentFixture.nativeElement; + const nativeLabel = parentNative.querySelector(labelSelector); + expect(nativeLabel.textContent).toContain('test label'); + }); + +}); diff --git a/src/app/+item-page/field-components/metadata-values/metadata-values.component.ts b/src/app/+item-page/field-components/metadata-values/metadata-values.component.ts index 36499c4721..1c94b56d57 100644 --- a/src/app/+item-page/field-components/metadata-values/metadata-values.component.ts +++ b/src/app/+item-page/field-components/metadata-values/metadata-values.component.ts @@ -1,4 +1,5 @@ import { Component, Input } from '@angular/core'; +import { Metadatum } from '../../../core/shared/metadatum.model'; /** * This component renders the configured 'values' into the ds-metadata-field-wrapper component. @@ -11,7 +12,7 @@ import { Component, Input } from '@angular/core'; }) export class MetadataValuesComponent { - @Input() values: any; + @Input() values: Metadatum[]; @Input() separator: string; diff --git a/src/app/+item-page/full/field-components/file-section/full-file-section.component.ts b/src/app/+item-page/full/field-components/file-section/full-file-section.component.ts index 331e979c8f..23d9ef05d0 100644 --- a/src/app/+item-page/full/field-components/file-section/full-file-section.component.ts +++ b/src/app/+item-page/full/field-components/file-section/full-file-section.component.ts @@ -1,10 +1,10 @@ +import { combineLatest as observableCombineLatest, Observable } from 'rxjs'; import { Component, Input, OnInit } from '@angular/core'; -import { Observable } from 'rxjs/Observable'; import { Bitstream } from '../../../../core/shared/bitstream.model'; import { Item } from '../../../../core/shared/item.model'; import { FileSectionComponent } from '../../../simple/field-components/file-section/file-section.component'; -import { hasValue } from '../../../../shared/empty.util'; +import { map } from 'rxjs/operators'; /** * This component renders the file section of the item @@ -33,7 +33,7 @@ export class FullFileSectionComponent extends FileSectionComponent implements On initialize(): void { const originals = this.item.getFiles(); const licenses = this.item.getBitstreamsByBundleName('LICENSE'); - this.bitstreamsObs = Observable.combineLatest(originals, licenses, (o, l) => [...o, ...l]); + this.bitstreamsObs = observableCombineLatest(originals, licenses).pipe(map(([o, l]) => [...o, ...l])); this.bitstreamsObs.subscribe( (files) => files.forEach( diff --git a/src/app/+item-page/full/full-item-page.component.ts b/src/app/+item-page/full/full-item-page.component.ts index dafecd748e..d09ac268ec 100644 --- a/src/app/+item-page/full/full-item-page.component.ts +++ b/src/app/+item-page/full/full-item-page.component.ts @@ -1,7 +1,9 @@ + +import {filter, map} from 'rxjs/operators'; import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; -import { Observable } from 'rxjs/Observable'; +import { Observable } from 'rxjs'; import { ItemPageComponent } from '../simple/item-page.component'; import { Metadatum } from '../../core/shared/metadatum.model'; @@ -41,9 +43,9 @@ export class FullItemPageComponent extends ItemPageComponent implements OnInit { /*** AoT inheritance fix, will hopefully be resolved in the near future **/ ngOnInit(): void { super.ngOnInit(); - this.metadata$ = this.itemRD$ - .map((rd: RemoteData) => rd.payload) - .filter((item: Item) => hasValue(item)) - .map((item: Item) => item.metadata); + this.metadata$ = this.itemRD$.pipe( + map((rd: RemoteData) => rd.payload), + filter((item: Item) => hasValue(item)), + map((item: Item) => item.metadata),); } } diff --git a/src/app/+item-page/item-page.resolver.ts b/src/app/+item-page/item-page.resolver.ts index c0f4147f47..c0ee6a84ee 100644 --- a/src/app/+item-page/item-page.resolver.ts +++ b/src/app/+item-page/item-page.resolver.ts @@ -1,6 +1,6 @@ import { Injectable } from '@angular/core'; import { ActivatedRouteSnapshot, Resolve, RouterStateSnapshot } from '@angular/router'; -import { Observable } from 'rxjs/Observable'; +import { Observable } from 'rxjs'; import { RemoteData } from '../core/data/remote-data'; import { getSucceededRemoteData } from '../core/shared/operators'; import { ItemDataService } from '../core/data/item-data.service'; diff --git a/src/app/+item-page/simple/field-components/file-section/file-section.component.ts b/src/app/+item-page/simple/field-components/file-section/file-section.component.ts index b42e73940f..8c40d123bf 100644 --- a/src/app/+item-page/simple/field-components/file-section/file-section.component.ts +++ b/src/app/+item-page/simple/field-components/file-section/file-section.component.ts @@ -1,5 +1,5 @@ import { Component, Input, OnInit } from '@angular/core'; -import { Observable } from 'rxjs/Observable'; +import { Observable } from 'rxjs'; import { Bitstream } from '../../../../core/shared/bitstream.model'; import { Item } from '../../../../core/shared/item.model'; diff --git a/src/app/+item-page/simple/item-page.component.ts b/src/app/+item-page/simple/item-page.component.ts index 7ff304236d..35162b011f 100644 --- a/src/app/+item-page/simple/item-page.component.ts +++ b/src/app/+item-page/simple/item-page.component.ts @@ -1,7 +1,9 @@ + +import {mergeMap, filter, map} from 'rxjs/operators'; import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; -import { Observable } from 'rxjs/Observable'; +import { Observable } from 'rxjs'; import { ItemDataService } from '../../core/data/item-data.service'; import { RemoteData } from '../../core/data/remote-data'; import { Bitstream } from '../../core/shared/bitstream.model'; @@ -44,11 +46,11 @@ export class ItemPageComponent implements OnInit { } ngOnInit(): void { - this.itemRD$ = this.route.data.map((data) => data.item); + this.itemRD$ = this.route.data.pipe(map((data) => data.item)); this.metadataService.processRemoteData(this.itemRD$); - this.thumbnail$ = this.itemRD$ - .map((rd: RemoteData) => rd.payload) - .filter((item: Item) => hasValue(item)) - .flatMap((item: Item) => item.getThumbnail()); + this.thumbnail$ = this.itemRD$.pipe( + map((rd: RemoteData) => rd.payload), + filter((item: Item) => hasValue(item)), + mergeMap((item: Item) => item.getThumbnail()),); } } diff --git a/src/app/+login-page/login-page.component.spec.ts b/src/app/+login-page/login-page.component.spec.ts index 609cf47794..234435a410 100644 --- a/src/app/+login-page/login-page.component.spec.ts +++ b/src/app/+login-page/login-page.component.spec.ts @@ -3,8 +3,7 @@ import { async, ComponentFixture, TestBed } from '@angular/core/testing'; import { Store } from '@ngrx/store'; import { TranslateModule } from '@ngx-translate/core'; -import { Observable } from 'rxjs/Observable'; -import 'rxjs/add/observable/of'; +import { of as observableOf } from 'rxjs'; import { LoginPageComponent } from './login-page.component'; @@ -16,7 +15,7 @@ describe('LoginPageComponent', () => { /* tslint:disable:no-empty */ dispatch: {}, /* tslint:enable:no-empty */ - select: Observable.of(true) + select: observableOf(true) }); beforeEach(async(() => { diff --git a/src/app/+search-page/paginated-search-options.model.spec.ts b/src/app/+search-page/paginated-search-options.model.spec.ts index e8688fd84f..22b3f146b2 100644 --- a/src/app/+search-page/paginated-search-options.model.spec.ts +++ b/src/app/+search-page/paginated-search-options.model.spec.ts @@ -1,4 +1,3 @@ -import 'rxjs/add/observable/of'; import { SortDirection, SortOptions } from '../core/cache/models/sort-options.model'; import { PaginationComponentOptions } from '../shared/pagination/pagination-component-options.model'; import { PaginatedSearchOptions } from './paginated-search-options.model'; diff --git a/src/app/+search-page/search-filters/search-filter/search-facet-filter/search-facet-filter.component.spec.ts b/src/app/+search-page/search-filters/search-filter/search-facet-filter/search-facet-filter.component.spec.ts index 49141c2b68..498c41dd6c 100644 --- a/src/app/+search-page/search-filters/search-filter/search-facet-filter/search-facet-filter.component.spec.ts +++ b/src/app/+search-page/search-filters/search-filter/search-facet-filter/search-facet-filter.component.spec.ts @@ -7,7 +7,7 @@ import { SearchFilterConfig } from '../../../search-service/search-filter-config import { FilterType } from '../../../search-service/filter-type.model'; import { FacetValue } from '../../../search-service/facet-value.model'; import { FormsModule } from '@angular/forms'; -import { Observable } from 'rxjs/Observable'; +import { of as observableOf } from 'rxjs'; import { SearchService } from '../../../search-service/search.service'; import { SearchServiceStub } from '../../../../shared/testing/search-service-stub'; import { RemoteData } from '../../../../core/data/remote-data'; @@ -54,9 +54,9 @@ describe('SearchFacetFilterComponent', () => { let filterService; let searchService; let router; - const page = Observable.of(0); + const page = observableOf(0); - const mockValues = Observable.of(new RemoteData(false, false, true, null, new PaginatedList(new PageInfo(), values))); + const mockValues = observableOf(new RemoteData(false, false, true, null, new PaginatedList(new PageInfo(), values))); beforeEach(async(() => { TestBed.configureTestingModule({ imports: [TranslateModule.forRoot(), NoopAnimationsModule, FormsModule], @@ -65,11 +65,11 @@ describe('SearchFacetFilterComponent', () => { { provide: SearchService, useValue: new SearchServiceStub(searchLink) }, { provide: Router, useValue: new RouterStub() }, { provide: FILTER_CONFIG, useValue: new SearchFilterConfig() }, - { provide: RemoteDataBuildService, useValue: {aggregate: () => Observable.of({})} }, - { provide: SearchConfigurationService, useValue: {searchOptions: Observable.of({})} }, + { provide: RemoteDataBuildService, useValue: {aggregate: () => observableOf({})} }, + { provide: SearchConfigurationService, useValue: {searchOptions: observableOf({})} }, { provide: SearchFilterService, useValue: { - getSelectedValuesForFilter: () => Observable.of(selectedValues), + getSelectedValuesForFilter: () => observableOf(selectedValues), isFilterActiveWithValue: (paramName: string, filterValue: string) => true, getPage: (paramName: string) => page, /* tslint:disable:no-empty */ diff --git a/src/app/+search-page/search-filters/search-filter/search-facet-filter/search-facet-filter.component.ts b/src/app/+search-page/search-filters/search-filter/search-facet-filter/search-facet-filter.component.ts index e0500b555e..4a171a3f3a 100644 --- a/src/app/+search-page/search-filters/search-filter/search-facet-filter/search-facet-filter.component.ts +++ b/src/app/+search-page/search-filters/search-filter/search-facet-filter/search-facet-filter.component.ts @@ -1,23 +1,26 @@ +import { + combineLatest as observableCombineLatest, + of as observableOf, + BehaviorSubject, + Observable, + Subject, + Subscription +} from 'rxjs'; +import { switchMap, distinctUntilChanged, first, map } from 'rxjs/operators'; import { animate, state, style, transition, trigger } from '@angular/animations'; import { Component, Inject, OnDestroy, OnInit } from '@angular/core'; import { Router } from '@angular/router'; -import { BehaviorSubject } from 'rxjs/BehaviorSubject'; -import { Observable } from 'rxjs/Observable'; -import { Subject } from 'rxjs/Subject'; -import { Subscription } from 'rxjs/Subscription'; import { RemoteDataBuildService } from '../../../../core/cache/builders/remote-data-build.service'; import { PaginatedList } from '../../../../core/data/paginated-list'; import { RemoteData } from '../../../../core/data/remote-data'; import { hasNoValue, hasValue, isNotEmpty } from '../../../../shared/empty.util'; import { EmphasizePipe } from '../../../../shared/utils/emphasize.pipe'; -import { SearchOptions } from '../../../search-options.model'; import { FacetValue } from '../../../search-service/facet-value.model'; import { SearchFilterConfig } from '../../../search-service/search-filter-config.model'; import { SearchService } from '../../../search-service/search.service'; import { FILTER_CONFIG, SearchFilterService } from '../search-filter.service'; import { SearchConfigurationService } from '../../../search-service/search-configuration.service'; import { getSucceededRemoteData } from '../../../../core/shared/operators'; -import { map } from 'rxjs/operators'; @Component({ selector: 'ds-search-facet-filter', @@ -56,7 +59,7 @@ export class SearchFacetFilterComponent implements OnInit, OnDestroy { /** * Emits the result values for this filter found by the current filter query */ - filterSearchResults: Observable = Observable.of([]); + filterSearchResults: Observable = observableOf([]); /** * Emits the active values for this filter @@ -82,25 +85,28 @@ export class SearchFacetFilterComponent implements OnInit, OnDestroy { */ ngOnInit(): void { this.filterValues$ = new BehaviorSubject(new RemoteData(true, false, undefined, undefined, undefined)); - this.currentPage = this.getCurrentPage().distinctUntilChanged(); + this.currentPage = this.getCurrentPage().pipe(distinctUntilChanged()); this.selectedValues = this.filterService.getSelectedValuesForFilter(this.filterConfig); const searchOptions = this.searchConfigService.searchOptions; this.subs.push(this.searchConfigService.searchOptions.subscribe(() => this.updateFilterValueList())); - const facetValues = Observable.combineLatest(searchOptions, this.currentPage, (options, page) => { - return { options, page } - }).switchMap(({ options, page }) => { - return this.searchService.getFacetValuesFor(this.filterConfig, page, options) - .pipe( - getSucceededRemoteData(), - map((results) => { - return { - values: Observable.of(results), - page: page - }; - } + const facetValues = observableCombineLatest(searchOptions, this.currentPage).pipe( + map(([options, page]) => { + return { options, page } + }), + switchMap(({ options, page }) => { + return this.searchService.getFacetValuesFor(this.filterConfig, page, options) + .pipe( + getSucceededRemoteData(), + map((results) => { + return { + values: observableOf(results), + page: page + }; + } + ) ) - ) - }); + }) + ); let filterValues = []; this.subs.push(facetValues.subscribe((facetOutcome) => { const newValues$ = facetOutcome.values; @@ -120,7 +126,7 @@ export class SearchFacetFilterComponent implements OnInit, OnDestroy { this.animationState = 'ready'; this.filterValues$.next(rd); })); - this.subs.push(newValues$.first().subscribe((rd) => { + this.subs.push(newValues$.pipe(first()).subscribe((rd) => { this.isLastPage$.next(hasNoValue(rd.payload.next)) })); })); @@ -183,7 +189,7 @@ export class SearchFacetFilterComponent implements OnInit, OnDestroy { * @param data The string from the input field */ onSubmit(data: any) { - this.selectedValues.first().subscribe((selectedValues) => { + this.selectedValues.pipe(first()).subscribe((selectedValues) => { if (isNotEmpty(data)) { this.router.navigate([this.getSearchLink()], { queryParams: @@ -192,7 +198,7 @@ export class SearchFacetFilterComponent implements OnInit, OnDestroy { }); this.filter = ''; } - this.filterSearchResults = Observable.of([]); + this.filterSearchResults = observableOf([]); } ) } @@ -214,12 +220,12 @@ export class SearchFacetFilterComponent implements OnInit, OnDestroy { * @returns {Observable} The changed filter parameters */ getRemoveParams(value: string): Observable { - return this.selectedValues.map((selectedValues) => { + return this.selectedValues.pipe(map((selectedValues) => { return { [this.filterConfig.paramName]: selectedValues.filter((v) => v !== value), page: 1 }; - }); + })); } /** @@ -228,12 +234,12 @@ export class SearchFacetFilterComponent implements OnInit, OnDestroy { * @returns {Observable} The changed filter parameters */ getAddParams(value: string): Observable { - return this.selectedValues.map((selectedValues) => { + return this.selectedValues.pipe(map((selectedValues) => { return { [this.filterConfig.paramName]: [...selectedValues, value], page: 1 }; - }); + })); } /** @@ -252,7 +258,7 @@ export class SearchFacetFilterComponent implements OnInit, OnDestroy { */ findSuggestions(data): void { if (isNotEmpty(data)) { - this.searchConfigService.searchOptions.first().subscribe( + this.searchConfigService.searchOptions.pipe(first()).subscribe( (options) => { this.filterSearchResults = this.searchService.getFacetValuesFor(this.filterConfig, 1, options, data.toLowerCase()) .pipe( @@ -267,7 +273,7 @@ export class SearchFacetFilterComponent implements OnInit, OnDestroy { } ) } else { - this.filterSearchResults = Observable.of([]); + this.filterSearchResults = observableOf([]); } } diff --git a/src/app/+search-page/search-filters/search-filter/search-filter.component.spec.ts b/src/app/+search-page/search-filters/search-filter/search-filter.component.spec.ts index 78d40b1cf6..caa5a6febc 100644 --- a/src/app/+search-page/search-filters/search-filter/search-filter.component.spec.ts +++ b/src/app/+search-page/search-filters/search-filter/search-filter.component.spec.ts @@ -3,7 +3,7 @@ import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; import { async, ComponentFixture, TestBed } from '@angular/core/testing'; import { RouterTestingModule } from '@angular/router/testing'; import { TranslateModule } from '@ngx-translate/core'; -import { Observable } from 'rxjs/Observable'; +import { Observable, of as observableOf } from 'rxjs'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { SearchFilterService } from './search-filter.service'; import { SearchService } from '../../search-service/search.service'; @@ -38,19 +38,19 @@ describe('SearchFilterComponent', () => { initialExpand: (filter) => { }, getSelectedValuesForFilter: (filter) => { - return Observable.of([filterName1, filterName2, filterName3]) + return observableOf([filterName1, filterName2, filterName3]) }, isFilterActive: (filter) => { - return Observable.of([filterName1, filterName2, filterName3].indexOf(filter) >= 0); + return observableOf([filterName1, filterName2, filterName3].indexOf(filter) >= 0); }, isCollapsed: (filter) => { - return Observable.of(true) + return observableOf(true) } /* tslint:enable:no-empty */ }; let filterService; - const mockResults = Observable.of(['test', 'data']); + const mockResults = observableOf(['test', 'data']); const searchServiceStub = { getFacetValuesFor: (filter) => mockResults }; @@ -140,7 +140,7 @@ describe('SearchFilterComponent', () => { describe('when isCollapsed is called and the filter is collapsed', () => { let isActive: Observable; beforeEach(() => { - filterService.isCollapsed = () => Observable.of(true); + filterService.isCollapsed = () => observableOf(true); isActive = comp.isCollapsed(); }); @@ -155,7 +155,7 @@ describe('SearchFilterComponent', () => { describe('when isCollapsed is called and the filter is not collapsed', () => { let isActive: Observable; beforeEach(() => { - filterService.isCollapsed = () => Observable.of(false); + filterService.isCollapsed = () => observableOf(false); isActive = comp.isCollapsed(); }); diff --git a/src/app/+search-page/search-filters/search-filter/search-filter.component.ts b/src/app/+search-page/search-filters/search-filter/search-filter.component.ts index bd3c9f7a0c..87f8edc1ea 100644 --- a/src/app/+search-page/search-filters/search-filter/search-filter.component.ts +++ b/src/app/+search-page/search-filters/search-filter/search-filter.component.ts @@ -1,7 +1,9 @@ + +import {first} from 'rxjs/operators'; import { Component, Input, OnInit } from '@angular/core'; import { SearchFilterConfig } from '../../search-service/search-filter-config.model'; import { SearchFilterService } from './search-filter.service'; -import { Observable } from 'rxjs/Observable'; +import { Observable } from 'rxjs'; import { slide } from '../../../shared/animations/slide'; import { isNotEmpty } from '../../../shared/empty.util'; @@ -35,7 +37,7 @@ export class SearchFilterComponent implements OnInit { * Else, the filter should initially be collapsed */ ngOnInit() { - this.getSelectedValues().first().subscribe((isActive) => { + this.getSelectedValues().pipe(first()).subscribe((isActive) => { if (this.filter.isOpenByDefault || isNotEmpty(isActive)) { this.initialExpand(); } else { diff --git a/src/app/+search-page/search-filters/search-filter/search-filter.service.spec.ts b/src/app/+search-page/search-filters/search-filter/search-filter.service.spec.ts index 6d250f6869..156e8d47ea 100644 --- a/src/app/+search-page/search-filters/search-filter/search-filter.service.spec.ts +++ b/src/app/+search-page/search-filters/search-filter/search-filter.service.spec.ts @@ -1,16 +1,20 @@ -import { Observable } from 'rxjs/Observable'; import { SearchFilterService } from './search-filter.service'; import { Store } from '@ngrx/store'; import { - SearchFilterCollapseAction, SearchFilterDecrementPageAction, SearchFilterExpandAction, + SearchFilterCollapseAction, + SearchFilterDecrementPageAction, + SearchFilterExpandAction, SearchFilterIncrementPageAction, - SearchFilterInitialCollapseAction, SearchFilterInitialExpandAction, SearchFilterResetPageAction, + SearchFilterInitialCollapseAction, + SearchFilterInitialExpandAction, + SearchFilterResetPageAction, SearchFilterToggleAction } from './search-filter.actions'; import { SearchFiltersState } from './search-filter.reducer'; import { SearchFilterConfig } from '../../search-service/search-filter-config.model'; import { FilterType } from '../../search-service/filter-type.model'; import { ActivatedRouteStub } from '../../../shared/testing/active-router-stub'; +import { of as observableOf } from 'rxjs'; describe('SearchFilterService', () => { let service: SearchFilterService; @@ -28,7 +32,7 @@ describe('SearchFilterService', () => { /* tslint:disable:no-empty */ dispatch: {}, /* tslint:enable:no-empty */ - select: Observable.of(true) + select: observableOf(true) }); const routeServiceStub: any = { @@ -42,10 +46,10 @@ describe('SearchFilterService', () => { addQueryParameterValue: (param: string, value: string) => { }, getQueryParameterValues: (param: string) => { - return Observable.of({}); + return observableOf({}); }, getQueryParamsWithPrefix: (param: string) => { - return Observable.of({}); + return observableOf({}); } /* tslint:enable:no-empty */ }; diff --git a/src/app/+search-page/search-filters/search-filter/search-filter.service.ts b/src/app/+search-page/search-filters/search-filter/search-filter.service.ts index 3b7c7b8e86..bf21eab367 100644 --- a/src/app/+search-page/search-filters/search-filter/search-filter.service.ts +++ b/src/app/+search-page/search-filters/search-filter/search-filter.service.ts @@ -1,8 +1,8 @@ +import { combineLatest as observableCombineLatest, Observable } from 'rxjs'; import { Injectable, InjectionToken } from '@angular/core'; -import { distinctUntilChanged, map } from 'rxjs/operators'; +import { map } from 'rxjs/operators'; import { SearchFiltersState, SearchFilterState } from './search-filter.reducer'; -import { createSelector, MemoizedSelector, Store } from '@ngrx/store'; -import { Observable } from 'rxjs/Observable'; +import { createSelector, MemoizedSelector, select, Store } from '@ngrx/store'; import { SearchFilterCollapseAction, SearchFilterDecrementPageAction, @@ -13,14 +13,10 @@ import { SearchFilterResetPageAction, SearchFilterToggleAction } from './search-filter.actions'; -import { hasValue, isEmpty, isNotEmpty, } from '../../../shared/empty.util'; +import { hasValue, isNotEmpty, } from '../../../shared/empty.util'; import { SearchFilterConfig } from '../../search-service/search-filter-config.model'; import { RouteService } from '../../../shared/services/route.service'; -import { SortDirection, SortOptions } from '../../../core/cache/models/sort-options.model'; -import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model'; -import { SearchOptions } from '../../search-options.model'; -import { PaginatedSearchOptions } from '../../paginated-search-options.model'; -import { ActivatedRoute, Params } from '@angular/router'; +import { Params } from '@angular/router'; const filterStateSelector = (state: SearchFiltersState) => state.searchFilter; @@ -63,13 +59,19 @@ export class SearchFilterService { */ getSelectedValuesForFilter(filterConfig: SearchFilterConfig): Observable { const values$ = this.routeService.getQueryParameterValues(filterConfig.paramName); - const prefixValues$ = this.routeService.getQueryParamsWithPrefix(filterConfig.paramName + '.').map((params: Params) => [].concat(...Object.values(params))); - return Observable.combineLatest(values$, prefixValues$, (values, prefixValues) => { - if (isNotEmpty(values)) { - return values; - } - return prefixValues; - }) + const prefixValues$ = this.routeService.getQueryParamsWithPrefix(filterConfig.paramName + '.').pipe( + map((params: Params) => [].concat(...Object.values(params))) + ); + + return observableCombineLatest(values$, prefixValues$).pipe( + map(([values, prefixValues]) => { + if (isNotEmpty(values)) { + return values; + } + return prefixValues; + } + ) + ) } /** @@ -78,14 +80,16 @@ export class SearchFilterService { * @returns {Observable} Emits the current collapsed state of the given filter, if it's unavailable, return false */ isCollapsed(filterName: string): Observable { - return this.store.select(filterByNameSelector(filterName)) - .map((object: SearchFilterState) => { + return this.store.pipe( + select(filterByNameSelector(filterName)), + map((object: SearchFilterState) => { if (object) { return object.filterCollapsed; } else { return false; } - }); + }) + ); } /** @@ -94,14 +98,15 @@ export class SearchFilterService { * @returns {Observable} Emits the current page state of the given filter, if it's unavailable, return 1 */ getPage(filterName: string): Observable { - return this.store.select(filterByNameSelector(filterName)) - .map((object: SearchFilterState) => { + return this.store.pipe( + select(filterByNameSelector(filterName)), + map((object: SearchFilterState) => { if (object) { return object.page; } else { return 1; } - }); + })); } /** @@ -159,6 +164,7 @@ export class SearchFilterService { public incrementPage(filterName: string): void { this.store.dispatch(new SearchFilterIncrementPageAction(filterName)); } + /** * Dispatches a reset page action to the store for a given filter * @param {string} filterName The filter for which the action is dispatched diff --git a/src/app/+search-page/search-filters/search-filter/search-range-filter/search-range-filter.component.spec.ts b/src/app/+search-page/search-filters/search-filter/search-range-filter/search-range-filter.component.spec.ts index 4e555459d6..6f3450e18e 100644 --- a/src/app/+search-page/search-filters/search-filter/search-range-filter/search-range-filter.component.spec.ts +++ b/src/app/+search-page/search-filters/search-filter/search-range-filter/search-range-filter.component.spec.ts @@ -7,7 +7,7 @@ import { SearchFilterConfig } from '../../../search-service/search-filter-config import { FilterType } from '../../../search-service/filter-type.model'; import { FacetValue } from '../../../search-service/facet-value.model'; import { FormsModule } from '@angular/forms'; -import { Observable } from 'rxjs/Observable'; +import { of as observableOf } from 'rxjs' import { SearchService } from '../../../search-service/search.service'; import { SearchServiceStub } from '../../../../shared/testing/search-service-stub'; import { RemoteData } from '../../../../core/data/remote-data'; @@ -56,13 +56,13 @@ describe('SearchRangeFilterComponent', () => { ]; const searchLink = '/search'; - const selectedValues = Observable.of([value1]); + const selectedValues = observableOf([value1]); let filterService; let searchService; let router; - const page = Observable.of(0); + const page = observableOf(0); - const mockValues = Observable.of(new RemoteData(false, false, true, null, new PaginatedList(new PageInfo(), values))); + const mockValues = observableOf(new RemoteData(false, false, true, null, new PaginatedList(new PageInfo(), values))); beforeEach(async(() => { TestBed.configureTestingModule({ imports: [TranslateModule.forRoot(), NoopAnimationsModule, FormsModule], @@ -71,10 +71,10 @@ describe('SearchRangeFilterComponent', () => { { provide: SearchService, useValue: new SearchServiceStub(searchLink) }, { provide: Router, useValue: new RouterStub() }, { provide: FILTER_CONFIG, useValue: mockFilterConfig }, - { provide: RemoteDataBuildService, useValue: {aggregate: () => Observable.of({})} }, - { provide: RouteService, useValue: {getQueryParameterValue: () => Observable.of({})} }, + { provide: RemoteDataBuildService, useValue: {aggregate: () => observableOf({})} }, + { provide: RouteService, useValue: {getQueryParameterValue: () => observableOf({})} }, { provide: SearchConfigurationService, useValue: { - searchOptions: Observable.of({}) } + searchOptions: observableOf({}) } }, { provide: SearchFilterService, useValue: { diff --git a/src/app/+search-page/search-filters/search-filter/search-range-filter/search-range-filter.component.ts b/src/app/+search-page/search-filters/search-filter/search-range-filter/search-range-filter.component.ts index 61e07b9b53..6cb04c6c1f 100644 --- a/src/app/+search-page/search-filters/search-filter/search-range-filter/search-range-filter.component.ts +++ b/src/app/+search-page/search-filters/search-filter/search-range-filter/search-range-filter.component.ts @@ -1,3 +1,10 @@ +import { + of as observableOf, + combineLatest as observableCombineLatest, + Observable, + Subscription +} from 'rxjs'; +import { map, startWith } from 'rxjs/operators'; import { isPlatformBrowser } from '@angular/common'; import { Component, Inject, OnDestroy, OnInit, PLATFORM_ID } from '@angular/core'; import { RemoteDataBuildService } from '../../../../core/cache/builders/remote-data-build.service'; @@ -12,10 +19,8 @@ import { FILTER_CONFIG, SearchFilterService } from '../search-filter.service'; import { SearchService } from '../../../search-service/search.service'; import { Router } from '@angular/router'; import * as moment from 'moment'; -import { Observable } from 'rxjs/Observable'; import { RouteService } from '../../../../shared/services/route.service'; import { hasValue } from '../../../../shared/empty.util'; -import { Subscription } from 'rxjs/Subscription'; import { SearchConfigurationService } from '../../../search-service/search-configuration.service'; /** @@ -80,13 +85,15 @@ export class SearchRangeFilterComponent extends SearchFacetFilterComponent imple super.ngOnInit(); this.min = moment(this.filterConfig.minValue, dateFormats).year() || this.min; this.max = moment(this.filterConfig.maxValue, dateFormats).year() || this.max; - const iniMin = this.route.getQueryParameterValue(this.filterConfig.paramName + minSuffix).startWith(undefined); - const iniMax = this.route.getQueryParameterValue(this.filterConfig.paramName + maxSuffix).startWith(undefined); - this.sub = Observable.combineLatest(iniMin, iniMax, (min, max) => { - const minimum = hasValue(min) ? min : this.min; - const maximum = hasValue(max) ? max : this.max; - return [minimum, maximum] - }).subscribe((minmax) => this.range = minmax); + const iniMin = this.route.getQueryParameterValue(this.filterConfig.paramName + minSuffix).pipe(startWith(undefined)); + const iniMax = this.route.getQueryParameterValue(this.filterConfig.paramName + maxSuffix).pipe(startWith(undefined)); + this.sub = observableCombineLatest(iniMin, iniMax).pipe( + map(([min, max]) => { + const minimum = hasValue(min) ? min : this.min; + const maximum = hasValue(max) ? max : this.max; + return [minimum, maximum] + }) + ).subscribe((minmax) => this.range = minmax); } /** @@ -98,7 +105,7 @@ export class SearchRangeFilterComponent extends SearchFacetFilterComponent imple const parts = value.split(rangeDelimiter); const min = parts.length > 1 ? parts[0].trim() : value; const max = parts.length > 1 ? parts[1].trim() : value; - return Observable.of( + return observableOf( { [this.filterConfig.paramName + minSuffix]: [min], [this.filterConfig.paramName + maxSuffix]: [max], diff --git a/src/app/+search-page/search-filters/search-filter/search-text-filter/search-text-filter.component.ts b/src/app/+search-page/search-filters/search-filter/search-text-filter/search-text-filter.component.ts index 9e603184e8..fd14d6d3de 100644 --- a/src/app/+search-page/search-filters/search-filter/search-text-filter/search-text-filter.component.ts +++ b/src/app/+search-page/search-filters/search-filter/search-text-filter/search-text-filter.component.ts @@ -1,6 +1,4 @@ -import { animate, state, style, transition, trigger } from '@angular/animations'; -import { Component, HostBinding, OnInit } from '@angular/core'; -import { Observable } from 'rxjs/Observable'; +import { Component, OnInit } from '@angular/core'; import { FilterType } from '../../../search-service/filter-type.model'; import { facetLoad, diff --git a/src/app/+search-page/search-filters/search-filters.component.spec.ts b/src/app/+search-page/search-filters/search-filters.component.spec.ts index 7f0d4ad748..db21fc8a69 100644 --- a/src/app/+search-page/search-filters/search-filters.component.spec.ts +++ b/src/app/+search-page/search-filters/search-filters.component.spec.ts @@ -7,8 +7,8 @@ import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { SearchFilterService } from './search-filter/search-filter.service'; import { SearchFiltersComponent } from './search-filters.component'; import { SearchService } from '../search-service/search.service'; -import { Observable } from 'rxjs/Observable'; import { SearchConfigurationService } from '../search-service/search-configuration.service'; +import { of as observableOf } from 'rxjs'; describe('SearchFiltersComponent', () => { let comp: SearchFiltersComponent; @@ -17,7 +17,7 @@ describe('SearchFiltersComponent', () => { const searchServiceStub = { /* tslint:disable:no-empty */ getConfig: () => - Observable.of({ hasSucceeded: true, payload: [] }), + observableOf({ hasSucceeded: true, payload: [] }), getClearFiltersQueryParams: () => { }, getSearchLink: () => { @@ -31,7 +31,7 @@ describe('SearchFiltersComponent', () => { }; const searchConfigServiceStub = jasmine.createSpyObj('SearchConfigurationService', { - getCurrentFrontendFilters: Observable.of({}) + getCurrentFrontendFilters: observableOf({}) }); beforeEach(async(() => { diff --git a/src/app/+search-page/search-filters/search-filters.component.ts b/src/app/+search-page/search-filters/search-filters.component.ts index 1da4e5860d..f16faff1f3 100644 --- a/src/app/+search-page/search-filters/search-filters.component.ts +++ b/src/app/+search-page/search-filters/search-filters.component.ts @@ -1,8 +1,10 @@ +import { Observable, of as observableOf } from 'rxjs'; + +import { filter, map, mergeMap, startWith, switchMap } from 'rxjs/operators'; import { Component } from '@angular/core'; import { SearchService } from '../search-service/search.service'; import { RemoteData } from '../../core/data/remote-data'; import { SearchFilterConfig } from '../search-service/search-filter-config.model'; -import { Observable } from 'rxjs/Observable'; import { SearchConfigurationService } from '../search-service/search-configuration.service'; import { isNotEmpty } from '../../shared/empty.util'; import { SearchFilterService } from './search-filter/search-filter.service'; @@ -37,10 +39,10 @@ export class SearchFiltersComponent { */ constructor(private searchService: SearchService, private searchConfigService: SearchConfigurationService, private filterService: SearchFilterService) { this.filters = searchService.getConfig().pipe(getSucceededRemoteData()); - this.clearParams = searchConfigService.getCurrentFrontendFilters().map((filters) => { + this.clearParams = searchConfigService.getCurrentFrontendFilters().pipe(map((filters) => { Object.keys(filters).forEach((f) => filters[f] = null); return filters; - }); + })); } /** @@ -55,22 +57,22 @@ export class SearchFiltersComponent { * @param {SearchFilterConfig} filter The filter to check for * @returns {Observable} Emits true whenever a given filter config should be shown */ - isActive(filter: SearchFilterConfig): Observable { - return this.filterService.getSelectedValuesForFilter(filter) - .flatMap((isActive) => { + isActive(filterConfig: SearchFilterConfig): Observable { + return this.filterService.getSelectedValuesForFilter(filterConfig).pipe( + mergeMap((isActive) => { if (isNotEmpty(isActive)) { - return Observable.of(true); + return observableOf(true); } else { - return this.searchConfigService.searchOptions - .switchMap((options) => { - return this.searchService.getFacetValuesFor(filter, 1, options) - .filter((RD) => !RD.isLoading) - .map((valuesRD) => { + return this.searchConfigService.searchOptions.pipe( + switchMap((options) => { + return this.searchService.getFacetValuesFor(filterConfig, 1, options).pipe( + filter((RD) => !RD.isLoading), + map((valuesRD) => { return valuesRD.payload.totalElements > 0 - }) + }),) } - ) + )) } - }).startWith(true); + }),startWith(true),); } } diff --git a/src/app/+search-page/search-labels/search-labels.component.spec.ts b/src/app/+search-page/search-labels/search-labels.component.spec.ts index bf512ed5db..81fa5b5df8 100644 --- a/src/app/+search-page/search-labels/search-labels.component.spec.ts +++ b/src/app/+search-page/search-labels/search-labels.component.spec.ts @@ -6,7 +6,7 @@ import { SearchService } from '../search-service/search.service'; import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { SearchServiceStub } from '../../shared/testing/search-service-stub'; -import { Observable } from 'rxjs/Observable'; +import { Observable, of as observableOf } from 'rxjs'; import { Params } from '@angular/router'; import { ObjectKeysPipe } from '../../shared/utils/object-keys-pipe'; import { SearchConfigurationService } from '../search-service/search-configuration.service'; @@ -35,7 +35,7 @@ describe('SearchLabelsComponent', () => { declarations: [SearchLabelsComponent, ObjectKeysPipe], providers: [ { provide: SearchService, useValue: new SearchServiceStub(searchLink) }, - { provide: SearchConfigurationService, useValue: {getCurrentFrontendFilters : () => Observable.of({})} } + { provide: SearchConfigurationService, useValue: {getCurrentFrontendFilters : () => observableOf({})} } ], schemas: [NO_ERRORS_SCHEMA] }).overrideComponent(SearchLabelsComponent, { @@ -47,7 +47,7 @@ describe('SearchLabelsComponent', () => { fixture = TestBed.createComponent(SearchLabelsComponent); comp = fixture.componentInstance; searchService = (comp as any).searchService; - (comp as any).appliedFilters = Observable.of(mockFilters); + (comp as any).appliedFilters = observableOf(mockFilters); fixture.detectChanges(); }); diff --git a/src/app/+search-page/search-labels/search-labels.component.ts b/src/app/+search-page/search-labels/search-labels.component.ts index 61482f8d8a..08e07cce3d 100644 --- a/src/app/+search-page/search-labels/search-labels.component.ts +++ b/src/app/+search-page/search-labels/search-labels.component.ts @@ -1,6 +1,6 @@ import { Component } from '@angular/core'; import { SearchService } from '../search-service/search.service'; -import { Observable } from 'rxjs/Observable'; +import { Observable } from 'rxjs'; import { Params } from '@angular/router'; import { map } from 'rxjs/operators'; import { hasValue, isNotEmpty } from '../../shared/empty.util'; diff --git a/src/app/+search-page/search-options.model.spec.ts b/src/app/+search-page/search-options.model.spec.ts index a624664637..a0ef2b31dd 100644 --- a/src/app/+search-page/search-options.model.spec.ts +++ b/src/app/+search-page/search-options.model.spec.ts @@ -1,4 +1,3 @@ -import 'rxjs/add/observable/of'; import { PaginatedSearchOptions } from './paginated-search-options.model'; import { SearchOptions } from './search-options.model'; import { SearchFilter } from './search-filter.model'; diff --git a/src/app/+search-page/search-page.component.spec.ts b/src/app/+search-page/search-page.component.spec.ts index 3ca18ce4c5..1991cf8f1b 100644 --- a/src/app/+search-page/search-page.component.spec.ts +++ b/src/app/+search-page/search-page.component.spec.ts @@ -5,8 +5,7 @@ import { RouterTestingModule } from '@angular/router/testing'; import { Store } from '@ngrx/store'; import { TranslateModule } from '@ngx-translate/core'; import { cold, hot } from 'jasmine-marbles'; -import { Observable } from 'rxjs/Observable'; -import 'rxjs/add/observable/of'; +import { of as observableOf } from 'rxjs'; import { SortDirection, SortOptions } from '../core/cache/models/sort-options.model'; import { CommunityDataService } from '../core/data/community-data.service'; import { HostWindowService } from '../shared/host-window.service'; @@ -30,18 +29,18 @@ describe('SearchPageComponent', () => { /* tslint:disable:no-empty */ dispatch: {}, /* tslint:enable:no-empty */ - select: Observable.of(true) + select: observableOf(true) }); const pagination: PaginationComponentOptions = new PaginationComponentOptions(); pagination.id = 'search-results-pagination'; pagination.currentPage = 1; pagination.pageSize = 10; const sort: SortOptions = new SortOptions('score', SortDirection.DESC); - const mockResults = Observable.of(new RemoteData(false, false, true, null, ['test', 'data'])); + const mockResults = observableOf(new RemoteData(false, false, true, null, ['test', 'data'])); const searchServiceStub = jasmine.createSpyObj('SearchService', { search: mockResults, getSearchLink: '/search', - getScopes: Observable.of(['test-scope']) + getScopes: observableOf(['test-scope']) }); const queryParam = 'test query'; const scopeParam = '7669c72a-3f2a-451f-a3b9-9210e7a4c02f'; @@ -52,15 +51,15 @@ describe('SearchPageComponent', () => { sort }; const activatedRouteStub = { - queryParams: Observable.of({ + queryParams: observableOf({ query: queryParam, scope: scopeParam }) }; const sidebarService = { - isCollapsed: Observable.of(true), - collapse: () => this.isCollapsed = Observable.of(true), - expand: () => this.isCollapsed = Observable.of(false) + isCollapsed: observableOf(true), + collapse: () => this.isCollapsed = observableOf(true), + expand: () => this.isCollapsed = observableOf(false) }; beforeEach(async(() => { @@ -80,9 +79,9 @@ describe('SearchPageComponent', () => { { provide: HostWindowService, useValue: jasmine.createSpyObj('hostWindowService', { - isXs: Observable.of(true), - isSm: Observable.of(false), - isXsOrSm: Observable.of(true) + isXs: observableOf(true), + isSm: observableOf(false), + isXsOrSm: observableOf(true) }) }, { @@ -98,7 +97,7 @@ describe('SearchPageComponent', () => { paginatedSearchOptions: hot('a', { a: paginatedSearchOptions }), - getCurrentScope: (a) => Observable.of('test-id') + getCurrentScope: (a) => observableOf('test-id') } }, ], @@ -154,7 +153,7 @@ describe('SearchPageComponent', () => { beforeEach(() => { menu = fixture.debugElement.query(By.css('#search-sidebar-sm')).nativeElement; - comp.isSidebarCollapsed = () => Observable.of(true); + comp.isSidebarCollapsed = () => observableOf(true); fixture.detectChanges(); }); @@ -169,7 +168,7 @@ describe('SearchPageComponent', () => { beforeEach(() => { menu = fixture.debugElement.query(By.css('#search-sidebar-sm')).nativeElement; - comp.isSidebarCollapsed = () => Observable.of(false); + comp.isSidebarCollapsed = () => observableOf(false); fixture.detectChanges(); }); diff --git a/src/app/+search-page/search-page.component.ts b/src/app/+search-page/search-page.component.ts index da862ee7fc..816e3d67bf 100644 --- a/src/app/+search-page/search-page.component.ts +++ b/src/app/+search-page/search-page.component.ts @@ -1,6 +1,6 @@ import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; -import { Observable } from 'rxjs/Observable'; -import { flatMap, switchMap, } from 'rxjs/operators'; +import { BehaviorSubject, Observable, Subscription } from 'rxjs'; +import { switchMap, } from 'rxjs/operators'; import { PaginatedList } from '../core/data/paginated-list'; import { RemoteData } from '../core/data/remote-data'; import { DSpaceObject } from '../core/shared/dspace-object.model'; @@ -11,9 +11,7 @@ import { SearchFilterService } from './search-filters/search-filter/search-filte import { SearchResult } from './search-result.model'; import { SearchService } from './search-service/search.service'; import { SearchSidebarService } from './search-sidebar/search-sidebar.service'; -import { Subscription } from 'rxjs/Subscription'; import { hasValue } from '../shared/empty.util'; -import { BehaviorSubject } from 'rxjs/BehaviorSubject'; import { SearchConfigurationService } from './search-service/search-configuration.service'; import { getSucceededRemoteData } from '../core/shared/operators'; @@ -78,8 +76,8 @@ export class SearchPageComponent implements OnInit { */ ngOnInit(): void { this.searchOptions$ = this.searchConfigService.paginatedSearchOptions; - this.sub = this.searchOptions$ - .switchMap((options) => this.service.search(options).pipe(getSucceededRemoteData())) + this.sub = this.searchOptions$.pipe( + switchMap((options) => this.service.search(options).pipe(getSucceededRemoteData()))) .subscribe((results) => { this.resultsRD$.next(results); }); diff --git a/src/app/+search-page/search-results/search-results.component.html b/src/app/+search-page/search-results/search-results.component.html index ed6fc18d9c..4915b552c3 100644 --- a/src/app/+search-page/search-results/search-results.component.html +++ b/src/app/+search-page/search-results/search-results.component.html @@ -7,5 +7,12 @@ [hideGear]="true"> - - + +
+ {{ 'search.results.no-results' | translate }} + + {{"search.results.no-results-link" | translate}} + +
diff --git a/src/app/+search-page/search-results/search-results.component.spec.ts b/src/app/+search-page/search-results/search-results.component.spec.ts index 4f299c5c50..54463d916d 100644 --- a/src/app/+search-page/search-results/search-results.component.spec.ts +++ b/src/app/+search-page/search-results/search-results.component.spec.ts @@ -1,40 +1,92 @@ import { ComponentFixture, TestBed, async, tick, fakeAsync } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; -import { DebugElement, NO_ERRORS_SCHEMA } from '@angular/core'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; import { ResourceType } from '../../core/shared/resource-type'; import { Community } from '../../core/shared/community.model'; import { TranslateModule } from '@ngx-translate/core'; import { SearchResultsComponent } from './search-results.component'; +import { QueryParamsDirectiveStub } from '../../shared/testing/query-params-directive-stub'; describe('SearchResultsComponent', () => { let comp: SearchResultsComponent; let fixture: ComponentFixture; - let heading: DebugElement; beforeEach(async(() => { TestBed.configureTestingModule({ - imports: [TranslateModule.forRoot()], - declarations: [SearchResultsComponent], + imports: [TranslateModule.forRoot(), NoopAnimationsModule], + declarations: [ + SearchResultsComponent, + QueryParamsDirectiveStub], schemas: [NO_ERRORS_SCHEMA] }).compileComponents(); })); beforeEach(() => { fixture = TestBed.createComponent(SearchResultsComponent); - comp = fixture.componentInstance; // SearchFormComponent test instance - heading = fixture.debugElement.query(By.css('heading')); + comp = fixture.componentInstance; // SearchResultsComponent test instance }); - it('should display heading when results are not empty', fakeAsync(() => { - (comp as any).searchResults = 'test'; - (comp as any).searchConfig = {pagination: ''}; + it('should display results when results are not empty', () => { + (comp as any).searchResults = { hasSucceeded: true, isLoading: false, payload: { page: { length: 2 } } }; + (comp as any).searchConfig = {}; fixture.detectChanges(); - tick(); - expect(heading).toBeDefined(); - })); + expect(fixture.debugElement.query(By.css('ds-viewable-collection'))).not.toBeNull(); + }); - it('should not display heading when results is empty', () => { - expect(heading).toBeNull(); + it('should not display link when results are not empty', () => { + (comp as any).searchResults = { hasSucceeded: true, isLoading: false, payload: { page: { length: 2 } } }; + (comp as any).searchConfig = {}; + fixture.detectChanges(); + expect(fixture.debugElement.query(By.css('a'))).toBeNull(); + }); + + it('should display error message if error is != 400', () => { + (comp as any).searchResults = { hasFailed: true, error: { statusCode: 500 } }; + fixture.detectChanges(); + expect(fixture.debugElement.query(By.css('ds-error'))).not.toBeNull(); + }); + + it('should display link with new search where query is quoted if search return a error 400', () => { + (comp as any).searchResults = { hasFailed: true, error: { statusCode: 400 } }; + (comp as any).searchConfig = { query: 'foobar' }; + fixture.detectChanges(); + + const linkDes = fixture.debugElement.queryAll(By.directive(QueryParamsDirectiveStub)); + + // get attached link directive instances + // using each DebugElement's injector + const routerLinkQuery = linkDes.map((de) => de.injector.get(QueryParamsDirectiveStub)); + + expect(routerLinkQuery.length).toBe(1, 'should have 1 router link with query params'); + expect(routerLinkQuery[0].queryParams.query).toBe('"foobar"', 'query params should be "foobar"'); + }); + + it('should display link with new search where query is quoted if search result is empty', () => { + (comp as any).searchResults = { payload: { page: { length: 0 } } }; + (comp as any).searchConfig = { query: 'foobar' }; + fixture.detectChanges(); + + const linkDes = fixture.debugElement.queryAll(By.directive(QueryParamsDirectiveStub)); + + // get attached link directive instances + // using each DebugElement's injector + const routerLinkQuery = linkDes.map((de) => de.injector.get(QueryParamsDirectiveStub)); + + expect(routerLinkQuery.length).toBe(1, 'should have 1 router link with query params'); + expect(routerLinkQuery[0].queryParams.query).toBe('"foobar"', 'query params should be "foobar"'); + }); + + it('should add quotes around the given string', () => { + expect(comp.surroundStringWithQuotes('teststring')).toEqual('"teststring"'); + }); + + it('should not add quotes around the given string if they are already there', () => { + expect(comp.surroundStringWithQuotes('"teststring"')).toEqual('"teststring"'); + }); + + it('should not add quotes around a given empty string', () => { + expect(comp.surroundStringWithQuotes('')).toEqual(''); }); }); diff --git a/src/app/+search-page/search-results/search-results.component.ts b/src/app/+search-page/search-results/search-results.component.ts index 6399243f92..ae0abfcd27 100644 --- a/src/app/+search-page/search-results/search-results.component.ts +++ b/src/app/+search-page/search-results/search-results.component.ts @@ -6,6 +6,7 @@ import { SearchOptions } from '../search-options.model'; import { SearchResult } from '../search-result.model'; import { PaginatedList } from '../../core/data/paginated-list'; import { ViewMode } from '../../core/shared/view-mode.model'; +import { isNotEmpty } from '../../shared/empty.util'; @Component({ selector: 'ds-search-results', @@ -35,4 +36,16 @@ export class SearchResultsComponent { */ @Input() viewMode: ViewMode; + /** + * Method to change the given string by surrounding it by quotes if not already present. + */ + surroundStringWithQuotes(input: string): string { + let result = input; + + if (isNotEmpty(result) && !(result.startsWith('\"') && result.endsWith('\"'))) { + result = `"${result}"`; + } + + return result; + } } diff --git a/src/app/+search-page/search-service/search-configuration.service.spec.ts b/src/app/+search-page/search-service/search-configuration.service.spec.ts index 9f2e6d5045..af8897c93b 100644 --- a/src/app/+search-page/search-service/search-configuration.service.spec.ts +++ b/src/app/+search-page/search-service/search-configuration.service.spec.ts @@ -3,8 +3,8 @@ import { ActivatedRouteStub } from '../../shared/testing/active-router-stub'; import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model'; import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model'; import { PaginatedSearchOptions } from '../paginated-search-options.model'; -import { Observable } from 'rxjs/Observable'; import { SearchFilter } from '../search-filter.model'; +import { of as observableOf } from 'rxjs'; describe('SearchConfigurationService', () => { let service: SearchConfigurationService; @@ -24,8 +24,8 @@ describe('SearchConfigurationService', () => { const backendFilters = [new SearchFilter('f.author', ['another value']), new SearchFilter('f.date', ['[2013 TO 2018]'])]; const spy = jasmine.createSpyObj('RouteService', { - getQueryParameterValue: Observable.of(value1), - getQueryParamsWithPrefix: Observable.of(prefixFilter) + getQueryParameterValue: observableOf(value1), + getQueryParamsWithPrefix: observableOf(prefixFilter) }); const activatedRoute: any = new ActivatedRouteStub(); diff --git a/src/app/+search-page/search-service/search-configuration.service.ts b/src/app/+search-page/search-service/search-configuration.service.ts index b4c06e83f3..292f26724d 100644 --- a/src/app/+search-page/search-service/search-configuration.service.ts +++ b/src/app/+search-page/search-service/search-configuration.service.ts @@ -1,15 +1,21 @@ +import { + BehaviorSubject, + combineLatest as observableCombineLatest, + merge as observableMerge, + Observable, + of as observableOf, + Subscription +} from 'rxjs'; +import { filter, map } from 'rxjs/operators'; import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model'; import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model'; import { SearchOptions } from '../search-options.model'; -import { Observable } from 'rxjs/Observable'; import { ActivatedRoute, Params } from '@angular/router'; import { PaginatedSearchOptions } from '../paginated-search-options.model'; import { Injectable, OnDestroy } from '@angular/core'; import { RouteService } from '../../shared/services/route.service'; -import { hasNoValue, hasValue, isEmpty, isNotEmpty } from '../../shared/empty.util'; +import { hasNoValue, hasValue, isNotEmpty } from '../../shared/empty.util'; import { RemoteData } from '../../core/data/remote-data'; -import { BehaviorSubject } from 'rxjs/BehaviorSubject'; -import { Subscription } from 'rxjs/Subscription'; import { getSucceededRemoteData } from '../../core/shared/operators'; import { SearchFilter } from '../search-filter.model'; import { DSpaceObjectType } from '../../core/shared/dspace-object-type.model'; @@ -87,27 +93,27 @@ export class SearchConfigurationService implements OnDestroy { * @returns {Observable} Emits the current scope's identifier */ getCurrentScope(defaultScope: string) { - return this.routeService.getQueryParameterValue('scope').map((scope) => { + return this.routeService.getQueryParameterValue('scope').pipe(map((scope) => { return scope || defaultScope; - }); + })); } /** * @returns {Observable} Emits the current query string */ getCurrentQuery(defaultQuery: string) { - return this.routeService.getQueryParameterValue('query').map((query) => { + return this.routeService.getQueryParameterValue('query').pipe(map((query) => { return query || defaultQuery; - }); + })); } /** * @returns {Observable} Emits the current DSpaceObject type as a number */ getCurrentDSOType(): Observable { - return this.routeService.getQueryParameterValue('dsoType') - .filter((type) => hasValue(type) && hasValue(DSpaceObjectType[type.toUpperCase()])) - .map((type) => DSpaceObjectType[type.toUpperCase()]); + return this.routeService.getQueryParameterValue('dsoType').pipe( + filter((type) => hasValue(type) && hasValue(DSpaceObjectType[type.toUpperCase()])), + map((type) => DSpaceObjectType[type.toUpperCase()]),); } /** @@ -116,12 +122,13 @@ export class SearchConfigurationService implements OnDestroy { getCurrentPagination(defaultPagination: PaginationComponentOptions): Observable { const page$ = this.routeService.getQueryParameterValue('page'); const size$ = this.routeService.getQueryParameterValue('pageSize'); - return Observable.combineLatest(page$, size$, (page, size) => { - return Object.assign(new PaginationComponentOptions(), defaultPagination, { - currentPage: page || defaultPagination.currentPage, - pageSize: size || defaultPagination.pageSize - }); - }); + return observableCombineLatest(page$, size$).pipe(map(([page, size]) => { + return Object.assign(new PaginationComponentOptions(), defaultPagination, { + currentPage: page || defaultPagination.currentPage, + pageSize: size || defaultPagination.pageSize + }); + }) + ); } /** @@ -130,7 +137,7 @@ export class SearchConfigurationService implements OnDestroy { getCurrentSort(defaultSort: SortOptions): Observable { const sortDirection$ = this.routeService.getQueryParameterValue('sortDirection'); const sortField$ = this.routeService.getQueryParameterValue('sortField'); - return Observable.combineLatest(sortDirection$, sortField$, (sortDirection, sortField) => { + return observableCombineLatest(sortDirection$, sortField$).pipe(map(([sortDirection, sortField]) => { // Dirty fix because sometimes the observable value is null somehow sortField = this.route.snapshot.queryParamMap.get('sortField'); @@ -138,20 +145,21 @@ export class SearchConfigurationService implements OnDestroy { const direction = SortDirection[sortDirection] || defaultSort.direction; return new SortOptions(field, direction) } - ) + ) + ); } /** * @returns {Observable} Emits the current active filters with their values as they are sent to the backend */ getCurrentFilters(): Observable { - return this.routeService.getQueryParamsWithPrefix('f.').map((filterParams) => { + return this.routeService.getQueryParamsWithPrefix('f.').pipe(map((filterParams) => { if (isNotEmpty(filterParams)) { const filters = []; Object.keys(filterParams).forEach((key) => { if (key.endsWith('.min') || key.endsWith('.max')) { const realKey = key.slice(0, -4); - if (hasNoValue(filters.find((filter) => filter.key === realKey))) { + if (hasNoValue(filters.find((f) => f.key === realKey))) { const min = filterParams[realKey + '.min'] ? filterParams[realKey + '.min'][0] : '*'; const max = filterParams[realKey + '.max'] ? filterParams[realKey + '.max'][0] : '*'; filters.push(new SearchFilter(realKey, ['[' + min + ' TO ' + max + ']'])); @@ -163,7 +171,7 @@ export class SearchConfigurationService implements OnDestroy { return filters; } return []; - }); + })); } /** @@ -179,7 +187,7 @@ export class SearchConfigurationService implements OnDestroy { * @returns {Subscription} The subscription to unsubscribe from */ subscribeToSearchOptions(defaults: SearchOptions): Subscription { - return Observable.merge( + return observableMerge( this.getScopePart(defaults.scope), this.getQueryPart(defaults.query), this.getDSOTypePart(), @@ -197,7 +205,7 @@ export class SearchConfigurationService implements OnDestroy { * @returns {Subscription} The subscription to unsubscribe from */ subscribeToPaginatedSearchOptions(defaults: PaginatedSearchOptions): Subscription { - return Observable.merge( + return observableMerge( this.getPaginationPart(defaults.pagination), this.getSortPart(defaults.sort), this.getScopePart(defaults.scope), @@ -222,7 +230,7 @@ export class SearchConfigurationService implements OnDestroy { scope: this.defaultScope, query: this.defaultQuery }); - this._defaults = Observable.of(new RemoteData(false, false, true, null, options)); + this._defaults = observableOf(new RemoteData(false, false, true, null, options)); } return this._defaults; } @@ -240,53 +248,53 @@ export class SearchConfigurationService implements OnDestroy { * @returns {Observable} Emits the current scope's identifier */ private getScopePart(defaultScope: string): Observable { - return this.getCurrentScope(defaultScope).map((scope) => { + return this.getCurrentScope(defaultScope).pipe(map((scope) => { return { scope } - }); + })); } /** * @returns {Observable} Emits the current query string as a partial SearchOptions object */ private getQueryPart(defaultQuery: string): Observable { - return this.getCurrentQuery(defaultQuery).map((query) => { + return this.getCurrentQuery(defaultQuery).pipe(map((query) => { return { query } - }); + })); } /** * @returns {Observable} Emits the current query string as a partial SearchOptions object */ private getDSOTypePart(): Observable { - return this.getCurrentDSOType().map((dsoType) => { + return this.getCurrentDSOType().pipe(map((dsoType) => { return { dsoType } - }); + })); } /** * @returns {Observable} Emits the current pagination settings as a partial SearchOptions object */ private getPaginationPart(defaultPagination: PaginationComponentOptions): Observable { - return this.getCurrentPagination(defaultPagination).map((pagination) => { + return this.getCurrentPagination(defaultPagination).pipe(map((pagination) => { return { pagination } - }); + })); } /** * @returns {Observable} Emits the current sorting settings as a partial SearchOptions object */ private getSortPart(defaultSort: SortOptions): Observable { - return this.getCurrentSort(defaultSort).map((sort) => { + return this.getCurrentSort(defaultSort).pipe(map((sort) => { return { sort } - }); + })); } /** * @returns {Observable} Emits the current active filters as a partial SearchOptions object */ private getFiltersPart(): Observable { - return this.getCurrentFilters().map((filters) => { + return this.getCurrentFilters().pipe(map((filters) => { return { filters } - }); + })); } } diff --git a/src/app/+search-page/search-service/search.service.spec.ts b/src/app/+search-page/search-service/search.service.spec.ts index 85424a3c20..4af0ffcb2e 100644 --- a/src/app/+search-page/search-service/search.service.spec.ts +++ b/src/app/+search-page/search-service/search.service.spec.ts @@ -8,26 +8,25 @@ import { SearchService } from './search.service'; import { RemoteDataBuildService } from '../../core/cache/builders/remote-data-build.service'; import { ActivatedRoute, Router, UrlTree } from '@angular/router'; import { RequestService } from '../../core/data/request.service'; -import { ResponseCacheService } from '../../core/cache/response-cache.service'; import { ActivatedRouteStub } from '../../shared/testing/active-router-stub'; import { RouterStub } from '../../shared/testing/router-stub'; import { HALEndpointService } from '../../core/shared/hal-endpoint.service'; -import { Observable } from 'rxjs/Observable'; +import { Observable, combineLatest as observableCombineLatest } from 'rxjs'; import { PaginatedSearchOptions } from '../paginated-search-options.model'; import { RemoteData } from '../../core/data/remote-data'; -import { ResponseCacheEntry } from '../../core/cache/response-cache.reducer'; import { RequestEntry } from '../../core/data/request.reducer'; import { getMockRequestService } from '../../shared/mocks/mock-request.service'; -import { getMockResponseCacheService } from '../../shared/mocks/mock-response-cache.service'; import { FacetConfigSuccessResponse, SearchSuccessResponse -} from '../../core/cache/response-cache.models'; +} from '../../core/cache/response.models'; import { SearchQueryResponse } from './search-query-response.model'; import { SearchFilterConfig } from './search-filter-config.model'; import { CommunityDataService } from '../../core/data/community-data.service'; import { ViewMode } from '../../core/shared/view-mode.model'; import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service'; +import { of as observableOf } from 'rxjs'; +import { map } from 'rxjs/operators'; @Component({ template: '' }) class DummyComponent { @@ -52,12 +51,11 @@ describe('SearchService', () => { providers: [ { provide: Router, useValue: router }, { provide: ActivatedRoute, useValue: route }, - { provide: ResponseCacheService, useValue: getMockResponseCacheService() }, { provide: RequestService, useValue: getMockRequestService() }, { provide: RemoteDataBuildService, useValue: {} }, { provide: HALEndpointService, useValue: {} }, - { provide: CommunityDataService, useValue: {}}, - { provide: DSpaceObjectDataService, useValue: {}}, + { provide: CommunityDataService, useValue: {} }, + { provide: DSpaceObjectDataService, useValue: {} }, SearchService ], }); @@ -84,14 +82,15 @@ describe('SearchService', () => { }; const remoteDataBuildService = { - toRemoteDataObservable: (requestEntryObs: Observable, responseCacheObs: Observable, payloadObs: Observable) => { - return Observable.combineLatest(requestEntryObs, - responseCacheObs, payloadObs, (req, res, pay) => { - return { req, res, pay }; - }); + toRemoteDataObservable: (requestEntryObs: Observable, payloadObs: Observable) => { + return observableCombineLatest(requestEntryObs, payloadObs).pipe( + map(([req, pay]) => { + return { req, pay }; + }) + ); }, aggregate: (input: Array>>): Observable> => { - return Observable.of(new RemoteData(false, false, true, null, [])); + return observableOf(new RemoteData(false, false, true, null, [])); } }; @@ -109,12 +108,11 @@ describe('SearchService', () => { providers: [ { provide: Router, useValue: router }, { provide: ActivatedRoute, useValue: route }, - { provide: ResponseCacheService, useValue: getMockResponseCacheService() }, { provide: RequestService, useValue: getMockRequestService() }, { provide: RemoteDataBuildService, useValue: remoteDataBuildService }, { provide: HALEndpointService, useValue: halService }, - { provide: CommunityDataService, useValue: {}}, - { provide: DSpaceObjectDataService, useValue: {}}, + { provide: CommunityDataService, useValue: {} }, + { provide: DSpaceObjectDataService, useValue: {} }, SearchService ], }); @@ -158,10 +156,8 @@ describe('SearchService', () => { const searchOptions = new PaginatedSearchOptions({}); const queryResponse = Object.assign(new SearchQueryResponse(), { objects: [] }); const response = new SearchSuccessResponse(queryResponse, '200'); - const responseEntry = Object.assign(new ResponseCacheEntry(), { response: response }); beforeEach(() => { - spyOn((searchService as any).halService, 'getEndpoint').and.returnValue(Observable.of(endPoint)); - (searchService as any).responseCache.get.and.returnValue(Observable.of(responseEntry)); + spyOn((searchService as any).halService, 'getEndpoint').and.returnValue(observableOf(endPoint)); /* tslint:disable:no-empty */ searchService.search(searchOptions).subscribe((t) => { }); // subscribe to make sure all methods are called @@ -179,19 +175,14 @@ describe('SearchService', () => { it('should call getByHref on the request service with the correct request url', () => { expect((searchService as any).requestService.getByHref).toHaveBeenCalledWith(endPoint); }); - it('should call get on the request service with the correct request url', () => { - expect((searchService as any).responseCache.get).toHaveBeenCalledWith(endPoint); - }); }); describe('when getConfig is called without a scope', () => { const endPoint = 'http://endpoint.com/test/config'; const filterConfig = [new SearchFilterConfig()]; const response = new FacetConfigSuccessResponse(filterConfig, '200'); - const responseEntry = Object.assign(new ResponseCacheEntry(), { response: response }); beforeEach(() => { - spyOn((searchService as any).halService, 'getEndpoint').and.returnValue(Observable.of(endPoint)); - (searchService as any).responseCache.get.and.returnValue(Observable.of(responseEntry)); + spyOn((searchService as any).halService, 'getEndpoint').and.returnValue(observableOf(endPoint)); /* tslint:disable:no-empty */ searchService.getConfig(null).subscribe((t) => { }); // subscribe to make sure all methods are called @@ -209,9 +200,6 @@ describe('SearchService', () => { it('should call getByHref on the request service with the correct request url', () => { expect((searchService as any).requestService.getByHref).toHaveBeenCalledWith(endPoint); }); - it('should call get on the request service with the correct request url', () => { - expect((searchService as any).responseCache.get).toHaveBeenCalledWith(endPoint); - }); }); describe('when getConfig is called with a scope', () => { @@ -220,10 +208,8 @@ describe('SearchService', () => { const requestUrl = endPoint + '?scope=' + scope; const filterConfig = [new SearchFilterConfig()]; const response = new FacetConfigSuccessResponse(filterConfig, '200'); - const responseEntry = Object.assign(new ResponseCacheEntry(), { response: response }); beforeEach(() => { - spyOn((searchService as any).halService, 'getEndpoint').and.returnValue(Observable.of(endPoint)); - (searchService as any).responseCache.get.and.returnValue(Observable.of(responseEntry)); + spyOn((searchService as any).halService, 'getEndpoint').and.returnValue(observableOf(endPoint)); /* tslint:disable:no-empty */ searchService.getConfig(scope).subscribe((t) => { }); // subscribe to make sure all methods are called @@ -241,9 +227,6 @@ describe('SearchService', () => { it('should call getByHref on the request service with the correct request url', () => { expect((searchService as any).requestService.getByHref).toHaveBeenCalledWith(requestUrl); }); - it('should call get on the request service with the correct request url', () => { - expect((searchService as any).responseCache.get).toHaveBeenCalledWith(requestUrl); - }); }); }); }); diff --git a/src/app/+search-page/search-service/search.service.ts b/src/app/+search-page/search-service/search.service.ts index ac5f7a6169..275b0b3340 100644 --- a/src/app/+search-page/search-service/search.service.ts +++ b/src/app/+search-page/search-service/search.service.ts @@ -1,3 +1,4 @@ +import { combineLatest as observableCombineLatest, Observable, of as observableOf } from 'rxjs'; import { Injectable, OnDestroy } from '@angular/core'; import { ActivatedRoute, @@ -6,16 +7,13 @@ import { Router, UrlSegmentGroup } from '@angular/router'; -import { Observable } from 'rxjs/Observable'; -import { flatMap, map, switchMap } from 'rxjs/operators'; +import { map, switchMap, tap } from 'rxjs/operators'; import { RemoteDataBuildService } from '../../core/cache/builders/remote-data-build.service'; import { FacetConfigSuccessResponse, FacetValueSuccessResponse, SearchSuccessResponse -} from '../../core/cache/response-cache.models'; -import { ResponseCacheEntry } from '../../core/cache/response-cache.reducer'; -import { ResponseCacheService } from '../../core/cache/response-cache.service'; +} from '../../core/cache/response.models'; import { PaginatedList } from '../../core/data/paginated-list'; import { ResponseParsingService } from '../../core/data/parsing.service'; import { RemoteData } from '../../core/data/remote-data'; @@ -24,7 +22,11 @@ import { RequestService } from '../../core/data/request.service'; import { DSpaceObject } from '../../core/shared/dspace-object.model'; import { GenericConstructor } from '../../core/shared/generic-constructor'; import { HALEndpointService } from '../../core/shared/hal-endpoint.service'; -import { configureRequest, getSucceededRemoteData } from '../../core/shared/operators'; +import { + configureRequest, + getResponseFromEntry, + getSucceededRemoteData +} from '../../core/shared/operators'; import { URLCombiner } from '../../core/url-combiner/url-combiner'; import { hasValue, isEmpty, isNotEmpty } from '../../shared/empty.util'; import { NormalizedSearchResult } from '../normalized-search-result.model'; @@ -68,7 +70,6 @@ export class SearchService implements OnDestroy { constructor(private router: Router, private route: ActivatedRoute, - protected responseCache: ResponseCacheService, protected requestService: RequestService, private rdb: RemoteDataBuildService, private halService: HALEndpointService, @@ -98,16 +99,12 @@ export class SearchService implements OnDestroy { configureRequest(this.requestService) ); const requestEntryObs = requestObs.pipe( - flatMap((request: RestRequest) => this.requestService.getByHref(request.href)) - ); - - const responseCacheObs = requestObs.pipe( - flatMap((request: RestRequest) => this.responseCache.get(request.href)) + switchMap((request: RestRequest) => this.requestService.getByHref(request.href)) ); // get search results from response cache - const sqrObs: Observable = responseCacheObs.pipe( - map((entry: ResponseCacheEntry) => entry.response), + const sqrObs: Observable = requestEntryObs.pipe( + getResponseFromEntry(), map((response: SearchSuccessResponse) => response.results) ); @@ -115,39 +112,43 @@ export class SearchService implements OnDestroy { // Turn list of observable remote data DSO's into observable remote data object with list of DSO const dsoObs: Observable> = sqrObs.pipe( map((sqr: SearchQueryResponse) => { - return sqr.objects.map((nsr: NormalizedSearchResult) => - this.rdb.buildSingle(nsr.dspaceObject)); + return sqr.objects.map((nsr: NormalizedSearchResult) => { + return this.rdb.buildSingle(nsr.dspaceObject); + }) }), - flatMap((input: Array>>) => this.rdb.aggregate(input)) + switchMap((input: Array>>) => this.rdb.aggregate(input)), ); // Create search results again with the correct dso objects linked to each result - const tDomainListObs = Observable.combineLatest(sqrObs, dsoObs, (sqr: SearchQueryResponse, dsos: RemoteData) => { + const tDomainListObs = observableCombineLatest(sqrObs, dsoObs).pipe( + map(([sqr, dsos]) => { + return sqr.objects.map((object: NormalizedSearchResult, index: number) => { + let co = DSpaceObject; + if (dsos.payload[index]) { + const constructor: GenericConstructor = dsos.payload[index].constructor as GenericConstructor; + co = getSearchResultFor(constructor); + return Object.assign(new co(), object, { + dspaceObject: dsos.payload[index] + }); + } else { + return undefined; + } + }); + }) + ); - return sqr.objects.map((object: NormalizedSearchResult, index: number) => { - let co = DSpaceObject; - if (dsos.payload[index]) { - const constructor: GenericConstructor = dsos.payload[index].constructor as GenericConstructor; - co = getSearchResultFor(constructor); - return Object.assign(new co(), object, { - dspaceObject: dsos.payload[index] - }); - } else { - return undefined; - } - }); - }); - - const pageInfoObs: Observable = responseCacheObs.pipe( - map((entry: ResponseCacheEntry) => entry.response), + const pageInfoObs: Observable = requestEntryObs.pipe( + getResponseFromEntry(), map((response: FacetValueSuccessResponse) => response.pageInfo) ); - const payloadObs = Observable.combineLatest(tDomainListObs, pageInfoObs, (tDomainList, pageInfo) => { - return new PaginatedList(pageInfo, tDomainList); - }); + const payloadObs = observableCombineLatest(tDomainListObs, pageInfoObs).pipe( + map(([tDomainList, pageInfo]) => { + return new PaginatedList(pageInfo, tDomainList); + }) + ); - return this.rdb.toRemoteDataObservable(requestEntryObs, responseCacheObs, payloadObs); + return this.rdb.toRemoteDataObservable(requestEntryObs, payloadObs); } /** @@ -179,21 +180,17 @@ export class SearchService implements OnDestroy { ); const requestEntryObs = requestObs.pipe( - flatMap((request: RestRequest) => this.requestService.getByHref(request.href)) - ); - - const responseCacheObs = requestObs.pipe( - flatMap((request: RestRequest) => this.responseCache.get(request.href)) + switchMap((request: RestRequest) => this.requestService.getByHref(request.href)) ); // get search results from response cache - const facetConfigObs: Observable = responseCacheObs.pipe( - map((entry: ResponseCacheEntry) => entry.response), + const facetConfigObs: Observable = requestEntryObs.pipe( + getResponseFromEntry(), map((response: FacetConfigSuccessResponse) => response.results.map((result: any) => Object.assign(new SearchFilterConfig(), result))) ); - return this.rdb.toRemoteDataObservable(requestEntryObs, responseCacheObs, facetConfigObs); + return this.rdb.toRemoteDataObservable(requestEntryObs, facetConfigObs); } /** @@ -226,29 +223,27 @@ export class SearchService implements OnDestroy { ); const requestEntryObs = requestObs.pipe( - flatMap((request: RestRequest) => this.requestService.getByHref(request.href)) - ); - - const responseCacheObs = requestObs.pipe( - flatMap((request: RestRequest) => this.responseCache.get(request.href)) + switchMap((request: RestRequest) => this.requestService.getByHref(request.href)) ); // get search results from response cache - const facetValueObs: Observable = responseCacheObs.pipe( - map((entry: ResponseCacheEntry) => entry.response), + const facetValueObs: Observable = requestEntryObs.pipe( + getResponseFromEntry(), map((response: FacetValueSuccessResponse) => response.results) ); - const pageInfoObs: Observable = responseCacheObs.pipe( - map((entry: ResponseCacheEntry) => entry.response), + const pageInfoObs: Observable = requestEntryObs.pipe( + getResponseFromEntry(), map((response: FacetValueSuccessResponse) => response.pageInfo) ); - const payloadObs = Observable.combineLatest(facetValueObs, pageInfoObs, (facetValue, pageInfo) => { - return new PaginatedList(pageInfo, facetValue); - }); + const payloadObs = observableCombineLatest(facetValueObs, pageInfoObs).pipe( + map(([facetValue, pageInfo]) => { + return new PaginatedList(pageInfo, facetValue); + }) + ); - return this.rdb.toRemoteDataObservable(requestEntryObs, responseCacheObs, payloadObs); + return this.rdb.toRemoteDataObservable(requestEntryObs, payloadObs); } /** @@ -272,12 +267,14 @@ export class SearchService implements OnDestroy { switchMap((dsoRD: RemoteData) => { if (dsoRD.payload.type === ResourceType.Community) { const community: Community = dsoRD.payload as Community; - return Observable.combineLatest(community.subcommunities, community.collections, (subCommunities, collections) => { - /*if this is a community, we also need to show the direct children*/ - return [community, ...subCommunities.payload.page, ...collections.payload.page] - }) + return observableCombineLatest(community.subcommunities, community.collections).pipe( + map(([subCommunities, collections]) => { + /*if this is a community, we also need to show the direct children*/ + return [community, ...subCommunities.payload.page, ...collections.payload.page] + }) + ); } else { - return Observable.of([dsoRD.payload]); + return observableOf([dsoRD.payload]); } } )); @@ -291,13 +288,13 @@ export class SearchService implements OnDestroy { * @returns {Observable} The current view mode */ getViewMode(): Observable { - return this.route.queryParams.map((params) => { + return this.route.queryParams.pipe(map((params) => { if (isNotEmpty(params.view) && hasValue(params.view)) { return params.view; } else { return ViewMode.List; } - }); + })); } /** diff --git a/src/app/+search-page/search-settings/search-settings.component.spec.ts b/src/app/+search-page/search-settings/search-settings.component.spec.ts index 5e6dc9b369..b1585c4347 100644 --- a/src/app/+search-page/search-settings/search-settings.component.spec.ts +++ b/src/app/+search-page/search-settings/search-settings.component.spec.ts @@ -1,7 +1,7 @@ import { SearchService } from '../search-service/search.service'; import { async, ComponentFixture, TestBed } from '@angular/core/testing'; import { SearchSettingsComponent } from './search-settings.component'; -import { Observable } from 'rxjs/Observable'; +import { of as observableOf } from 'rxjs'; import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model'; import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model'; import { TranslateModule } from '@ngx-translate/core'; @@ -15,6 +15,7 @@ import { SearchFilterService } from '../search-filters/search-filter/search-filt import { hot } from 'jasmine-marbles'; import { VarDirective } from '../../shared/utils/var.directive'; import { SearchConfigurationService } from '../search-service/search-configuration.service'; +import { first } from 'rxjs/operators'; describe('SearchSettingsComponent', () => { @@ -43,16 +44,16 @@ describe('SearchSettingsComponent', () => { }; const activatedRouteStub = { - queryParams: Observable.of({ + queryParams: observableOf({ query: queryParam, scope: scopeParam }) }; const sidebarService = { - isCollapsed: Observable.of(true), - collapse: () => this.isCollapsed = Observable.of(true), - expand: () => this.isCollapsed = Observable.of(false) + isCollapsed: observableOf(true), + collapse: () => this.isCollapsed = observableOf(true), + expand: () => this.isCollapsed = observableOf(false) }; beforeEach(async(() => { @@ -101,7 +102,7 @@ describe('SearchSettingsComponent', () => { }); it('it should show the order settings with the respective selectable options', () => { - (comp as any).searchOptions$.first().subscribe((options) => { + (comp as any).searchOptions$.pipe(first()).subscribe((options) => { fixture.detectChanges(); const orderSetting = fixture.debugElement.query(By.css('div.result-order-settings')); expect(orderSetting).toBeDefined(); @@ -111,7 +112,7 @@ describe('SearchSettingsComponent', () => { }); it('it should show the size settings with the respective selectable options', () => { - (comp as any).searchOptions$.first().subscribe((options) => { + (comp as any).searchOptions$.pipe(first()).subscribe((options) => { fixture.detectChanges(); const pageSizeSetting = fixture.debugElement.query(By.css('div.page-size-settings')); expect(pageSizeSetting).toBeDefined(); @@ -122,7 +123,7 @@ describe('SearchSettingsComponent', () => { }); it('should have the proper order value selected by default', () => { - (comp as any).searchOptions$.first().subscribe((options) => { + (comp as any).searchOptions$.pipe(first()).subscribe((options) => { fixture.detectChanges(); const orderSetting = fixture.debugElement.query(By.css('div.result-order-settings')); const childElementToBeSelected = orderSetting.query(By.css('.form-control option[value="0"][selected="selected"]')); @@ -131,7 +132,7 @@ describe('SearchSettingsComponent', () => { }); it('should have the proper rpp value selected by default', () => { - (comp as any).searchOptions$.first().subscribe((options) => { + (comp as any).searchOptions$.pipe(first()).subscribe((options) => { fixture.detectChanges(); const pageSizeSetting = fixture.debugElement.query(By.css('div.page-size-settings')); const childElementToBeSelected = pageSizeSetting.query(By.css('.form-control option[value="10"][selected="selected"]')); diff --git a/src/app/+search-page/search-settings/search-settings.component.ts b/src/app/+search-page/search-settings/search-settings.component.ts index 81e2366e39..7fc5645fcc 100644 --- a/src/app/+search-page/search-settings/search-settings.component.ts +++ b/src/app/+search-page/search-settings/search-settings.component.ts @@ -3,8 +3,7 @@ import { SearchService } from '../search-service/search.service'; import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model'; import { ActivatedRoute, NavigationExtras, Router } from '@angular/router'; import { PaginatedSearchOptions } from '../paginated-search-options.model'; -import { SearchFilterService } from '../search-filters/search-filter/search-filter.service'; -import { Observable } from 'rxjs/Observable'; +import { Observable } from 'rxjs'; import { SearchConfigurationService } from '../search-service/search-configuration.service'; @Component({ diff --git a/src/app/+search-page/search-sidebar/search-sidebar.effects.spec.ts b/src/app/+search-page/search-sidebar/search-sidebar.effects.spec.ts index 146b1fdcdb..f34f6b72de 100644 --- a/src/app/+search-page/search-sidebar/search-sidebar.effects.spec.ts +++ b/src/app/+search-page/search-sidebar/search-sidebar.effects.spec.ts @@ -1,5 +1,5 @@ import { TestBed } from '@angular/core/testing'; -import { Observable } from 'rxjs/Observable'; +import { Observable } from 'rxjs'; import { provideMockActions } from '@ngrx/effects/testing'; import { cold, hot } from 'jasmine-marbles'; import * as fromRouter from '@ngrx/router-store'; diff --git a/src/app/+search-page/search-sidebar/search-sidebar.effects.ts b/src/app/+search-page/search-sidebar/search-sidebar.effects.ts index 758ef2320b..1f5fb0ef60 100644 --- a/src/app/+search-page/search-sidebar/search-sidebar.effects.ts +++ b/src/app/+search-page/search-sidebar/search-sidebar.effects.ts @@ -1,5 +1,6 @@ +import { map, tap, filter } from 'rxjs/operators'; import { Injectable } from '@angular/core'; -import { Effect, Actions } from '@ngrx/effects' +import { Effect, Actions, ofType } from '@ngrx/effects' import * as fromRouter from '@ngrx/router-store'; import { SearchSidebarCollapseAction } from './search-sidebar.actions'; @@ -12,10 +13,14 @@ import { URLBaser } from '../../core/url-baser/url-baser'; export class SearchSidebarEffects { private previousPath: string; @Effect() routeChange$ = this.actions$ - .ofType(fromRouter.ROUTER_NAVIGATION) - .filter((action) => this.previousPath !== this.getBaseUrl(action)) - .do((action) => {this.previousPath = this.getBaseUrl(action)}) - .map(() => new SearchSidebarCollapseAction()); + .pipe( + ofType(fromRouter.ROUTER_NAVIGATION), + filter((action) => this.previousPath !== this.getBaseUrl(action)), + tap((action) => { + this.previousPath = this.getBaseUrl(action) + }), + map(() => new SearchSidebarCollapseAction()) + ); constructor(private actions$: Actions) { diff --git a/src/app/+search-page/search-sidebar/search-sidebar.service.spec.ts b/src/app/+search-page/search-sidebar/search-sidebar.service.spec.ts index b6439be4df..0cccf9ea40 100644 --- a/src/app/+search-page/search-sidebar/search-sidebar.service.spec.ts +++ b/src/app/+search-page/search-sidebar/search-sidebar.service.spec.ts @@ -1,9 +1,8 @@ import { Store } from '@ngrx/store'; import { SearchSidebarService } from './search-sidebar.service'; import { AppState } from '../../app.reducer'; -import { async, inject, TestBed } from '@angular/core/testing'; -import { Observable } from 'rxjs/Observable'; -import 'rxjs/add/observable/of'; +import { async, TestBed } from '@angular/core/testing'; +import { of as observableOf } from 'rxjs'; import { SearchSidebarCollapseAction, SearchSidebarExpandAction } from './search-sidebar.actions'; import { HostWindowService } from '../../shared/host-window.service'; @@ -13,13 +12,13 @@ describe('SearchSidebarService', () => { /* tslint:disable:no-empty */ dispatch: {}, /* tslint:enable:no-empty */ - select: Observable.of(true) + pipe: observableOf(true) }); const windowService = jasmine.createSpyObj('hostWindowService', { - isXs: Observable.of(true), - isSm: Observable.of(false), - isXsOrSm: Observable.of(true) + isXs: observableOf(true), + isSm: observableOf(false), + isXsOrSm: observableOf(true) }); beforeEach(async(() => { TestBed.configureTestingModule({ diff --git a/src/app/+search-page/search-sidebar/search-sidebar.service.ts b/src/app/+search-page/search-sidebar/search-sidebar.service.ts index 8cf9339c5c..7185984538 100644 --- a/src/app/+search-page/search-sidebar/search-sidebar.service.ts +++ b/src/app/+search-page/search-sidebar/search-sidebar.service.ts @@ -1,10 +1,11 @@ +import { combineLatest as observableCombineLatest, Observable } from 'rxjs'; import { Injectable } from '@angular/core'; import { SearchSidebarState } from './search-sidebar.reducer'; -import { createSelector, Store } from '@ngrx/store'; +import { createSelector, select, Store } from '@ngrx/store'; import { SearchSidebarCollapseAction, SearchSidebarExpandAction } from './search-sidebar.actions'; -import { Observable } from 'rxjs/Observable'; import { AppState } from '../../app.reducer'; import { HostWindowService } from '../../shared/host-window.service'; +import { map } from 'rxjs/operators'; const sidebarStateSelector = (state: AppState) => state.searchSidebar; const sidebarCollapsedSelector = createSelector(sidebarStateSelector, (sidebar: SearchSidebarState) => sidebar.sidebarCollapsed); @@ -26,7 +27,7 @@ export class SearchSidebarService { constructor(private store: Store, private windowService: HostWindowService) { this.isXsOrSm$ = this.windowService.isXsOrSm(); - this.isCollapsedInStore = this.store.select(sidebarCollapsedSelector); + this.isCollapsedInStore = this.store.pipe(select(sidebarCollapsedSelector)); } /** @@ -34,10 +35,12 @@ export class SearchSidebarService { * @returns {Observable} Emits true if the user's screen size is mobile or when the state in the store is currently collapsed */ get isCollapsed(): Observable { - return Observable.combineLatest( + return observableCombineLatest( this.isXsOrSm$, - this.isCollapsedInStore, - (mobile, store) => mobile ? store : true); + this.isCollapsedInStore + ).pipe( + map(([mobile, store]) => mobile ? store : true) + ); } /** diff --git a/src/app/app.component.spec.ts b/src/app/app.component.spec.ts index 0e5af10bcc..c88b999786 100644 --- a/src/app/app.component.spec.ts +++ b/src/app/app.component.spec.ts @@ -92,7 +92,7 @@ describe('App component', () => { let store: Store; beforeEach(() => { - store = fixture.debugElement.injector.get(Store); + store = fixture.debugElement.injector.get(Store) as Store; spyOn(store, 'dispatch'); window.dispatchEvent(new Event('resize')); diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 092c61bdf9..7d4bfe4f33 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -1,3 +1,4 @@ +import { filter, first, take } from 'rxjs/operators'; import { AfterViewInit, ChangeDetectionStrategy, @@ -9,7 +10,7 @@ import { } from '@angular/core'; import { NavigationCancel, NavigationEnd, NavigationStart, Router } from '@angular/router'; -import { Store } from '@ngrx/store'; +import { select, Store } from '@ngrx/store'; import { TranslateService } from '@ngx-translate/core'; @@ -62,10 +63,10 @@ export class AppComponent implements OnInit, AfterViewInit { this.dispatchWindowSize(this._window.nativeWindow.innerWidth, this._window.nativeWindow.innerHeight); // Whether is not authenticathed try to retrieve a possible stored auth token - this.store.select(isAuthenticated) - .take(1) - .filter((authenticated) => !authenticated) - .subscribe((authenticated) => this.authService.checkAuthenticationToken()); + this.store.pipe(select(isAuthenticated), + first(), + filter((authenticated) => !authenticated) + ).subscribe((authenticated) => this.authService.checkAuthenticationToken()); } diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 528c84fd3b..9618dfaca3 100755 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -45,54 +45,72 @@ export function getMetaReducers(config: GlobalConfig): Array { - const [successResponse, errorResponse] = this.responseCache.get(request.href) - .map((entry: ResponseCacheEntry) => entry.response) + return this.requestService.getByUUID(request.uuid).pipe( + getResponseFromEntry(), // TODO to review when https://github.com/DSpace/dspace-angular/issues/217 will be fixed - .do(() => this.responseCache.remove(request.href)) - .partition((response: RestResponse) => response.isSuccessful); - return Observable.merge( - errorResponse.flatMap((response: ErrorResponse) => - Observable.throw(new Error(response.errorMessage))), - successResponse - .filter((response: AuthStatusResponse) => isNotEmpty(response)) - .map((response: AuthStatusResponse) => response.response) - .distinctUntilChanged()); + // tap(() => this.responseCache.remove(request.href)), + mergeMap((response) => { + if (response.isSuccessful && isNotEmpty(response)) { + return observableOf((response as AuthStatusResponse).response); + } else if (!response.isSuccessful) { + return observableThrowError(new Error((response as ErrorResponse).errorMessage)); + } + }) + ); } protected getEndpointByMethod(endpoint: string, method: string): string { @@ -42,24 +42,24 @@ export class AuthRequestService { } public postToEndpoint(method: string, body: any, options?: HttpOptions): Observable { - return this.halService.getEndpoint(this.linkName) - .filter((href: string) => isNotEmpty(href)) - .map((endpointURL) => this.getEndpointByMethod(endpointURL, method)) - .distinctUntilChanged() - .map((endpointURL: string) => new AuthPostRequest(this.requestService.generateRequestId(), endpointURL, body, options)) - .do((request: PostRequest) => this.requestService.configure(request, true)) - .flatMap((request: PostRequest) => this.fetchRequest(request)) - .distinctUntilChanged(); + return this.halService.getEndpoint(this.linkName).pipe( + filter((href: string) => isNotEmpty(href)), + map((endpointURL) => this.getEndpointByMethod(endpointURL, method)), + distinctUntilChanged(), + map((endpointURL: string) => new AuthPostRequest(this.requestService.generateRequestId(), endpointURL, body, options)), + tap((request: PostRequest) => this.requestService.configure(request, true)), + mergeMap((request: PostRequest) => this.fetchRequest(request)), + distinctUntilChanged()); } public getRequest(method: string, options?: HttpOptions): Observable { - return this.halService.getEndpoint(this.linkName) - .filter((href: string) => isNotEmpty(href)) - .map((endpointURL) => this.getEndpointByMethod(endpointURL, method)) - .distinctUntilChanged() - .map((endpointURL: string) => new AuthGetRequest(this.requestService.generateRequestId(), endpointURL, options)) - .do((request: PostRequest) => this.requestService.configure(request, true)) - .flatMap((request: PostRequest) => this.fetchRequest(request)) - .distinctUntilChanged(); + return this.halService.getEndpoint(this.linkName).pipe( + filter((href: string) => isNotEmpty(href)), + map((endpointURL) => this.getEndpointByMethod(endpointURL, method)), + distinctUntilChanged(), + map((endpointURL: string) => new AuthGetRequest(this.requestService.generateRequestId(), endpointURL, options)), + tap((request: PostRequest) => this.requestService.configure(request, true)), + mergeMap((request: PostRequest) => this.fetchRequest(request)), + distinctUntilChanged()); } } diff --git a/src/app/core/auth/auth-response-parsing.service.spec.ts b/src/app/core/auth/auth-response-parsing.service.spec.ts index f6dd87e99a..a4131db489 100644 --- a/src/app/core/auth/auth-response-parsing.service.spec.ts +++ b/src/app/core/auth/auth-response-parsing.service.spec.ts @@ -1,21 +1,18 @@ -import { AuthStatusResponse } from '../cache/response-cache.models'; +import { AuthStatusResponse } from '../cache/response.models'; import { ObjectCacheService } from '../cache/object-cache.service'; -import { GlobalConfig } from '../../../config/global-config.interface'; - -import { Store } from '@ngrx/store'; -import { CoreState } from '../core.reducers'; import { AuthStatus } from './models/auth-status.model'; import { AuthResponseParsingService } from './auth-response-parsing.service'; import { AuthGetRequest, AuthPostRequest } from '../data/request.models'; -import { getMockStore } from '../../shared/mocks/mock-store'; +import { MockStore } from '../../shared/testing/mock-store'; +import { ObjectCacheState } from '../cache/object-cache.reducer'; describe('AuthResponseParsingService', () => { let service: AuthResponseParsingService; - const EnvConfig = {cache: {msToLive: 1000}} as GlobalConfig; - const store = getMockStore() as Store; - const objectCacheService = new ObjectCacheService(store); + const EnvConfig = { cache: { msToLive: 1000 } } as any; + const store = new MockStore({}); + const objectCacheService = new ObjectCacheService(store as any); beforeEach(() => { service = new AuthResponseParsingService(EnvConfig, objectCacheService); diff --git a/src/app/core/auth/auth-response-parsing.service.ts b/src/app/core/auth/auth-response-parsing.service.ts index 8efa36f9e2..61559991ec 100644 --- a/src/app/core/auth/auth-response-parsing.service.ts +++ b/src/app/core/auth/auth-response-parsing.service.ts @@ -2,7 +2,7 @@ import { Inject, Injectable } from '@angular/core'; import { AuthObjectFactory } from './auth-object-factory'; import { BaseResponseParsingService } from '../data/base-response-parsing.service'; -import { AuthStatusResponse, RestResponse } from '../cache/response-cache.models'; +import { AuthStatusResponse, RestResponse } from '../cache/response.models'; import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; import { GLOBAL_CONFIG } from '../../../config'; import { GlobalConfig } from '../../../config/global-config.interface'; @@ -27,7 +27,7 @@ export class AuthResponseParsingService extends BaseResponseParsingService imple parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse { if (isNotEmpty(data.payload) && isNotEmpty(data.payload._links) && (data.statusCode === '200' || data.statusCode === 'OK')) { - const response = this.process(data.payload, request.href); + const response = this.process(data.payload, request.uuid); return new AuthStatusResponse(response, data.statusCode); } else { return new AuthStatusResponse(data.payload as AuthStatus, data.statusCode); diff --git a/src/app/core/auth/auth.effects.spec.ts b/src/app/core/auth/auth.effects.spec.ts index d75b407516..0dc8abf860 100644 --- a/src/app/core/auth/auth.effects.spec.ts +++ b/src/app/core/auth/auth.effects.spec.ts @@ -4,8 +4,7 @@ import { provideMockActions } from '@ngrx/effects/testing'; import { Store } from '@ngrx/store'; import { cold, hot } from 'jasmine-marbles'; -import { Observable } from 'rxjs/Observable'; -import 'rxjs/add/observable/of' +import { Observable, of as observableOf, throwError as observableThrow } from 'rxjs'; import { AuthEffects } from './auth.effects'; import { @@ -30,16 +29,21 @@ import { EPersonMock } from '../../shared/testing/eperson-mock'; describe('AuthEffects', () => { let authEffects: AuthEffects; let actions: Observable; - const authServiceStub = new AuthServiceStub(); + let authServiceStub; const store: Store = jasmine.createSpyObj('store', { /* tslint:disable:no-empty */ dispatch: {}, /* tslint:enable:no-empty */ - select: Observable.of(true) + select: observableOf(true) }); - const token = authServiceStub.getToken(); + let token; + function init() { + authServiceStub = new AuthServiceStub(); + token = authServiceStub.getToken(); + } beforeEach(() => { + init(); TestBed.configureTestingModule({ providers: [ AuthEffects, @@ -71,7 +75,7 @@ describe('AuthEffects', () => { describe('when credentials are wrong', () => { it('should return a AUTHENTICATE_ERROR action in response to a AUTHENTICATE action', () => { - spyOn((authEffects as any).authService, 'authenticate').and.returnValue(Observable.throw(new Error('Message Error test'))); + spyOn((authEffects as any).authService, 'authenticate').and.returnValue(observableThrow(new Error('Message Error test'))); actions = hot('--a-', { a: { @@ -112,7 +116,7 @@ describe('AuthEffects', () => { describe('when token is not valid', () => { it('should return a AUTHENTICATED_ERROR action in response to a AUTHENTICATED action', () => { - spyOn((authEffects as any).authService, 'authenticatedUser').and.returnValue(Observable.throw(new Error('Message Error test'))); + spyOn((authEffects as any).authService, 'authenticatedUser').and.returnValue(observableThrow(new Error('Message Error test'))); actions = hot('--a-', {a: {type: AuthActionTypes.AUTHENTICATED, payload: token}}); @@ -138,7 +142,7 @@ describe('AuthEffects', () => { describe('when check token failed', () => { it('should return a CHECK_AUTHENTICATION_TOKEN_ERROR action in response to a CHECK_AUTHENTICATION_TOKEN action', () => { - spyOn((authEffects as any).authService, 'hasValidAuthenticationToken').and.returnValue(Observable.throw('')); + spyOn((authEffects as any).authService, 'hasValidAuthenticationToken').and.returnValue(observableThrow('')); actions = hot('--a-', {a: {type: AuthActionTypes.CHECK_AUTHENTICATION_TOKEN, payload: token}}); @@ -164,7 +168,7 @@ describe('AuthEffects', () => { describe('when refresh token failed', () => { it('should return a REFRESH_TOKEN_ERROR action in response to a REFRESH_TOKEN action', () => { - spyOn((authEffects as any).authService, 'refreshAuthenticationToken').and.returnValue(Observable.throw('')); + spyOn((authEffects as any).authService, 'refreshAuthenticationToken').and.returnValue(observableThrow('')); actions = hot('--a-', {a: {type: AuthActionTypes.REFRESH_TOKEN, payload: token}}); @@ -190,7 +194,7 @@ describe('AuthEffects', () => { describe('when refresh token failed', () => { it('should return a REFRESH_TOKEN_ERROR action in response to a LOG_OUT action', () => { - spyOn((authEffects as any).authService, 'logout').and.returnValue(Observable.throw(new Error('Message Error test'))); + spyOn((authEffects as any).authService, 'logout').and.returnValue(observableThrow(new Error('Message Error test'))); actions = hot('--a-', {a: {type: AuthActionTypes.LOG_OUT, payload: token}}); diff --git a/src/app/core/auth/auth.effects.ts b/src/app/core/auth/auth.effects.ts index 82def702eb..c57fa3f70e 100644 --- a/src/app/core/auth/auth.effects.ts +++ b/src/app/core/auth/auth.effects.ts @@ -1,11 +1,11 @@ +import { of as observableOf, Observable } from 'rxjs'; + +import { filter, debounceTime, switchMap, take, tap, catchError, map, first } from 'rxjs/operators'; import { Injectable } from '@angular/core'; // import @ngrx -import { Actions, Effect } from '@ngrx/effects'; -import { Action, Store } from '@ngrx/store'; - -// import rxjs -import { Observable } from 'rxjs/Observable'; +import { Actions, Effect, ofType } from '@ngrx/effects'; +import { Action, select, Store } from '@ngrx/store'; // import services import { AuthService } from './auth.service'; @@ -43,112 +43,131 @@ export class AuthEffects { * @method authenticate */ @Effect() - public authenticate$: Observable = this.actions$ - .ofType(AuthActionTypes.AUTHENTICATE) - .switchMap((action: AuthenticateAction) => { - return this.authService.authenticate(action.payload.email, action.payload.password) - .first() - .map((response: AuthStatus) => new AuthenticationSuccessAction(response.token)) - .catch((error) => Observable.of(new AuthenticationErrorAction(error))); - }); + public authenticate$: Observable = this.actions$.pipe( + ofType(AuthActionTypes.AUTHENTICATE), + switchMap((action: AuthenticateAction) => { + return this.authService.authenticate(action.payload.email, action.payload.password).pipe( + first(), + map((response: AuthStatus) => new AuthenticationSuccessAction(response.token)), + catchError((error) => observableOf(new AuthenticationErrorAction(error))) + ); + }) + ); @Effect() - public authenticateSuccess$: Observable = this.actions$ - .ofType(AuthActionTypes.AUTHENTICATE_SUCCESS) - .do((action: AuthenticationSuccessAction) => this.authService.storeToken(action.payload)) - .map((action: AuthenticationSuccessAction) => new AuthenticatedAction(action.payload)); + public authenticateSuccess$: Observable = this.actions$.pipe( + ofType(AuthActionTypes.AUTHENTICATE_SUCCESS), + tap((action: AuthenticationSuccessAction) => this.authService.storeToken(action.payload)), + map((action: AuthenticationSuccessAction) => new AuthenticatedAction(action.payload)) + ); @Effect() - public authenticated$: Observable = this.actions$ - .ofType(AuthActionTypes.AUTHENTICATED) - .switchMap((action: AuthenticatedAction) => { - return this.authService.authenticatedUser(action.payload) - .map((user: EPerson) => new AuthenticatedSuccessAction((user !== null), action.payload, user)) - .catch((error) => Observable.of(new AuthenticatedErrorAction(error))); - }); + public authenticated$: Observable = this.actions$.pipe( + ofType(AuthActionTypes.AUTHENTICATED), + switchMap((action: AuthenticatedAction) => { + return this.authService.authenticatedUser(action.payload).pipe( + map((user: EPerson) => new AuthenticatedSuccessAction((user !== null), action.payload, user)), + catchError((error) => observableOf(new AuthenticatedErrorAction(error))),); + }) + ); // It means "reacts to this action but don't send another" - @Effect({dispatch: false}) - public authenticatedError$: Observable = this.actions$ - .ofType(AuthActionTypes.AUTHENTICATED_ERROR) - .do((action: LogOutSuccessAction) => this.authService.removeToken()); + @Effect({ dispatch: false }) + public authenticatedError$: Observable = this.actions$.pipe( + ofType(AuthActionTypes.AUTHENTICATED_ERROR), + tap((action: LogOutSuccessAction) => this.authService.removeToken()) + ); @Effect() - public checkToken$: Observable = this.actions$ - .ofType(AuthActionTypes.CHECK_AUTHENTICATION_TOKEN) - .switchMap(() => { - return this.authService.hasValidAuthenticationToken() - .map((token: AuthTokenInfo) => new AuthenticatedAction(token)) - .catch((error) => Observable.of(new CheckAuthenticationTokenErrorAction())); - }); + public checkToken$: Observable = this.actions$.pipe(ofType(AuthActionTypes.CHECK_AUTHENTICATION_TOKEN), + switchMap(() => { + return this.authService.hasValidAuthenticationToken().pipe( + map((token: AuthTokenInfo) => new AuthenticatedAction(token)), + catchError((error) => observableOf(new CheckAuthenticationTokenErrorAction())) + ); + }) + ); @Effect() - public createUser$: Observable = this.actions$ - .ofType(AuthActionTypes.REGISTRATION) - .debounceTime(500) // to remove when functionality is implemented - .switchMap((action: RegistrationAction) => { - return this.authService.create(action.payload) - .map((user: EPerson) => new RegistrationSuccessAction(user)) - .catch((error) => Observable.of(new RegistrationErrorAction(error))); - }); + public createUser$: Observable = this.actions$.pipe( + ofType(AuthActionTypes.REGISTRATION), + debounceTime(500), // to remove when functionality is implemented + switchMap((action: RegistrationAction) => { + return this.authService.create(action.payload).pipe( + map((user: EPerson) => new RegistrationSuccessAction(user)), + catchError((error) => observableOf(new RegistrationErrorAction(error))) + ); + }) + ); @Effect() - public refreshToken$: Observable = this.actions$ - .ofType(AuthActionTypes.REFRESH_TOKEN) - .switchMap((action: RefreshTokenAction) => { - return this.authService.refreshAuthenticationToken(action.payload) - .map((token: AuthTokenInfo) => new RefreshTokenSuccessAction(token)) - .catch((error) => Observable.of(new RefreshTokenErrorAction())); - }); + public refreshToken$: Observable = this.actions$.pipe(ofType(AuthActionTypes.REFRESH_TOKEN), + switchMap((action: RefreshTokenAction) => { + return this.authService.refreshAuthenticationToken(action.payload).pipe( + map((token: AuthTokenInfo) => new RefreshTokenSuccessAction(token)), + catchError((error) => observableOf(new RefreshTokenErrorAction())) + ); + }) + ); // It means "reacts to this action but don't send another" - @Effect({dispatch: false}) - public refreshTokenSuccess$: Observable = this.actions$ - .ofType(AuthActionTypes.REFRESH_TOKEN_SUCCESS) - .do((action: RefreshTokenSuccessAction) => this.authService.replaceToken(action.payload)); + @Effect({ dispatch: false }) + public refreshTokenSuccess$: Observable = this.actions$.pipe( + ofType(AuthActionTypes.REFRESH_TOKEN_SUCCESS), + tap((action: RefreshTokenSuccessAction) => this.authService.replaceToken(action.payload)) + ); /** * When the store is rehydrated in the browser, * clear a possible invalid token or authentication errors */ - @Effect({dispatch: false}) - public clearInvalidTokenOnRehydrate$: Observable = this.actions$ - .ofType(StoreActionTypes.REHYDRATE) - .switchMap(() => { - return this.store.select(isAuthenticated) - .take(1) - .filter((authenticated) => !authenticated) - .do(() => this.authService.removeToken()) - .do(() => this.authService.resetAuthenticationError()); - }); + @Effect({ dispatch: false }) + public clearInvalidTokenOnRehydrate$: Observable = this.actions$.pipe( + ofType(StoreActionTypes.REHYDRATE), + switchMap(() => { + return this.store.pipe( + select(isAuthenticated), + first(), + filter((authenticated) => !authenticated), + tap(() => this.authService.removeToken()), + tap(() => this.authService.resetAuthenticationError()) + ); + })); @Effect() public logOut$: Observable = this.actions$ - .ofType(AuthActionTypes.LOG_OUT) - .switchMap(() => { - return this.authService.logout() - .map((value) => new LogOutSuccessAction()) - .catch((error) => Observable.of(new LogOutErrorAction(error))); - }); + .pipe( + ofType(AuthActionTypes.LOG_OUT), + switchMap(() => { + return this.authService.logout().pipe( + map((value) => new LogOutSuccessAction()), + catchError((error) => observableOf(new LogOutErrorAction(error))) + ); + }) + ); - @Effect({dispatch: false}) + @Effect({ dispatch: false }) public logOutSuccess$: Observable = this.actions$ - .ofType(AuthActionTypes.LOG_OUT_SUCCESS) - .do(() => this.authService.removeToken()) - .do(() => this.authService.clearRedirectUrl()) - .do(() => this.authService.refreshAfterLogout()); + .pipe(ofType(AuthActionTypes.LOG_OUT_SUCCESS), + tap(() => this.authService.removeToken()), + tap(() => this.authService.clearRedirectUrl()), + tap(() => this.authService.refreshAfterLogout()) + ); - @Effect({dispatch: false}) + @Effect({ dispatch: false }) public redirectToLogin$: Observable = this.actions$ - .ofType(AuthActionTypes.REDIRECT_AUTHENTICATION_REQUIRED) - .do(() => this.authService.removeToken()) - .do(() => this.authService.redirectToLogin()); + .pipe(ofType(AuthActionTypes.REDIRECT_AUTHENTICATION_REQUIRED), + tap(() => this.authService.removeToken()), + tap(() => this.authService.redirectToLogin()) + ); - @Effect({dispatch: false}) + @Effect({ dispatch: false }) public redirectToLoginTokenExpired$: Observable = this.actions$ - .ofType(AuthActionTypes.REDIRECT_TOKEN_EXPIRED) - .do(() => this.authService.removeToken()) - .do(() => this.authService.redirectToLoginWhenTokenExpired()); + .pipe( + ofType(AuthActionTypes.REDIRECT_TOKEN_EXPIRED), + tap(() => this.authService.removeToken()), + tap(() => this.authService.redirectToLoginWhenTokenExpired()) + ); /** * @constructor diff --git a/src/app/core/auth/auth.interceptor.spec.ts b/src/app/core/auth/auth.interceptor.spec.ts index 528c2cfab3..72b0cc2616 100644 --- a/src/app/core/auth/auth.interceptor.spec.ts +++ b/src/app/core/auth/auth.interceptor.spec.ts @@ -4,15 +4,15 @@ import { HTTP_INTERCEPTORS } from '@angular/common/http'; import { Router } from '@angular/router'; import { Store } from '@ngrx/store'; -import { Observable } from 'rxjs/Observable'; +import { of as observableOf } from 'rxjs'; import { AuthInterceptor } from './auth.interceptor'; import { AuthService } from './auth.service'; import { DSpaceRESTv2Service } from '../dspace-rest-v2/dspace-rest-v2.service'; -import { RestRequestMethod } from '../data/request.models'; import { RouterStub } from '../../shared/testing/router-stub'; import { TruncatablesState } from '../../shared/truncatable/truncatable.reducer'; import { AuthServiceStub } from '../../shared/testing/auth-service-stub'; +import { RestRequestMethod } from '../data/rest-request-method'; describe(`AuthInterceptor`, () => { let service: DSpaceRESTv2Service; @@ -23,7 +23,7 @@ describe(`AuthInterceptor`, () => { /* tslint:disable:no-empty */ dispatch: {}, /* tslint:enable:no-empty */ - select: Observable.of(true) + select: observableOf(true) }); beforeEach(() => { @@ -49,7 +49,7 @@ describe(`AuthInterceptor`, () => { describe('when has a valid token', () => { it('should not add an Authorization header when we’re sending a HTTP request to \'authn\' endpoint', () => { - service.request(RestRequestMethod.Post, 'dspace-spring-rest/api/authn/login', 'password=password&user=user').subscribe((response) => { + service.request(RestRequestMethod.POST, 'dspace-spring-rest/api/authn/login', 'password=password&user=user').subscribe((response) => { expect(response).toBeTruthy(); }); @@ -60,7 +60,7 @@ describe(`AuthInterceptor`, () => { }); it('should add an Authorization header when we’re sending a HTTP request to \'authn\' endpoint', () => { - service.request(RestRequestMethod.Post, 'dspace-spring-rest/api/submission/workspaceitems', 'test').subscribe((response) => { + service.request(RestRequestMethod.POST, 'dspace-spring-rest/api/submission/workspaceitems', 'test').subscribe((response) => { expect(response).toBeTruthy(); }); @@ -85,11 +85,11 @@ describe(`AuthInterceptor`, () => { it('should redirect to login', () => { - service.request(RestRequestMethod.Post, 'dspace-spring-rest/api/submission/workspaceitems', 'password=password&user=user').subscribe((response) => { + service.request(RestRequestMethod.POST, 'dspace-spring-rest/api/submission/workspaceitems', 'password=password&user=user').subscribe((response) => { expect(response).toBeTruthy(); }); - service.request(RestRequestMethod.Post, 'dspace-spring-rest/api/submission/workspaceitems', 'password=password&user=user'); + service.request(RestRequestMethod.POST, 'dspace-spring-rest/api/submission/workspaceitems', 'password=password&user=user'); httpMock.expectNone('dspace-spring-rest/api/submission/workspaceitems'); }); diff --git a/src/app/core/auth/auth.interceptor.ts b/src/app/core/auth/auth.interceptor.ts index f38abb90bc..dd9e3fb5e7 100644 --- a/src/app/core/auth/auth.interceptor.ts +++ b/src/app/core/auth/auth.interceptor.ts @@ -1,13 +1,16 @@ +import { Observable, of as observableOf, throwError as observableThrowError } from 'rxjs'; + +import { catchError, filter, map } from 'rxjs/operators'; import { Injectable, Injector } from '@angular/core'; import { - HttpEvent, HttpInterceptor, HttpHandler, HttpRequest, HttpResponse, - HttpErrorResponse, HttpResponseBase + HttpErrorResponse, + HttpEvent, + HttpHandler, + HttpInterceptor, + HttpRequest, + HttpResponse, + HttpResponseBase } from '@angular/common/http'; - -import { Observable } from 'rxjs/Rx'; -import 'rxjs/add/observable/throw' -import 'rxjs/add/operator/catch'; - import { find } from 'lodash'; import { AppState } from '../../app.reducer'; @@ -79,11 +82,11 @@ export class AuthInterceptor implements HttpInterceptor { // The access token is expired // Redirect to the login route this.store.dispatch(new RedirectWhenTokenExpiredAction('auth.messages.expired')); - return Observable.of(null); + return observableOf(null); } else if (!this.isAuthRequest(req) && isNotEmpty(token)) { // Intercept a request that is not to the authentication endpoint - authService.isTokenExpiring() - .filter((isExpiring) => isExpiring) + authService.isTokenExpiring().pipe( + filter((isExpiring) => isExpiring)) .subscribe(() => { // If the current request url is already in the refresh token request list, skip it if (isUndefined(find(this.refreshTokenRequestUrls, req.url))) { @@ -101,8 +104,8 @@ export class AuthInterceptor implements HttpInterceptor { } // Pass on the new request instead of the original request. - return next.handle(newReq) - .map((response) => { + return next.handle(newReq).pipe( + map((response) => { // Intercept a Login/Logout response if (response instanceof HttpResponse && this.isSuccess(response) && (this.isLoginResponse(response) || this.isLogoutResponse(response))) { // It's a success Login/Logout response @@ -122,8 +125,8 @@ export class AuthInterceptor implements HttpInterceptor { } else { return response; } - }) - .catch((error, caught) => { + }), + catchError((error, caught) => { // Intercept an error response if (error instanceof HttpErrorResponse) { // Checks if is a response from a request to an authentication endpoint @@ -138,7 +141,7 @@ export class AuthInterceptor implements HttpInterceptor { statusText: error.statusText, url: error.url }); - return Observable.of(authResponse); + return observableOf(authResponse); } else if (this.isUnauthorized(error)) { // The access token provided is expired, revoked, malformed, or invalid for other reasons // Redirect to the login route @@ -146,8 +149,7 @@ export class AuthInterceptor implements HttpInterceptor { } } // Return error response as is. - return Observable.throw(error); - }) as any; - + return observableThrowError(error); + })) as any; } } diff --git a/src/app/core/auth/auth.service.spec.ts b/src/app/core/auth/auth.service.spec.ts index c943a815e7..187db93f3c 100644 --- a/src/app/core/auth/auth.service.spec.ts +++ b/src/app/core/auth/auth.service.spec.ts @@ -3,9 +3,8 @@ import { CommonModule } from '@angular/common'; import { ActivatedRoute, Router } from '@angular/router'; import { Store, StoreModule } from '@ngrx/store'; -import { Observable } from 'rxjs/Observable'; import { REQUEST } from '@nguniversal/express-engine/tokens'; -import 'rxjs/add/observable/of'; +import { of as observableOf } from 'rxjs'; import { authReducer, AuthState } from './auth.reducer'; import { NativeWindowRef, NativeWindowService } from '../../shared/services/window.service'; @@ -29,41 +28,53 @@ describe('AuthService test', () => { const mockStore: Store = jasmine.createSpyObj('store', { dispatch: {}, - select: Observable.of(true) + pipe: observableOf(true) }); let authService: AuthService; - const authRequest = new AuthRequestServiceStub(); + let authRequest; const window = new NativeWindowRef(); const routerStub = new RouterStub(); - const routeStub = new ActivatedRouteStub(); + let routeStub; let storage: CookieService; - const token: AuthTokenInfo = new AuthTokenInfo('test_token'); - token.expires = Date.now() + (1000 * 60 * 60); - let authenticatedState = { - authenticated: true, - loaded: true, - loading: false, - authToken: token, - user: EPersonMock - }; + let token: AuthTokenInfo; + let authenticatedState; const rdbService = getMockRemoteDataBuildService(); - describe('', () => { + function init() { + token = new AuthTokenInfo('test_token'); + token.expires = Date.now() + (1000 * 60 * 60); + authenticatedState = { + authenticated: true, + loaded: true, + loading: false, + authToken: token, + user: EPersonMock + }; + authRequest = new AuthRequestServiceStub(); + routeStub = new ActivatedRouteStub(); + } + + beforeEach(() => { + init(); + }); + + describe('', () => { beforeEach(() => { + TestBed.configureTestingModule({ imports: [ CommonModule, - StoreModule.forRoot({authReducer}), + StoreModule.forRoot({ authReducer }), ], declarations: [], providers: [ - {provide: AuthRequestService, useValue: authRequest}, - {provide: NativeWindowService, useValue: window}, - {provide: REQUEST, useValue: {}}, - {provide: Router, useValue: routerStub}, - {provide: ActivatedRoute, useValue: routeStub}, + { provide: AuthRequestService, useValue: authRequest }, + { provide: NativeWindowService, useValue: window }, + { provide: REQUEST, useValue: {} }, + { provide: Router, useValue: routerStub }, + { provide: ActivatedRoute, useValue: routeStub }, {provide: Store, useValue: mockStore}, - {provide: RemoteDataBuildService, useValue: rdbService}, + { provide: RemoteDataBuildService, useValue: rdbService }, CookieService, AuthService ], @@ -116,15 +127,16 @@ describe('AuthService test', () => { describe('', () => { beforeEach(async(() => { + init(); TestBed.configureTestingModule({ imports: [ - StoreModule.forRoot({authReducer}) + StoreModule.forRoot({ authReducer }) ], providers: [ - {provide: AuthRequestService, useValue: authRequest}, - {provide: REQUEST, useValue: {}}, - {provide: Router, useValue: routerStub}, - {provide: RemoteDataBuildService, useValue: rdbService}, + { provide: AuthRequestService, useValue: authRequest }, + { provide: REQUEST, useValue: {} }, + { provide: Router, useValue: routerStub }, + { provide: RemoteDataBuildService, useValue: rdbService }, CookieService ] }).compileComponents(); @@ -168,12 +180,12 @@ describe('AuthService test', () => { beforeEach(async(() => { TestBed.configureTestingModule({ imports: [ - StoreModule.forRoot({authReducer}) + StoreModule.forRoot({ authReducer }) ], providers: [ - {provide: AuthRequestService, useValue: authRequest}, - {provide: REQUEST, useValue: {}}, - {provide: Router, useValue: routerStub}, + { provide: AuthRequestService, useValue: authRequest }, + { provide: REQUEST, useValue: {} }, + { provide: Router, useValue: routerStub }, ClientCookieService, CookieService ] diff --git a/src/app/core/auth/auth.service.ts b/src/app/core/auth/auth.service.ts index 5f113b0262..229c44bcfa 100644 --- a/src/app/core/auth/auth.service.ts +++ b/src/app/core/auth/auth.service.ts @@ -1,13 +1,22 @@ +import { Observable, of as observableOf } from 'rxjs'; +import { + distinctUntilChanged, + filter, + first, + map, + startWith, + switchMap, + take, + withLatestFrom +} from 'rxjs/operators'; import { Inject, Injectable } from '@angular/core'; import { PRIMARY_OUTLET, Router, UrlSegmentGroup, UrlTree } from '@angular/router'; import { HttpHeaders } from '@angular/common/http'; import { REQUEST } from '@nguniversal/express-engine/tokens'; import { RouterReducerState } from '@ngrx/router-store'; -import { Store } from '@ngrx/store'; +import { select, Store } from '@ngrx/store'; import { CookieAttributes } from 'js-cookie'; -import { Observable } from 'rxjs/Observable'; -import { map, switchMap, withLatestFrom } from 'rxjs/operators'; import { EPerson } from '../eperson/models/eperson.model'; import { AuthRequestService } from './auth-request.service'; @@ -55,21 +64,24 @@ export class AuthService { protected store: Store, protected rdbService: RemoteDataBuildService ) { - this.store.select(isAuthenticated) - .startWith(false) - .subscribe((authenticated: boolean) => this._authenticated = authenticated); + 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.select(routerStateSelector) - .filter((routerState: RouterReducerState) => isNotUndefined(routerState) && isNotUndefined(routerState.state)) - .filter((routerState: RouterReducerState) => !this.isLoginRoute(routerState.state.url)) - .map((routerState: RouterReducerState) => routerState.state.url); - const redirectUrl$ = this.store.select(getRedirectUrl).distinctUntilChanged(); + const routeUrl$ = this.store.pipe( + select(routerStateSelector), + filter((routerState: RouterReducerState) => isNotUndefined(routerState) && isNotUndefined(routerState.state)), + 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]) - ).filter(([routeUrl, redirectUrl]) => isNotEmpty(redirectUrl) && (routeUrl !== redirectUrl)) + ).pipe(filter(([routeUrl, redirectUrl]) => isNotEmpty(redirectUrl) && (routeUrl !== redirectUrl))) .subscribe(() => { this.clearRedirectUrl(); }); @@ -102,14 +114,14 @@ export class AuthService { let headers = new HttpHeaders(); headers = headers.append('Content-Type', 'application/x-www-form-urlencoded'); options.headers = headers; - return this.authRequestService.postToEndpoint('login', body, options) - .map((status: AuthStatus) => { + return this.authRequestService.postToEndpoint('login', body, options).pipe( + map((status: AuthStatus) => { if (status.authenticated) { return status; } else { throw(new Error('Invalid email or password')); } - }) + })) } @@ -118,7 +130,7 @@ export class AuthService { * @returns {Observable} */ public isAuthenticated(): Observable { - return this.store.select(isAuthenticated); + return this.store.pipe(select(isAuthenticated)); } /** @@ -158,9 +170,10 @@ export class AuthService { * Checks if token is present into storage and is not expired */ public hasValidAuthenticationToken(): Observable { - return this.store.select(getAuthenticationToken) - .take(1) - .map((authTokenInfo: AuthTokenInfo) => { + return this.store.pipe( + select(getAuthenticationToken), + take(1), + map((authTokenInfo: AuthTokenInfo) => { let token: AuthTokenInfo; // Retrieve authentication token info and check if is valid token = isNotEmpty(authTokenInfo) ? authTokenInfo : this.storage.get(TOKENITEM); @@ -169,7 +182,8 @@ export class AuthService { } else { throw false; } - }); + }) + ); } /** @@ -181,14 +195,14 @@ export class AuthService { headers = headers.append('Accept', 'application/json'); headers = headers.append('Authorization', `Bearer ${token.accessToken}`); options.headers = headers; - return this.authRequestService.postToEndpoint('login', {}, options) - .map((status: AuthStatus) => { + return this.authRequestService.postToEndpoint('login', {}, options).pipe( + map((status: AuthStatus) => { if (status.authenticated) { return status.token; } else { throw(new Error('Not authenticated')); } - }); + })); } /** @@ -207,7 +221,7 @@ export class AuthService { // details and then return the new user object // but, let's just return the new user for this example. // this._authenticated = true; - return Observable.of(user); + return observableOf(user); } /** @@ -219,14 +233,15 @@ export class AuthService { let headers = new HttpHeaders(); headers = headers.append('Content-Type', 'application/x-www-form-urlencoded'); const options: HttpOptions = Object.create({ headers, responseType: 'text' }); - return this.authRequestService.getRequest('logout', options) - .map((status: AuthStatus) => { + return this.authRequestService.getRequest('logout', options).pipe( + map((status: AuthStatus) => { if (!status.authenticated) { return true; } else { throw(new Error('auth.errors.invalid-user')); } - }) + })) + } /** @@ -246,7 +261,7 @@ export class AuthService { */ public getToken(): AuthTokenInfo { let token: AuthTokenInfo; - this.store.select(getAuthenticationToken) + this.store.pipe(select(getAuthenticationToken)) .subscribe((authTokenInfo: AuthTokenInfo) => { // Retrieve authentication token info and check if is valid token = authTokenInfo || null; @@ -259,9 +274,10 @@ export class AuthService { * @returns {boolean} */ public isTokenExpiring(): Observable { - return this.store.select(isTokenRefreshing) - .first() - .map((isRefreshing: boolean) => { + return this.store.pipe( + select(isTokenRefreshing), + first(), + map((isRefreshing: boolean) => { if (this.isTokenExpired() || isRefreshing) { return false; } else { @@ -269,6 +285,7 @@ export class AuthService { return token.expires - (60 * 5 * 1000) < Date.now(); } }) + ) } /** @@ -337,8 +354,8 @@ export class AuthService { * Redirect to the route navigated before the login */ public redirectToPreviousUrl() { - this.getRedirectUrl() - .first() + this.getRedirectUrl().pipe( + first()) .subscribe((redirectUrl) => { if (isNotEmpty(redirectUrl)) { this.clearRedirectUrl(); @@ -376,9 +393,9 @@ export class AuthService { getRedirectUrl(): Observable { const redirectUrl = this.storage.get(REDIRECT_COOKIE); if (isNotEmpty(redirectUrl)) { - return Observable.of(redirectUrl); + return observableOf(redirectUrl); } else { - return this.store.select(getRedirectUrl); + return this.store.pipe(select(getRedirectUrl)); } } diff --git a/src/app/core/auth/authenticated.guard.ts b/src/app/core/auth/authenticated.guard.ts index 42c39b403c..b9091a86ad 100644 --- a/src/app/core/auth/authenticated.guard.ts +++ b/src/app/core/auth/authenticated.guard.ts @@ -1,8 +1,10 @@ + +import {take} from 'rxjs/operators'; import { Injectable } from '@angular/core'; import { ActivatedRouteSnapshot, CanActivate, CanLoad, Route, Router, RouterStateSnapshot } from '@angular/router'; -import { Observable } from 'rxjs/Observable'; -import { Store } from '@ngrx/store'; +import { Observable } from 'rxjs'; +import { select, Store } from '@ngrx/store'; // reducers import { CoreState } from '../core.reducers'; @@ -52,12 +54,12 @@ export class AuthenticatedGuard implements CanActivate, CanLoad { private handleAuth(url: string): Observable { // get observable - const observable = this.store.select(isAuthenticated); + const observable = this.store.pipe(select(isAuthenticated)); // redirect to sign in page if user is not authenticated - observable + observable.pipe( // .filter(() => isEmpty(this.router.routerState.snapshot.url) || this.router.routerState.snapshot.url === url) - .take(1) + take(1)) .subscribe((authenticated) => { if (!authenticated) { this.authService.setRedirectUrl(url); diff --git a/src/app/core/auth/models/auth-status.model.ts b/src/app/core/auth/models/auth-status.model.ts index b8ccf9ed6d..37f8d76672 100644 --- a/src/app/core/auth/models/auth-status.model.ts +++ b/src/app/core/auth/models/auth-status.model.ts @@ -2,7 +2,7 @@ import { AuthError } from './auth-error.model'; import { AuthTokenInfo } from './auth-token-info.model'; import { EPerson } from '../../eperson/models/eperson.model'; import { RemoteData } from '../../data/remote-data'; -import { Observable } from 'rxjs/Observable'; +import { Observable } from 'rxjs'; export class AuthStatus { diff --git a/src/app/core/auth/server-auth.service.ts b/src/app/core/auth/server-auth.service.ts index 9030443a1e..c83410f6e3 100644 --- a/src/app/core/auth/server-auth.service.ts +++ b/src/app/core/auth/server-auth.service.ts @@ -1,7 +1,7 @@ import { first, map, switchMap } from 'rxjs/operators'; import { Injectable } from '@angular/core'; -import { Observable } from 'rxjs/Observable'; +import { Observable } from 'rxjs'; import { HttpHeaders } from '@angular/common/http'; import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service'; import { AuthStatus } from './models/auth-status.model'; @@ -41,12 +41,13 @@ export class ServerAuthService extends AuthService { // TODO this should be cleaned up, AuthStatus could be parsed by the RemoteDataService as a whole... const person$ = this.rdbService.buildSingle(status.eperson.toString()); - // person$.subscribe(() => console.log('test')); - return person$.pipe(map((eperson) => eperson.payload)); + return person$.pipe( + map((eperson) => eperson.payload) + ); } else { throw(new Error('Not authenticated')); } - })) + })); } /** diff --git a/src/app/core/browse/browse.service.spec.ts b/src/app/core/browse/browse.service.spec.ts index daaca1e5b2..da75e1a877 100644 --- a/src/app/core/browse/browse.service.spec.ts +++ b/src/app/core/browse/browse.service.spec.ts @@ -1,20 +1,19 @@ import { cold, getTestScheduler, hot } from 'jasmine-marbles'; -import { TestScheduler } from 'rxjs/Rx'; +import { TestScheduler } from 'rxjs/testing'; import { getMockRemoteDataBuildService } from '../../shared/mocks/mock-remote-data-build.service'; import { getMockRequestService } from '../../shared/mocks/mock-request.service'; -import { getMockResponseCacheService } from '../../shared/mocks/mock-response-cache.service'; import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service-stub'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import { ResponseCacheService } from '../cache/response-cache.service'; import { BrowseEndpointRequest, BrowseEntriesRequest, BrowseItemsRequest } from '../data/request.models'; import { RequestService } from '../data/request.service'; import { BrowseDefinition } from '../shared/browse-definition.model'; import { BrowseService } from './browse.service'; +import { RequestEntry } from '../data/request.reducer'; +import { of as observableOf } from 'rxjs'; describe('BrowseService', () => { let scheduler: TestScheduler; let service: BrowseService; - let responseCache: ResponseCacheService; let requestService: RequestService; let rdbService: RemoteDataBuildService; @@ -79,22 +78,14 @@ describe('BrowseService', () => { }) ]; - function initMockResponseCacheService(isSuccessful: boolean) { - const rcs = getMockResponseCacheService(); - (rcs.get as any).and.returnValue(cold('b-', { - b: { - response: { - isSuccessful, - payload: browseDefinitions, - } - } - })); - return rcs; - } + const getRequestEntry$ = (successful: boolean) => { + return observableOf({ + response: { isSuccessful: successful, payload: browseDefinitions } as any + } as RequestEntry) + }; function initTestService() { return new BrowseService( - responseCache, requestService, halService, rdbService @@ -108,8 +99,7 @@ describe('BrowseService', () => { describe('getBrowseDefinitions', () => { beforeEach(() => { - responseCache = initMockResponseCacheService(true); - requestService = getMockRequestService(); + requestService = getMockRequestService(getRequestEntry$(true)); rdbService = getMockRemoteDataBuildService(); service = initTestService(); spyOn(halService, 'getEndpoint').and @@ -147,8 +137,7 @@ describe('BrowseService', () => { const mockAuthorName = 'Donald Smith'; beforeEach(() => { - responseCache = initMockResponseCacheService(true); - requestService = getMockRequestService(); + requestService = getMockRequestService(getRequestEntry$(true)); rdbService = getMockRemoteDataBuildService(); service = initTestService(); spyOn(service, 'getBrowseDefinitions').and @@ -221,8 +210,7 @@ describe('BrowseService', () => { describe('if getBrowseDefinitions fires', () => { beforeEach(() => { - responseCache = initMockResponseCacheService(true); - requestService = getMockRequestService(); + requestService = getMockRequestService(getRequestEntry$(true)); rdbService = getMockRemoteDataBuildService(); service = initTestService(); spyOn(service, 'getBrowseDefinitions').and @@ -277,8 +265,7 @@ describe('BrowseService', () => { describe('if getBrowseDefinitions doesn\'t fire', () => { it('should return undefined', () => { - responseCache = initMockResponseCacheService(true); - requestService = getMockRequestService(); + requestService = getMockRequestService(getRequestEntry$(true)); rdbService = getMockRemoteDataBuildService(); service = initTestService(); spyOn(service, 'getBrowseDefinitions').and diff --git a/src/app/core/browse/browse.service.ts b/src/app/core/browse/browse.service.ts index 003f92698c..b807a77e99 100644 --- a/src/app/core/browse/browse.service.ts +++ b/src/app/core/browse/browse.service.ts @@ -1,5 +1,5 @@ import { Injectable } from '@angular/core'; -import { Observable } from 'rxjs/Observable'; +import { Observable } from 'rxjs'; import { distinctUntilChanged, map, startWith } from 'rxjs/operators'; import { ensureArrayHasValue, @@ -11,16 +11,13 @@ import { import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { SortOptions } from '../cache/models/sort-options.model'; -import { GenericSuccessResponse } from '../cache/response-cache.models'; -import { ResponseCacheEntry } from '../cache/response-cache.reducer'; -import { ResponseCacheService } from '../cache/response-cache.service'; +import { GenericSuccessResponse } from '../cache/response.models'; import { PaginatedList } from '../data/paginated-list'; import { RemoteData } from '../data/remote-data'; import { BrowseEndpointRequest, BrowseEntriesRequest, BrowseItemsRequest, - GetRequest, RestRequest } from '../data/request.models'; import { RequestService } from '../data/request.service'; @@ -29,10 +26,9 @@ import { BrowseEntry } from '../shared/browse-entry.model'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { configureRequest, - filterSuccessfulResponses, getBrowseDefinitionLinks, - getRemoteDataPayload, - getRequestFromSelflink, - getResponseFromSelflink + filterSuccessfulResponses, + getBrowseDefinitionLinks, + getRemoteDataPayload, getRequestFromRequestHref } from '../shared/operators'; import { URLCombiner } from '../url-combiner/url-combiner'; import { Item } from '../shared/item.model'; @@ -56,7 +52,6 @@ export class BrowseService { } constructor( - protected responseCache: ResponseCacheService, protected requestService: RequestService, protected halService: HALEndpointService, private rdb: RemoteDataBuildService, @@ -72,11 +67,9 @@ export class BrowseService { ); const href$ = request$.pipe(map((request: RestRequest) => request.href)); - const requestEntry$ = href$.pipe(getRequestFromSelflink(this.requestService)); - const responseCache$ = href$.pipe(getResponseFromSelflink(this.responseCache)); - const payload$ = responseCache$.pipe( + const requestEntry$ = href$.pipe(getRequestFromRequestHref(this.requestService)); + const payload$ = requestEntry$.pipe( filterSuccessfulResponses(), - map((entry: ResponseCacheEntry) => entry.response), map((response: GenericSuccessResponse) => response.payload), ensureArrayHasValue(), map((definitions: BrowseDefinition[]) => definitions @@ -84,7 +77,7 @@ export class BrowseService { distinctUntilChanged() ); - return this.rdb.toRemoteDataObservable(requestEntry$, responseCache$, payload$); + return this.rdb.toRemoteDataObservable(requestEntry$, payload$); } getBrowseEntriesFor(definitionID: string, options: { @@ -117,12 +110,10 @@ export class BrowseService { const href$ = request$.pipe(map((request: RestRequest) => request.href)); - const requestEntry$ = href$.pipe(getRequestFromSelflink(this.requestService)); - const responseCache$ = href$.pipe(getResponseFromSelflink(this.responseCache)); + const requestEntry$ = href$.pipe(getRequestFromRequestHref(this.requestService)); - const payload$ = responseCache$.pipe( + const payload$ = requestEntry$.pipe( filterSuccessfulResponses(), - map((entry: ResponseCacheEntry) => entry.response), map((response: GenericSuccessResponse) => new PaginatedList(response.pageInfo, response.payload)), map((list: PaginatedList) => Object.assign(list, { page: list.page ? list.page.map((entry: BrowseEntry) => Object.assign(new BrowseEntry(), entry)) : list.page @@ -130,7 +121,7 @@ export class BrowseService { distinctUntilChanged() ); - return this.rdb.toRemoteDataObservable(requestEntry$, responseCache$, payload$); + return this.rdb.toRemoteDataObservable(requestEntry$, payload$); } /** @@ -174,12 +165,10 @@ export class BrowseService { const href$ = request$.pipe(map((request: RestRequest) => request.href)); - const requestEntry$ = href$.pipe(getRequestFromSelflink(this.requestService)); - const responseCache$ = href$.pipe(getResponseFromSelflink(this.responseCache)); + const requestEntry$ = href$.pipe(getRequestFromRequestHref(this.requestService)); - const payload$ = responseCache$.pipe( + const payload$ = requestEntry$.pipe( filterSuccessfulResponses(), - map((entry: ResponseCacheEntry) => entry.response), map((response: GenericSuccessResponse) => new PaginatedList(response.pageInfo, response.payload)), map((list: PaginatedList) => Object.assign(list, { page: list.page ? list.page.map((item: DSpaceObject) => Object.assign(new Item(), item)) : list.page @@ -187,7 +176,7 @@ export class BrowseService { distinctUntilChanged() ); - return this.rdb.toRemoteDataObservable(requestEntry$, responseCache$, payload$); + return this.rdb.toRemoteDataObservable(requestEntry$, payload$); } getBrowseURLFor(metadatumKey: string, linkPath: string): Observable { diff --git a/src/app/core/cache/builders/remote-data-build.service.spec.ts b/src/app/core/cache/builders/remote-data-build.service.spec.ts new file mode 100644 index 0000000000..e4444ca803 --- /dev/null +++ b/src/app/core/cache/builders/remote-data-build.service.spec.ts @@ -0,0 +1,59 @@ +import { RemoteDataBuildService } from './remote-data-build.service'; +import { Item } from '../../shared/item.model'; +import { PaginatedList } from '../../data/paginated-list'; +import { PageInfo } from '../../shared/page-info.model'; +import { RemoteData } from '../../data/remote-data'; +import { of as observableOf } from 'rxjs'; + +const pageInfo = new PageInfo(); +const array = [ + Object.assign(new Item(), { + metadata: [ + { + key: 'dc.title', + language: 'en_US', + value: 'Item nr 1' + }] + }), + Object.assign(new Item(), { + metadata: [ + { + key: 'dc.title', + language: 'en_US', + value: 'Item nr 2' + }] + }) +]; +const paginatedList = new PaginatedList(pageInfo, array); +const arrayRD = new RemoteData(false, false, true, undefined, array); +const paginatedListRD = new RemoteData(false, false, true, undefined, paginatedList); + +describe('RemoteDataBuildService', () => { + let service: RemoteDataBuildService; + + beforeEach(() => { + service = new RemoteDataBuildService(undefined, undefined); + }); + + describe('when toPaginatedList is called', () => { + let expected: RemoteData>; + + beforeEach(() => { + expected = paginatedListRD; + }); + + it('should return the correct remoteData of a paginatedList when the input is a (remoteData of an) array', () => { + const result = (service as any).toPaginatedList(observableOf(arrayRD), pageInfo); + result.subscribe((resultRD) => { + expect(resultRD).toEqual(expected); + }); + }); + + it('should return the correct remoteData of a paginatedList when the input is a (remoteData of a) paginated list', () => { + const result = (service as any).toPaginatedList(observableOf(paginatedListRD), pageInfo); + result.subscribe((resultRD) => { + expect(resultRD).toEqual(expected); + }); + }); + }); +}); diff --git a/src/app/core/cache/builders/remote-data-build.service.ts b/src/app/core/cache/builders/remote-data-build.service.ts index d934f60e48..52ec4382ae 100644 --- a/src/app/core/cache/builders/remote-data-build.service.ts +++ b/src/app/core/cache/builders/remote-data-build.service.ts @@ -1,6 +1,11 @@ +import { + combineLatest as observableCombineLatest, + Observable, + of as observableOf, + race as observableRace +} from 'rxjs'; import { Injectable } from '@angular/core'; -import { Observable } from 'rxjs/Observable'; -import { distinctUntilChanged, flatMap, map, startWith } from 'rxjs/operators'; +import { distinctUntilChanged, first, flatMap, map, startWith, switchMap } from 'rxjs/operators'; import { hasValue, hasValueOperator, isEmpty, isNotEmpty } from '../../../shared/empty.util'; import { PaginatedList } from '../../data/paginated-list'; import { RemoteData } from '../../data/remote-data'; @@ -11,69 +16,63 @@ import { RequestService } from '../../data/request.service'; import { NormalizedObject } from '../models/normalized-object.model'; import { ObjectCacheService } from '../object-cache.service'; -import { DSOSuccessResponse, ErrorResponse } from '../response-cache.models'; -import { ResponseCacheEntry } from '../response-cache.reducer'; -import { ResponseCacheService } from '../response-cache.service'; +import { DSOSuccessResponse, ErrorResponse } from '../response.models'; import { getMapsTo, getRelationMetadata, getRelationships } from './build-decorators'; import { PageInfo } from '../../shared/page-info.model'; import { - getRequestFromSelflink, - getResourceLinksFromResponse, - getResponseFromSelflink, - filterSuccessfulResponses + filterSuccessfulResponses, + getRequestFromRequestHref, getRequestFromRequestUUID, + getResourceLinksFromResponse } from '../../shared/operators'; @Injectable() export class RemoteDataBuildService { constructor(protected objectCache: ObjectCacheService, - protected responseCache: ResponseCacheService, protected requestService: RequestService) { } buildSingle(href$: string | Observable): Observable> { if (typeof href$ === 'string') { - href$ = Observable.of(href$); + href$ = observableOf(href$); } - const requestHref$ = href$.pipe(flatMap((href: string) => - this.objectCache.getRequestHrefBySelfLink(href))); - - const requestEntry$ = Observable.race( - href$.pipe(getRequestFromSelflink(this.requestService)), - requestHref$.pipe(getRequestFromSelflink(this.requestService)) + const requestUUID$ = href$.pipe( + switchMap((href: string) => + this.objectCache.getRequestUUIDBySelfLink(href)), ); - const responseCache$ = Observable.race( - href$.pipe(getResponseFromSelflink(this.responseCache)), - requestHref$.pipe(getResponseFromSelflink(this.responseCache)) + const requestEntry$ = observableRace( + href$.pipe(getRequestFromRequestHref(this.requestService)), + requestUUID$.pipe(getRequestFromRequestUUID(this.requestService)), + ).pipe( + first() ); // always use self link if that is cached, only if it isn't, get it via the response. const payload$ = - Observable.combineLatest( + observableCombineLatest( href$.pipe( - flatMap((href: string) => this.objectCache.getBySelfLink(href)), - startWith(undefined) - ), - responseCache$.pipe( + switchMap((href: string) => this.objectCache.getBySelfLink(href)), + startWith(undefined)), + requestEntry$.pipe( getResourceLinksFromResponse(), - flatMap((resourceSelfLinks: string[]) => { + switchMap((resourceSelfLinks: string[]) => { if (isNotEmpty(resourceSelfLinks)) { return this.objectCache.getBySelfLink(resourceSelfLinks[0]); } else { - return Observable.of(undefined); + return observableOf(undefined); } }), distinctUntilChanged(), startWith(undefined) - ), - (fromSelfLink, fromResponse) => { + ) + ).pipe( + map(([fromSelfLink, fromResponse]) => { if (hasValue(fromSelfLink)) { return fromSelfLink; } else { return fromResponse; } - } - ).pipe( + }), hasValueOperator(), map((normalized: TNormalized) => { return this.build(normalized); @@ -81,21 +80,21 @@ export class RemoteDataBuildService { startWith(undefined), distinctUntilChanged() ); - return this.toRemoteDataObservable(requestEntry$, responseCache$, payload$); + return this.toRemoteDataObservable(requestEntry$, payload$); } - toRemoteDataObservable(requestEntry$: Observable, responseCache$: Observable, payload$: Observable) { - return Observable.combineLatest(requestEntry$, responseCache$.startWith(undefined), payload$, - (reqEntry: RequestEntry, resEntry: ResponseCacheEntry, payload: T) => { + toRemoteDataObservable(requestEntry$: Observable, payload$: Observable) { + return observableCombineLatest(requestEntry$, payload$).pipe( + map(([reqEntry, payload]) => { const requestPending = hasValue(reqEntry.requestPending) ? reqEntry.requestPending : true; const responsePending = hasValue(reqEntry.responsePending) ? reqEntry.responsePending : false; let isSuccessful: boolean; let error: RemoteDataError; - if (hasValue(resEntry) && hasValue(resEntry.response)) { - isSuccessful = resEntry.response.isSuccessful; - const errorMessage = isSuccessful === false ? (resEntry.response as ErrorResponse).errorMessage : undefined; + if (hasValue(reqEntry) && hasValue(reqEntry.response)) { + isSuccessful = reqEntry.response.isSuccessful; + const errorMessage = isSuccessful === false ? (reqEntry.response as ErrorResponse).errorMessage : undefined; if (hasValue(errorMessage)) { - error = new RemoteDataError(resEntry.response.statusCode, errorMessage); + error = new RemoteDataError(reqEntry.response.statusCode, errorMessage); } } return new RemoteData( @@ -105,36 +104,34 @@ export class RemoteDataBuildService { error, payload ); - }); + }) + ); } buildList(href$: string | Observable): Observable>> { if (typeof href$ === 'string') { - href$ = Observable.of(href$); + href$ = observableOf(href$); } - const requestEntry$ = href$.pipe(getRequestFromSelflink(this.requestService)); - const responseCache$ = href$.pipe(getResponseFromSelflink(this.responseCache)); - - const tDomainList$ = responseCache$.pipe( + const requestEntry$ = href$.pipe(getRequestFromRequestHref(this.requestService)); + const tDomainList$ = requestEntry$.pipe( getResourceLinksFromResponse(), flatMap((resourceUUIDs: string[]) => { - return this.objectCache.getList(resourceUUIDs) - .map((normList: TNormalized[]) => { + return this.objectCache.getList(resourceUUIDs).pipe( + map((normList: TNormalized[]) => { return normList.map((normalized: TNormalized) => { return this.build(normalized); }); - }); + })); }), startWith([]), - distinctUntilChanged() + distinctUntilChanged(), ); - - const pageInfo$ = responseCache$.pipe( + const pageInfo$ = requestEntry$.pipe( filterSuccessfulResponses(), - map((entry: ResponseCacheEntry) => { - if (hasValue((entry.response as DSOSuccessResponse).pageInfo)) { - const resPageInfo = (entry.response as DSOSuccessResponse).pageInfo; + map((response: DSOSuccessResponse) => { + if (hasValue((response as DSOSuccessResponse).pageInfo)) { + const resPageInfo = (response as DSOSuccessResponse).pageInfo; if (isNotEmpty(resPageInfo) && resPageInfo.currentPage >= 0) { return Object.assign({}, resPageInfo, { currentPage: resPageInfo.currentPage + 1 }); } else { @@ -142,18 +139,19 @@ export class RemoteDataBuildService { } } }) - ); + ); - const payload$ = Observable.combineLatest(tDomainList$, pageInfo$, (tDomainList, pageInfo) => { - return new PaginatedList(pageInfo, tDomainList); - }); + const payload$ = observableCombineLatest(tDomainList$, pageInfo$).pipe( + map(([tDomainList, pageInfo]) => { + return new PaginatedList(pageInfo, tDomainList); + }) + ); - return this.toRemoteDataObservable(requestEntry$, responseCache$, payload$); + return this.toRemoteDataObservable(requestEntry$, payload$); } build(normalized: TNormalized): TDomain { const links: any = {}; - const relationships = getRelationships(normalized.constructor) || []; relationships.forEach((relationship: string) => { @@ -190,13 +188,12 @@ export class RemoteDataBuildService { } if (hasValue(normalized[relationship].page)) { - links[relationship] = this.aggregatePaginatedList(result, normalized[relationship].pageInfo); + links[relationship] = this.toPaginatedList(result, normalized[relationship].pageInfo); } else { links[relationship] = result; } } }); - const domainModel = getMapsTo(normalized.constructor); return Object.assign(new domainModel(), normalized, links); } @@ -204,12 +201,11 @@ export class RemoteDataBuildService { aggregate(input: Array>>): Observable> { if (isEmpty(input)) { - return Observable.of(new RemoteData(false, false, true, null, [])); + return observableOf(new RemoteData(false, false, true, null, [])); } - return Observable.combineLatest( - ...input, - (...arr: Array>) => { + return observableCombineLatest(...input).pipe( + map((arr) => { const requestPending: boolean = arr .map((d: RemoteData) => d.isRequestPending) .every((b: boolean) => b === true); @@ -251,11 +247,19 @@ export class RemoteDataBuildService { error, payload ); - }) + })) } - aggregatePaginatedList(input: Observable>, pageInfo: PageInfo): Observable>> { - return input.map((rd) => Object.assign(rd, {payload: new PaginatedList(pageInfo, rd.payload)})); + private toPaginatedList(input: Observable>>, pageInfo: PageInfo): Observable>> { + return input.pipe( + map((rd: RemoteData>) => { + if (Array.isArray(rd.payload)) { + return Object.assign(rd, { payload: new PaginatedList(pageInfo, rd.payload) }) + } else { + return Object.assign(rd, { payload: new PaginatedList(pageInfo, rd.payload.page) }); + } + }) + ); } } diff --git a/src/app/core/cache/object-cache.actions.ts b/src/app/core/cache/object-cache.actions.ts index a136b04248..8531677ffc 100644 --- a/src/app/core/cache/object-cache.actions.ts +++ b/src/app/core/cache/object-cache.actions.ts @@ -2,6 +2,7 @@ import { Action } from '@ngrx/store'; import { type } from '../../shared/ngrx/type'; import { CacheableObject } from './object-cache.reducer'; +import { Operation } from 'fast-json-patch'; /** * The list of ObjectCacheAction type definitions @@ -9,7 +10,9 @@ import { CacheableObject } from './object-cache.reducer'; export const ObjectCacheActionTypes = { ADD: type('dspace/core/cache/object/ADD'), REMOVE: type('dspace/core/cache/object/REMOVE'), - RESET_TIMESTAMPS: type('dspace/core/cache/object/RESET_TIMESTAMPS') + RESET_TIMESTAMPS: type('dspace/core/cache/object/RESET_TIMESTAMPS'), + ADD_PATCH: type('dspace/core/cache/object/ADD_PATCH'), + APPLY_PATCH: type('dspace/core/cache/object/APPLY_PATCH') }; /* tslint:disable:max-classes-per-file */ @@ -22,7 +25,7 @@ export class AddToObjectCacheAction implements Action { objectToCache: CacheableObject; timeAdded: number; msToLive: number; - requestHref: string; + requestUUID: string; }; /** @@ -39,8 +42,8 @@ export class AddToObjectCacheAction implements Action { * This isn't necessarily the same as the object's self * link, it could have been part of a list for example */ - constructor(objectToCache: CacheableObject, timeAdded: number, msToLive: number, requestHref: string) { - this.payload = { objectToCache, timeAdded, msToLive, requestHref }; + constructor(objectToCache: CacheableObject, timeAdded: number, msToLive: number, requestUUID: string) { + this.payload = { objectToCache, timeAdded, msToLive, requestUUID }; } } @@ -54,11 +57,11 @@ export class RemoveFromObjectCacheAction implements Action { /** * Create a new RemoveFromObjectCacheAction * - * @param uuid - * the UUID of the object to remove + * @param href + * the unique href of the object to remove */ - constructor(uuid: string) { - this.payload = uuid; + constructor(href: string) { + this.payload = href; } } @@ -79,6 +82,48 @@ export class ResetObjectCacheTimestampsAction implements Action { this.payload = newTimestamp; } } + +/** + * An ngrx action to add new operations to a specified cached object + */ +export class AddPatchObjectCacheAction implements Action { + type = ObjectCacheActionTypes.ADD_PATCH; + payload: { + href: string, + operations: Operation[] + }; + + /** + * Create a new AddPatchObjectCacheAction + * + * @param href + * the unique href of the object that should be updated + * @param operations + * the list of operations to add + */ + constructor(href: string, operations: Operation[]) { + this.payload = { href, operations }; + } +} + +/** + * An ngrx action to apply all existing operations to a specified cached object + */ +export class ApplyPatchObjectCacheAction implements Action { + type = ObjectCacheActionTypes.APPLY_PATCH; + payload: string; + + /** + * Create a new ApplyPatchObjectCacheAction + * + * @param href + * the unique href of the object that should be updated + */ + constructor(href: string) { + this.payload = href; + } +} + /* tslint:enable:max-classes-per-file */ /** @@ -87,4 +132,6 @@ export class ResetObjectCacheTimestampsAction implements Action { export type ObjectCacheAction = AddToObjectCacheAction | RemoveFromObjectCacheAction - | ResetObjectCacheTimestampsAction; + | ResetObjectCacheTimestampsAction + | AddPatchObjectCacheAction + | ApplyPatchObjectCacheAction; diff --git a/src/app/core/cache/object-cache.effects.spec.ts b/src/app/core/cache/object-cache.effects.spec.ts index d0a97a18fd..36502be849 100644 --- a/src/app/core/cache/object-cache.effects.spec.ts +++ b/src/app/core/cache/object-cache.effects.spec.ts @@ -1,5 +1,5 @@ import { TestBed } from '@angular/core/testing'; -import { Observable } from 'rxjs/Observable'; +import { Observable } from 'rxjs'; import { provideMockActions } from '@ngrx/effects/testing'; import { cold, hot } from 'jasmine-marbles'; import { ObjectCacheEffects } from './object-cache.effects'; diff --git a/src/app/core/cache/object-cache.effects.ts b/src/app/core/cache/object-cache.effects.ts index 019c792973..2bd8ad0e3c 100644 --- a/src/app/core/cache/object-cache.effects.ts +++ b/src/app/core/cache/object-cache.effects.ts @@ -1,5 +1,6 @@ +import { map } from 'rxjs/operators'; import { Injectable } from '@angular/core'; -import { Actions, Effect } from '@ngrx/effects'; +import { Actions, Effect, ofType } from '@ngrx/effects'; import { StoreActionTypes } from '../../store.actions'; import { ResetObjectCacheTimestampsAction } from './object-cache.actions'; @@ -16,9 +17,11 @@ export class ObjectCacheEffects { * time ago, and will likely need to be revisited later */ @Effect() fixTimestampsOnRehydrate = this.actions$ - .ofType(StoreActionTypes.REHYDRATE) - .map(() => new ResetObjectCacheTimestampsAction(new Date().getTime())); + .pipe(ofType(StoreActionTypes.REHYDRATE), + map(() => new ResetObjectCacheTimestampsAction(new Date().getTime())) + ); - constructor(private actions$: Actions) { } + constructor(private actions$: Actions) { + } } diff --git a/src/app/core/cache/object-cache.reducer.spec.ts b/src/app/core/cache/object-cache.reducer.spec.ts index 2c059c4dd3..efa28d7249 100644 --- a/src/app/core/cache/object-cache.reducer.spec.ts +++ b/src/app/core/cache/object-cache.reducer.spec.ts @@ -2,9 +2,13 @@ import * as deepFreeze from 'deep-freeze'; import { objectCacheReducer } from './object-cache.reducer'; import { + AddPatchObjectCacheAction, AddToObjectCacheAction, - RemoveFromObjectCacheAction, ResetObjectCacheTimestampsAction + ApplyPatchObjectCacheAction, + RemoveFromObjectCacheAction, + ResetObjectCacheTimestampsAction } from './object-cache.actions'; +import { Operation } from 'fast-json-patch'; class NullAction extends RemoveFromObjectCacheAction { type = null; @@ -16,8 +20,11 @@ class NullAction extends RemoveFromObjectCacheAction { } describe('objectCacheReducer', () => { + const requestUUID1 = '8646169a-a8fc-4b31-a368-384c07867eb1'; + const requestUUID2 = 'bd36820b-4bf7-4d58-bd80-b832058b7279'; const selfLink1 = 'https://localhost:8080/api/core/items/1698f1d3-be98-4c51-9fd8-6bfedcbd59b7'; const selfLink2 = 'https://localhost:8080/api/core/items/28b04544-1766-4e82-9728-c4e93544ecd3'; + const newName = 'new different name'; const testState = { [selfLink1]: { data: { @@ -26,16 +33,20 @@ describe('objectCacheReducer', () => { }, timeAdded: new Date().getTime(), msToLive: 900000, - requestHref: selfLink1 + requestUUID: requestUUID1, + patches: [], + isDirty: false }, [selfLink2]: { data: { - self: selfLink2, + self: requestUUID2, foo: 'baz' }, timeAdded: new Date().getTime(), msToLive: 900000, - requestHref: selfLink2 + requestUUID: selfLink2, + patches: [], + isDirty: false } }; deepFreeze(testState); @@ -59,8 +70,8 @@ describe('objectCacheReducer', () => { const objectToCache = { self: selfLink1 }; const timeAdded = new Date().getTime(); const msToLive = 900000; - const requestHref = 'https://rest.api/endpoint/selfLink1'; - const action = new AddToObjectCacheAction(objectToCache, timeAdded, msToLive, requestHref); + const requestUUID = requestUUID1; + const action = new AddToObjectCacheAction(objectToCache, timeAdded, msToLive, requestUUID); const newState = objectCacheReducer(state, action); expect(newState[selfLink1].data).toEqual(objectToCache); @@ -72,8 +83,8 @@ describe('objectCacheReducer', () => { const objectToCache = { self: selfLink1, foo: 'baz', somethingElse: true }; const timeAdded = new Date().getTime(); const msToLive = 900000; - const requestHref = 'https://rest.api/endpoint/selfLink1'; - const action = new AddToObjectCacheAction(objectToCache, timeAdded, msToLive, requestHref); + const requestUUID = requestUUID1; + const action = new AddToObjectCacheAction(objectToCache, timeAdded, msToLive, requestUUID); const newState = objectCacheReducer(testState, action); /* tslint:disable:no-string-literal */ @@ -87,8 +98,8 @@ describe('objectCacheReducer', () => { const objectToCache = { self: selfLink1 }; const timeAdded = new Date().getTime(); const msToLive = 900000; - const requestHref = 'https://rest.api/endpoint/selfLink1'; - const action = new AddToObjectCacheAction(objectToCache, timeAdded, msToLive, requestHref); + const requestUUID = requestUUID1; + const action = new AddToObjectCacheAction(objectToCache, timeAdded, msToLive, requestUUID); deepFreeze(state); objectCacheReducer(state, action); @@ -132,4 +143,32 @@ describe('objectCacheReducer', () => { objectCacheReducer(testState, action); }); + it('should perform the ADD_PATCH action without affecting the previous state', () => { + const action = new AddPatchObjectCacheAction(selfLink1, [{ + op: 'replace', + path: '/name', + value: 'random string' + }]); + // testState has already been frozen above + objectCacheReducer(testState, action); + }); + + it('should when the ADD_PATCH action dispatched', () => { + const patch = [{ op: 'add', path: '/name', value: newName } as Operation]; + const action = new AddPatchObjectCacheAction(selfLink1, patch); + const newState = objectCacheReducer(testState, action); + expect(newState[selfLink1].patches.map((p) => p.operations)).toContain(patch); + }); + + it('should when the APPLY_PATCH action dispatched', () => { + const patch = [{ op: 'add', path: '/name', value: newName } as Operation]; + const addPatchAction = new AddPatchObjectCacheAction(selfLink1, patch); + const stateWithPatch = objectCacheReducer(testState, addPatchAction); + + const action = new ApplyPatchObjectCacheAction(selfLink1); + const newState = objectCacheReducer(stateWithPatch, action); + expect(newState[selfLink1].patches).toEqual([]); + expect((newState[selfLink1].data as any).name).toEqual(newName); + }); + }); diff --git a/src/app/core/cache/object-cache.reducer.ts b/src/app/core/cache/object-cache.reducer.ts index 3a1830e14a..867f31e1bb 100644 --- a/src/app/core/cache/object-cache.reducer.ts +++ b/src/app/core/cache/object-cache.reducer.ts @@ -1,10 +1,15 @@ import { - ObjectCacheAction, ObjectCacheActionTypes, AddToObjectCacheAction, - RemoveFromObjectCacheAction, ResetObjectCacheTimestampsAction + ObjectCacheAction, + ObjectCacheActionTypes, + AddToObjectCacheAction, + RemoveFromObjectCacheAction, + ResetObjectCacheTimestampsAction, + AddPatchObjectCacheAction, ApplyPatchObjectCacheAction } from './object-cache.actions'; -import { hasValue } from '../../shared/empty.util'; +import { hasValue, isNotEmpty } from '../../shared/empty.util'; import { CacheEntry } from './cache-entry'; import { ResourceType } from '../shared/resource-type'; +import { applyPatch, Operation } from 'fast-json-patch'; export enum DirtyType { Created = 'Created', @@ -12,7 +17,12 @@ export enum DirtyType { Deleted = 'Deleted' } -/** +export interface Patch { + uuid?: string; + operations: Operation[]; +} + +/**conca * An interface to represent objects that can be cached * * A cacheable object should have a self link @@ -35,7 +45,9 @@ export class ObjectCacheEntry implements CacheEntry { data: CacheableObject; timeAdded: number; msToLive: number; - requestHref: string; + requestUUID: string; + patches: Patch[] = []; + isDirty: boolean; } /** @@ -76,6 +88,14 @@ export function objectCacheReducer(state = initialState, action: ObjectCacheActi return resetObjectCacheTimestamps(state, action as ResetObjectCacheTimestampsAction) } + case ObjectCacheActionTypes.ADD_PATCH: { + return addPatchObjectCache(state, action as AddPatchObjectCacheAction); + } + + case ObjectCacheActionTypes.APPLY_PATCH: { + return applyPatchObjectCache(state, action as ApplyPatchObjectCacheAction); + } + default: { return state; } @@ -93,12 +113,15 @@ export function objectCacheReducer(state = initialState, action: ObjectCacheActi * the new state, with the object added, or overwritten. */ function addToObjectCache(state: ObjectCacheState, action: AddToObjectCacheAction): ObjectCacheState { + const existing = state[action.payload.objectToCache.self]; return Object.assign({}, state, { [action.payload.objectToCache.self]: { data: action.payload.objectToCache, timeAdded: action.payload.timeAdded, msToLive: action.payload.msToLive, - requestHref: action.payload.requestHref + requestUUID: action.payload.requestUUID, + isDirty: (hasValue(existing) ? isNotEmpty(existing.patches) : false), + patches: (hasValue(existing) ? existing.patches : []) } }); } @@ -143,3 +166,49 @@ function resetObjectCacheTimestamps(state: ObjectCacheState, action: ResetObject }); return newState; } + +/** + * Add the list of patch operations to a cached object + * + * @param state + * the current state + * @param action + * an AddPatchObjectCacheAction + * @return ObjectCacheState + * the new state, with the new operations added to the state of the specified ObjectCacheEntry + */ +function addPatchObjectCache(state: ObjectCacheState, action: AddPatchObjectCacheAction): ObjectCacheState { + const uuid = action.payload.href; + const operations = action.payload.operations; + const newState = Object.assign({}, state); + if (hasValue(newState[uuid])) { + const patches = newState[uuid].patches; + newState[uuid] = Object.assign({}, newState[uuid], { + patches: [...patches, { operations } as Patch], + isDirty: true + }); + } + return newState; +} + +/** + * Apply the list of patch operations to a cached object + * + * @param state + * the current state + * @param action + * an ApplyPatchObjectCacheAction + * @return ObjectCacheState + * the new state, with the new operations applied to the state of the specified ObjectCacheEntry + */ +function applyPatchObjectCache(state: ObjectCacheState, action: ApplyPatchObjectCacheAction): ObjectCacheState { + const uuid = action.payload; + const newState = Object.assign({}, state); + if (hasValue(newState[uuid])) { + // flatten two dimensional array + const flatPatch: Operation[] = [].concat(...newState[uuid].patches.map((patch) => patch.operations)); + const newData = applyPatch(newState[uuid].data, flatPatch, undefined, false); + newState[uuid] = Object.assign({}, newState[uuid], { data: newData.newDocument, patches: [] }); + } + return newState; +} diff --git a/src/app/core/cache/object-cache.service.spec.ts b/src/app/core/cache/object-cache.service.spec.ts index 80a9121544..af353a38c1 100644 --- a/src/app/core/cache/object-cache.service.spec.ts +++ b/src/app/core/cache/object-cache.service.spec.ts @@ -1,31 +1,53 @@ import { Store } from '@ngrx/store'; -import { Observable } from 'rxjs/Observable'; +import { of as observableOf } from 'rxjs'; import { ObjectCacheService } from './object-cache.service'; -import { AddToObjectCacheAction, RemoveFromObjectCacheAction } from './object-cache.actions'; +import { + AddPatchObjectCacheAction, + AddToObjectCacheAction, ApplyPatchObjectCacheAction, + RemoveFromObjectCacheAction +} from './object-cache.actions'; import { CoreState } from '../core.reducers'; import { ResourceType } from '../shared/resource-type'; import { NormalizedItem } from './models/normalized-item.model'; +import { first } from 'rxjs/operators'; +import * as ngrx from '@ngrx/store'; +import { Operation } from '../../../../node_modules/fast-json-patch'; +import { RestRequestMethod } from '../data/rest-request-method'; +import { AddToSSBAction } from './server-sync-buffer.actions'; +import { Patch } from './object-cache.reducer'; describe('ObjectCacheService', () => { let service: ObjectCacheService; let store: Store; const selfLink = 'https://rest.api/endpoint/1698f1d3-be98-4c51-9fd8-6bfedcbd59b7'; + const requestUUID = '4d3a4ce8-a375-4b98-859b-39f0a014d736'; const timestamp = new Date().getTime(); const msToLive = 900000; - const objectToCache = { + let objectToCache = { self: selfLink, type: ResourceType.Item }; - const cacheEntry = { - data: objectToCache, - timeAdded: timestamp, - msToLive: msToLive - }; - const invalidCacheEntry = Object.assign({}, cacheEntry, { msToLive: -1 }); + let cacheEntry; + let invalidCacheEntry; + const operations = [{ op: 'replace', path: '/name', value: 'random string' } as Operation]; + + function init() { + objectToCache = { + self: selfLink, + type: ResourceType.Item + }; + cacheEntry = { + data: objectToCache, + timeAdded: timestamp, + msToLive: msToLive + }; + invalidCacheEntry = Object.assign({}, cacheEntry, { msToLive: -1 }) + } beforeEach(() => { + init(); store = new Store(undefined, undefined, undefined); spyOn(store, 'dispatch'); service = new ObjectCacheService(store); @@ -37,8 +59,8 @@ describe('ObjectCacheService', () => { describe('add', () => { it('should dispatch an ADD action with the object to add, the time to live, and the current timestamp', () => { - service.add(objectToCache, msToLive, selfLink); - expect(store.dispatch).toHaveBeenCalledWith(new AddToObjectCacheAction(objectToCache, timestamp, msToLive, selfLink)); + service.add(objectToCache, msToLive, requestUUID); + expect(store.dispatch).toHaveBeenCalledWith(new AddToObjectCacheAction(objectToCache, timestamp, msToLive, requestUUID)); }); }); @@ -51,10 +73,14 @@ describe('ObjectCacheService', () => { describe('getBySelfLink', () => { it('should return an observable of the cached object with the specified self link and type', () => { - spyOn(store, 'select').and.returnValue(Observable.of(cacheEntry)); + spyOnProperty(ngrx, 'select').and.callFake(() => { + return () => { + return () => observableOf(cacheEntry); + }; + }); // due to the implementation of spyOn above, this subscribe will be synchronous - service.getBySelfLink(selfLink).take(1).subscribe((o) => { + service.getBySelfLink(selfLink).pipe(first()).subscribe((o) => { expect(o.self).toBe(selfLink); // this only works if testObj is an instance of TestClass expect(o instanceof NormalizedItem).toBeTruthy(); @@ -63,7 +89,11 @@ describe('ObjectCacheService', () => { }); it('should not return a cached object that has exceeded its time to live', () => { - spyOn(store, 'select').and.returnValue(Observable.of(invalidCacheEntry)); + spyOnProperty(ngrx, 'select').and.callFake(() => { + return () => { + return () => observableOf(invalidCacheEntry); + }; + }); let getObsHasFired = false; const subscription = service.getBySelfLink(selfLink).subscribe((o) => getObsHasFired = true); @@ -76,9 +106,9 @@ describe('ObjectCacheService', () => { it('should return an observable of the array of cached objects with the specified self link and type', () => { const item = new NormalizedItem(); item.self = selfLink; - spyOn(service, 'getBySelfLink').and.returnValue(Observable.of(item)); + spyOn(service, 'getBySelfLink').and.returnValue(observableOf(item)); - service.getList([selfLink, selfLink]).take(1).subscribe((arr) => { + service.getList([selfLink, selfLink]).pipe(first()).subscribe((arr) => { expect(arr[0].self).toBe(selfLink); expect(arr[0] instanceof NormalizedItem).toBeTruthy(); }); @@ -87,22 +117,60 @@ describe('ObjectCacheService', () => { describe('has', () => { it('should return true if the object with the supplied self link is cached and still valid', () => { - spyOn(store, 'select').and.returnValue(Observable.of(cacheEntry)); + spyOnProperty(ngrx, 'select').and.callFake(() => { + return () => { + return () => observableOf(cacheEntry); + }; + }); expect(service.hasBySelfLink(selfLink)).toBe(true); }); it("should return false if the object with the supplied self link isn't cached", () => { - spyOn(store, 'select').and.returnValue(Observable.of(undefined)); + spyOnProperty(ngrx, 'select').and.callFake(() => { + return () => { + return () => observableOf(undefined); + }; + }); expect(service.hasBySelfLink(selfLink)).toBe(false); }); it('should return false if the object with the supplied self link is cached but has exceeded its time to live', () => { - spyOn(store, 'select').and.returnValue(Observable.of(invalidCacheEntry)); + spyOnProperty(ngrx, 'select').and.callFake(() => { + return () => { + return () => observableOf(invalidCacheEntry); + }; + }); expect(service.hasBySelfLink(selfLink)).toBe(false); }); }); + describe('patch methods', () => { + it('should dispatch the correct actions when addPatch is called', () => { + service.addPatch(selfLink, operations); + expect(store.dispatch).toHaveBeenCalledWith(new AddPatchObjectCacheAction(selfLink, operations)); + expect(store.dispatch).toHaveBeenCalledWith(new AddToSSBAction(selfLink, RestRequestMethod.PATCH)); + }); + + it('isDirty should return true when the patches list in the cache entry is not empty', () => { + cacheEntry.patches = [ + { + operations: operations + } as Patch]; + const result = (service as any).isDirty(cacheEntry); + expect(result).toBe(true); + }); + + it('isDirty should return false when the patches list in the cache entry is empty', () => { + cacheEntry.patches = []; + const result = (service as any).isDirty(cacheEntry); + expect(result).toBe(false); + }); + it('should dispatch the correct actions when applyPatchesToCachedObject is called', () => { + (service as any).applyPatchesToCachedObject(selfLink); + expect(store.dispatch).toHaveBeenCalledWith(new ApplyPatchObjectCacheAction(selfLink)); + }); + }); }); diff --git a/src/app/core/cache/object-cache.service.ts b/src/app/core/cache/object-cache.service.ts index 9344f4d5f0..44297d6f61 100644 --- a/src/app/core/cache/object-cache.service.ts +++ b/src/app/core/cache/object-cache.service.ts @@ -1,25 +1,33 @@ -import { Injectable } from '@angular/core'; -import { MemoizedSelector, Store } from '@ngrx/store'; +import { combineLatest as observableCombineLatest, Observable } from 'rxjs'; -import { Observable } from 'rxjs/Observable'; +import { distinctUntilChanged, filter, first, map, mergeMap, } from 'rxjs/operators'; +import { Injectable } from '@angular/core'; +import { MemoizedSelector, select, Store } from '@ngrx/store'; import { IndexName } from '../index/index.reducer'; -import { ObjectCacheEntry, CacheableObject } from './object-cache.reducer'; -import { AddToObjectCacheAction, RemoveFromObjectCacheAction } from './object-cache.actions'; -import { hasNoValue } from '../../shared/empty.util'; +import { CacheableObject, ObjectCacheEntry } from './object-cache.reducer'; +import { + AddPatchObjectCacheAction, + AddToObjectCacheAction, + ApplyPatchObjectCacheAction, + RemoveFromObjectCacheAction +} from './object-cache.actions'; +import { hasNoValue, isNotEmpty } from '../../shared/empty.util'; import { GenericConstructor } from '../shared/generic-constructor'; import { coreSelector, CoreState } from '../core.reducers'; import { pathSelector } from '../shared/selectors'; -import { Item } from '../shared/item.model'; import { NormalizedObjectFactory } from './models/normalized-object-factory'; import { NormalizedObject } from './models/normalized-object.model'; +import { applyPatch, Operation } from 'fast-json-patch'; +import { AddToSSBAction } from './server-sync-buffer.actions'; +import { RestRequestMethod } from '../data/rest-request-method'; function selfLinkFromUuidSelector(uuid: string): MemoizedSelector { return pathSelector(coreSelector, 'index', IndexName.OBJECT, uuid); } function entryFromSelfLinkSelector(selfLink: string): MemoizedSelector { - return pathSelector(coreSelector, 'data/object', selfLink); + return pathSelector(coreSelector, 'cache/object', selfLink); } /** @@ -37,20 +45,18 @@ export class ObjectCacheService { * The object to add * @param msToLive * The number of milliseconds it should be cached for - * @param requestHref - * The selfLink of the request that resulted in this object - * This isn't necessarily the same as the object's self - * link, it could have been part of a list for example + * @param requestUUID + * The UUID of the request that resulted in this object */ - add(objectToCache: CacheableObject, msToLive: number, requestHref: string): void { - this.store.dispatch(new AddToObjectCacheAction(objectToCache, new Date().getTime(), msToLive, requestHref)); + add(objectToCache: CacheableObject, msToLive: number, requestUUID: string): void { + this.store.dispatch(new AddToObjectCacheAction(objectToCache, new Date().getTime(), msToLive, requestUUID)); } /** - * Remove the object with the supplied UUID from the cache + * Remove the object with the supplied href from the cache * - * @param uuid - * The UUID of the object to be removed + * @param href + * The unique href of the object to be removed */ remove(uuid: string): void { this.store.dispatch(new RemoveFromObjectCacheAction(uuid)); @@ -73,33 +79,51 @@ export class ObjectCacheService { * An observable of the requested object */ getByUUID(uuid: string): Observable { - return this.store.select(selfLinkFromUuidSelector(uuid)) - .flatMap((selfLink: string) => this.getBySelfLink(selfLink)) + return this.store.pipe( + select(selfLinkFromUuidSelector(uuid)), + mergeMap((selfLink: string) => this.getBySelfLink(selfLink) + ) + ) } getBySelfLink(selfLink: string): Observable { - return this.getEntry(selfLink) - .map((entry: ObjectCacheEntry) => { - const type: GenericConstructor= NormalizedObjectFactory.getConstructor(entry.data.type); + return this.getEntry(selfLink).pipe( + map((entry: ObjectCacheEntry) => { + if (isNotEmpty(entry.patches)) { + const flatPatch: Operation[] = [].concat(...entry.patches.map((patch) => patch.operations)); + const patchedData = applyPatch(entry.data, flatPatch, undefined, false).newDocument; + return Object.assign({}, entry, { data: patchedData }); + } else { + return entry; + } + } + ), + map((entry: ObjectCacheEntry) => { + const type: GenericConstructor = NormalizedObjectFactory.getConstructor(entry.data.type); return Object.assign(new type(), entry.data) as T - }); + }) + ); } private getEntry(selfLink: string): Observable { - return this.store.select(entryFromSelfLinkSelector(selfLink)) - .filter((entry) => this.isValid(entry)) - .distinctUntilChanged(); + return this.store.pipe( + select(entryFromSelfLinkSelector(selfLink)), + filter((entry) => this.isValid(entry)), + distinctUntilChanged() + ); } - getRequestHrefBySelfLink(selfLink: string): Observable { - return this.getEntry(selfLink) - .map((entry: ObjectCacheEntry) => entry.requestHref) - .distinctUntilChanged(); + getRequestUUIDBySelfLink(selfLink: string): Observable { + return this.getEntry(selfLink).pipe( + map((entry: ObjectCacheEntry) => entry.requestUUID), + distinctUntilChanged()); } - getRequestHrefByUUID(uuid: string): Observable { - return this.store.select(selfLinkFromUuidSelector(uuid)) - .flatMap((selfLink: string) => this.getRequestHrefBySelfLink(selfLink)); + getRequestUUIDByObjectUUID(uuid: string): Observable { + return this.store.pipe( + select(selfLinkFromUuidSelector(uuid)), + mergeMap((selfLink: string) => this.getRequestUUIDBySelfLink(selfLink)) + ); } /** @@ -122,7 +146,7 @@ export class ObjectCacheService { * @return Observable> */ getList(selfLinks: string[]): Observable { - return Observable.combineLatest( + return observableCombineLatest( selfLinks.map((selfLink: string) => this.getBySelfLink(selfLink)) ); } @@ -139,9 +163,10 @@ export class ObjectCacheService { hasByUUID(uuid: string): boolean { let result: boolean; - this.store.select(selfLinkFromUuidSelector(uuid)) - .take(1) - .subscribe((selfLink: string) => result = this.hasBySelfLink(selfLink)); + this.store.pipe( + select(selfLinkFromUuidSelector(uuid)), + first() + ).subscribe((selfLink: string) => result = this.hasBySelfLink(selfLink)); return result; } @@ -158,9 +183,9 @@ export class ObjectCacheService { hasBySelfLink(selfLink: string): boolean { let result = false; - this.store.select(entryFromSelfLinkSelector(selfLink)) - .take(1) - .subscribe((entry: ObjectCacheEntry) => result = this.isValid(entry)); + this.store.pipe(select(entryFromSelfLinkSelector(selfLink)), + first() + ).subscribe((entry: ObjectCacheEntry) => result = this.isValid(entry)); return result; } @@ -187,4 +212,39 @@ export class ObjectCacheService { } } + /** + * Add operations to the existing list of operations for an ObjectCacheEntry + * Makes sure the ServerSyncBuffer for this ObjectCacheEntry is updated + * @param {string} uuid + * the uuid of the ObjectCacheEntry + * @param {Operation[]} patch + * list of operations to perform + */ + public addPatch(selfLink: string, patch: Operation[]) { + this.store.dispatch(new AddPatchObjectCacheAction(selfLink, patch)); + this.store.dispatch(new AddToSSBAction(selfLink, RestRequestMethod.PATCH)); + } + + /** + * Check whether there are any unperformed operations for an ObjectCacheEntry + * + * @param entry + * the entry to check + * @return boolean + * false if the entry is there are no operations left in the ObjectCacheEntry, true otherwise + */ + private isDirty(entry: ObjectCacheEntry): boolean { + return isNotEmpty(entry.patches); + } + + /** + * Apply the existing operations on an ObjectCacheEntry in the store + * NB: this does not make any server side changes + * @param {string} uuid + * the uuid of the ObjectCacheEntry + */ + private applyPatchesToCachedObject(selfLink: string) { + this.store.dispatch(new ApplyPatchObjectCacheAction(selfLink)); + } + } diff --git a/src/app/core/cache/response-cache.actions.ts b/src/app/core/cache/response-cache.actions.ts deleted file mode 100644 index 0389067690..0000000000 --- a/src/app/core/cache/response-cache.actions.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { Action } from '@ngrx/store'; - -import { type } from '../../shared/ngrx/type'; -import { RestResponse } from './response-cache.models'; - -/** - * The list of ResponseCacheAction type definitions - */ -export const ResponseCacheActionTypes = { - ADD: type('dspace/core/cache/response/ADD'), - REMOVE: type('dspace/core/cache/response/REMOVE'), - RESET_TIMESTAMPS: type('dspace/core/cache/response/RESET_TIMESTAMPS') -}; - -/* tslint:disable:max-classes-per-file */ -export class ResponseCacheAddAction implements Action { - type = ResponseCacheActionTypes.ADD; - payload: { - key: string, - response: RestResponse - timeAdded: number; - msToLive: number; - }; - - constructor(key: string, response: RestResponse, timeAdded: number, msToLive: number) { - this.payload = { key, response, timeAdded, msToLive }; - } -} - -/** - * An ngrx action to remove a request from the cache - */ -export class ResponseCacheRemoveAction implements Action { - type = ResponseCacheActionTypes.REMOVE; - payload: string; - - /** - * Create a new ResponseCacheRemoveAction - * @param key - * The key of the request to remove - */ - constructor(key: string) { - this.payload = key; - } -} - -/** - * An ngrx action to reset the timeAdded property of all cached objects - */ -export class ResetResponseCacheTimestampsAction implements Action { - type = ResponseCacheActionTypes.RESET_TIMESTAMPS; - payload: number; - - /** - * Create a new ResetObjectCacheTimestampsAction - * - * @param newTimestamp - * the new timeAdded all objects should get - */ - constructor(newTimestamp: number) { - this.payload = newTimestamp; - } -} -/* tslint:enable:max-classes-per-file */ - -/** - * A type to encompass all ResponseCacheActions - */ -export type ResponseCacheAction - = ResponseCacheAddAction - | ResponseCacheRemoveAction - | ResetResponseCacheTimestampsAction; diff --git a/src/app/core/cache/response-cache.effects.spec.ts b/src/app/core/cache/response-cache.effects.spec.ts deleted file mode 100644 index e58ec536e3..0000000000 --- a/src/app/core/cache/response-cache.effects.spec.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { TestBed } from '@angular/core/testing'; -import { Observable } from 'rxjs/Observable'; -import { provideMockActions } from '@ngrx/effects/testing'; -import { cold, hot } from 'jasmine-marbles'; -import { StoreActionTypes } from '../../store.actions'; -import { ResponseCacheEffects } from './response-cache.effects'; -import { ResetResponseCacheTimestampsAction } from './response-cache.actions'; - -describe('ResponseCacheEffects', () => { - let cacheEffects: ResponseCacheEffects; - let actions: Observable; - const timestamp = 10000; - beforeEach(() => { - TestBed.configureTestingModule({ - providers: [ - ResponseCacheEffects, - provideMockActions(() => actions), - // other providers - ], - }); - - cacheEffects = TestBed.get(ResponseCacheEffects); - }); - - describe('fixTimestampsOnRehydrate$', () => { - - it('should return a RESET_TIMESTAMPS action in response to a REHYDRATE action', () => { - spyOn(Date.prototype, 'getTime').and.callFake(() => { - return timestamp; - }); - actions = hot('--a-', { a: { type: StoreActionTypes.REHYDRATE, payload: {} } }); - - const expected = cold('--b-', { b: new ResetResponseCacheTimestampsAction(new Date().getTime()) }); - - expect(cacheEffects.fixTimestampsOnRehydrate).toBeObservable(expected); - }); - }); -}); diff --git a/src/app/core/cache/response-cache.effects.ts b/src/app/core/cache/response-cache.effects.ts deleted file mode 100644 index d340750797..0000000000 --- a/src/app/core/cache/response-cache.effects.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { Injectable } from '@angular/core'; -import { Actions, Effect } from '@ngrx/effects'; - -import { ResetResponseCacheTimestampsAction } from './response-cache.actions'; -import { StoreActionTypes } from '../../store.actions'; - -@Injectable() -export class ResponseCacheEffects { - - /** - * When the store is rehydrated in the browser, set all cache - * timestamps to 'now', because the time zone of the server can - * differ from the client. - * - * This assumes that the server cached everything a negligible - * time ago, and will likely need to be revisited later - */ - @Effect() fixTimestampsOnRehydrate = this.actions$ - .ofType(StoreActionTypes.REHYDRATE) - .map(() => new ResetResponseCacheTimestampsAction(new Date().getTime())); - - constructor(private actions$: Actions, ) { } - -} diff --git a/src/app/core/cache/response-cache.reducer.spec.ts b/src/app/core/cache/response-cache.reducer.spec.ts deleted file mode 100644 index 9037b20030..0000000000 --- a/src/app/core/cache/response-cache.reducer.spec.ts +++ /dev/null @@ -1,124 +0,0 @@ -import * as deepFreeze from 'deep-freeze'; - -import { responseCacheReducer, ResponseCacheState } from './response-cache.reducer'; - -import { - ResponseCacheRemoveAction, - ResetResponseCacheTimestampsAction, ResponseCacheAddAction -} from './response-cache.actions'; -import { RestResponse } from './response-cache.models'; - -class NullAction extends ResponseCacheRemoveAction { - type = null; - payload = null; - - constructor() { - super(null); - } -} - -describe('responseCacheReducer', () => { - const keys = ['125c17f89046283c5f0640722aac9feb', 'a06c3006a41caec5d635af099b0c780c']; - const msToLive = 900000; - const uuids = [ - '9e32a2e2-6b91-4236-a361-995ccdc14c60', - '598ce822-c357-46f3-ab70-63724d02d6ad', - 'be8325f7-243b-49f4-8a4b-df2b793ff3b5' - ]; - const testState: ResponseCacheState = { - [keys[0]]: { - key: keys[0], - response: new RestResponse(true, '200'), - timeAdded: new Date().getTime(), - msToLive: msToLive - }, - [keys[1]]: { - key: keys[1], - response: new RestResponse(true, '200'), - timeAdded: new Date().getTime(), - msToLive: msToLive - } - }; - deepFreeze(testState); - const errorState: {} = { - [keys[0]]: { - errorMessage: 'error', - resourceUUIDs: uuids - } - }; - deepFreeze(errorState); - - it('should return the current state when no valid actions have been made', () => { - const action = new NullAction(); - const newState = responseCacheReducer(testState, action); - - expect(newState).toEqual(testState); - }); - - it('should start with an empty cache', () => { - const action = new NullAction(); - const initialState = responseCacheReducer(undefined, action); - - expect(initialState).toEqual(Object.create(null)); - }); - - describe('ADD', () => { - const addTimeAdded = new Date().getTime(); - const addMsToLive = 5; - const addResponse = new RestResponse(true, '200'); - const action = new ResponseCacheAddAction(keys[0], addResponse, addTimeAdded, addMsToLive); - - it('should perform the action without affecting the previous state', () => { - // testState has already been frozen above - responseCacheReducer(testState, action); - }); - - it('should add the response to the cached request', () => { - const newState = responseCacheReducer(testState, action); - expect(newState[keys[0]].timeAdded).toBe(addTimeAdded); - expect(newState[keys[0]].msToLive).toBe(addMsToLive); - expect(newState[keys[0]].response).toBe(addResponse); - }); - }); - - describe('REMOVE', () => { - it('should perform the action without affecting the previous state', () => { - const action = new ResponseCacheRemoveAction(keys[0]); - // testState has already been frozen above - responseCacheReducer(testState, action); - }); - - it('should remove the specified request from the cache', () => { - const action = new ResponseCacheRemoveAction(keys[0]); - const newState = responseCacheReducer(testState, action); - expect(testState[keys[0]]).not.toBeUndefined(); - expect(newState[keys[0]]).toBeUndefined(); - }); - - it('shouldn\'t do anything when the specified key isn\'t cached', () => { - const wrongKey = 'this isn\'t cached'; - const action = new ResponseCacheRemoveAction(wrongKey); - const newState = responseCacheReducer(testState, action); - expect(testState[wrongKey]).toBeUndefined(); - expect(newState).toEqual(testState); - }); - }); - - describe('RESET_TIMESTAMPS', () => { - const newTimeStamp = new Date().getTime(); - const action = new ResetResponseCacheTimestampsAction(newTimeStamp); - - it('should perform the action without affecting the previous state', () => { - // testState has already been frozen above - responseCacheReducer(testState, action); - }); - - it('should set the timestamp of all requests in the cache', () => { - const newState = responseCacheReducer(testState, action); - Object.keys(newState).forEach((key) => { - expect(newState[key].timeAdded).toEqual(newTimeStamp); - }); - }); - - }); -}); diff --git a/src/app/core/cache/response-cache.reducer.ts b/src/app/core/cache/response-cache.reducer.ts deleted file mode 100644 index 73c680c1f5..0000000000 --- a/src/app/core/cache/response-cache.reducer.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { - ResponseCacheAction, ResponseCacheActionTypes, - ResponseCacheRemoveAction, ResetResponseCacheTimestampsAction, - ResponseCacheAddAction -} from './response-cache.actions'; -import { CacheEntry } from './cache-entry'; -import { hasValue } from '../../shared/empty.util'; -import { RestResponse } from './response-cache.models'; - -/** - * An entry in the ResponseCache - */ -export class ResponseCacheEntry implements CacheEntry { - key: string; - response: RestResponse; - timeAdded: number; - msToLive: number; -} - -/** - * The ResponseCache State - */ -export interface ResponseCacheState { - [key: string]: ResponseCacheEntry -} - -// Object.create(null) ensures the object has no default js properties (e.g. `__proto__`) -const initialState = Object.create(null); - -/** - * The ResponseCache Reducer - * - * @param state - * the current state - * @param action - * the action to perform on the state - * @return ResponseCacheState - * the new state - */ -export function responseCacheReducer(state = initialState, action: ResponseCacheAction): ResponseCacheState { - switch (action.type) { - - case ResponseCacheActionTypes.ADD: { - return addToCache(state, action as ResponseCacheAddAction); - } - - case ResponseCacheActionTypes.REMOVE: { - return removeFromCache(state, action as ResponseCacheRemoveAction); - } - - case ResponseCacheActionTypes.RESET_TIMESTAMPS: { - return resetResponseCacheTimestamps(state, action as ResetResponseCacheTimestampsAction) - } - - default: { - return state; - } - } -} - -function addToCache(state: ResponseCacheState, action: ResponseCacheAddAction): ResponseCacheState { - return Object.assign({}, state, { - [action.payload.key]: { - key: action.payload.key, - response: action.payload.response, - timeAdded: action.payload.timeAdded, - msToLive: action.payload.msToLive - } - }); -} - -/** - * Remove a request from the cache - * - * @param state - * the current state - * @param action - * an ResponseCacheRemoveAction - * @return ResponseCacheState - * the new state, with the request removed if it existed. - */ -function removeFromCache(state: ResponseCacheState, action: ResponseCacheRemoveAction): ResponseCacheState { - if (hasValue(state[action.payload])) { - const newCache = Object.assign({}, state); - delete newCache[action.payload]; - - return newCache; - } else { - return state; - } -} - -/** - * Set the timeAdded timestamp of every cached request to the specified value - * - * @param state - * the current state - * @param action - * a ResetResponseCacheTimestampsAction - * @return ResponseCacheState - * the new state, with all timeAdded timestamps set to the specified value - */ -function resetResponseCacheTimestamps(state: ResponseCacheState, action: ResetResponseCacheTimestampsAction): ResponseCacheState { - const newState = Object.create(null); - Object.keys(state).forEach((key) => { - newState[key] = Object.assign({}, state[key], { - timeAdded: action.payload - }); - }); - return newState; -} diff --git a/src/app/core/cache/response-cache.service.spec.ts b/src/app/core/cache/response-cache.service.spec.ts deleted file mode 100644 index 77838b6eb6..0000000000 --- a/src/app/core/cache/response-cache.service.spec.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { Store } from '@ngrx/store'; - -import { ResponseCacheService } from './response-cache.service'; -import { Observable } from 'rxjs/Observable'; -import { CoreState } from '../core.reducers'; -import { RestResponse } from './response-cache.models'; -import { ResponseCacheEntry } from './response-cache.reducer'; - -describe('ResponseCacheService', () => { - let service: ResponseCacheService; - let store: Store; - - const keys = ['125c17f89046283c5f0640722aac9feb', 'a06c3006a41caec5d635af099b0c780c']; - const timestamp = new Date().getTime(); - const validCacheEntry = (key) => { - return { - key: key, - response: new RestResponse(true, '200'), - timeAdded: timestamp, - msToLive: 24 * 60 * 60 * 1000 // a day - } - }; - const invalidCacheEntry = (key) => { - return { - key: key, - response: new RestResponse(true, '200'), - timeAdded: 0, - msToLive: 0 - } - }; - - beforeEach(() => { - store = new Store(undefined, undefined, undefined); - spyOn(store, 'dispatch'); - service = new ResponseCacheService(store); - spyOn(Date.prototype, 'getTime').and.callFake(() => { - return timestamp; - }); - }); - - describe('get', () => { - it('should return an observable of the cached request with the specified key', () => { - spyOn(store, 'select').and.callFake((...args: any[]) => { - return Observable.of(validCacheEntry(keys[1])); - }); - - let testObj: ResponseCacheEntry; - service.get(keys[1]).first().subscribe((entry) => { - testObj = entry; - }); - expect(testObj.key).toEqual(keys[1]); - }); - - it('should not return a cached request that has exceeded its time to live', () => { - spyOn(store, 'select').and.callFake((...args: any[]) => { - return Observable.of(invalidCacheEntry(keys[1])); - }); - - let getObsHasFired = false; - const subscription = service.get(keys[1]).subscribe((entry) => getObsHasFired = true); - expect(getObsHasFired).toBe(false); - subscription.unsubscribe(); - }); - }); - - describe('has', () => { - it('should return true if the request with the supplied key is cached and still valid', () => { - spyOn(store, 'select').and.returnValue(Observable.of(validCacheEntry(keys[1]))); - expect(service.has(keys[1])).toBe(true); - }); - - it('should return false if the request with the supplied key isn\'t cached', () => { - spyOn(store, 'select').and.returnValue(Observable.of(undefined)); - expect(service.has(keys[1])).toBe(false); - }); - - it('should return false if the request with the supplied key is cached but has exceeded its time to live', () => { - spyOn(store, 'select').and.returnValue(Observable.of(invalidCacheEntry(keys[1]))); - expect(service.has(keys[1])).toBe(false); - }); - }); -}); diff --git a/src/app/core/cache/response-cache.service.ts b/src/app/core/cache/response-cache.service.ts deleted file mode 100644 index a0e3740094..0000000000 --- a/src/app/core/cache/response-cache.service.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { Injectable } from '@angular/core'; -import { MemoizedSelector, Store } from '@ngrx/store'; - -import { Observable } from 'rxjs/Observable'; - -import { ResponseCacheEntry } from './response-cache.reducer'; -import { hasNoValue } from '../../shared/empty.util'; -import { ResponseCacheRemoveAction, ResponseCacheAddAction } from './response-cache.actions'; -import { RestResponse } from './response-cache.models'; -import { coreSelector, CoreState } from '../core.reducers'; -import { pathSelector } from '../shared/selectors'; - -function entryFromKeySelector(key: string): MemoizedSelector { - return pathSelector(coreSelector, 'data/response', key); -} - -/** - * A service to interact with the response cache - */ -@Injectable() -export class ResponseCacheService { - constructor( - private store: Store - ) { } - - add(key: string, response: RestResponse, msToLive: number): Observable { - if (!this.has(key)) { - this.store.dispatch(new ResponseCacheAddAction(key, response, new Date().getTime(), msToLive)); - } - return this.get(key); - } - - /** - * Get an observable of the response with the specified key - * - * @param key - * the key of the response to get - * @return Observable - * an observable of the ResponseCacheEntry with the specified key - */ - get(key: string): Observable { - return this.store.select(entryFromKeySelector(key)) - .filter((entry: ResponseCacheEntry) => this.isValid(entry)) - .distinctUntilChanged() - } - - /** - * Check whether the response with the specified key is cached - * - * @param key - * the key of the response to check - * @return boolean - * true if the response with the specified key is cached, - * false otherwise - */ - has(key: string): boolean { - let result: boolean; - - this.store.select(entryFromKeySelector(key)) - .take(1) - .subscribe((entry: ResponseCacheEntry) => { - result = this.isValid(entry); - }); - - return result; - } - - remove(key: string): void { - if (this.has(key)) { - this.store.dispatch(new ResponseCacheRemoveAction(key)); - } - } - /** - * Check whether a ResponseCacheEntry should still be cached - * - * @param entry - * the entry to check - * @return boolean - * false if the entry is null, undefined, or its time to - * live has been exceeded, true otherwise - */ - private isValid(entry: ResponseCacheEntry): boolean { - if (hasNoValue(entry)) { - return false; - } else { - const timeOutdated = entry.timeAdded + entry.msToLive; - const isOutDated = new Date().getTime() > timeOutdated; - if (isOutDated) { - this.store.dispatch(new ResponseCacheRemoveAction(entry.key)); - } - return !isOutDated; - } - } - -} diff --git a/src/app/core/cache/response-cache.models.ts b/src/app/core/cache/response.models.ts similarity index 98% rename from src/app/core/cache/response-cache.models.ts rename to src/app/core/cache/response.models.ts index 9b1b5b89eb..fcec635655 100644 --- a/src/app/core/cache/response-cache.models.ts +++ b/src/app/core/cache/response.models.ts @@ -13,7 +13,7 @@ import { AuthStatus } from '../auth/models/auth-status.model'; /* tslint:disable:max-classes-per-file */ export class RestResponse { - public toCache = true; + public timeAdded: number; constructor( public isSuccessful: boolean, @@ -140,7 +140,7 @@ export class ErrorResponse extends RestResponse { constructor(error: RequestError) { super(false, error.statusText); - console.error(error); + // console.error(error); this.errorMessage = error.message; } } diff --git a/src/app/core/cache/server-sync-buffer.actions.ts b/src/app/core/cache/server-sync-buffer.actions.ts new file mode 100644 index 0000000000..fd7e04ef8a --- /dev/null +++ b/src/app/core/cache/server-sync-buffer.actions.ts @@ -0,0 +1,82 @@ +import { Action } from '@ngrx/store'; + +import { type } from '../../shared/ngrx/type'; +import { RestRequestMethod } from '../data/rest-request-method'; + +/** + * The list of ServerSyncBufferAction type definitions + */ +export const ServerSyncBufferActionTypes = { + ADD: type('dspace/core/cache/syncbuffer/ADD'), + COMMIT: type('dspace/core/cache/syncbuffer/COMMIT'), + EMPTY: type('dspace/core/cache/syncbuffer/EMPTY'), +}; + +/* tslint:disable:max-classes-per-file */ + +/** + * An ngrx action to add a new cached object to the server sync buffer + */ +export class AddToSSBAction implements Action { + type = ServerSyncBufferActionTypes.ADD; + payload: { + href: string, + method: RestRequestMethod + }; + + /** + * Create a new AddToSSBAction + * + * @param href + * the unique href of the cached object entry that should be updated + */ + constructor(href: string, method: RestRequestMethod) { + this.payload = { href, method: method }; + } +} + +/** + * An ngrx action to commit everything (for a certain method, when specified) in the ServerSyncBuffer to the server + */ +export class CommitSSBAction implements Action { + type = ServerSyncBufferActionTypes.COMMIT; + payload?: RestRequestMethod; + + /** + * Create a new CommitSSBAction + * + * @param method + * an optional method for which the ServerSyncBuffer should send its entries to the server + */ + constructor(method?: RestRequestMethod) { + this.payload = method; + } +} +/** + * An ngrx action to remove everything (for a certain method, when specified) from the ServerSyncBuffer to the server + */ +export class EmptySSBAction implements Action { + type = ServerSyncBufferActionTypes.EMPTY; + payload?: RestRequestMethod; + + /** + * Create a new EmptySSBAction + * + * @param method + * an optional method for which the ServerSyncBuffer should remove its entries + * if this parameter is omitted, the buffer will be emptied as a whole + */ + constructor(method?: RestRequestMethod) { + this.payload = method; + } +} + +/* tslint:enable:max-classes-per-file */ + +/** + * A type to encompass all ServerSyncBufferActions + */ +export type ServerSyncBufferAction + = AddToSSBAction + | CommitSSBAction + | EmptySSBAction diff --git a/src/app/core/cache/server-sync-buffer.effects.spec.ts b/src/app/core/cache/server-sync-buffer.effects.spec.ts new file mode 100644 index 0000000000..0a8d50107e --- /dev/null +++ b/src/app/core/cache/server-sync-buffer.effects.spec.ts @@ -0,0 +1,139 @@ +import { TestBed } from '@angular/core/testing'; +import { Observable, of as observableOf } from 'rxjs'; +import { provideMockActions } from '@ngrx/effects/testing'; +import { cold, hot } from 'jasmine-marbles'; +import { ServerSyncBufferEffects } from './server-sync-buffer.effects'; +import { GLOBAL_CONFIG } from '../../../config'; +import { + CommitSSBAction, + EmptySSBAction, + ServerSyncBufferActionTypes +} from './server-sync-buffer.actions'; +import { RestRequestMethod } from '../data/rest-request-method'; +import { Store } from '@ngrx/store'; +import { RequestService } from '../data/request.service'; +import { ObjectCacheService } from './object-cache.service'; +import { MockStore } from '../../shared/testing/mock-store'; +import { ObjectCacheState } from './object-cache.reducer'; +import * as operators from 'rxjs/operators'; +import { spyOnOperator } from '../../shared/testing/utils'; +import { DSpaceObject } from '../shared/dspace-object.model'; +import { getMockRequestService } from '../../shared/mocks/mock-request.service'; +import { ApplyPatchObjectCacheAction } from './object-cache.actions'; + +describe('ServerSyncBufferEffects', () => { + let ssbEffects: ServerSyncBufferEffects; + let actions: Observable; + const testConfig = { + cache: + { + autoSync: + { + timePerMethod: {}, + defaultTime: 0 + } + } + }; + const selfLink = 'https://rest.api/endpoint/1698f1d3-be98-4c51-9fd8-6bfedcbd59b7'; + let store; + + beforeEach(() => { + store = new MockStore({}); + TestBed.configureTestingModule({ + providers: [ + ServerSyncBufferEffects, + provideMockActions(() => actions), + { provide: GLOBAL_CONFIG, useValue: testConfig }, + { provide: RequestService, useValue: getMockRequestService() }, + { + provide: ObjectCacheService, useValue: { + getBySelfLink: (link) => { + const object = new DSpaceObject(); + object.self = link; + return observableOf(object); + } + } + }, + { provide: Store, useValue: store } + // other providers + ], + }); + + ssbEffects = TestBed.get(ServerSyncBufferEffects); + }); + + describe('setTimeoutForServerSync', () => { + beforeEach(() => { + spyOnOperator(operators, 'delay').and.returnValue((v) => v); + }); + + it('should return a COMMIT action in response to an ADD action', () => { + actions = hot('a', { + a: { + type: ServerSyncBufferActionTypes.ADD, + payload: { href: selfLink, method: RestRequestMethod.PUT } + } + }); + + const expected = cold('b', { b: new CommitSSBAction(RestRequestMethod.PUT) }); + + expect(ssbEffects.setTimeoutForServerSync).toBeObservable(expected); + }); + }); + + describe('commitServerSyncBuffer', () => { + describe('when the buffer is not empty', () => { + beforeEach(() => { + store + .subscribe((state) => { + (state as any).core = Object({}); + (state as any).core['cache/syncbuffer'] = { + buffer: [{ + href: selfLink, + method: RestRequestMethod.PATCH + }] + }; + }); + }); + it('should return a list of actions in response to a COMMIT action', () => { + actions = hot('a', { + a: { + type: ServerSyncBufferActionTypes.COMMIT, + payload: RestRequestMethod.PATCH + } + }); + + const expected = cold('(bc)', { + b: new ApplyPatchObjectCacheAction(selfLink), + c: new EmptySSBAction(RestRequestMethod.PATCH) + }); + + expect(ssbEffects.commitServerSyncBuffer).toBeObservable(expected); + }); + }); + + describe('when the buffer is empty', () => { + beforeEach(() => { + store + .subscribe((state) => { + (state as any).core = Object({}); + (state as any).core['cache/syncbuffer'] = { + buffer: [] + }; + }); + }); + it('should return a placeholder action in response to a COMMIT action', () => { + store.subscribe(); + actions = hot('a', { + a: { + type: ServerSyncBufferActionTypes.COMMIT, + payload: { method: RestRequestMethod.PATCH } + } + }); + const expected = cold('b', { b: { type: 'NO_ACTION' } }); + + expect(ssbEffects.commitServerSyncBuffer).toBeObservable(expected); + }); + }); + }); +}); diff --git a/src/app/core/cache/server-sync-buffer.effects.ts b/src/app/core/cache/server-sync-buffer.effects.ts new file mode 100644 index 0000000000..5a0a5527d1 --- /dev/null +++ b/src/app/core/cache/server-sync-buffer.effects.ts @@ -0,0 +1,122 @@ +import { delay, exhaustMap, first, map, switchMap, tap } from 'rxjs/operators'; +import { Inject, Injectable } from '@angular/core'; +import { Actions, Effect, ofType } from '@ngrx/effects'; +import { + AddToSSBAction, + CommitSSBAction, + EmptySSBAction, + ServerSyncBufferActionTypes +} from './server-sync-buffer.actions'; +import { GLOBAL_CONFIG } from '../../../config'; +import { GlobalConfig } from '../../../config/global-config.interface'; +import { coreSelector, CoreState } from '../core.reducers'; +import { Action, createSelector, MemoizedSelector, select, Store } from '@ngrx/store'; +import { ServerSyncBufferEntry, ServerSyncBufferState } from './server-sync-buffer.reducer'; +import { combineLatest as observableCombineLatest, of as observableOf } from 'rxjs'; +import { RequestService } from '../data/request.service'; +import { PutRequest } from '../data/request.models'; +import { ObjectCacheService } from './object-cache.service'; +import { ApplyPatchObjectCacheAction } from './object-cache.actions'; +import { DSpaceRESTv2Serializer } from '../dspace-rest-v2/dspace-rest-v2.serializer'; +import { GenericConstructor } from '../shared/generic-constructor'; +import { hasValue, isNotEmpty, isNotUndefined } from '../../shared/empty.util'; +import { Observable } from 'rxjs/internal/Observable'; +import { RestRequestMethod } from '../data/rest-request-method'; + +@Injectable() +export class ServerSyncBufferEffects { + + /** + * When an ADDToSSBAction is dispatched + * Set a time out (configurable per method type) + * Then dispatch a CommitSSBAction + * When the delay is running, no new AddToSSBActions are processed in this effect + */ + @Effect() setTimeoutForServerSync = this.actions$ + .pipe( + ofType(ServerSyncBufferActionTypes.ADD), + exhaustMap((action: AddToSSBAction) => { + const autoSyncConfig = this.EnvConfig.cache.autoSync; + const timeoutInSeconds = autoSyncConfig.timePerMethod[action.payload.method] || autoSyncConfig.defaultTime; + return observableOf(new CommitSSBAction(action.payload.method)).pipe( + delay(timeoutInSeconds * 1000), + ) + }) + ); + + /** + * When a CommitSSBAction is dispatched + * Create a list of actions for each entry in the current buffer state to be dispatched + * When the list of actions is not empty, also dispatch an EmptySSBAction + * When the list is empty dispatch a NO_ACTION placeholder action + */ + @Effect() commitServerSyncBuffer = this.actions$ + .pipe( + ofType(ServerSyncBufferActionTypes.COMMIT), + switchMap((action: CommitSSBAction) => { + return this.store.pipe( + select(serverSyncBufferSelector()), + first(), /* necessary, otherwise delay will not have any effect after the first run */ + switchMap((bufferState: ServerSyncBufferState) => { + const actions: Array> = bufferState.buffer + .filter((entry: ServerSyncBufferEntry) => { + /* If there's a request method, filter + If there's no filter, commit everything */ + if (hasValue(action.payload)) { + return entry.method === action.payload; + } + return true; + }) + .map((entry: ServerSyncBufferEntry) => { + if (entry.method === RestRequestMethod.PATCH) { + return this.applyPatch(entry.href); + } else { + /* TODO implement for other request method types */ + } + }); + + /* Add extra action to array, to make sure the ServerSyncBuffer is emptied afterwards */ + if (isNotEmpty(actions) && isNotUndefined(actions[0])) { + return observableCombineLatest(...actions).pipe( + switchMap((array) => [...array, new EmptySSBAction(action.payload)]) + ); + } else { + return observableOf({ type: 'NO_ACTION' }); + } + }) + ) + }) + ); + + /** + * private method to create an ApplyPatchObjectCacheAction based on a cache entry + * and to do the actual patch request to the server + * @param {string} href The self link of the cache entry + * @returns {Observable} ApplyPatchObjectCacheAction to be dispatched + */ + private applyPatch(href: string): Observable { + const patchObject = this.objectCache.getBySelfLink(href).pipe(first()); + + return patchObject.pipe( + map((object) => { + const serializedObject = new DSpaceRESTv2Serializer(object.constructor as GenericConstructor<{}>).serialize(object); + + this.requestService.configure(new PutRequest(this.requestService.generateRequestId(), href, serializedObject)); + + return new ApplyPatchObjectCacheAction(href) + }) + ) + } + + constructor(private actions$: Actions, + private store: Store, + private requestService: RequestService, + private objectCache: ObjectCacheService, + @Inject(GLOBAL_CONFIG) private EnvConfig: GlobalConfig) { + + } +} + +export function serverSyncBufferSelector(): MemoizedSelector { + return createSelector(coreSelector, (state: CoreState) => state['cache/syncbuffer']); +} diff --git a/src/app/core/cache/server-sync-buffer.reducer.spec.ts b/src/app/core/cache/server-sync-buffer.reducer.spec.ts new file mode 100644 index 0000000000..8f1392c99d --- /dev/null +++ b/src/app/core/cache/server-sync-buffer.reducer.spec.ts @@ -0,0 +1,85 @@ +import * as deepFreeze from 'deep-freeze'; +import { RemoveFromObjectCacheAction } from './object-cache.actions'; +import { serverSyncBufferReducer } from './server-sync-buffer.reducer'; +import { RestRequestMethod } from '../data/rest-request-method'; +import { AddToSSBAction, EmptySSBAction } from './server-sync-buffer.actions'; + +class NullAction extends RemoveFromObjectCacheAction { + type = null; + payload = null; + + constructor() { + super(null); + } +} + +describe('serverSyncBufferReducer', () => { + const selfLink1 = 'https://localhost:8080/api/core/items/1698f1d3-be98-4c51-9fd8-6bfedcbd59b7'; + const selfLink2 = 'https://localhost:8080/api/core/items/28b04544-1766-4e82-9728-c4e93544ecd3'; + const testState = { + buffer: + [ + { + href: selfLink1, + method: RestRequestMethod.PATCH, + }, + { + href: selfLink2, + method: RestRequestMethod.GET, + } + ] + }; + const newSelfLink = 'https://localhost:8080/api/core/items/1ce6b5ae-97e1-4e5a-b4b0-f9029bad10c0'; + + deepFreeze(testState); + + it('should return the current state when no valid actions have been made', () => { + const action = new NullAction(); + const newState = serverSyncBufferReducer(testState, action); + + expect(newState).toEqual(testState); + }); + + it('should start with an empty buffer array', () => { + const action = new NullAction(); + const initialState = serverSyncBufferReducer(undefined, action); + + expect(initialState).toEqual({ buffer: [] }); + }); + + it('should perform the ADD action without affecting the previous state', () => { + const action = new AddToSSBAction(selfLink1, RestRequestMethod.POST); + // testState has already been frozen above + serverSyncBufferReducer(testState, action); + }); + + it('should perform the EMPTY action without affecting the previous state', () => { + const action = new EmptySSBAction(); + // testState has already been frozen above + serverSyncBufferReducer(testState, action); + }); + + it('should empty the buffer if the EmptySSBAction is dispatched without a payload', () => { + const action = new EmptySSBAction(); + // testState has already been frozen above + const emptyState = serverSyncBufferReducer(testState, action); + expect(emptyState).toEqual({ buffer: [] }); + }); + + it('should empty the buffer partially if the EmptySSBAction is dispatched with a payload', () => { + const action = new EmptySSBAction(RestRequestMethod.PATCH); + // testState has already been frozen above + const emptyState = serverSyncBufferReducer(testState, action); + expect(emptyState).toEqual({ buffer: testState.buffer.filter((entry) => entry.method !== RestRequestMethod.PATCH) }); + }); + + it('should add an entry to the buffer if the AddSSBAction is dispatched', () => { + const action = new AddToSSBAction(newSelfLink, RestRequestMethod.PUT); + // testState has already been frozen above + const newState = serverSyncBufferReducer(testState, action); + expect(newState.buffer).toContain({ + href: newSelfLink, method: RestRequestMethod.PUT + }) + ; + }) +}); diff --git a/src/app/core/cache/server-sync-buffer.reducer.ts b/src/app/core/cache/server-sync-buffer.reducer.ts new file mode 100644 index 0000000000..3e3715d186 --- /dev/null +++ b/src/app/core/cache/server-sync-buffer.reducer.ts @@ -0,0 +1,92 @@ +import { hasNoValue, hasValue } from '../../shared/empty.util'; +import { + AddToSSBAction, + EmptySSBAction, + ServerSyncBufferAction, + ServerSyncBufferActionTypes +} from './server-sync-buffer.actions'; +import { RestRequestMethod } from '../data/rest-request-method'; + +/** + * An entry in the ServerSyncBufferState + * href: unique href of an ObjectCacheEntry + * method: RestRequestMethod type + */ +export class ServerSyncBufferEntry { + href: string; + method: RestRequestMethod; +} + +/** + * The ServerSyncBuffer State + * + * Consists list of ServerSyncBufferState + */ +export interface ServerSyncBufferState { + buffer: ServerSyncBufferEntry[]; +} + +// Object.create(null) ensures the object has no default js properties (e.g. `__proto__`) +const initialState: ServerSyncBufferState = { buffer: [] }; + +/** + * The ServerSyncBuffer Reducer + * + * @param state + * the current state + * @param action + * the action to perform on the state + * @return ServerSyncBufferState + * the new state + */ +export function serverSyncBufferReducer(state = initialState, action: ServerSyncBufferAction): ServerSyncBufferState { + switch (action.type) { + + case ServerSyncBufferActionTypes.ADD: { + return addToServerSyncQueue(state, action as AddToSSBAction) + } + + case ServerSyncBufferActionTypes.EMPTY: { + return emptyServerSyncQueue(state, action as EmptySSBAction); + } + default: { + return state; + } + } +} + +/** + * Add a new entry to the buffer with a specified method + * + * @param state + * the current state + * @param action + * an AddToSSBAction + * @return ServerSyncBufferState + * the new state, with a new entry added to the buffer + */ +function addToServerSyncQueue(state: ServerSyncBufferState, action: AddToSSBAction): ServerSyncBufferState { + const actionEntry = action.payload as ServerSyncBufferEntry; + if (hasNoValue(state.buffer.find((entry) => entry.href === actionEntry.href && entry.method === actionEntry.method))) { + return Object.assign({}, state, { buffer: state.buffer.concat(actionEntry) }); + } +} + +/** + * Remove all ServerSyncBuffers entry from the buffer with a specified method + * If no method is specified, empty the whole buffer + * + * @param state + * the current state + * @param action + * an AddToSSBAction + * @return ServerSyncBufferState + * the new state, with a new entry added to the buffer + */ +function emptyServerSyncQueue(state: ServerSyncBufferState, action: EmptySSBAction): ServerSyncBufferState { + let newBuffer = []; + if (hasValue(action.payload)) { + newBuffer = state.buffer.filter((entry) => entry.method !== action.payload); + } + return Object.assign({}, state, { buffer: newBuffer }); +} diff --git a/src/app/core/config/config.service.spec.ts b/src/app/core/config/config.service.spec.ts index 4b05d5c929..8e9f7db27a 100644 --- a/src/app/core/config/config.service.spec.ts +++ b/src/app/core/config/config.service.spec.ts @@ -1,7 +1,6 @@ import { cold, getTestScheduler, hot } from 'jasmine-marbles'; -import { TestScheduler } from 'rxjs/Rx'; +import { TestScheduler } from 'rxjs/testing'; import { getMockRequestService } from '../../shared/mocks/mock-request.service'; -import { ResponseCacheService } from '../cache/response-cache.service'; import { ConfigService } from './config.service'; import { RequestService } from '../data/request.service'; import { ConfigRequest, FindAllOptions } from '../data/request.models'; @@ -16,7 +15,6 @@ class TestService extends ConfigService { protected browseEndpoint = BROWSE; constructor( - protected responseCache: ResponseCacheService, protected requestService: RequestService, protected halService: HALEndpointService) { super(); @@ -26,7 +24,6 @@ class TestService extends ConfigService { describe('ConfigService', () => { let scheduler: TestScheduler; let service: TestService; - let responseCache: ResponseCacheService; let requestService: RequestService; let halService: any; @@ -39,28 +36,19 @@ describe('ConfigService', () => { const scopedEndpoint = `${serviceEndpoint}/${scopeName}`; const searchEndpoint = `${serviceEndpoint}/${BROWSE}?uuid=${scopeID}`; - function initMockResponseCacheService(isSuccessful: boolean): ResponseCacheService { - return jasmine.createSpyObj('responseCache', { - get: cold('c-', { - c: { response: { isSuccessful } } - }) - }); - } function initTestService(): TestService { return new TestService( - responseCache, requestService, halService ); } beforeEach(() => { - responseCache = initMockResponseCacheService(true); - requestService = getMockRequestService(); - service = initTestService(); scheduler = getTestScheduler(); + requestService = getMockRequestService(); halService = new HALEndpointServiceStub(configEndpoint); + service = initTestService(); }); describe('getConfigByHref', () => { diff --git a/src/app/core/config/config.service.ts b/src/app/core/config/config.service.ts index bb863ad46f..c6c2e2e7d2 100644 --- a/src/app/core/config/config.service.ts +++ b/src/app/core/config/config.service.ts @@ -1,33 +1,35 @@ -import { Observable } from 'rxjs/Observable'; - +import { merge as observableMerge, Observable, throwError as observableThrowError } from 'rxjs'; +import { distinctUntilChanged, filter, map, mergeMap, tap } from 'rxjs/operators'; import { RequestService } from '../data/request.service'; -import { ResponseCacheService } from '../cache/response-cache.service'; -import { ConfigSuccessResponse, ErrorResponse, RestResponse } from '../cache/response-cache.models'; +import { ConfigSuccessResponse } from '../cache/response.models'; import { ConfigRequest, FindAllOptions, RestRequest } from '../data/request.models'; -import { ResponseCacheEntry } from '../cache/response-cache.reducer'; import { hasValue, isNotEmpty } from '../../shared/empty.util'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { ConfigData } from './config-data'; +import { RequestEntry } from '../data/request.reducer'; +import { getResponseFromEntry } from '../shared/operators'; export abstract class ConfigService { protected request: ConfigRequest; - protected abstract responseCache: ResponseCacheService; protected abstract requestService: RequestService; protected abstract linkPath: string; protected abstract browseEndpoint: string; protected abstract halService: HALEndpointService; protected getConfig(request: RestRequest): Observable { - const [successResponse, errorResponse] = this.responseCache.get(request.href) - .map((entry: ResponseCacheEntry) => entry.response) - .partition((response: RestResponse) => response.isSuccessful); - return Observable.merge( - errorResponse.flatMap((response: ErrorResponse) => - Observable.throw(new Error(`Couldn't retrieve the config`))), - successResponse - .filter((response: ConfigSuccessResponse) => isNotEmpty(response) && isNotEmpty(response.configDefinition)) - .map((response: ConfigSuccessResponse) => new ConfigData(response.pageInfo, response.configDefinition)) - .distinctUntilChanged()); + const responses = this.requestService.getByHref(request.href).pipe( + getResponseFromEntry() + ); + const errorResponses = responses.pipe( + filter((response) => !response.isSuccessful), + mergeMap(() => observableThrowError(new Error(`Couldn't retrieve the config`))) + ); + const successResponses = responses.pipe( + filter((response) => response.isSuccessful && isNotEmpty(response) && isNotEmpty((response as ConfigSuccessResponse).configDefinition)), + map((response: ConfigSuccessResponse) => new ConfigData(response.pageInfo, response.configDefinition)) + ); + return observableMerge(errorResponses, successResponses); + } protected getConfigByNameHref(endpoint, resourceName): string { @@ -65,13 +67,13 @@ export abstract class ConfigService { } public getConfigAll(): Observable { - return this.halService.getEndpoint(this.linkPath) - .filter((href: string) => isNotEmpty(href)) - .distinctUntilChanged() - .map((endpointURL: string) => new ConfigRequest(this.requestService.generateRequestId(), endpointURL)) - .do((request: RestRequest) => this.requestService.configure(request)) - .flatMap((request: RestRequest) => this.getConfig(request)) - .distinctUntilChanged(); + return this.halService.getEndpoint(this.linkPath).pipe( + filter((href: string) => isNotEmpty(href)), + distinctUntilChanged(), + map((endpointURL: string) => new ConfigRequest(this.requestService.generateRequestId(), endpointURL)), + tap((request: RestRequest) => this.requestService.configure(request)), + mergeMap((request: RestRequest) => this.getConfig(request)), + distinctUntilChanged()); } public getConfigByHref(href: string): Observable { @@ -82,25 +84,25 @@ export abstract class ConfigService { } public getConfigByName(name: string): Observable { - return this.halService.getEndpoint(this.linkPath) - .map((endpoint: string) => this.getConfigByNameHref(endpoint, name)) - .filter((href: string) => isNotEmpty(href)) - .distinctUntilChanged() - .map((endpointURL: string) => new ConfigRequest(this.requestService.generateRequestId(), endpointURL)) - .do((request: RestRequest) => this.requestService.configure(request)) - .flatMap((request: RestRequest) => this.getConfig(request)) - .distinctUntilChanged(); + return this.halService.getEndpoint(this.linkPath).pipe( + map((endpoint: string) => this.getConfigByNameHref(endpoint, name)), + filter((href: string) => isNotEmpty(href)), + distinctUntilChanged(), + map((endpointURL: string) => new ConfigRequest(this.requestService.generateRequestId(), endpointURL)), + tap((request: RestRequest) => this.requestService.configure(request)), + mergeMap((request: RestRequest) => this.getConfig(request)), + distinctUntilChanged()); } public getConfigBySearch(options: FindAllOptions = {}): Observable { - return this.halService.getEndpoint(this.linkPath) - .map((endpoint: string) => this.getConfigSearchHref(endpoint, options)) - .filter((href: string) => isNotEmpty(href)) - .distinctUntilChanged() - .map((endpointURL: string) => new ConfigRequest(this.requestService.generateRequestId(), endpointURL)) - .do((request: RestRequest) => this.requestService.configure(request)) - .flatMap((request: RestRequest) => this.getConfig(request)) - .distinctUntilChanged(); + return this.halService.getEndpoint(this.linkPath).pipe( + map((endpoint: string) => this.getConfigSearchHref(endpoint, options)), + filter((href: string) => isNotEmpty(href)), + distinctUntilChanged(), + map((endpointURL: string) => new ConfigRequest(this.requestService.generateRequestId(), endpointURL)), + tap((request: RestRequest) => this.requestService.configure(request)), + mergeMap((request: RestRequest) => this.getConfig(request)), + distinctUntilChanged()); } } diff --git a/src/app/core/config/submission-definitions-config.service.ts b/src/app/core/config/submission-definitions-config.service.ts index 6cbe0c55b5..b7b0873c21 100644 --- a/src/app/core/config/submission-definitions-config.service.ts +++ b/src/app/core/config/submission-definitions-config.service.ts @@ -1,7 +1,6 @@ import { Injectable } from '@angular/core'; import { ConfigService } from './config.service'; -import { ResponseCacheService } from '../cache/response-cache.service'; import { RequestService } from '../data/request.service'; import { HALEndpointService } from '../shared/hal-endpoint.service'; @@ -11,7 +10,6 @@ export class SubmissionDefinitionsConfigService extends ConfigService { protected browseEndpoint = 'search/findByCollection'; constructor( - protected responseCache: ResponseCacheService, protected requestService: RequestService, protected halService: HALEndpointService) { super(); diff --git a/src/app/core/config/submission-forms-config.service.ts b/src/app/core/config/submission-forms-config.service.ts index 27eac78218..b688859ec9 100644 --- a/src/app/core/config/submission-forms-config.service.ts +++ b/src/app/core/config/submission-forms-config.service.ts @@ -1,7 +1,6 @@ import { Injectable } from '@angular/core'; import { ConfigService } from './config.service'; -import { ResponseCacheService } from '../cache/response-cache.service'; import { RequestService } from '../data/request.service'; import { HALEndpointService } from '../shared/hal-endpoint.service'; @@ -11,7 +10,6 @@ export class SubmissionFormsConfigService extends ConfigService { protected browseEndpoint = ''; constructor( - protected responseCache: ResponseCacheService, protected requestService: RequestService, protected halService: HALEndpointService) { super(); diff --git a/src/app/core/config/submission-sections-config.service.ts b/src/app/core/config/submission-sections-config.service.ts index 6d4d2ca825..c8bbc0dd97 100644 --- a/src/app/core/config/submission-sections-config.service.ts +++ b/src/app/core/config/submission-sections-config.service.ts @@ -1,7 +1,6 @@ import { Injectable } from '@angular/core'; import { ConfigService } from './config.service'; -import { ResponseCacheService } from '../cache/response-cache.service'; import { RequestService } from '../data/request.service'; import { HALEndpointService } from '../shared/hal-endpoint.service'; @@ -11,7 +10,6 @@ export class SubmissionSectionsConfigService extends ConfigService { protected browseEndpoint = ''; constructor( - protected responseCache: ResponseCacheService, protected requestService: RequestService, protected halService: HALEndpointService) { super(); diff --git a/src/app/core/core.effects.ts b/src/app/core/core.effects.ts index bc534a36b0..c9a352c545 100644 --- a/src/app/core/core.effects.ts +++ b/src/app/core/core.effects.ts @@ -1,14 +1,14 @@ import { ObjectCacheEffects } from './cache/object-cache.effects'; -import { ResponseCacheEffects } from './cache/response-cache.effects'; import { UUIDIndexEffects } from './index/index.effects'; import { RequestEffects } from './data/request.effects'; import { AuthEffects } from './auth/auth.effects'; +import { ServerSyncBufferEffects } from './cache/server-sync-buffer.effects'; export const coreEffects = [ - ResponseCacheEffects, RequestEffects, ObjectCacheEffects, UUIDIndexEffects, - AuthEffects + AuthEffects, + ServerSyncBufferEffects ]; diff --git a/src/app/core/core.module.ts b/src/app/core/core.module.ts index 256d58b5b8..1c8810f7ee 100644 --- a/src/app/core/core.module.ts +++ b/src/app/core/core.module.ts @@ -32,7 +32,6 @@ import { ObjectCacheService } from './cache/object-cache.service'; import { PaginationComponentOptions } from '../shared/pagination/pagination-component-options.model'; import { RemoteDataBuildService } from './cache/builders/remote-data-build.service'; import { RequestService } from './data/request.service'; -import { ResponseCacheService } from './cache/response-cache.service'; import { EndpointMapResponseParsingService } from './data/endpoint-map-response-parsing.service'; import { ServerResponseService } from '../shared/services/server-response.service'; import { NativeWindowFactory, NativeWindowService } from '../shared/services/window.service'; @@ -104,7 +103,6 @@ const PROVIDERS = [ RegistryService, RemoteDataBuildService, RequestService, - ResponseCacheService, EndpointMapResponseParsingService, FacetValueResponseParsingService, FacetValueMapResponseParsingService, diff --git a/src/app/core/core.reducers.ts b/src/app/core/core.reducers.ts index c764a2acff..1843e10671 100644 --- a/src/app/core/core.reducers.ts +++ b/src/app/core/core.reducers.ts @@ -1,22 +1,22 @@ import { ActionReducerMap, createFeatureSelector } from '@ngrx/store'; -import { responseCacheReducer, ResponseCacheState } from './cache/response-cache.reducer'; import { objectCacheReducer, ObjectCacheState } from './cache/object-cache.reducer'; import { indexReducer, IndexState } from './index/index.reducer'; import { requestReducer, RequestState } from './data/request.reducer'; import { authReducer, AuthState } from './auth/auth.reducer'; +import { serverSyncBufferReducer, ServerSyncBufferState } from './cache/server-sync-buffer.reducer'; export interface CoreState { - 'data/object': ObjectCacheState, - 'data/response': ResponseCacheState, + 'cache/object': ObjectCacheState, + 'cache/syncbuffer': ServerSyncBufferState, 'data/request': RequestState, 'index': IndexState, 'auth': AuthState, } export const coreReducers: ActionReducerMap = { - 'data/object': objectCacheReducer, - 'data/response': responseCacheReducer, + 'cache/object': objectCacheReducer, + 'cache/syncbuffer': serverSyncBufferReducer, 'data/request': requestReducer, 'index': indexReducer, 'auth': authReducer diff --git a/src/app/core/data/base-response-parsing.service.ts b/src/app/core/data/base-response-parsing.service.ts index fdf5b4eb97..eada156ce9 100644 --- a/src/app/core/data/base-response-parsing.service.ts +++ b/src/app/core/data/base-response-parsing.service.ts @@ -6,7 +6,6 @@ import { ObjectCacheService } from '../cache/object-cache.service'; import { GlobalConfig } from '../../../config/global-config.interface'; import { GenericConstructor } from '../shared/generic-constructor'; import { PaginatedList } from './paginated-list'; -import { NormalizedObject } from '../cache/models/normalized-object.model'; import { ResourceType } from '../shared/resource-type'; import { RESTURLCombiner } from '../url-combiner/rest-url-combiner'; @@ -15,7 +14,7 @@ function isObjectLevel(halObj: any) { } function isPaginatedResponse(halObj: any) { - return isNotEmpty(halObj.page) && hasValue(halObj._embedded); + return hasValue(halObj.page) && hasValue(halObj._embedded); } /* tslint:disable:max-classes-per-file */ @@ -26,15 +25,14 @@ export abstract class BaseResponseParsingService { protected abstract objectFactory: any; protected abstract toCache: boolean; - protected process(data: any, requestHref: string): any { - + protected process(data: any, requestUUID: string): any { if (isNotEmpty(data)) { if (hasNoValue(data) || (typeof data !== 'object')) { return data; } else if (isPaginatedResponse(data)) { - return this.processPaginatedList(data, requestHref); + return this.processPaginatedList(data, requestUUID); } else if (Array.isArray(data)) { - return this.processArray(data, requestHref); + return this.processArray(data, requestUUID); } else if (isObjectLevel(data)) { data = this.fixBadEPersonRestResponse(data); const object = this.deserialize(data); @@ -43,7 +41,7 @@ export abstract class BaseResponseParsingService { .keys(data._embedded) .filter((property) => data._embedded.hasOwnProperty(property)) .forEach((property) => { - const parsedObj = this.process(data._embedded[property], requestHref); + const parsedObj = this.process(data._embedded[property], requestUUID); if (isNotEmpty(parsedObj)) { if (isPaginatedResponse(data._embedded[property])) { object[property] = parsedObj; @@ -57,7 +55,7 @@ export abstract class BaseResponseParsingService { }); } - this.cache(object, requestHref); + this.cache(object, requestUUID); return object; } const result = {}; @@ -65,7 +63,7 @@ export abstract class BaseResponseParsingService { .filter((property) => data.hasOwnProperty(property)) .filter((property) => hasValue(data[property])) .forEach((property) => { - const obj = this.process(data[property], requestHref); + const obj = this.process(data[property], requestUUID); result[property] = obj; }); return result; @@ -73,7 +71,7 @@ export abstract class BaseResponseParsingService { } } - protected processPaginatedList(data: any, requestHref: string): PaginatedList { + protected processPaginatedList(data: any, requestUUID: string): PaginatedList { const pageInfo: PageInfo = this.processPageInfo(data); let list = data._embedded; @@ -81,14 +79,14 @@ export abstract class BaseResponseParsingService { if (!Array.isArray(list)) { list = this.flattenSingleKeyObject(list); } - const page: ObjectDomain[] = this.processArray(list, requestHref); + const page: ObjectDomain[] = this.processArray(list, requestUUID); return new PaginatedList(pageInfo, page); } - protected processArray(data: any, requestHref: string): ObjectDomain[] { + protected processArray(data: any, requestUUID: string): ObjectDomain[] { let array: ObjectDomain[] = []; data.forEach((datum) => { - array = [...array, this.process(datum, requestHref)]; + array = [...array, this.process(datum, requestUUID)]; } ); return array; @@ -116,21 +114,21 @@ export abstract class BaseResponseParsingService { } } - protected cache(obj, requestHref) { + protected cache(obj, requestUUID) { if (this.toCache) { - this.addToObjectCache(obj, requestHref); + this.addToObjectCache(obj, requestUUID); } } - protected addToObjectCache(co: CacheableObject, requestHref: string): void { + protected addToObjectCache(co: CacheableObject, requestUUID: string): void { if (hasNoValue(co) || hasNoValue(co.self)) { throw new Error('The server returned an invalid object'); } - this.objectCache.add(co, this.EnvConfig.cache.msToLive, requestHref); + this.objectCache.add(co, this.EnvConfig.cache.msToLive.default, requestUUID); } processPageInfo(payload: any): PageInfo { - if (isNotEmpty(payload.page)) { + if (hasValue(payload.page)) { const pageObj = Object.assign({}, payload.page, { _links: payload._links }); const pageInfoObject = new DSpaceRESTv2Serializer(PageInfo).deserialize(pageObj); if (pageInfoObject.currentPage >= 0) { diff --git a/src/app/core/data/browse-entries-response-parsing.service.spec.ts b/src/app/core/data/browse-entries-response-parsing.service.spec.ts index dd04e4f2f5..a61da7aa95 100644 --- a/src/app/core/data/browse-entries-response-parsing.service.spec.ts +++ b/src/app/core/data/browse-entries-response-parsing.service.spec.ts @@ -1,5 +1,5 @@ import { getMockObjectCacheService } from '../../shared/mocks/mock-object-cache.service'; -import { ErrorResponse, GenericSuccessResponse } from '../cache/response-cache.models'; +import { ErrorResponse, GenericSuccessResponse } from '../cache/response.models'; import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; import { BrowseEntriesResponseParsingService } from './browse-entries-response-parsing.service'; import { BrowseEntriesRequest } from './request.models'; 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 171def60df..39600b637d 100644 --- a/src/app/core/data/browse-entries-response-parsing.service.ts +++ b/src/app/core/data/browse-entries-response-parsing.service.ts @@ -7,7 +7,7 @@ import { ErrorResponse, GenericSuccessResponse, RestResponse -} from '../cache/response-cache.models'; +} from '../cache/response.models'; import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; import { DSpaceRESTv2Serializer } from '../dspace-rest-v2/dspace-rest-v2.serializer'; import { BrowseEntry } from '../shared/browse-entry.model'; diff --git a/src/app/core/data/browse-items-response-parsing-service.spec.ts b/src/app/core/data/browse-items-response-parsing-service.spec.ts index 6a141c01c4..99ea474dc6 100644 --- a/src/app/core/data/browse-items-response-parsing-service.spec.ts +++ b/src/app/core/data/browse-items-response-parsing-service.spec.ts @@ -1,5 +1,5 @@ import { getMockObjectCacheService } from '../../shared/mocks/mock-object-cache.service'; -import { ErrorResponse, GenericSuccessResponse } from '../cache/response-cache.models'; +import { ErrorResponse, GenericSuccessResponse } from '../cache/response.models'; import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; import { BrowseEntriesResponseParsingService } from './browse-entries-response-parsing.service'; import { BrowseEntriesRequest, BrowseItemsRequest } from './request.models'; diff --git a/src/app/core/data/browse-items-response-parsing-service.ts b/src/app/core/data/browse-items-response-parsing-service.ts index e513ad0898..218c25bac6 100644 --- a/src/app/core/data/browse-items-response-parsing-service.ts +++ b/src/app/core/data/browse-items-response-parsing-service.ts @@ -7,7 +7,7 @@ import { ErrorResponse, GenericSuccessResponse, RestResponse -} from '../cache/response-cache.models'; +} from '../cache/response.models'; import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; import { DSpaceRESTv2Serializer } from '../dspace-rest-v2/dspace-rest-v2.serializer'; import { BaseResponseParsingService } from './base-response-parsing.service'; diff --git a/src/app/core/data/browse-response-parsing.service.spec.ts b/src/app/core/data/browse-response-parsing.service.spec.ts index b0fbb1f977..bedf5f03a7 100644 --- a/src/app/core/data/browse-response-parsing.service.spec.ts +++ b/src/app/core/data/browse-response-parsing.service.spec.ts @@ -1,6 +1,6 @@ import { BrowseResponseParsingService } from './browse-response-parsing.service'; import { BrowseEndpointRequest } from './request.models'; -import { GenericSuccessResponse, ErrorResponse } from '../cache/response-cache.models'; +import { GenericSuccessResponse, ErrorResponse } from '../cache/response.models'; import { BrowseDefinition } from '../shared/browse-definition.model'; import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; @@ -10,134 +10,148 @@ describe('BrowseResponseParsingService', () => { beforeEach(() => { service = new BrowseResponseParsingService(); }); + let validRequest; + let validResponse; + let invalidResponse1; + let invalidResponse2; + let invalidResponse3; + let definitions; describe('parse', () => { - const validRequest = new BrowseEndpointRequest('client/b186e8ce-e99c-4183-bc9a-42b4821bdb78', 'https://rest.api/discover/browses'); + beforeEach(() => { + validRequest = new BrowseEndpointRequest('client/b186e8ce-e99c-4183-bc9a-42b4821bdb78', 'https://rest.api/discover/browses'); - const validResponse = { - payload: { - _embedded: { - browses: [{ - metadataBrowse: false, - sortOptions: [{ name: 'title', metadata: 'dc.title' }, { + validResponse = { + payload: { + _embedded: { + browses: [{ + metadataBrowse: false, + sortOptions: [{ name: 'title', metadata: 'dc.title' }, { + name: 'dateissued', + metadata: 'dc.date.issued' + }, { name: 'dateaccessioned', metadata: 'dc.date.accessioned' }], + order: 'ASC', + type: 'browse', + metadata: ['dc.date.issued'], + _links: { + self: { href: 'https://rest.api/discover/browses/dateissued' }, + items: { href: 'https://rest.api/discover/browses/dateissued/items' } + } + }, { + metadataBrowse: true, + sortOptions: [{ name: 'title', metadata: 'dc.title' }, { + name: 'dateissued', + metadata: 'dc.date.issued' + }, { name: 'dateaccessioned', metadata: 'dc.date.accessioned' }], + order: 'ASC', + type: 'browse', + metadata: ['dc.contributor.*', 'dc.creator'], + _links: { + self: { href: 'https://rest.api/discover/browses/author' }, + entries: { href: 'https://rest.api/discover/browses/author/entries' }, + items: { href: 'https://rest.api/discover/browses/author/items' } + } + }] + }, + _links: { self: { href: 'https://rest.api/discover/browses' } }, + page: { size: 20, totalElements: 2, totalPages: 1, number: 0 } + }, statusCode: '200' + } as DSpaceRESTV2Response; + + invalidResponse1 = { + payload: { + _embedded: { + browse: { + metadataBrowse: false, + sortOptions: [{ name: 'title', metadata: 'dc.title' }, { + name: 'dateissued', + metadata: 'dc.date.issued' + }, { name: 'dateaccessioned', metadata: 'dc.date.accessioned' }], + order: 'ASC', + type: 'browse', + metadata: ['dc.date.issued'], + _links: { + self: { href: 'https://rest.api/discover/browses/dateissued' }, + items: { href: 'https://rest.api/discover/browses/dateissued/items' } + } + } + }, + _links: { self: { href: 'https://rest.api/discover/browses' } }, + page: { size: 20, totalElements: 2, totalPages: 1, number: 0 } + }, statusCode: '200' + } as DSpaceRESTV2Response; + + invalidResponse2 = { + payload: { + _links: { self: { href: 'https://rest.api/discover/browses' } }, + page: { size: 20, totalElements: 2, totalPages: 1, number: 0 } + }, statusCode: '200' + } as DSpaceRESTV2Response; + + invalidResponse3 = { + payload: { + _links: { self: { href: 'https://rest.api/discover/browses' } }, + page: { size: 20, totalElements: 2, totalPages: 1, number: 0 } + }, statusCode: '500' + } as DSpaceRESTV2Response; + + definitions = [ + Object.assign(new BrowseDefinition(), { + metadataBrowse: false, + sortOptions: [ + { + name: 'title', + metadata: 'dc.title' + }, + { name: 'dateissued', metadata: 'dc.date.issued' - }, { name: 'dateaccessioned', metadata: 'dc.date.accessioned' }], - order: 'ASC', - type: 'browse', - metadata: ['dc.date.issued'], - _links: { - self: { href: 'https://rest.api/discover/browses/dateissued' }, - items: { href: 'https://rest.api/discover/browses/dateissued/items' } + }, + { + name: 'dateaccessioned', + metadata: 'dc.date.accessioned' } - }, { - metadataBrowse: true, - sortOptions: [{ name: 'title', metadata: 'dc.title' }, { + ], + defaultSortOrder: 'ASC', + type: 'browse', + metadataKeys: [ + 'dc.date.issued' + ], + _links: { + self: 'https://rest.api/discover/browses/dateissued', + items: 'https://rest.api/discover/browses/dateissued/items' + } + }), + Object.assign(new BrowseDefinition(), { + metadataBrowse: true, + sortOptions: [ + { + name: 'title', + metadata: 'dc.title' + }, + { name: 'dateissued', metadata: 'dc.date.issued' - }, { name: 'dateaccessioned', metadata: 'dc.date.accessioned' }], - order: 'ASC', - type: 'browse', - metadata: ['dc.contributor.*', 'dc.creator'], - _links: { - self: { href: 'https://rest.api/discover/browses/author' }, - entries: { href: 'https://rest.api/discover/browses/author/entries' }, - items: { href: 'https://rest.api/discover/browses/author/items' } - } - }] - }, - _links: { self: { href: 'https://rest.api/discover/browses' } }, - page: { size: 20, totalElements: 2, totalPages: 1, number: 0 } - }, statusCode: '200' - } as DSpaceRESTV2Response; - - const invalidResponse1 = { - payload: { - _embedded: { - browse: { - metadataBrowse: false, - sortOptions: [{ name: 'title', metadata: 'dc.title' }, { - name: 'dateissued', - metadata: 'dc.date.issued' - }, { name: 'dateaccessioned', metadata: 'dc.date.accessioned' }], - order: 'ASC', - type: 'browse', - metadata: ['dc.date.issued'], - _links: { - self: { href: 'https://rest.api/discover/browses/dateissued' }, - items: { href: 'https://rest.api/discover/browses/dateissued/items' } + }, + { + name: 'dateaccessioned', + metadata: 'dc.date.accessioned' } + ], + defaultSortOrder: 'ASC', + type: 'browse', + metadataKeys: [ + 'dc.contributor.*', + 'dc.creator' + ], + _links: { + self: 'https://rest.api/discover/browses/author', + entries: 'https://rest.api/discover/browses/author/entries', + items: 'https://rest.api/discover/browses/author/items' } - }, - _links: { self: { href: 'https://rest.api/discover/browses' } }, - page: { size: 20, totalElements: 2, totalPages: 1, number: 0 } - }, statusCode: '200' - } as DSpaceRESTV2Response; - - const invalidResponse2 = { - payload: { - _links: { self: { href: 'https://rest.api/discover/browses' } }, - page: { size: 20, totalElements: 2, totalPages: 1, number: 0 } - }, statusCode: '200' - } as DSpaceRESTV2Response ; - - const invalidResponse3 = { - payload: { - _links: { self: { href: 'https://rest.api/discover/browses' } }, - page: { size: 20, totalElements: 2, totalPages: 1, number: 0 } - }, statusCode: '500' - } as DSpaceRESTV2Response; - - const definitions = [ - Object.assign(new BrowseDefinition(), { - metadataBrowse: false, - sortOptions: [ - { - name: 'title', - metadata: 'dc.title' - }, - { - name: 'dateissued', - metadata: 'dc.date.issued' - }, - { - name: 'dateaccessioned', - metadata: 'dc.date.accessioned' - } - ], - defaultSortOrder: 'ASC', - type: 'browse', - metadataKeys: [ - 'dc.date.issued' - ], - _links: { } - }), - Object.assign(new BrowseDefinition(), { - metadataBrowse: true, - sortOptions: [ - { - name: 'title', - metadata: 'dc.title' - }, - { - name: 'dateissued', - metadata: 'dc.date.issued' - }, - { - name: 'dateaccessioned', - metadata: 'dc.date.accessioned' - } - ], - defaultSortOrder: 'ASC', - type: 'browse', - metadataKeys: [ - 'dc.contributor.*', - 'dc.creator' - ], - _links: { } - }) - ]; - + }) + ]; + }); it('should return a GenericSuccessResponse if data contains a valid browse endpoint response', () => { const response = service.parse(validRequest, validResponse); expect(response.constructor).toBe(GenericSuccessResponse); diff --git a/src/app/core/data/browse-response-parsing.service.ts b/src/app/core/data/browse-response-parsing.service.ts index 8feb1bc82b..523fffd565 100644 --- a/src/app/core/data/browse-response-parsing.service.ts +++ b/src/app/core/data/browse-response-parsing.service.ts @@ -2,7 +2,7 @@ import { Injectable } from '@angular/core'; import { ResponseParsingService } from './parsing.service'; import { RestRequest } from './request.models'; import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; -import { GenericSuccessResponse, ErrorResponse, RestResponse } from '../cache/response-cache.models'; +import { GenericSuccessResponse, ErrorResponse, RestResponse } from '../cache/response.models'; import { isNotEmpty } from '../../shared/empty.util'; import { DSpaceRESTv2Serializer } from '../dspace-rest-v2/dspace-rest-v2.serializer'; import { BrowseDefinition } from '../shared/browse-definition.model'; diff --git a/src/app/core/data/collection-data.service.ts b/src/app/core/data/collection-data.service.ts index b4648097c3..d0479a9da6 100644 --- a/src/app/core/data/collection-data.service.ts +++ b/src/app/core/data/collection-data.service.ts @@ -3,7 +3,6 @@ import { Store } from '@ngrx/store'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { NormalizedCollection } from '../cache/models/normalized-collection.model'; import { ObjectCacheService } from '../cache/object-cache.service'; -import { ResponseCacheService } from '../cache/response-cache.service'; import { CoreState } from '../core.reducers'; import { Collection } from '../shared/collection.model'; import { ComColDataService } from './comcol-data.service'; @@ -30,13 +29,12 @@ export class CollectionDataService extends ComColDataService, protected cds: CommunityDataService, - protected objectCache: ObjectCacheService, - protected halService: HALEndpointService + protected halService: HALEndpointService, + protected objectCache: ObjectCacheService ) { super(); } diff --git a/src/app/core/data/comcol-data.service.spec.ts b/src/app/core/data/comcol-data.service.spec.ts index 59ae6619d8..867d559c70 100644 --- a/src/app/core/data/comcol-data.service.spec.ts +++ b/src/app/core/data/comcol-data.service.spec.ts @@ -1,12 +1,10 @@ import { Store } from '@ngrx/store'; import { cold, getTestScheduler, hot } from 'jasmine-marbles'; -import { TestScheduler } from 'rxjs/Rx'; +import { TestScheduler } from 'rxjs/testing'; import { GlobalConfig } from '../../../config'; import { getMockRequestService } from '../../shared/mocks/mock-request.service'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import { NormalizedCommunity } from '../cache/models/normalized-community.model'; import { ObjectCacheService } from '../cache/object-cache.service'; -import { ResponseCacheService } from '../cache/response-cache.service'; import { CoreState } from '../core.reducers'; import { ComColDataService } from './comcol-data.service'; import { CommunityDataService } from './community-data.service'; @@ -14,6 +12,8 @@ import { FindAllOptions, FindByIDRequest } from './request.models'; import { RequestService } from './request.service'; import { NormalizedObject } from '../cache/models/normalized-object.model'; import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { RequestEntry } from './request.reducer'; +import { of as observableOf } from 'rxjs'; const LINK_NAME = 'test'; @@ -24,25 +24,24 @@ class NormalizedTestObject extends NormalizedObject { class TestService extends ComColDataService { constructor( - protected responseCache: ResponseCacheService, protected requestService: RequestService, protected rdbService: RemoteDataBuildService, protected store: Store, protected EnvConfig: GlobalConfig, protected cds: CommunityDataService, - protected objectCache: ObjectCacheService, protected halService: HALEndpointService, + protected objectCache: ObjectCacheService, protected linkPath: string ) { super(); } } + /* tslint:enable:max-classes-per-file */ describe('ComColDataService', () => { let scheduler: TestScheduler; let service: TestService; - let responseCache: ResponseCacheService; let requestService: RequestService; let cds: CommunityDataService; let objectCache: ObjectCacheService; @@ -56,6 +55,11 @@ describe('ComColDataService', () => { const options = Object.assign(new FindAllOptions(), { scopeID: scopeID }); + const getRequestEntry$ = (successful: boolean) => { + return observableOf({ + response: { isSuccessful: successful } as any + } as RequestEntry) + }; const communitiesEndpoint = 'https://rest.api/core/communities'; const communityEndpoint = `${communitiesEndpoint}/${scopeID}`; @@ -69,14 +73,6 @@ describe('ComColDataService', () => { }); } - function initMockResponseCacheService(isSuccessful: boolean): ResponseCacheService { - return jasmine.createSpyObj('responseCache', { - get: cold('c-', { - c: { response: { isSuccessful } } - }) - }); - } - function initMockObjectCacheService(): ObjectCacheService { return jasmine.createSpyObj('objectCache', { getByUUID: cold('d-', { @@ -91,14 +87,13 @@ describe('ComColDataService', () => { function initTestService(): TestService { return new TestService( - responseCache, requestService, rdbService, store, EnvConfig, cds, - objectCache, halService, + objectCache, LINK_NAME ); } @@ -110,9 +105,8 @@ describe('ComColDataService', () => { it('should configure a new FindByIDRequest for the scope Community', () => { cds = initMockCommunityDataService(); - requestService = getMockRequestService(); + requestService = getMockRequestService(getRequestEntry$(true)); objectCache = initMockObjectCacheService(); - responseCache = initMockResponseCacheService(true); service = initTestService(); const expected = new FindByIDRequest(requestService.generateRequestId(), communityEndpoint, scopeID); @@ -126,9 +120,8 @@ describe('ComColDataService', () => { describe('if the scope Community can be found', () => { beforeEach(() => { cds = initMockCommunityDataService(); - requestService = getMockRequestService(); + requestService = getMockRequestService(getRequestEntry$(true)); objectCache = initMockObjectCacheService(); - responseCache = initMockResponseCacheService(true); service = initTestService(); }); @@ -140,18 +133,17 @@ describe('ComColDataService', () => { it('should return the endpoint to fetch resources within the given scope', () => { const result = service.getBrowseEndpoint(options); - const expected = cold('--e-', { e: scopedEndpoint }); + const expected = '--e-'; - expect(result).toBeObservable(expected); + scheduler.expectObservable(result).toBe(expected, { e: scopedEndpoint }); }); }); describe('if the scope Community can\'t be found', () => { beforeEach(() => { cds = initMockCommunityDataService(); - requestService = getMockRequestService(); + requestService = getMockRequestService(getRequestEntry$(false)); objectCache = initMockObjectCacheService(); - responseCache = initMockResponseCacheService(false); service = initTestService(); }); diff --git a/src/app/core/data/comcol-data.service.ts b/src/app/core/data/comcol-data.service.ts index bb624eda0f..63c11dd8cb 100644 --- a/src/app/core/data/comcol-data.service.ts +++ b/src/app/core/data/comcol-data.service.ts @@ -1,18 +1,27 @@ -import { Observable } from 'rxjs/Observable'; -import { isEmpty, isNotEmpty } from '../../shared/empty.util'; +import { + distinctUntilChanged, + filter, + first, + map, + mergeMap, + share, + take, + tap +} from 'rxjs/operators'; +import { merge as observableMerge, Observable, throwError as observableThrowError } from 'rxjs'; +import { hasValue, isEmpty, isNotEmpty } from '../../shared/empty.util'; import { NormalizedCommunity } from '../cache/models/normalized-community.model'; -import { CacheableObject } from '../cache/object-cache.reducer'; import { ObjectCacheService } from '../cache/object-cache.service'; -import { DSOSuccessResponse, ErrorResponse, RestResponse } from '../cache/response-cache.models'; -import { ResponseCacheEntry } from '../cache/response-cache.reducer'; import { CommunityDataService } from './community-data.service'; import { DataService } from './data.service'; import { FindAllOptions, FindByIDRequest } from './request.models'; import { NormalizedObject } from '../cache/models/normalized-object.model'; import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { RequestEntry } from './request.reducer'; +import { getResponseFromEntry } from '../shared/operators'; -export abstract class ComColDataService extends DataService { +export abstract class ComColDataService extends DataService { protected abstract cds: CommunityDataService; protected abstract objectCache: ObjectCacheService; protected abstract halService: HALEndpointService; @@ -27,33 +36,52 @@ export abstract class ComColDataService } * an Observable containing the scoped URL */ - public getBrowseEndpoint(options: FindAllOptions = {}): Observable { + public getBrowseEndpoint(options: FindAllOptions = {}, linkPath: string = this.linkPath): Observable { if (isEmpty(options.scopeID)) { - return this.halService.getEndpoint(this.linkPath); + return this.halService.getEndpoint(linkPath); } else { - const scopeCommunityHrefObs = this.cds.getEndpoint() - .flatMap((endpoint: string) => this.cds.getFindByIDHref(endpoint, options.scopeID)) - .filter((href: string) => isNotEmpty(href)) - .take(1) - .do((href: string) => { + const scopeCommunityHrefObs = this.cds.getEndpoint().pipe( + mergeMap((endpoint: string) => this.cds.getFindByIDHref(endpoint, options.scopeID)), + filter((href: string) => isNotEmpty(href)), + take(1), + tap((href: string) => { const request = new FindByIDRequest(this.requestService.generateRequestId(), href, options.scopeID); this.requestService.configure(request); - }); + })); - const [successResponse, errorResponse] = scopeCommunityHrefObs - .flatMap((href: string) => this.responseCache.get(href)) - .map((entry: ResponseCacheEntry) => entry.response) - .share() - .partition((response: RestResponse) => response.isSuccessful); + // return scopeCommunityHrefObs.pipe( + // mergeMap((href: string) => this.responseCache.get(href)), + // map((entry: ResponseCacheEntry) => entry.response), + // mergeMap((response) => { + // if (response.isSuccessful) { + // const community$: Observable = this.objectCache.getByUUID(scopeID); + // return community$.pipe( + // map((community) => community._links[linkPath]), + // filter((href) => isNotEmpty(href)), + // distinctUntilChanged() + // ); + // } else if (!response.isSuccessful) { + // return observableThrowError(new Error(`The Community with scope ${scopeID} couldn't be retrieved`)) + // } + // }), + // distinctUntilChanged() + // ); + const responses = scopeCommunityHrefObs.pipe( + mergeMap((href: string) => this.requestService.getByHref(href)), + getResponseFromEntry() + ); + const errorResponses = responses.pipe( + filter((response) => !response.isSuccessful), + mergeMap(() => observableThrowError(new Error(`The Community with scope ${options.scopeID} couldn't be retrieved`))) + ); + const successResponses = responses.pipe( + filter((response) => response.isSuccessful), + mergeMap(() => this.objectCache.getByUUID(options.scopeID)), + map((nc: NormalizedCommunity) => nc._links[linkPath]), + filter((href) => isNotEmpty(href)) + ); - return Observable.merge( - errorResponse.flatMap((response: ErrorResponse) => - Observable.throw(new Error(`The Community with scope ${options.scopeID} couldn't be retrieved`))), - successResponse - .flatMap((response: DSOSuccessResponse) => this.objectCache.getByUUID(options.scopeID)) - .map((nc: NormalizedCommunity) => nc._links[this.linkPath]) - .filter((href) => isNotEmpty(href)) - ).distinctUntilChanged(); + return observableMerge(errorResponses, successResponses).pipe(distinctUntilChanged(), share()); } } } diff --git a/src/app/core/data/community-data.service.ts b/src/app/core/data/community-data.service.ts index 9313823a33..a037936202 100644 --- a/src/app/core/data/community-data.service.ts +++ b/src/app/core/data/community-data.service.ts @@ -1,10 +1,10 @@ +import { filter, mergeMap, take } from 'rxjs/operators'; import { Injectable } from '@angular/core'; import { Store } from '@ngrx/store'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { NormalizedCommunity } from '../cache/models/normalized-community.model'; import { ObjectCacheService } from '../cache/object-cache.service'; -import { ResponseCacheService } from '../cache/response-cache.service'; import { CoreState } from '../core.reducers'; import { Community } from '../shared/community.model'; import { ComColDataService } from './comcol-data.service'; @@ -13,7 +13,7 @@ import { HALEndpointService } from '../shared/hal-endpoint.service'; import { FindAllOptions, FindAllRequest } from './request.models'; import { RemoteData } from './remote-data'; import { hasValue, isNotEmpty } from '../../shared/empty.util'; -import { Observable } from 'rxjs/Observable'; +import { Observable } from 'rxjs'; import { PaginatedList } from './paginated-list'; @Injectable() @@ -23,12 +23,11 @@ export class CommunityDataService extends ComColDataService, - protected objectCache: ObjectCacheService, - protected halService: HALEndpointService + protected halService: HALEndpointService, + protected objectCache: ObjectCacheService ) { super(); } @@ -38,11 +37,10 @@ export class CommunityDataService extends ComColDataService>> { - const hrefObs = this.getFindAllHref(options); - - hrefObs - .filter((href: string) => hasValue(href)) - .take(1) + const hrefObs = this.getFindAllHref(options, this.topLinkPath); + hrefObs.pipe( + filter((href: string) => hasValue(href)), + take(1)) .subscribe((href: string) => { const request = new FindAllRequest(this.requestService.generateRequestId(), href, options); this.requestService.configure(request); diff --git a/src/app/core/data/config-response-parsing.service.spec.ts b/src/app/core/data/config-response-parsing.service.spec.ts index 654ee53651..a33c5cf5b5 100644 --- a/src/app/core/data/config-response-parsing.service.spec.ts +++ b/src/app/core/data/config-response-parsing.service.spec.ts @@ -1,4 +1,4 @@ -import { ConfigSuccessResponse, ErrorResponse } from '../cache/response-cache.models'; +import { ConfigSuccessResponse, ErrorResponse } from '../cache/response.models'; import { ConfigResponseParsingService } from './config-response-parsing.service'; import { ObjectCacheService } from '../cache/object-cache.service'; import { GlobalConfig } from '../../../config/global-config.interface'; diff --git a/src/app/core/data/config-response-parsing.service.ts b/src/app/core/data/config-response-parsing.service.ts index 2b1b923625..50303d0a09 100644 --- a/src/app/core/data/config-response-parsing.service.ts +++ b/src/app/core/data/config-response-parsing.service.ts @@ -3,7 +3,7 @@ import { Inject, Injectable } from '@angular/core'; import { ResponseParsingService } from './parsing.service'; import { RestRequest } from './request.models'; import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; -import { ConfigSuccessResponse, ErrorResponse, RestResponse } from '../cache/response-cache.models'; +import { ConfigSuccessResponse, ErrorResponse, RestResponse } from '../cache/response.models'; import { isNotEmpty } from '../../shared/empty.util'; import { ConfigObjectFactory } from '../shared/config/config-object-factory'; @@ -28,7 +28,7 @@ export class ConfigResponseParsingService extends BaseResponseParsingService imp parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse { if (isNotEmpty(data.payload) && isNotEmpty(data.payload._links) && (data.statusCode === '201' || data.statusCode === '200' || data.statusCode === 'OK')) { - const configDefinition = this.process(data.payload, request.href); + const configDefinition = this.process(data.payload, request.uuid); return new ConfigSuccessResponse(configDefinition, data.statusCode, this.processPageInfo(data.payload)); } else { return new ErrorResponse( diff --git a/src/app/core/data/data.service.spec.ts b/src/app/core/data/data.service.spec.ts index 7af06ff62a..7da709abd5 100644 --- a/src/app/core/data/data.service.spec.ts +++ b/src/app/core/data/data.service.spec.ts @@ -1,14 +1,17 @@ import { DataService } from './data.service'; import { NormalizedObject } from '../cache/models/normalized-object.model'; -import { ResponseCacheService } from '../cache/response-cache.service'; import { RequestService } from './request.service'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { CoreState } from '../core.reducers'; import { Store } from '@ngrx/store'; import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { Observable } from 'rxjs/Observable'; +import { Observable } from 'rxjs'; import { FindAllOptions } from './request.models'; import { SortOptions, SortDirection } from '../cache/models/sort-options.model'; +import { of as observableOf } from 'rxjs'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { Operation } from '../../../../node_modules/fast-json-patch'; +import { DSpaceObject } from '../shared/dspace-object.model'; const endpoint = 'https://rest.api/core'; @@ -17,107 +20,162 @@ class NormalizedTestObject extends NormalizedObject { } class TestService extends DataService { - constructor( - protected responseCache: ResponseCacheService, - protected requestService: RequestService, - protected rdbService: RemoteDataBuildService, - protected store: Store, - protected linkPath: string, - protected halService: HALEndpointService - ) { - super(); - } + constructor( + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected store: Store, + protected linkPath: string, + protected halService: HALEndpointService, + protected objectCache: ObjectCacheService + ) { + super(); + } - public getBrowseEndpoint(options: FindAllOptions): Observable { - return Observable.of(endpoint); - } + public getBrowseEndpoint(options: FindAllOptions = {}, linkPath: string = this.linkPath): Observable { + return observableOf(endpoint); + } } describe('DataService', () => { - let service: TestService; - let options: FindAllOptions; - const responseCache = {} as ResponseCacheService; - const requestService = {} as RequestService; - const halService = {} as HALEndpointService; - const rdbService = {} as RemoteDataBuildService; - const store = {} as Store; - - function initTestService(): TestService { - return new TestService( - responseCache, - requestService, - rdbService, - store, - endpoint, - halService - ); + let service: TestService; + let options: FindAllOptions; + const requestService = {} as RequestService; + const halService = {} as HALEndpointService; + const rdbService = {} as RemoteDataBuildService; + const objectCache = { + addPatch: () => { + /* empty */ + }, + getBySelfLink: () => { + /* empty */ } + } as any; + const store = {} as Store; - service = initTestService(); + function initTestService(): TestService { + return new TestService( + requestService, + rdbService, + store, + endpoint, + halService, + objectCache + ); + } - describe('getFindAllHref', () => { + service = initTestService(); - it('should return an observable with the endpoint', () => { - options = {}; + describe('getFindAllHref', () => { - (service as any).getFindAllHref(options).subscribe((value) => { - expect(value).toBe(endpoint); - } - ); - }); + it('should return an observable with the endpoint', () => { + options = {}; - it('should include page in href if currentPage provided in options', () => { - options = { currentPage: 2 }; - const expected = `${endpoint}?page=${options.currentPage - 1}`; - - (service as any).getFindAllHref(options).subscribe((value) => { - expect(value).toBe(expected); - }); - }); - - it('should include size in href if elementsPerPage provided in options', () => { - options = { elementsPerPage: 5 }; - const expected = `${endpoint}?size=${options.elementsPerPage}`; - - (service as any).getFindAllHref(options).subscribe((value) => { - expect(value).toBe(expected); - }); - }); - - it('should include sort href if SortOptions provided in options', () => { - const sortOptions = new SortOptions('field1', SortDirection.ASC); - options = { sort: sortOptions}; - const expected = `${endpoint}?sort=${sortOptions.field},${sortOptions.direction}`; - - (service as any).getFindAllHref(options).subscribe((value) => { - expect(value).toBe(expected); - }); - }); - - it('should include startsWith in href if startsWith provided in options', () => { - options = { startsWith: 'ab' }; - const expected = `${endpoint}?startsWith=${options.startsWith}`; - - (service as any).getFindAllHref(options).subscribe((value) => { - expect(value).toBe(expected); - }); - }); - - it('should include all provided options in href', () => { - const sortOptions = new SortOptions('field1', SortDirection.DESC) - options = { - currentPage: 6, - elementsPerPage: 10, - sort: sortOptions, - startsWith: 'ab' - } - const expected = `${endpoint}?page=${options.currentPage - 1}&size=${options.elementsPerPage}` + - `&sort=${sortOptions.field},${sortOptions.direction}&startsWith=${options.startsWith}`; - - (service as any).getFindAllHref(options).subscribe((value) => { - expect(value).toBe(expected); - }); - }) + (service as any).getFindAllHref(options).subscribe((value) => { + expect(value).toBe(endpoint); + } + ); }); + it('should include page in href if currentPage provided in options', () => { + options = { currentPage: 2 }; + const expected = `${endpoint}?page=${options.currentPage - 1}`; + + (service as any).getFindAllHref(options).subscribe((value) => { + expect(value).toBe(expected); + }); + }); + + it('should include size in href if elementsPerPage provided in options', () => { + options = { elementsPerPage: 5 }; + const expected = `${endpoint}?size=${options.elementsPerPage}`; + + (service as any).getFindAllHref(options).subscribe((value) => { + expect(value).toBe(expected); + }); + }); + + it('should include sort href if SortOptions provided in options', () => { + const sortOptions = new SortOptions('field1', SortDirection.ASC); + options = { sort: sortOptions }; + const expected = `${endpoint}?sort=${sortOptions.field},${sortOptions.direction}`; + + (service as any).getFindAllHref(options).subscribe((value) => { + expect(value).toBe(expected); + }); + }); + + it('should include startsWith in href if startsWith provided in options', () => { + options = { startsWith: 'ab' }; + const expected = `${endpoint}?startsWith=${options.startsWith}`; + + (service as any).getFindAllHref(options).subscribe((value) => { + expect(value).toBe(expected); + }); + }); + + it('should include all provided options in href', () => { + const sortOptions = new SortOptions('field1', SortDirection.DESC) + options = { + currentPage: 6, + elementsPerPage: 10, + sort: sortOptions, + startsWith: 'ab' + } + const expected = `${endpoint}?page=${options.currentPage - 1}&size=${options.elementsPerPage}` + + `&sort=${sortOptions.field},${sortOptions.direction}&startsWith=${options.startsWith}`; + + (service as any).getFindAllHref(options).subscribe((value) => { + expect(value).toBe(expected); + }); + }) + }); + describe('patch', () => { + let operations; + let selfLink; + + beforeEach(() => { + operations = [{ op: 'replace', path: '/name', value: 'random string' } as Operation]; + selfLink = 'https://rest.api/endpoint/1698f1d3-be98-4c51-9fd8-6bfedcbd59b7'; + spyOn(objectCache, 'addPatch'); + }); + + it('should call addPatch on the object cache with the right parameters', () => { + service.patch(selfLink, operations); + expect(objectCache.addPatch).toHaveBeenCalledWith(selfLink, operations); + }); + }); + + describe('update', () => { + let operations; + let selfLink; + let dso; + let dso2; + const name1 = 'random string'; + const name2 = 'another random string'; + beforeEach(() => { + operations = [{ op: 'replace', path: '/name', value: name2 } as Operation]; + selfLink = 'https://rest.api/endpoint/1698f1d3-be98-4c51-9fd8-6bfedcbd59b7'; + + dso = new DSpaceObject(); + dso.self = selfLink; + dso.name = name1; + + dso2 = new DSpaceObject(); + dso2.self = selfLink; + dso2.name = name2; + + spyOn(objectCache, 'getBySelfLink').and.returnValue(dso); + spyOn(objectCache, 'addPatch'); + }); + + it('should call addPatch on the object cache with the right parameters when there are differences', () => { + service.update(dso2); + expect(objectCache.addPatch).toHaveBeenCalledWith(selfLink, operations); + }); + + it('should not call addPatch on the object cache with the right parameters when there are no differences', () => { + service.update(dso); + expect(objectCache.addPatch).not.toHaveBeenCalled(); + }); + }); }); diff --git a/src/app/core/data/data.service.ts b/src/app/core/data/data.service.ts index 3c22ccfad9..6a7916854b 100644 --- a/src/app/core/data/data.service.ts +++ b/src/app/core/data/data.service.ts @@ -1,9 +1,8 @@ -import { filter, take, tap } from 'rxjs/operators'; +import { delay, distinctUntilChanged, filter, first, map, take, tap } from 'rxjs/operators'; +import { Observable } from 'rxjs'; import { Store } from '@ngrx/store'; -import { Observable } from 'rxjs/Observable'; import { hasValue, isNotEmpty } from '../../shared/empty.util'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import { ResponseCacheService } from '../cache/response-cache.service'; import { CoreState } from '../core.reducers'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { URLCombiner } from '../url-combiner/url-combiner'; @@ -12,42 +11,41 @@ import { RemoteData } from './remote-data'; import { FindAllOptions, FindAllRequest, FindByIDRequest, GetRequest } from './request.models'; import { RequestService } from './request.service'; import { NormalizedObject } from '../cache/models/normalized-object.model'; +import { compare, Operation } from 'fast-json-patch'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { DSpaceObject } from '../shared/dspace-object.model'; +import { of } from 'rxjs/internal/observable/of'; export abstract class DataService { - protected abstract responseCache: ResponseCacheService; protected abstract requestService: RequestService; protected abstract rdbService: RemoteDataBuildService; protected abstract store: Store; protected abstract linkPath: string; protected abstract halService: HALEndpointService; + protected abstract objectCache: ObjectCacheService; - public abstract getBrowseEndpoint(options: FindAllOptions): Observable + public abstract getBrowseEndpoint(options: FindAllOptions, linkPath?: string): Observable - protected getFindAllHref(options: FindAllOptions = {}): Observable { + protected getFindAllHref(options: FindAllOptions = {}, linkPath?: string): Observable { let result: Observable; const args = []; - result = this.getBrowseEndpoint(options).distinctUntilChanged(); - + result = this.getBrowseEndpoint(options, linkPath); 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 (hasValue(options.startsWith)) { args.push(`startsWith=${options.startsWith}`); } - if (isNotEmpty(args)) { - return result.map((href: string) => new URLCombiner(href, `?${args.join('&')}`).toString()); + return result.pipe(map((href: string) => new URLCombiner(href, `?${args.join('&')}`).toString())); } else { return result; } @@ -58,7 +56,7 @@ export abstract class DataService hrefObs.pipe( filter((href: string) => hasValue(href)), - take(1), tap((value) => console.log(value))) + take(1)) .subscribe((href: string) => { const request = new FindAllRequest(this.requestService.generateRequestId(), href, options); this.requestService.configure(request); @@ -72,11 +70,11 @@ export abstract class DataService } findById(id: string): Observable> { - const hrefObs = this.halService.getEndpoint(this.linkPath) - .map((endpoint: string) => this.getFindByIDHref(endpoint, id)); + const hrefObs = this.halService.getEndpoint(this.linkPath).pipe( + map((endpoint: string) => this.getFindByIDHref(endpoint, id))); - hrefObs - .first((href: string) => hasValue(href)) + hrefObs.pipe( + first((href: string) => hasValue(href))) .subscribe((href: string) => { const request = new FindByIDRequest(this.requestService.generateRequestId(), href, id); this.requestService.configure(request); @@ -90,6 +88,28 @@ export abstract class DataService return this.rdbService.buildSingle(href); } + /** + * Add a new patch to the object cache to a specified object + * @param {string} href The selflink of the object that will be patched + * @param {Operation[]} operations The patch operations to be performed + */ + patch(href: string, operations: Operation[]) { + this.objectCache.addPatch(href, operations); + } + + /** + * Add a new patch to the object cache + * The patch is derived from the differences between the given object and its version in the object cache + * @param {DSpaceObject} object The given object + */ + update(object: DSpaceObject) { + const oldVersion = this.objectCache.getBySelfLink(object.self); + const operations = compare(oldVersion, object); + if (isNotEmpty(operations)) { + this.objectCache.addPatch(object.self, operations); + } + } + // TODO implement, after the structure of the REST server's POST response is finalized // create(dso: DSpaceObject): Observable> { // const postHrefObs = this.getEndpoint(); @@ -101,7 +121,7 @@ export abstract class DataService // .filter((href: string) => hasValue(href)) // .take(1) // .subscribe((href: string) => { - // const request = new RestRequest(this.requestService.generateRequestId(), href, RestRequestMethod.Post, dso); + // const request = new RestRequest(this.requestService.generateRequestId(), href, RestRequestMethod.POST, dso); // this.requestService.configure(request); // }); // diff --git a/src/app/core/data/debug-response-parsing.service.ts b/src/app/core/data/debug-response-parsing.service.ts index d530948559..174abec897 100644 --- a/src/app/core/data/debug-response-parsing.service.ts +++ b/src/app/core/data/debug-response-parsing.service.ts @@ -1,5 +1,5 @@ import { Injectable } from '@angular/core'; -import { RestResponse } from '../cache/response-cache.models'; +import { RestResponse } from '../cache/response.models'; import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; import { ResponseParsingService } from './parsing.service'; import { RestRequest } from './request.models'; diff --git a/src/app/core/data/dso-response-parsing.service.ts b/src/app/core/data/dso-response-parsing.service.ts index aff450781f..1066d11a50 100644 --- a/src/app/core/data/dso-response-parsing.service.ts +++ b/src/app/core/data/dso-response-parsing.service.ts @@ -7,7 +7,7 @@ import { NormalizedObject } from '../cache/models/normalized-object.model'; import { ResourceType } from '../shared/resource-type'; import { NormalizedObjectFactory } from '../cache/models/normalized-object-factory'; import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; -import { RestResponse, DSOSuccessResponse } from '../cache/response-cache.models'; +import { RestResponse, DSOSuccessResponse } from '../cache/response.models'; import { RestRequest } from './request.models'; import { ResponseParsingService } from './parsing.service'; @@ -23,12 +23,14 @@ export class DSOResponseParsingService extends BaseResponseParsingService implem constructor( @Inject(GLOBAL_CONFIG) protected EnvConfig: GlobalConfig, protected objectCache: ObjectCacheService, - ) { super(); + ) { + super(); } parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse { - const processRequestDTO = this.process(data.payload, request.href); + const processRequestDTO = this.process(data.payload, request.uuid); let objectList = processRequestDTO; + if (hasNoValue(processRequestDTO)) { return new DSOSuccessResponse([], data.statusCode, undefined) } diff --git a/src/app/core/data/dspace-object-data.service.spec.ts b/src/app/core/data/dspace-object-data.service.spec.ts index bb2bdc675d..cdddcb7ce6 100644 --- a/src/app/core/data/dspace-object-data.service.spec.ts +++ b/src/app/core/data/dspace-object-data.service.spec.ts @@ -1,11 +1,12 @@ import { cold, getTestScheduler } from 'jasmine-marbles'; -import { TestScheduler } from '../../../../node_modules/rxjs'; +import { TestScheduler } from 'rxjs/testing'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { DSpaceObject } from '../shared/dspace-object.model'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { FindByIDRequest } from './request.models'; import { RequestService } from './request.service'; import { DSpaceObjectDataService } from './dspace-object-data.service'; +import { ObjectCacheService } from '../cache/object-cache.service'; describe('DSpaceObjectDataService', () => { let scheduler: TestScheduler; @@ -13,6 +14,7 @@ describe('DSpaceObjectDataService', () => { let halService: HALEndpointService; let requestService: RequestService; let rdbService: RemoteDataBuildService; + let objectCache: ObjectCacheService; const testObject = { uuid: '9b4f22f4-164a-49db-8817-3316b6ee5746' } as DSpaceObject; @@ -37,11 +39,13 @@ describe('DSpaceObjectDataService', () => { } }) }); + objectCache = {} as ObjectCacheService; service = new DSpaceObjectDataService( requestService, rdbService, - halService + halService, + objectCache ) }); diff --git a/src/app/core/data/dspace-object-data.service.ts b/src/app/core/data/dspace-object-data.service.ts index afd9d74f5b..324692c676 100644 --- a/src/app/core/data/dspace-object-data.service.ts +++ b/src/app/core/data/dspace-object-data.service.ts @@ -1,9 +1,8 @@ import { Injectable } from '@angular/core'; import { Store } from '@ngrx/store'; -import { Observable } from 'rxjs/Observable'; +import { Observable } from 'rxjs'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { NormalizedDSpaceObject } from '../cache/models/normalized-dspace-object.model'; -import { ResponseCacheService } from '../cache/response-cache.service'; import { CoreState } from '../core.reducers'; import { DSpaceObject } from '../shared/dspace-object.model'; import { HALEndpointService } from '../shared/hal-endpoint.service'; @@ -11,22 +10,23 @@ import { DataService } from './data.service'; import { RemoteData } from './remote-data'; import { RequestService } from './request.service'; import { FindAllOptions } from './request.models'; +import { ObjectCacheService } from '../cache/object-cache.service'; /* tslint:disable:max-classes-per-file */ class DataServiceImpl extends DataService { protected linkPath = 'dso'; constructor( - protected responseCache: ResponseCacheService, protected requestService: RequestService, protected rdbService: RemoteDataBuildService, protected store: Store, - protected halService: HALEndpointService) { + protected halService: HALEndpointService, + protected objectCache: ObjectCacheService) { super(); } - getBrowseEndpoint(options: FindAllOptions): Observable { - return this.halService.getEndpoint(this.linkPath); + getBrowseEndpoint(options: FindAllOptions = {}, linkPath: string = this.linkPath): Observable { + return this.halService.getEndpoint(linkPath); } getFindByIDHref(endpoint, resourceID): string { @@ -42,8 +42,9 @@ export class DSpaceObjectDataService { constructor( protected requestService: RequestService, protected rdbService: RemoteDataBuildService, - protected halService: HALEndpointService) { - this.dataService = new DataServiceImpl(null, requestService, rdbService, null, halService); + protected halService: HALEndpointService, + protected objectCache: ObjectCacheService) { + this.dataService = new DataServiceImpl(requestService, rdbService, null, halService, objectCache); } findById(uuid: string): Observable> { diff --git a/src/app/core/data/endpoint-map-response-parsing.service.ts b/src/app/core/data/endpoint-map-response-parsing.service.ts index b850e13932..a145477953 100644 --- a/src/app/core/data/endpoint-map-response-parsing.service.ts +++ b/src/app/core/data/endpoint-map-response-parsing.service.ts @@ -1,7 +1,7 @@ import { Inject, Injectable } from '@angular/core'; import { GLOBAL_CONFIG } from '../../../config'; import { GlobalConfig } from '../../../config/global-config.interface'; -import { ErrorResponse, RestResponse, EndpointMapSuccessResponse } from '../cache/response-cache.models'; +import { ErrorResponse, RestResponse, EndpointMapSuccessResponse } from '../cache/response.models'; import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; import { ResponseParsingService } from './parsing.service'; import { RestRequest } from './request.models'; diff --git a/src/app/core/data/facet-config-response-parsing.service.ts b/src/app/core/data/facet-config-response-parsing.service.ts index b0d89fb03e..02b12dfa10 100644 --- a/src/app/core/data/facet-config-response-parsing.service.ts +++ b/src/app/core/data/facet-config-response-parsing.service.ts @@ -2,7 +2,7 @@ import { Inject, Injectable } from '@angular/core'; import { FacetConfigSuccessResponse, RestResponse -} from '../cache/response-cache.models'; +} from '../cache/response.models'; import { ResponseParsingService } from './parsing.service'; import { RestRequest } from './request.models'; import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; diff --git a/src/app/core/data/facet-value-map-response-parsing.service.ts b/src/app/core/data/facet-value-map-response-parsing.service.ts index 8588e4aa0b..2f580ee952 100644 --- a/src/app/core/data/facet-value-map-response-parsing.service.ts +++ b/src/app/core/data/facet-value-map-response-parsing.service.ts @@ -4,13 +4,11 @@ import { FacetValueMapSuccessResponse, FacetValueSuccessResponse, RestResponse -} from '../cache/response-cache.models'; +} from '../cache/response.models'; import { ResponseParsingService } from './parsing.service'; import { RestRequest } from './request.models'; import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; import { DSpaceRESTv2Serializer } from '../dspace-rest-v2/dspace-rest-v2.serializer'; -import { PageInfo } from '../shared/page-info.model'; -import { isNotEmpty } from '../../shared/empty.util'; import { FacetValue } from '../../+search-page/search-service/facet-value.model'; import { BaseResponseParsingService } from './base-response-parsing.service'; import { ObjectCacheService } from '../cache/object-cache.service'; diff --git a/src/app/core/data/facet-value-response-parsing.service.ts b/src/app/core/data/facet-value-response-parsing.service.ts index bc3f4e5368..54f36a0564 100644 --- a/src/app/core/data/facet-value-response-parsing.service.ts +++ b/src/app/core/data/facet-value-response-parsing.service.ts @@ -1,16 +1,9 @@ import { Inject, Injectable } from '@angular/core'; -import { - FacetValueMap, - FacetValueMapSuccessResponse, - FacetValueSuccessResponse, - RestResponse -} from '../cache/response-cache.models'; +import { FacetValueSuccessResponse, RestResponse } from '../cache/response.models'; import { ResponseParsingService } from './parsing.service'; import { RestRequest } from './request.models'; import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; import { DSpaceRESTv2Serializer } from '../dspace-rest-v2/dspace-rest-v2.serializer'; -import { PageInfo } from '../shared/page-info.model'; -import { isNotEmpty } from '../../shared/empty.util'; import { FacetValue } from '../../+search-page/search-service/facet-value.model'; import { BaseResponseParsingService } from './base-response-parsing.service'; import { ObjectCacheService } from '../cache/object-cache.service'; diff --git a/src/app/core/data/item-data.service.spec.ts b/src/app/core/data/item-data.service.spec.ts index 4cf126157a..bb67fc8412 100644 --- a/src/app/core/data/item-data.service.spec.ts +++ b/src/app/core/data/item-data.service.spec.ts @@ -1,13 +1,13 @@ import { Store } from '@ngrx/store'; import { cold, getTestScheduler } from 'jasmine-marbles'; -import { TestScheduler } from 'rxjs/Rx'; +import { TestScheduler } from 'rxjs/testing'; import { BrowseService } from '../browse/browse.service'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import { ResponseCacheService } from '../cache/response-cache.service'; import { CoreState } from '../core.reducers'; import { ItemDataService } from './item-data.service'; import { RequestService } from './request.service'; import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { ObjectCacheService } from '../cache/object-cache.service'; import { FindAllOptions } from './request.models'; describe('ItemDataService', () => { @@ -15,8 +15,8 @@ describe('ItemDataService', () => { let service: ItemDataService; let bs: BrowseService; const requestService = {} as RequestService; - const responseCache = {} as ResponseCacheService; const rdbService = {} as RemoteDataBuildService; + const objectCache = {} as ObjectCacheService; const store = {} as Store; const halEndpointService = {} as HALEndpointService; @@ -46,12 +46,12 @@ describe('ItemDataService', () => { function initTestService() { return new ItemDataService( - responseCache, requestService, rdbService, store, bs, - halEndpointService + halEndpointService, + objectCache ); } diff --git a/src/app/core/data/item-data.service.ts b/src/app/core/data/item-data.service.ts index 3b6d3d90ab..15dc01d03a 100644 --- a/src/app/core/data/item-data.service.ts +++ b/src/app/core/data/item-data.service.ts @@ -1,13 +1,12 @@ -import { Inject, Injectable } from '@angular/core'; +import { distinctUntilChanged, map, filter, switchMap } from 'rxjs/operators'; +import { Injectable } from '@angular/core'; import { Store } from '@ngrx/store'; -import { Observable } from 'rxjs/Observable'; -import { GLOBAL_CONFIG, GlobalConfig } from '../../../config'; -import { ensureArrayHasValue, isEmpty, isNotEmpty, isNotEmptyOperator } from '../../shared/empty.util'; +import { Observable } from 'rxjs'; +import { isNotEmpty, isNotEmptyOperator } from '../../shared/empty.util'; import { BrowseService } from '../browse/browse.service'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { NormalizedItem } from '../cache/models/normalized-item.model'; -import { ResponseCacheService } from '../cache/response-cache.service'; import { CoreState } from '../core.reducers'; import { Item } from '../shared/item.model'; import { URLCombiner } from '../url-combiner/url-combiner'; @@ -15,40 +14,25 @@ import { URLCombiner } from '../url-combiner/url-combiner'; import { DataService } from './data.service'; import { RequestService } from './request.service'; import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { - DeleteRequest, - FindAllOptions, - GetRequest, - MappingCollectionsRequest, - PostRequest, - RestRequest -} from './request.models'; -import { distinctUntilChanged, map } from 'rxjs/operators'; -import { - configureRequest, - filterSuccessfulResponses, - getRequestFromSelflink, - getResponseFromSelflink -} from '../shared/operators'; -import { ResponseCacheEntry } from '../cache/response-cache.reducer'; -import { DSOSuccessResponse, GenericSuccessResponse, RestResponse } from '../cache/response-cache.models'; -import { BrowseDefinition } from '../shared/browse-definition.model'; -import { Collection } from '../shared/collection.model'; -import { NormalizedCollection } from '../cache/models/normalized-collection.model'; +import { DeleteRequest, FindAllOptions, MappingCollectionsRequest, PostRequest, RestRequest } from './request.models'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { GenericSuccessResponse, RestResponse } from '../cache/response.models'; +import { configureRequest, filterSuccessfulResponses, getResponseFromEntry } from '../shared/operators'; import { RemoteData } from './remote-data'; import { PaginatedList } from './paginated-list'; +import { Collection } from '../shared/collection.model'; @Injectable() export class ItemDataService extends DataService { protected linkPath = 'items'; constructor( - protected responseCache: ResponseCacheService, protected requestService: RequestService, protected rdbService: RemoteDataBuildService, protected store: Store, private bs: BrowseService, - protected halService: HALEndpointService) { + protected halService: HALEndpointService, + protected objectCache: ObjectCacheService) { super(); } @@ -58,15 +42,15 @@ export class ItemDataService extends DataService { * @param {FindAllOptions} options * @returns {Observable} */ - public getBrowseEndpoint(options: FindAllOptions = {}): Observable { + public getBrowseEndpoint(options: FindAllOptions = {}, linkPath: string = this.linkPath): Observable { let field = 'dc.date.issued'; if (options.sort && options.sort.field) { field = options.sort.field; } - return this.bs.getBrowseURLFor(field, this.linkPath) - .filter((href: string) => isNotEmpty(href)) - .map((href: string) => new URLCombiner(href, `?scope=${options.scopeID}`).toString()) - .distinctUntilChanged(); + return this.bs.getBrowseURLFor(field, linkPath).pipe( + filter((href: string) => isNotEmpty(href)), + map((href: string) => new URLCombiner(href, `?scope=${options.scopeID}`).toString()), + distinctUntilChanged(),); } public getMappingCollectionsEndpoint(itemId: string, collectionId?: string): Observable { @@ -82,9 +66,8 @@ export class ItemDataService extends DataService { distinctUntilChanged(), map((endpointURL: string) => new DeleteRequest(this.requestService.generateRequestId(), endpointURL)), configureRequest(this.requestService), - map((request: RestRequest) => request.href), - getResponseFromSelflink(this.responseCache), - map((responseCacheEntry: ResponseCacheEntry) => responseCacheEntry.response) + switchMap((request: RestRequest) => this.requestService.getByHref(request.href)), + getResponseFromEntry() ); } @@ -94,9 +77,8 @@ export class ItemDataService extends DataService { distinctUntilChanged(), map((endpointURL: string) => new PostRequest(this.requestService.generateRequestId(), endpointURL)), configureRequest(this.requestService), - map((request: RestRequest) => request.href), - getResponseFromSelflink(this.responseCache), - map((responseCacheEntry: ResponseCacheEntry) => responseCacheEntry.response) + switchMap((request: RestRequest) => this.requestService.getByHref(request.href)), + getResponseFromEntry() ); } @@ -108,16 +90,15 @@ export class ItemDataService extends DataService { configureRequest(this.requestService) ); - const href$ = request$.pipe(map((request: RestRequest) => request.href)); - const requestEntry$ = href$.pipe(getRequestFromSelflink(this.requestService)); - const responseCache$ = href$.pipe(getResponseFromSelflink(this.responseCache)); - const payload$ = responseCache$.pipe( + const requestEntry$ = request$.pipe( + switchMap((request: RestRequest) => this.requestService.getByHref(request.href)) + ); + const payload$ = requestEntry$.pipe( filterSuccessfulResponses(), - map((entry: ResponseCacheEntry) => entry.response), map((response: GenericSuccessResponse>) => response.payload) ); - return this.rdbService.toRemoteDataObservable(requestEntry$, responseCache$, payload$); + return this.rdbService.toRemoteDataObservable(requestEntry$, payload$); } } diff --git a/src/app/core/data/metadataschema-parsing.service.ts b/src/app/core/data/metadataschema-parsing.service.ts index cdd87c19d4..78a5257456 100644 --- a/src/app/core/data/metadataschema-parsing.service.ts +++ b/src/app/core/data/metadataschema-parsing.service.ts @@ -4,7 +4,7 @@ import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response. import { RestRequest } from './request.models'; import { ResponseParsingService } from './parsing.service'; import { Injectable } from '@angular/core'; -import { MetadataschemaSuccessResponse, RestResponse } from '../cache/response-cache.models'; +import { MetadataschemaSuccessResponse, RestResponse } from '../cache/response.models'; @Injectable() export class MetadataschemaParsingService implements ResponseParsingService { diff --git a/src/app/core/data/parsing.service.ts b/src/app/core/data/parsing.service.ts index a137b99079..ea8d1ea810 100644 --- a/src/app/core/data/parsing.service.ts +++ b/src/app/core/data/parsing.service.ts @@ -1,6 +1,6 @@ import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; import { RestRequest } from './request.models'; -import { RestResponse } from '../cache/response-cache.models'; +import { RestResponse } from '../cache/response.models'; export interface ResponseParsingService { parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse; diff --git a/src/app/core/data/registry-bitstreamformats-response-parsing.service.ts b/src/app/core/data/registry-bitstreamformats-response-parsing.service.ts index d981a12719..2ee3bbf75e 100644 --- a/src/app/core/data/registry-bitstreamformats-response-parsing.service.ts +++ b/src/app/core/data/registry-bitstreamformats-response-parsing.service.ts @@ -1,4 +1,4 @@ -import { RegistryBitstreamformatsSuccessResponse, RestResponse } from '../cache/response-cache.models'; +import { RegistryBitstreamformatsSuccessResponse, RestResponse } from '../cache/response.models'; import { RegistryBitstreamformatsResponse } from '../registry/registry-bitstreamformats-response.model'; import { DSpaceRESTv2Serializer } from '../dspace-rest-v2/dspace-rest-v2.serializer'; import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; diff --git a/src/app/core/data/registry-metadatafields-response-parsing.service.ts b/src/app/core/data/registry-metadatafields-response-parsing.service.ts index 1fe8b1e15f..0b0982d048 100644 --- a/src/app/core/data/registry-metadatafields-response-parsing.service.ts +++ b/src/app/core/data/registry-metadatafields-response-parsing.service.ts @@ -1,7 +1,7 @@ import { RegistryMetadatafieldsSuccessResponse, RestResponse -} from '../cache/response-cache.models'; +} from '../cache/response.models'; import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; import { RestRequest } from './request.models'; import { ResponseParsingService } from './parsing.service'; diff --git a/src/app/core/data/registry-metadataschemas-response-parsing.service.ts b/src/app/core/data/registry-metadataschemas-response-parsing.service.ts index 2bb1302450..a70c985b15 100644 --- a/src/app/core/data/registry-metadataschemas-response-parsing.service.ts +++ b/src/app/core/data/registry-metadataschemas-response-parsing.service.ts @@ -1,4 +1,4 @@ -import { RegistryMetadataschemasSuccessResponse, RestResponse } from '../cache/response-cache.models'; +import { RegistryMetadataschemasSuccessResponse, RestResponse } from '../cache/response.models'; import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; import { RestRequest } from './request.models'; import { ResponseParsingService } from './parsing.service'; diff --git a/src/app/core/data/request.actions.ts b/src/app/core/data/request.actions.ts index 436c365caa..28149c2ead 100644 --- a/src/app/core/data/request.actions.ts +++ b/src/app/core/data/request.actions.ts @@ -1,6 +1,7 @@ import { Action } from '@ngrx/store'; import { type } from '../../shared/ngrx/type'; import { RestRequest } from './request.models'; +import { RestResponse } from '../cache/response.models'; /** * The list of RequestAction type definitions @@ -8,7 +9,8 @@ import { RestRequest } from './request.models'; export const RequestActionTypes = { CONFIGURE: type('dspace/core/data/request/CONFIGURE'), EXECUTE: type('dspace/core/data/request/EXECUTE'), - COMPLETE: type('dspace/core/data/request/COMPLETE') + COMPLETE: type('dspace/core/data/request/COMPLETE'), + RESET_TIMESTAMPS: type('dspace/core/data/request/RESET_TIMESTAMPS') }; /* tslint:disable:max-classes-per-file */ @@ -43,7 +45,10 @@ export class RequestExecuteAction implements Action { */ export class RequestCompleteAction implements Action { type = RequestActionTypes.COMPLETE; - payload: string; + payload: { + uuid: string, + response: RestResponse + }; /** * Create a new RequestCompleteAction @@ -51,10 +56,32 @@ export class RequestCompleteAction implements Action { * @param uuid * the request's uuid */ - constructor(uuid: string) { - this.payload = uuid; + constructor(uuid: string, response: RestResponse) { + this.payload = { + uuid, + response + }; } } + +/** + * An ngrx action to reset the timeAdded property of all responses in the cached objects + */ +export class ResetResponseTimestampsAction implements Action { + type = RequestActionTypes.RESET_TIMESTAMPS; + payload: number; + + /** + * Create a new ResetResponseTimestampsAction + * + * @param newTimestamp + * the new timeAdded all objects should get + */ + constructor(newTimestamp: number) { + this.payload = newTimestamp; + } +} + /* tslint:enable:max-classes-per-file */ /** @@ -63,4 +90,5 @@ export class RequestCompleteAction implements Action { export type RequestAction = RequestConfigureAction | RequestExecuteAction - | RequestCompleteAction; + | RequestCompleteAction + | ResetResponseTimestampsAction; diff --git a/src/app/core/data/request.effects.ts b/src/app/core/data/request.effects.ts index 5fadd316f4..5e7bec698b 100644 --- a/src/app/core/data/request.effects.ts +++ b/src/app/core/data/request.effects.ts @@ -1,41 +1,46 @@ +import { Observable, of as observableOf } from 'rxjs'; import { Inject, Injectable, Injector } from '@angular/core'; -import { Request } from '@angular/http'; -import { RequestArgs } from '@angular/http/src/interfaces'; import { Actions, Effect, ofType } from '@ngrx/effects'; -// tslint:disable-next-line:import-blacklist -import { Observable } from 'rxjs'; import { GLOBAL_CONFIG, GlobalConfig } from '../../../config'; -import { isNotEmpty } from '../../shared/empty.util'; -import { ErrorResponse, RestResponse } from '../cache/response-cache.models'; -import { ResponseCacheService } from '../cache/response-cache.service'; +import { hasValue, isNotEmpty } from '../../shared/empty.util'; import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; import { DSpaceRESTv2Service } from '../dspace-rest-v2/dspace-rest-v2.service'; -import { RequestActionTypes, RequestCompleteAction, RequestExecuteAction } from './request.actions'; +import { + RequestActionTypes, + RequestCompleteAction, + RequestExecuteAction, + ResetResponseTimestampsAction +} from './request.actions'; import { RequestError, RestRequest } from './request.models'; import { RequestEntry } from './request.reducer'; import { RequestService } from './request.service'; import { DSpaceRESTv2Serializer } from '../dspace-rest-v2/dspace-rest-v2.serializer'; import { NormalizedObjectFactory } from '../cache/models/normalized-object-factory'; -import { catchError, flatMap, map, take, tap } from 'rxjs/operators'; +import { catchError, filter, flatMap, map, take, tap } from 'rxjs/operators'; +import { ErrorResponse, RestResponse } from '../cache/response.models'; +import { StoreActionTypes } from '../../store.actions'; -export const addToResponseCacheAndCompleteAction = (request: RestRequest, responseCache: ResponseCacheService, envConfig: GlobalConfig) => - (source: Observable): Observable => +export const addToResponseCacheAndCompleteAction = (request: RestRequest, envConfig: GlobalConfig) => + (source: Observable): Observable => source.pipe( - tap((response: RestResponse) => responseCache.add(request.href, response, envConfig.cache.msToLive)), - map((response: RestResponse) => new RequestCompleteAction(request.uuid)) + map((response: RestResponse) => { + return new RequestCompleteAction(request.uuid, response) + }) ); @Injectable() export class RequestEffects { - @Effect() execute = this.actions$.ofType(RequestActionTypes.EXECUTE).pipe( + @Effect() execute = this.actions$.pipe( + ofType(RequestActionTypes.EXECUTE), flatMap((action: RequestExecuteAction) => { return this.requestService.getByUUID(action.payload).pipe( take(1) ); }), + filter((entry: RequestEntry) => hasValue(entry)), map((entry: RequestEntry) => entry.request), flatMap((request: RestRequest) => { let body; @@ -45,20 +50,32 @@ export class RequestEffects { } return this.restApi.request(request.method, request.href, body, request.options).pipe( map((data: DSpaceRESTV2Response) => this.injector.get(request.getResponseParser()).parse(request, data)), - addToResponseCacheAndCompleteAction(request, this.responseCache, this.EnvConfig), - catchError((error: RequestError) => Observable.of(new ErrorResponse(error)).pipe( - addToResponseCacheAndCompleteAction(request, this.responseCache, this.EnvConfig) + addToResponseCacheAndCompleteAction(request, this.EnvConfig), + catchError((error: RequestError) => observableOf(new ErrorResponse(error)).pipe( + addToResponseCacheAndCompleteAction(request, this.EnvConfig) )) ); }) ); + /** + * When the store is rehydrated in the browser, set all cache + * timestamps to 'now', because the time zone of the server can + * differ from the client. + * + * This assumes that the server cached everything a negligible + * time ago, and will likely need to be revisited later + */ + @Effect() fixTimestampsOnRehydrate = this.actions$ + .pipe(ofType(StoreActionTypes.REHYDRATE), + map(() => new ResetResponseTimestampsAction(new Date().getTime())) + ); + constructor( @Inject(GLOBAL_CONFIG) private EnvConfig: GlobalConfig, private actions$: Actions, private restApi: DSpaceRESTv2Service, private injector: Injector, - private responseCache: ResponseCacheService, protected requestService: RequestService ) { } diff --git a/src/app/core/data/request.models.ts b/src/app/core/data/request.models.ts index 001c416f69..dee30bda0c 100644 --- a/src/app/core/data/request.models.ts +++ b/src/app/core/data/request.models.ts @@ -1,7 +1,5 @@ import { SortOptions } from '../cache/models/sort-options.model'; import { GenericConstructor } from '../shared/generic-constructor'; -import { GlobalConfig } from '../../../config/global-config.interface'; -import { RESTURLCombiner } from '../url-combiner/rest-url-combiner'; import { BrowseEntriesResponseParsingService } from './browse-entries-response-parsing.service'; import { DSOResponseParsingService } from './dso-response-parsing.service'; import { ResponseParsingService } from './parsing.service'; @@ -10,55 +8,43 @@ import { BrowseResponseParsingService } from './browse-response-parsing.service' import { ConfigResponseParsingService } from './config-response-parsing.service'; import { AuthResponseParsingService } from '../auth/auth-response-parsing.service'; import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service'; -import { HttpHeaders } from '@angular/common/http'; import { IntegrationResponseParsingService } from '../integration/integration-response-parsing.service'; +import { RestRequestMethod } from './rest-request-method'; import { BrowseItemsResponseParsingService } from './browse-items-response-parsing-service'; import { MappingCollectionsReponseParsingService } from './mapping-collections-reponse-parsing.service'; /* tslint:disable:max-classes-per-file */ -/** - * Represents a Request Method. - * - * I didn't reuse the RequestMethod enum in @angular/http because - * it uses numbers. The string values here are more clear when - * debugging. - * - * The ones commented out are still unsupported in the rest of the codebase - */ -export enum RestRequestMethod { - Get = 'GET', - Post = 'POST', - Put = 'PUT', - Delete = 'DELETE', - Options = 'OPTIONS', - Head = 'HEAD', - Patch = 'PATCH' -} - export abstract class RestRequest { + public responseMsToLive = 0; constructor( public uuid: string, public href: string, - public method: RestRequestMethod = RestRequestMethod.Get, + public method: RestRequestMethod = RestRequestMethod.GET, public body?: any, - public options?: HttpOptions + public options?: HttpOptions, ) { } getResponseParser(): GenericConstructor { return DSOResponseParsingService; } + + get toCache(): boolean { + return this.responseMsToLive > 0; + } } export class GetRequest extends RestRequest { + public responseMsToLive = 60 * 15 * 1000; + constructor( public uuid: string, public href: string, public body?: any, - public options?: HttpOptions + public options?: HttpOptions, ) { - super(uuid, href, RestRequestMethod.Get, body) + super(uuid, href, RestRequestMethod.GET, body, options) } } @@ -69,7 +55,7 @@ export class PostRequest extends RestRequest { public body?: any, public options?: HttpOptions ) { - super(uuid, href, RestRequestMethod.Post, body) + super(uuid, href, RestRequestMethod.POST, body) } } @@ -80,7 +66,7 @@ export class PutRequest extends RestRequest { public body?: any, public options?: HttpOptions ) { - super(uuid, href, RestRequestMethod.Put, body) + super(uuid, href, RestRequestMethod.PUT, body) } } @@ -91,7 +77,7 @@ export class DeleteRequest extends RestRequest { public body?: any, public options?: HttpOptions ) { - super(uuid, href, RestRequestMethod.Delete, body) + super(uuid, href, RestRequestMethod.DELETE, body) } } @@ -102,7 +88,7 @@ export class OptionsRequest extends RestRequest { public body?: any, public options?: HttpOptions ) { - super(uuid, href, RestRequestMethod.Options, body) + super(uuid, href, RestRequestMethod.OPTIONS, body) } } @@ -113,7 +99,7 @@ export class HeadRequest extends RestRequest { public body?: any, public options?: HttpOptions ) { - super(uuid, href, RestRequestMethod.Head, body) + super(uuid, href, RestRequestMethod.HEAD, body) } } @@ -124,7 +110,7 @@ export class PatchRequest extends RestRequest { public body?: any, public options?: HttpOptions ) { - super(uuid, href, RestRequestMethod.Patch, body) + super(uuid, href, RestRequestMethod.PATCH, body) } } @@ -237,6 +223,7 @@ export class IntegrationRequest extends GetRequest { return IntegrationResponseParsingService; } } + export class RequestError extends Error { statusText: string; } diff --git a/src/app/core/data/request.reducer.spec.ts b/src/app/core/data/request.reducer.spec.ts index bd8fad5de7..57fbb01ce1 100644 --- a/src/app/core/data/request.reducer.spec.ts +++ b/src/app/core/data/request.reducer.spec.ts @@ -2,16 +2,20 @@ import * as deepFreeze from 'deep-freeze'; import { requestReducer, RequestState } from './request.reducer'; import { - RequestCompleteAction, RequestConfigureAction, RequestExecuteAction + RequestCompleteAction, + RequestConfigureAction, + RequestExecuteAction, ResetResponseTimestampsAction } from './request.actions'; -import { GetRequest, RestRequest } from './request.models'; +import { GetRequest } from './request.models'; +import { RestResponse } from '../cache/response.models'; +const response = new RestResponse(true, 'OK'); class NullAction extends RequestCompleteAction { type = null; payload = null; constructor() { - super(null); + super(null, null); } } @@ -25,7 +29,8 @@ describe('requestReducer', () => { request: new GetRequest(id1, link1), requestPending: false, responsePending: false, - completed: false + completed: false, + response: undefined } }; deepFreeze(testState); @@ -56,6 +61,7 @@ describe('requestReducer', () => { expect(newState[id2].requestPending).toEqual(true); expect(newState[id2].responsePending).toEqual(false); expect(newState[id2].completed).toEqual(false); + expect(newState[id2].response).toEqual(undefined); }); it('should set \'requestPending\' to false, \'responsePending\' to true and leave \'completed\' untouched for the given RestRequest in the state, in response to an EXECUTE action', () => { @@ -69,11 +75,13 @@ describe('requestReducer', () => { expect(newState[id1].requestPending).toEqual(false); expect(newState[id1].responsePending).toEqual(true); expect(newState[id1].completed).toEqual(state[id1].completed); + expect(newState[id1].response).toEqual(undefined) }); + it('should leave \'requestPending\' untouched, set \'responsePending\' to false and \'completed\' to true for the given RestRequest in the state, in response to a COMPLETE action', () => { const state = testState; - const action = new RequestCompleteAction(id1); + const action = new RequestCompleteAction(id1, response); const newState = requestReducer(state, action); expect(newState[id1].request.uuid).toEqual(id1); @@ -81,5 +89,25 @@ describe('requestReducer', () => { expect(newState[id1].requestPending).toEqual(state[id1].requestPending); expect(newState[id1].responsePending).toEqual(false); expect(newState[id1].completed).toEqual(true); + expect(newState[id1].response.isSuccessful).toEqual(response.isSuccessful) + expect(newState[id1].response.statusCode).toEqual(response.statusCode) + expect(newState[id1].response.timeAdded).toBeTruthy() + }); + + it('should leave \'requestPending\' untouched, should leave \'responsePending\' untouched and leave \'completed\' untouched, but update the response\'s timeAdded for the given RestRequest in the state, in response to a COMPLETE action', () => { + const update = Object.assign({}, testState[id1], {response}); + const state = Object.assign({}, testState, {[id1]: update}); + const timeStamp = 1000; + const action = new ResetResponseTimestampsAction(timeStamp); + const newState = requestReducer(state, action); + + expect(newState[id1].request.uuid).toEqual(state[id1].request.uuid); + expect(newState[id1].request.href).toEqual(state[id1].request.href); + expect(newState[id1].requestPending).toEqual(state[id1].requestPending); + expect(newState[id1].responsePending).toEqual(state[id1].responsePending); + expect(newState[id1].completed).toEqual(state[id1].completed); + expect(newState[id1].response.isSuccessful).toEqual(response.isSuccessful); + expect(newState[id1].response.statusCode).toEqual(response.statusCode); + expect(newState[id1].response.timeAdded).toBe(timeStamp); }); }); diff --git a/src/app/core/data/request.reducer.ts b/src/app/core/data/request.reducer.ts index 3ac35d2741..a680de2d6b 100644 --- a/src/app/core/data/request.reducer.ts +++ b/src/app/core/data/request.reducer.ts @@ -1,14 +1,16 @@ import { RequestActionTypes, RequestAction, RequestConfigureAction, - RequestExecuteAction, RequestCompleteAction + RequestExecuteAction, RequestCompleteAction, ResetResponseTimestampsAction } from './request.actions'; import { RestRequest } from './request.models'; +import { RestResponse } from '../cache/response.models'; export class RequestEntry { request: RestRequest; requestPending: boolean; responsePending: boolean; completed: boolean; + response: RestResponse } export interface RequestState { @@ -32,6 +34,9 @@ export function requestReducer(state = initialState, action: RequestAction): Req case RequestActionTypes.COMPLETE: { return completeRequest(state, action as RequestCompleteAction); } + case RequestActionTypes.RESET_TIMESTAMPS: { + return resetResponseTimestamps(state, action as ResetResponseTimestampsAction); + } default: { return state; @@ -45,18 +50,19 @@ function configureRequest(state: RequestState, action: RequestConfigureAction): request: action.payload, requestPending: true, responsePending: false, - completed: false + completed: false, } }); } function executeRequest(state: RequestState, action: RequestExecuteAction): RequestState { - return Object.assign({}, state, { + const obs = Object.assign({}, state, { [action.payload]: Object.assign({}, state[action.payload], { requestPending: false, responsePending: true }) }); + return obs; } /** @@ -70,10 +76,22 @@ function executeRequest(state: RequestState, action: RequestExecuteAction): Requ * the new state, with the response added to the request */ function completeRequest(state: RequestState, action: RequestCompleteAction): RequestState { + const time = new Date().getTime(); return Object.assign({}, state, { - [action.payload]: Object.assign({}, state[action.payload], { + [action.payload.uuid]: Object.assign({}, state[action.payload.uuid], { responsePending: false, - completed: true + completed: true, + response: Object.assign({}, action.payload.response, { timeAdded: time }) }) }); } + +function resetResponseTimestamps(state: RequestState, action: ResetResponseTimestampsAction) { + const newState = Object.create(null); + Object.keys(state).forEach((key) => { + newState[key] = Object.assign({}, state[key], + { response: Object.assign({}, state[key].response, { timeAdded: action.payload }) } + ); + }); + return newState; +} diff --git a/src/app/core/data/request.service.spec.ts b/src/app/core/data/request.service.spec.ts index aa9954f680..90d2edfc84 100644 --- a/src/app/core/data/request.service.spec.ts +++ b/src/app/core/data/request.service.spec.ts @@ -1,16 +1,12 @@ -import { Store } from '@ngrx/store'; -import { cold, hot } from 'jasmine-marbles'; -import { Observable } from 'rxjs/Observable'; -import 'rxjs/add/observable/of'; +import { cold, getTestScheduler, hot } from 'jasmine-marbles'; +import { of as observableOf } from 'rxjs'; import { getMockObjectCacheService } from '../../shared/mocks/mock-object-cache.service'; -import { getMockResponseCacheService } from '../../shared/mocks/mock-response-cache.service'; -import { getMockStore } from '../../shared/mocks/mock-store'; import { defaultUUID, getMockUUIDService } from '../../shared/mocks/mock-uuid.service'; import { ObjectCacheService } from '../cache/object-cache.service'; -import { ResponseCacheService } from '../cache/response-cache.service'; import { CoreState } from '../core.reducers'; import { UUIDService } from '../shared/uuid.service'; import { RequestConfigureAction, RequestExecuteAction } from './request.actions'; +import * as ngrx from '@ngrx/store'; import { DeleteRequest, GetRequest, @@ -18,15 +14,19 @@ import { OptionsRequest, PatchRequest, PostRequest, - PutRequest, RestRequest + PutRequest, + RestRequest } from './request.models'; import { RequestService } from './request.service'; +import { ActionsSubject, Store } from '@ngrx/store'; +import { TestScheduler } from 'rxjs/testing'; +import { BehaviorSubject } from 'rxjs/internal/BehaviorSubject'; describe('RequestService', () => { + let scheduler: TestScheduler; let service: RequestService; let serviceAsAny: any; let objectCache: ObjectCacheService; - let responseCache: ResponseCacheService; let uuidService: UUIDService; let store: Store; @@ -39,23 +39,25 @@ describe('RequestService', () => { const testOptionsRequest = new OptionsRequest(testUUID, testHref); const testHeadRequest = new HeadRequest(testUUID, testHref); const testPatchRequest = new PatchRequest(testUUID, testHref); - + let selectSpy; beforeEach(() => { + scheduler = getTestScheduler(); + objectCache = getMockObjectCacheService(); (objectCache.hasBySelfLink as any).and.returnValue(false); - responseCache = getMockResponseCacheService(); - (responseCache.has as any).and.returnValue(false); - (responseCache.get as any).and.returnValue(Observable.of(undefined)); - uuidService = getMockUUIDService(); - store = getMockStore(); - (store.select as any).and.returnValue(Observable.of(undefined)); + store = new Store(new BehaviorSubject({}), new ActionsSubject(), null); + selectSpy = spyOnProperty(ngrx, 'select'); + selectSpy.and.callFake(() => { + return () => { + return () => cold('a', { a: undefined }); + }; + }); service = new RequestService( objectCache, - responseCache, uuidService, store ); @@ -74,7 +76,7 @@ describe('RequestService', () => { describe('isPending', () => { describe('before the request is configured', () => { beforeEach(() => { - spyOn(service, 'getByHref').and.returnValue(Observable.of(undefined)); + spyOn(service, 'getByHref').and.returnValue(observableOf(undefined)); }); it('should return false', () => { @@ -87,7 +89,7 @@ describe('RequestService', () => { describe('when the request has been configured but hasn\'t reached the store yet', () => { beforeEach(() => { - spyOn(service, 'getByHref').and.returnValue(Observable.of(undefined)); + spyOn(service, 'getByHref').and.returnValue(observableOf(undefined)); serviceAsAny.requestsOnTheirWayToTheStore = [testHref]; }); @@ -101,7 +103,7 @@ describe('RequestService', () => { describe('when the request has reached the store, before the server responds', () => { beforeEach(() => { - spyOn(service, 'getByHref').and.returnValue(Observable.of({ + spyOn(service, 'getByHref').and.returnValue(observableOf({ completed: false })) }); @@ -116,7 +118,7 @@ describe('RequestService', () => { describe('after the server responds', () => { beforeEach(() => { - spyOn(service, 'getByHref').and.returnValues(Observable.of({ + spyOn(service, 'getByHref').and.returnValues(observableOf({ completed: true })); }); @@ -134,11 +136,15 @@ describe('RequestService', () => { describe('getByUUID', () => { describe('if the request with the specified UUID exists in the store', () => { beforeEach(() => { - (store.select as any).and.returnValues(hot('a', { - a: { - completed: true - } - })); + selectSpy.and.callFake(() => { + return () => { + return () => hot('a', { + a: { + completed: true + } + }); + }; + }); }); it('should return an Observable of the RequestEntry', () => { @@ -155,18 +161,20 @@ describe('RequestService', () => { describe('if the request with the specified UUID doesn\'t exist in the store', () => { beforeEach(() => { - (store.select as any).and.returnValues(hot('a', { - a: undefined - })); + selectSpy.and.callFake(() => { + return () => { + return () => hot('a', { a: undefined }); + }; + }); }); it('should return an Observable of undefined', () => { const result = service.getByUUID(testUUID); - const expected = cold('b', { - b: undefined - }); + // const expected = cold('b', { + // b: undefined + // }); - expect(result).toBeObservable(expected); + scheduler.expectObservable(result).toBe('b', { b: undefined }); }); }); @@ -175,9 +183,11 @@ describe('RequestService', () => { describe('getByHref', () => { describe('when the request with the specified href exists in the store', () => { beforeEach(() => { - (store.select as any).and.returnValues(hot('a', { - a: testUUID - })); + selectSpy.and.callFake(() => { + return () => { + return () => hot('a', { a: testUUID }); + }; + }); spyOn(service, 'getByUUID').and.returnValue(cold('b', { b: { completed: true @@ -199,9 +209,11 @@ describe('RequestService', () => { describe('when the request with the specified href doesn\'t exist in the store', () => { beforeEach(() => { - (store.select as any).and.returnValues(hot('a', { - a: undefined - })); + selectSpy.and.callFake(() => { + return () => { + return () => hot('a', { a: undefined }); + }; + }); spyOn(service, 'getByUUID').and.returnValue(cold('b', { b: undefined })); @@ -241,7 +253,8 @@ describe('RequestService', () => { }); it('should dispatch the request', () => { - service.configure(request); + scheduler.schedule(() => service.configure(request)); + scheduler.flush(); expect(serviceAsAny.dispatchRequest).toHaveBeenCalledWith(request); }); }); @@ -306,7 +319,7 @@ describe('RequestService', () => { describe('when the request is cached', () => { describe('in the ObjectCache', () => { beforeEach(() => { - (objectCache.hasBySelfLink as any).and.returnValues(true); + (objectCache.hasBySelfLink as any).and.returnValue(true); }); it('should return true', () => { @@ -318,12 +331,13 @@ describe('RequestService', () => { }); describe('in the responseCache', () => { beforeEach(() => { - (responseCache.has as any).and.returnValues(true); + spyOn(serviceAsAny, 'isReusable').and.returnValue(observableOf(true)); + spyOn(serviceAsAny, 'getByHref').and.returnValue(observableOf(undefined)); }); describe('and it\'s a DSOSuccessResponse', () => { beforeEach(() => { - (responseCache.get as any).and.returnValues(Observable.of({ + (serviceAsAny.getByHref as any).and.returnValue(observableOf({ response: { isSuccessful: true, resourceSelfLinks: [ @@ -345,6 +359,7 @@ describe('RequestService', () => { }); it('should return false if not all top level links in the response are cached in the object cache', () => { (objectCache.hasBySelfLink as any).and.returnValues(false, true, false); + spyOn(service, 'isPending').and.returnValue(false); const result = serviceAsAny.isCachedOrPending(testGetRequest); const expected = false; @@ -352,11 +367,12 @@ describe('RequestService', () => { expect(result).toEqual(expected); }); }); + describe('and it isn\'t a DSOSuccessResponse', () => { beforeEach(() => { - (objectCache.hasBySelfLink as any).and.returnValues(false); - (responseCache.has as any).and.returnValues(true); - (responseCache.get as any).and.returnValues(Observable.of({ + (objectCache.hasBySelfLink as any).and.returnValue(false); + (service as any).isReusable.and.returnValue(observableOf(true)); + (serviceAsAny.getByHref as any).and.returnValue(observableOf({ response: { isSuccessful: true } @@ -398,6 +414,10 @@ describe('RequestService', () => { }); describe('dispatchRequest', () => { + beforeEach(() => { + spyOn(store, 'dispatch'); + }); + it('should dispatch a RequestConfigureAction', () => { const request = testGetRequest; serviceAsAny.dispatchRequest(request); @@ -428,10 +448,115 @@ describe('RequestService', () => { describe('when the request is added to the store', () => { it('should stop tracking the request', () => { - (store.select as any).and.returnValues(Observable.of({ request })); + selectSpy.and.callFake(() => { + return () => { + return () => observableOf({ request }); + }; + }); serviceAsAny.trackRequestsOnTheirWayToTheStore(request); expect(serviceAsAny.requestsOnTheirWayToTheStore.includes(request.href)).toBeFalsy(); }); }); }); + + describe('isReusable', () => { + describe('when the given UUID is has no value', () => { + let reusable; + beforeEach(() => { + const uuid = undefined; + reusable = serviceAsAny.isReusable(uuid); + }); + it('return an observable emitting false', () => { + reusable.subscribe((isReusable) => expect(isReusable).toBe(false)); + }) + }); + + describe('when the given UUID has a value, but no cached entry is found', () => { + let reusable; + beforeEach(() => { + spyOn(service, 'getByUUID').and.returnValue(observableOf(undefined)); + const uuid = 'a45bb291-1adb-40d9-b2fc-7ad9080607be'; + reusable = serviceAsAny.isReusable(uuid); + }); + it('return an observable emitting false', () => { + reusable.subscribe((isReusable) => expect(isReusable).toBe(false)); + }) + }); + + describe('when the given UUID has a value, a cached entry is found, but it has no response', () => { + let reusable; + beforeEach(() => { + spyOn(service, 'getByUUID').and.returnValue(observableOf({ response: undefined })); + const uuid = '53c9b814-ad8b-4567-9bc1-d9bb6cfba6c8'; + reusable = serviceAsAny.isReusable(uuid); + }); + it('return an observable emitting false', () => { + reusable.subscribe((isReusable) => expect(isReusable).toBe(false)); + }) + }); + + describe('when the given UUID has a value, a cached entry is found, but its response was not successful', () => { + let reusable; + beforeEach(() => { + spyOn(service, 'getByUUID').and.returnValue(observableOf({ response: { isSuccessful: false } })); + const uuid = '694c9b32-7b2e-4788-835b-ef3fc2252e6c'; + reusable = serviceAsAny.isReusable(uuid); + }); + it('return an observable emitting false', () => { + reusable.subscribe((isReusable) => expect(isReusable).toBe(false)); + }) + }); + + describe('when the given UUID has a value, a cached entry is found, its response was successful, but the response is outdated', () => { + let reusable; + const now = 100000; + const timeAdded = 99899; + const msToLive = 100; + + beforeEach(() => { + spyOn(Date.prototype, 'getTime').and.returnValue(now); + spyOn(service, 'getByUUID').and.returnValue(observableOf({ + response: { + isSuccessful: true, + timeAdded: timeAdded + }, + request: { + responseMsToLive: msToLive + } + })); + const uuid = 'f9b85788-881c-4994-86b6-bae8dad024d2'; + reusable = serviceAsAny.isReusable(uuid); + }); + + it('return an observable emitting false', () => { + reusable.subscribe((isReusable) => expect(isReusable).toBe(false)); + }) + }); + + describe('when the given UUID has a value, a cached entry is found, its response was successful, and the response is not outdated', () => { + let reusable; + const now = 100000; + const timeAdded = 99999; + const msToLive = 100; + + beforeEach(() => { + spyOn(Date.prototype, 'getTime').and.returnValue(now); + spyOn(service, 'getByUUID').and.returnValue(observableOf({ + response: { + isSuccessful: true, + timeAdded: timeAdded + }, + request: { + responseMsToLive: msToLive + } + })); + const uuid = 'f9b85788-881c-4994-86b6-bae8dad024d2'; + reusable = serviceAsAny.isReusable(uuid); + }); + + it('return an observable emitting true', () => { + reusable.subscribe((isReusable) => expect(isReusable).toBe(true)); + }) + }) + }) }); diff --git a/src/app/core/data/request.service.ts b/src/app/core/data/request.service.ts index 12933f83fc..285ed06545 100644 --- a/src/app/core/data/request.service.ts +++ b/src/app/core/data/request.service.ts @@ -1,30 +1,43 @@ +import { merge as observableMerge, Observable, of as observableOf } from 'rxjs'; +import { + distinctUntilChanged, + filter, + find, + first, + map, + mergeMap, + reduce, + startWith, + switchMap, + take, + tap +} from 'rxjs/operators'; +import { race as observableRace } from 'rxjs'; import { Injectable } from '@angular/core'; -import { createSelector, MemoizedSelector, Store } from '@ngrx/store'; - -import { Observable } from 'rxjs/Observable'; -import { hasValue } from '../../shared/empty.util'; +import { MemoizedSelector, select, Store } from '@ngrx/store'; +import { hasNoValue, hasValue, isNotUndefined } from '../../shared/empty.util'; import { CacheableObject } from '../cache/object-cache.reducer'; import { ObjectCacheService } from '../cache/object-cache.service'; -import { DSOSuccessResponse, RestResponse } from '../cache/response-cache.models'; -import { ResponseCacheEntry } from '../cache/response-cache.reducer'; -import { ResponseCacheService } from '../cache/response-cache.service'; +import { DSOSuccessResponse, RestResponse } from '../cache/response.models'; import { coreSelector, CoreState } from '../core.reducers'; import { IndexName } from '../index/index.reducer'; import { pathSelector } from '../shared/selectors'; import { UUIDService } from '../shared/uuid.service'; import { RequestConfigureAction, RequestExecuteAction } from './request.actions'; -import { GetRequest, RestRequest, RestRequestMethod } from './request.models'; +import { GetRequest, RestRequest } from './request.models'; -import { RequestEntry, RequestState } from './request.reducer'; -import { ResponseCacheRemoveAction } from '../cache/response-cache.actions'; +import { RequestEntry } from './request.reducer'; +import { CommitSSBAction } from '../cache/server-sync-buffer.actions'; +import { RestRequestMethod } from './rest-request-method'; +import { getResponseFromEntry } from '../shared/operators'; +import { AddToIndexAction } from '../index/index.actions'; @Injectable() export class RequestService { private requestsOnTheirWayToTheStore: string[] = []; constructor(private objectCache: ObjectCacheService, - private responseCache: ResponseCacheService, private uuidService: UUIDService, private store: Store) { } @@ -37,6 +50,10 @@ export class RequestService { return pathSelector(coreSelector, 'index', IndexName.REQUEST, href); } + private originalUUIDFromUUIDSelector(uuid: string): MemoizedSelector { + return pathSelector(coreSelector, 'index', IndexName.UUID_MAPPING, uuid); + } + generateRequestId(): string { return `client/${this.uuidService.generate()}`; } @@ -49,8 +66,8 @@ export class RequestService { // then check the store let isPending = false; - this.getByHref(request.href) - .take(1) + this.getByHref(request.href).pipe( + take(1)) .subscribe((re: RequestEntry) => { isPending = (hasValue(re) && !re.completed) }); @@ -59,54 +76,87 @@ export class RequestService { } getByUUID(uuid: string): Observable { - return this.store.select(this.entryFromUUIDSelector(uuid)); + return observableRace( + this.store.pipe(select(this.entryFromUUIDSelector(uuid))), + this.store.pipe( + select(this.originalUUIDFromUUIDSelector(uuid)), + switchMap((originalUUID) => { + return this.store.pipe(select(this.entryFromUUIDSelector(originalUUID))) + }, + )) + ); } getByHref(href: string): Observable { - return this.store.select(this.uuidFromHrefSelector(href)) - .flatMap((uuid: string) => this.getByUUID(uuid)); + return this.store.pipe( + select(this.uuidFromHrefSelector(href)), + mergeMap((uuid: string) => this.getByUUID(uuid)) + ); } + /** + * Configure a certain request + * Used to make sure a request is in the cache + * @param {RestRequest} request The request to send out + * @param {boolean} forceBypassCache When true, a new request is always dispatched + */ // TODO to review "overrideRequest" param when https://github.com/DSpace/dspace-angular/issues/217 will be fixed configure(request: RestRequest, forceBypassCache: boolean = false): void { - const isGetRequest = request.method === RestRequestMethod.Get; + const isGetRequest = request.method === RestRequestMethod.GET; if (!isGetRequest || !this.isCachedOrPending(request) || forceBypassCache) { this.dispatchRequest(request); if (isGetRequest && !forceBypassCache) { this.trackRequestsOnTheirWayToTheStore(request); } + } else { + this.getByHref(request.href).pipe( + filter((entry) => hasValue(entry)), + take(1) + ).subscribe((entry) => { + return this.store.dispatch(new AddToIndexAction(IndexName.UUID_MAPPING, request.uuid, entry.request.uuid)) + } + ) } } + /** + * Check if a request is in the cache or if it's still pending + * @param {GetRequest} request The request to check + * @returns {boolean} True if the request is cached or still pending + */ private isCachedOrPending(request: GetRequest) { let isCached = this.objectCache.hasBySelfLink(request.href); - if (!isCached && this.responseCache.has(request.href)) { - const [successResponse, errorResponse] = this.responseCache.get(request.href) - .take(1) - .map((entry: ResponseCacheEntry) => entry.response) - .share() - .partition((response: RestResponse) => response.isSuccessful); + if (isCached) { + const responses: Observable = this.isReusable(request.uuid).pipe( + filter((reusable: boolean) => reusable), + switchMap(() => { + return this.getByHref(request.href).pipe( + getResponseFromEntry(), + take(1) + ); + } + )); - const [dsoSuccessResponse, otherSuccessResponse] = successResponse - .share() - .partition((response: DSOSuccessResponse) => hasValue(response.resourceSelfLinks)); + const errorResponses = responses.pipe(filter((response) => !response.isSuccessful), map(() => true)); // TODO add a configurable number of retries in case of an error. + const dsoSuccessResponses = responses.pipe( + filter((response) => response.isSuccessful && hasValue((response as DSOSuccessResponse).resourceSelfLinks)), + map((response: DSOSuccessResponse) => response.resourceSelfLinks), + map((resourceSelfLinks: string[]) => resourceSelfLinks + .every((selfLink) => this.objectCache.hasBySelfLink(selfLink)) + )); - Observable.merge( - errorResponse.map(() => true), // TODO add a configurable number of retries in case of an error. - otherSuccessResponse.map(() => true), - dsoSuccessResponse // a DSOSuccessResponse should only be considered cached if all its resources are cached - .map((response: DSOSuccessResponse) => response.resourceSelfLinks) - .map((resourceSelfLinks: string[]) => resourceSelfLinks - .every((selfLink) => this.objectCache.hasBySelfLink(selfLink)) - ) - ).subscribe((c) => isCached = c); + const otherSuccessResponses = responses.pipe(filter((response) => response.isSuccessful && !hasValue((response as DSOSuccessResponse).resourceSelfLinks)), map(() => true)); + + observableMerge(errorResponses, otherSuccessResponses, dsoSuccessResponses).subscribe((c) => isCached = c); } - const isPending = this.isPending(request); - return isCached || isPending; } + /** + * Configure and execute the request + * @param {RestRequest} request to dispatch + */ private dispatchRequest(request: RestRequest) { this.store.dispatch(new RequestConfigureAction(request)); this.store.dispatch(new RequestExecuteAction(request.uuid)); @@ -121,11 +171,48 @@ export class RequestService { */ private trackRequestsOnTheirWayToTheStore(request: GetRequest) { this.requestsOnTheirWayToTheStore = [...this.requestsOnTheirWayToTheStore, request.href]; - this.store.select(this.entryFromUUIDSelector(request.href)) - .filter((re: RequestEntry) => hasValue(re)) - .take(1) - .subscribe((re: RequestEntry) => { - this.requestsOnTheirWayToTheStore = this.requestsOnTheirWayToTheStore.filter((pendingHref: string) => pendingHref !== request.href) - }); + this.store.pipe(select(this.entryFromUUIDSelector(request.href)), + filter((re: RequestEntry) => hasValue(re)), + take(1) + ).subscribe((re: RequestEntry) => { + this.requestsOnTheirWayToTheStore = this.requestsOnTheirWayToTheStore.filter((pendingHref: string) => pendingHref !== request.href) + }); + } + + /** + * Dispatch commit action to send all changes (for a certain method) to the server (buffer) + * @param {RestRequestMethod} method RestRequestMethod for which the changes should be committed + */ + commit(method?: RestRequestMethod) { + this.store.dispatch(new CommitSSBAction(method)) + } + + /** + * Check whether a Response should still be cached + * + * @param uuid + * the uuid of the entry to check + * @return boolean + * false if the uuid has no value, no entry could be found, the response was nog successful or its time to + * live has exceeded, true otherwise + */ + private isReusable(uuid: string): Observable { + if (hasNoValue(uuid)) { + return observableOf(false); + } else { + const requestEntry$ = this.getByUUID(uuid); + return requestEntry$.pipe( + filter((entry: RequestEntry) => hasValue(entry) && hasValue(entry.response)), + map((entry: RequestEntry) => { + if (hasValue(entry) && entry.response.isSuccessful) { + const timeOutdated = entry.response.timeAdded + entry.request.responseMsToLive; + const isOutDated = new Date().getTime() > timeOutdated; + return !isOutDated; + } else { + return false; + } + }) + ); + } } } diff --git a/src/app/core/data/rest-request-method.ts b/src/app/core/data/rest-request-method.ts new file mode 100644 index 0000000000..03ae7ad0c4 --- /dev/null +++ b/src/app/core/data/rest-request-method.ts @@ -0,0 +1,18 @@ +/** + * Represents a Request Method. + * + * I didn't reuse the RequestMethod enum in @angular/http because + * it uses numbers. The string values here are more clear when + * debugging. + * + * The ones commented out are still unsupported in the rest of the codebase + */ +export enum RestRequestMethod { + GET = 'GET', + POST = 'POST', + PUT = 'PUT', + DELETE = 'DELETE', + OPTIONS = 'OPTIONS', + HEAD = 'HEAD', + PATCH = 'PATCH' +} diff --git a/src/app/core/data/search-response-parsing.service.ts b/src/app/core/data/search-response-parsing.service.ts index 4039b8f761..7ee2b60f89 100644 --- a/src/app/core/data/search-response-parsing.service.ts +++ b/src/app/core/data/search-response-parsing.service.ts @@ -1,5 +1,5 @@ import { Injectable } from '@angular/core'; -import { RestResponse, SearchSuccessResponse } from '../cache/response-cache.models'; +import { RestResponse, SearchSuccessResponse } from '../cache/response.models'; import { DSOResponseParsingService } from './dso-response-parsing.service'; import { ResponseParsingService } from './parsing.service'; import { RestRequest } from './request.models'; diff --git a/src/app/core/dspace-rest-v2/dspace-rest-v2.service.ts b/src/app/core/dspace-rest-v2/dspace-rest-v2.service.ts index 78c93b8c08..7173e5ba0d 100644 --- a/src/app/core/dspace-rest-v2/dspace-rest-v2.service.ts +++ b/src/app/core/dspace-rest-v2/dspace-rest-v2.service.ts @@ -1,11 +1,11 @@ +import {throwError as observableThrowError, Observable } from 'rxjs'; +import {catchError, map} from 'rxjs/operators'; import { Injectable } from '@angular/core'; -import { Request } from '@angular/http'; import { HttpClient, HttpHeaders, HttpParams, HttpResponse } from '@angular/common/http' -import { Observable } from 'rxjs/Observable'; -import { RestRequestMethod } from '../data/request.models'; import { DSpaceRESTV2Response } from './dspace-rest-v2-response.model'; import { HttpObserve } from '@angular/common/http/src/client'; +import { RestRequestMethod } from '../data/rest-request-method'; export interface HttpOptions { body?: any; @@ -36,12 +36,12 @@ export class DSpaceRESTv2Service { * An Observable containing the response from the server */ get(absoluteURL: string): Observable { - return this.http.get(absoluteURL, { observe: 'response' }) - .map((res: HttpResponse) => ({ payload: res.body, statusCode: res.statusText })) - .catch((err) => { + return this.http.get(absoluteURL, { observe: 'response' }).pipe( + map((res: HttpResponse) => ({ payload: res.body, statusCode: res.statusText })), + catchError((err) => { console.log('Error: ', err); - return Observable.throw(err); - }); + return observableThrowError(err); + })); } /** @@ -66,12 +66,12 @@ export class DSpaceRESTv2Service { if (options && options.responseType) { requestOptions.responseType = options.responseType; } - return this.http.request(method, url, requestOptions) - .map((res) => ({ payload: res.body, headers: res.headers, statusCode: res.statusText })) - .catch((err) => { + return this.http.request(method, url, requestOptions).pipe( + map((res) => ({ payload: res.body, headers: res.headers, statusCode: res.statusText })), + catchError((err) => { console.log('Error: ', err); - return Observable.throw(err); - }); + return observableThrowError(err); + })); } } diff --git a/src/app/core/index/index.effects.ts b/src/app/core/index/index.effects.ts index 05ae529c8e..b152f8488d 100644 --- a/src/app/core/index/index.effects.ts +++ b/src/app/core/index/index.effects.ts @@ -1,58 +1,67 @@ +import { filter, map } from 'rxjs/operators'; import { Injectable } from '@angular/core'; -import { Effect, Actions } from '@ngrx/effects'; +import { Effect, Actions, ofType } from '@ngrx/effects'; import { ObjectCacheActionTypes, AddToObjectCacheAction, RemoveFromObjectCacheAction } from '../cache/object-cache.actions'; import { RequestActionTypes, RequestConfigureAction } from '../data/request.actions'; -import { RestRequestMethod } from '../data/request.models'; import { AddToIndexAction, RemoveFromIndexByValueAction } from './index.actions'; import { hasValue } from '../../shared/empty.util'; import { IndexName } from './index.reducer'; +import { RestRequestMethod } from '../data/rest-request-method'; @Injectable() export class UUIDIndexEffects { @Effect() addObject$ = this.actions$ - .ofType(ObjectCacheActionTypes.ADD) - .filter((action: AddToObjectCacheAction) => hasValue(action.payload.objectToCache.uuid)) - .map((action: AddToObjectCacheAction) => { - return new AddToIndexAction( - IndexName.OBJECT, - action.payload.objectToCache.uuid, - action.payload.objectToCache.self - ); - }); + .pipe( + ofType(ObjectCacheActionTypes.ADD), + filter((action: AddToObjectCacheAction) => hasValue(action.payload.objectToCache.uuid)), + map((action: AddToObjectCacheAction) => { + return new AddToIndexAction( + IndexName.OBJECT, + action.payload.objectToCache.uuid, + action.payload.objectToCache.self + ); + }) + ); @Effect() removeObject$ = this.actions$ - .ofType(ObjectCacheActionTypes.REMOVE) - .map((action: RemoveFromObjectCacheAction) => { - return new RemoveFromIndexByValueAction( - IndexName.OBJECT, - action.payload - ); - }); + .pipe( + ofType(ObjectCacheActionTypes.REMOVE), + map((action: RemoveFromObjectCacheAction) => { + return new RemoveFromIndexByValueAction( + IndexName.OBJECT, + action.payload + ); + }) + ); @Effect() addRequest$ = this.actions$ - .ofType(RequestActionTypes.CONFIGURE) - .filter((action: RequestConfigureAction) => action.payload.method === RestRequestMethod.Get) - .map((action: RequestConfigureAction) => { - return new AddToIndexAction( - IndexName.REQUEST, - action.payload.href, - action.payload.uuid - ); - }); + .pipe( + ofType(RequestActionTypes.CONFIGURE), + filter((action: RequestConfigureAction) => action.payload.method === RestRequestMethod.GET), + map((action: RequestConfigureAction) => { + return new AddToIndexAction( + IndexName.REQUEST, + action.payload.href, + action.payload.uuid + ); + }) + ); // @Effect() removeRequest$ = this.actions$ - // .ofType(ObjectCacheActionTypes.REMOVE) - // .map((action: RemoveFromObjectCacheAction) => { + // .pipe( + // ofType(ObjectCacheActionTypes.REMOVE), + // map((action: RemoveFromObjectCacheAction) => { // return new RemoveFromIndexByValueAction( // IndexName.OBJECT, // action.payload // ); - // }); + // }) + // ) constructor(private actions$: Actions) { diff --git a/src/app/core/index/index.reducer.spec.ts b/src/app/core/index/index.reducer.spec.ts index a1cf92aeb3..ffc2c9fadc 100644 --- a/src/app/core/index/index.reducer.spec.ts +++ b/src/app/core/index/index.reducer.spec.ts @@ -20,6 +20,10 @@ describe('requestReducer', () => { const testState: IndexState = { [IndexName.OBJECT]: { [key1]: val1 + },[IndexName.REQUEST]: { + [key1]: val1 + },[IndexName.UUID_MAPPING]: { + [key1]: val1 } }; deepFreeze(testState); diff --git a/src/app/core/index/index.reducer.ts b/src/app/core/index/index.reducer.ts index 869dee9e51..c179182509 100644 --- a/src/app/core/index/index.reducer.ts +++ b/src/app/core/index/index.reducer.ts @@ -7,13 +7,12 @@ import { export enum IndexName { OBJECT = 'object/uuid-to-self-link', - REQUEST = 'get-request/href-to-uuid' + REQUEST = 'get-request/href-to-uuid', + UUID_MAPPING = 'get-request/configured-to-cache-uuid' } -export interface IndexState { - // TODO this should be `[name in IndexName]: {` but that's currently broken, - // see https://github.com/Microsoft/TypeScript/issues/13042 - [name: string]: { +export type IndexState = { + [name in IndexName]: { [key: string]: string } } @@ -43,9 +42,10 @@ function addToIndex(state: IndexState, action: AddToIndexAction): IndexState { const newSubState = Object.assign({}, subState, { [action.payload.key]: action.payload.value }); - return Object.assign({}, state, { + const obs = Object.assign({}, state, { [action.payload.name]: newSubState - }) + }); + return obs; } function removeFromIndexByValue(state: IndexState, action: RemoveFromIndexByValueAction): IndexState { diff --git a/src/app/core/integration/authority.service.ts b/src/app/core/integration/authority.service.ts index cb2595adc4..a5fa3a8d09 100644 --- a/src/app/core/integration/authority.service.ts +++ b/src/app/core/integration/authority.service.ts @@ -1,6 +1,5 @@ import { Injectable } from '@angular/core'; -import { ResponseCacheService } from '../cache/response-cache.service'; import { RequestService } from '../data/request.service'; import { IntegrationService } from './integration.service'; import { HALEndpointService } from '../shared/hal-endpoint.service'; @@ -11,7 +10,6 @@ export class AuthorityService extends IntegrationService { protected browseEndpoint = 'entries'; constructor( - protected responseCache: ResponseCacheService, protected requestService: RequestService, protected halService: HALEndpointService) { super(); diff --git a/src/app/core/integration/integration-response-parsing.service.spec.ts b/src/app/core/integration/integration-response-parsing.service.spec.ts index 9c3e5b0344..38741da4e2 100644 --- a/src/app/core/integration/integration-response-parsing.service.spec.ts +++ b/src/app/core/integration/integration-response-parsing.service.spec.ts @@ -1,4 +1,4 @@ -import { ErrorResponse, IntegrationSuccessResponse } from '../cache/response-cache.models'; +import { ErrorResponse, IntegrationSuccessResponse } from '../cache/response.models'; import { ObjectCacheService } from '../cache/object-cache.service'; import { GlobalConfig } from '../../../config/global-config.interface'; diff --git a/src/app/core/integration/integration-response-parsing.service.ts b/src/app/core/integration/integration-response-parsing.service.ts index 06c6b9620d..ef278c93de 100644 --- a/src/app/core/integration/integration-response-parsing.service.ts +++ b/src/app/core/integration/integration-response-parsing.service.ts @@ -6,7 +6,7 @@ import { ErrorResponse, IntegrationSuccessResponse, RestResponse -} from '../cache/response-cache.models'; +} from '../cache/response.models'; import { isNotEmpty } from '../../shared/empty.util'; import { IntegrationObjectFactory } from './integration-object-factory'; @@ -32,7 +32,7 @@ export class IntegrationResponseParsingService extends BaseResponseParsingServic parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse { if (isNotEmpty(data.payload) && isNotEmpty(data.payload._links)) { - const dataDefinition = this.process(data.payload, request.href); + const dataDefinition = this.process(data.payload, request.uuid); return new IntegrationSuccessResponse(dataDefinition, data.statusCode, this.processPageInfo(data.payload.page)); } else { return new ErrorResponse( diff --git a/src/app/core/integration/integration.service.spec.ts b/src/app/core/integration/integration.service.spec.ts index b7f4e019f7..158f4b0680 100644 --- a/src/app/core/integration/integration.service.spec.ts +++ b/src/app/core/integration/integration.service.spec.ts @@ -1,7 +1,6 @@ import { cold, getTestScheduler } from 'jasmine-marbles'; -import { TestScheduler } from 'rxjs/Rx'; +import { TestScheduler } from 'rxjs/testing'; import { getMockRequestService } from '../../shared/mocks/mock-request.service'; -import { ResponseCacheService } from '../cache/response-cache.service'; import { RequestService } from '../data/request.service'; import { IntegrationRequest } from '../data/request.models'; @@ -18,7 +17,6 @@ class TestService extends IntegrationService { protected browseEndpoint = BROWSE; constructor( - protected responseCache: ResponseCacheService, protected requestService: RequestService, protected halService: HALEndpointService) { super(); @@ -28,7 +26,6 @@ class TestService extends IntegrationService { describe('IntegrationService', () => { let scheduler: TestScheduler; let service: TestService; - let responseCache: ResponseCacheService; let requestService: RequestService; let halService: any; let findOptions: IntegrationSearchOptions; @@ -43,24 +40,14 @@ describe('IntegrationService', () => { findOptions = new IntegrationSearchOptions(uuid, name, metadata); - function initMockResponseCacheService(isSuccessful: boolean): ResponseCacheService { - return jasmine.createSpyObj('responseCache', { - get: cold('c-', { - c: {response: {isSuccessful}} - }) - }); - } - - function initTestService(): TestService { + function initTestService(): TestService { return new TestService( - responseCache, requestService, halService ); } beforeEach(() => { - responseCache = initMockResponseCacheService(true); requestService = getMockRequestService(); scheduler = getTestScheduler(); halService = new HALEndpointServiceStub(integrationEndpoint); diff --git a/src/app/core/integration/integration.service.ts b/src/app/core/integration/integration.service.ts index f1c770336a..2ace710dc7 100644 --- a/src/app/core/integration/integration.service.ts +++ b/src/app/core/integration/integration.service.ts @@ -1,33 +1,35 @@ -import { Observable } from 'rxjs/Observable'; +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 { ResponseCacheService } from '../cache/response-cache.service'; -import { ErrorResponse, IntegrationSuccessResponse, RestResponse } from '../cache/response-cache.models'; +import { IntegrationSuccessResponse } from '../cache/response.models'; import { GetRequest, IntegrationRequest } from '../data/request.models'; -import { ResponseCacheEntry } from '../cache/response-cache.reducer'; 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 { RequestEntry } from '../data/request.reducer'; +import { getResponseFromEntry } from '../shared/operators'; export abstract class IntegrationService { protected request: IntegrationRequest; - protected abstract responseCache: ResponseCacheService; protected abstract requestService: RequestService; protected abstract linkPath: string; protected abstract browseEndpoint: string; protected abstract halService: HALEndpointService; protected getData(request: GetRequest): Observable { - const [successResponse, errorResponse] = this.responseCache.get(request.href) - .map((entry: ResponseCacheEntry) => entry.response) - .partition((response: RestResponse) => response.isSuccessful); - return Observable.merge( - errorResponse.flatMap((response: ErrorResponse) => - Observable.throw(new Error(`Couldn't retrieve the integration data`))), - successResponse - .filter((response: IntegrationSuccessResponse) => isNotEmpty(response)) - .map((response: IntegrationSuccessResponse) => new IntegrationData(response.pageInfo, response.dataDefinition)) - .distinctUntilChanged()); + return this.requestService.getByHref(request.href).pipe( + getResponseFromEntry(), + mergeMap((response) => { + if (response.isSuccessful && isNotEmpty(response)) { + const dataResponse = response as IntegrationSuccessResponse; + return observableOf(new IntegrationData(dataResponse.pageInfo, dataResponse.dataDefinition)); + } else if (!response.isSuccessful) { + return observableThrowError(new Error(`Couldn't retrieve the integration data`)); + } + }), + distinctUntilChanged() + ); } protected getIntegrationHref(endpoint, options: IntegrationSearchOptions = new IntegrationSearchOptions()): string { @@ -72,14 +74,14 @@ export abstract class IntegrationService { } public getEntriesByName(options: IntegrationSearchOptions): Observable { - return this.halService.getEndpoint(this.linkPath) - .map((endpoint: string) => this.getIntegrationHref(endpoint, options)) - .filter((href: string) => isNotEmpty(href)) - .distinctUntilChanged() - .map((endpointURL: string) => new IntegrationRequest(this.requestService.generateRequestId(), endpointURL)) - .do((request: GetRequest) => this.requestService.configure(request)) - .flatMap((request: GetRequest) => this.getData(request)) - .distinctUntilChanged(); + return this.halService.getEndpoint(this.linkPath).pipe( + map((endpoint: string) => this.getIntegrationHref(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/metadata/metadata.service.spec.ts b/src/app/core/metadata/metadata.service.spec.ts index f8f36a358e..3be50a7450 100644 --- a/src/app/core/metadata/metadata.service.spec.ts +++ b/src/app/core/metadata/metadata.service.spec.ts @@ -1,17 +1,15 @@ -import { ComponentFixture, TestBed, async, fakeAsync, inject, tick } from '@angular/core/testing'; +import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing'; import { RouterTestingModule } from '@angular/router/testing'; -import { Location, CommonModule } from '@angular/common'; +import { CommonModule, Location } from '@angular/common'; import { Component, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; -import { By, Meta, MetaDefinition, Title } from '@angular/platform-browser'; +import { Meta, MetaDefinition, Title } from '@angular/platform-browser'; import { ActivatedRoute, Router } from '@angular/router'; -import { TranslateModule, TranslateLoader } from '@ngx-translate/core'; +import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; import { Store, StoreModule } from '@ngrx/store'; - -import { Observable } from 'rxjs/Observable'; -import { RemoteDataError } from '../data/remote-data-error'; +import { Observable, of as observableOf } from 'rxjs'; import { UUIDService } from '../shared/uuid.service'; import { MetadataService } from './metadata.service'; @@ -25,7 +23,6 @@ import { ItemDataService } from '../data/item-data.service'; import { ObjectCacheService } from '../cache/object-cache.service'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { RequestService } from '../data/request.service'; -import { ResponseCacheService } from '../cache/response-cache.service'; import { RemoteData } from '../../core/data/remote-data'; import { Item } from '../../core/shared/item.model'; @@ -34,6 +31,7 @@ import { MockItem } from '../../shared/mocks/mock-item'; import { MockTranslateLoader } from '../../shared/mocks/mock-translate-loader'; import { BrowseService } from '../browse/browse.service'; import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { EmptyError } from 'rxjs/internal-compatibility'; /* tslint:disable:max-classes-per-file */ @Component({ @@ -64,7 +62,6 @@ describe('MetadataService', () => { let store: Store; let objectCacheService: ObjectCacheService; - let responseCacheService: ResponseCacheService; let requestService: RequestService; let uuidService: UUIDService; let remoteDataBuildService: RemoteDataBuildService; @@ -84,10 +81,9 @@ describe('MetadataService', () => { spyOn(store, 'dispatch'); objectCacheService = new ObjectCacheService(store); - responseCacheService = new ResponseCacheService(store); uuidService = new UUIDService(); - requestService = new RequestService(objectCacheService, responseCacheService, uuidService, store); - remoteDataBuildService = new RemoteDataBuildService(objectCacheService, responseCacheService, requestService); + requestService = new RequestService(objectCacheService, uuidService, store); + remoteDataBuildService = new RemoteDataBuildService(objectCacheService, requestService); TestBed.configureTestingModule({ imports: [ @@ -110,7 +106,6 @@ describe('MetadataService', () => { ], providers: [ { provide: ObjectCacheService, useValue: objectCacheService }, - { provide: ResponseCacheService, useValue: responseCacheService }, { provide: RequestService, useValue: requestService }, { provide: RemoteDataBuildService, useValue: remoteDataBuildService }, { provide: GLOBAL_CONFIG, useValue: ENV_CONFIG }, @@ -181,8 +176,24 @@ describe('MetadataService', () => { expect(tagStore.get('description')[0].content).toEqual('This is a dummy item component for testing!'); })); + describe('when the item has no bitstreams', () => { + + beforeEach(() => { + spyOn(MockItem, 'getFiles').and.returnValue(observableOf([])); + }); + + it('processRemoteData should not produce an EmptyError', fakeAsync(() => { + spyOn(itemDataService, 'findById').and.returnValue(mockRemoteData(MockItem)); + spyOn(metadataService, 'processRemoteData').and.callThrough(); + router.navigate(['/items/0ec7ff22-f211-40ab-a69e-c819b0b1f357']); + tick(); + expect(metadataService.processRemoteData).not.toThrow(new EmptyError()); + })); + + }); + const mockRemoteData = (mockItem: Item): Observable> => { - return Observable.of(new RemoteData( + return observableOf(new RemoteData( false, false, true, diff --git a/src/app/core/metadata/metadata.service.ts b/src/app/core/metadata/metadata.service.ts index cf597195e9..8bec058d08 100644 --- a/src/app/core/metadata/metadata.service.ts +++ b/src/app/core/metadata/metadata.service.ts @@ -1,28 +1,18 @@ -import 'rxjs/add/operator/first' -import 'rxjs/add/operator/take' - +import { catchError, distinctUntilKeyChanged, filter, first, map, take } from 'rxjs/operators'; import { Inject, Injectable } from '@angular/core'; -import { - ActivatedRoute, - Event, - NavigationEnd, - Params, - Router -} from '@angular/router'; +import { ActivatedRoute, NavigationEnd, Router } from '@angular/router'; import { Meta, MetaDefinition, Title } from '@angular/platform-browser'; import { TranslateService } from '@ngx-translate/core'; -import { BehaviorSubject } from 'rxjs/BehaviorSubject'; -import { Observable } from 'rxjs/Observable'; +import { BehaviorSubject, Observable } from 'rxjs'; import { RemoteData } from '../data/remote-data'; import { Bitstream } from '../shared/bitstream.model'; import { CacheableObject } from '../cache/object-cache.reducer'; import { DSpaceObject } from '../shared/dspace-object.model'; import { Item } from '../shared/item.model'; -import { Metadatum } from '../shared/metadatum.model'; import { GLOBAL_CONFIG, GlobalConfig } from '../../../config'; import { BitstreamFormat } from '../shared/bitstream-format.model'; @@ -55,21 +45,21 @@ export class MetadataService { } public listenForRouteChange(): void { - this.router.events - .filter((event) => event instanceof NavigationEnd) - .map(() => this.router.routerState.root) - .map((route: ActivatedRoute) => { + this.router.events.pipe( + filter((event) => event instanceof NavigationEnd), + map(() => this.router.routerState.root), + map((route: ActivatedRoute) => { route = this.getCurrentRoute(route); return { params: route.params, data: route.data }; - }).subscribe((routeInfo: any) => { - this.processRouteChange(routeInfo); - }); + }),).subscribe((routeInfo: any) => { + this.processRouteChange(routeInfo); + }); } public processRemoteData(remoteData: Observable>): void { - remoteData.map((rd: RemoteData) => rd.payload) - .filter((co: CacheableObject) => hasValue(co)) - .take(1) + remoteData.pipe(map((rd: RemoteData) => rd.payload), + filter((co: CacheableObject) => hasValue(co)), + take(1),) .subscribe((dspaceObject: DSpaceObject) => { if (!this.initialized) { this.initialize(dspaceObject); @@ -83,13 +73,13 @@ export class MetadataService { this.clearMetaTags(); } if (routeInfo.data.value.title) { - this.translate.get(routeInfo.data.value.title).take(1).subscribe((translatedTitle: string) => { + this.translate.get(routeInfo.data.value.title).pipe(take(1)).subscribe((translatedTitle: string) => { this.addMetaTag('title', translatedTitle); this.title.setTitle(translatedTitle); }); } if (routeInfo.data.value.description) { - this.translate.get(routeInfo.data.value.description).take(1).subscribe((translatedDescription: string) => { + this.translate.get(routeInfo.data.value.description).pipe(take(1)).subscribe((translatedDescription: string) => { this.addMetaTag('description', translatedDescription); }); } @@ -97,7 +87,7 @@ export class MetadataService { private initialize(dspaceObject: DSpaceObject): void { this.currentObject = new BehaviorSubject(dspaceObject); - this.currentObject.asObservable().distinctUntilKeyChanged('uuid').subscribe(() => { + this.currentObject.asObservable().pipe(distinctUntilKeyChanged('uuid')).subscribe(() => { this.setMetaTags(); }); this.initialized = true; @@ -269,18 +259,30 @@ export class MetadataService { private setCitationPdfUrlTag(): void { if (this.currentObject.value instanceof Item) { const item = this.currentObject.value as Item; - item.getFiles().filter((files) => isNotEmpty(files)).first().subscribe((bitstreams: Bitstream[]) => { - for (const bitstream of bitstreams) { - bitstream.format.first() - .map((rd: RemoteData) => rd.payload) - .filter((format: BitstreamFormat) => hasValue(format)) - .subscribe((format: BitstreamFormat) => { - if (format.mimetype === 'application/pdf') { - this.addMetaTag('citation_pdf_url', bitstream.content); - } - }); - } - }); + item.getFiles() + .pipe( + first((files) => isNotEmpty(files)), + catchError((error) => { + console.debug(error.message); + return [] + })) + .subscribe((bitstreams: Bitstream[]) => { + for (const bitstream of bitstreams) { + bitstream.format.pipe( + first(), + catchError((error: Error) => { + console.debug(error.message); + return [] + }), + map((rd: RemoteData) => rd.payload), + filter((format: BitstreamFormat) => hasValue(format))) + .subscribe((format: BitstreamFormat) => { + if (format.mimetype === 'application/pdf') { + this.addMetaTag('citation_pdf_url', bitstream.content); + } + }); + } + }); } } diff --git a/src/app/core/registry/registry.service.spec.ts b/src/app/core/registry/registry.service.spec.ts index ef1533278d..c87597cffc 100644 --- a/src/app/core/registry/registry.service.spec.ts +++ b/src/app/core/registry/registry.service.spec.ts @@ -1,31 +1,26 @@ -import { async, TestBed } from '@angular/core/testing'; +import { TestBed } from '@angular/core/testing'; import { RegistryService } from './registry.service'; import { CommonModule } from '@angular/common'; -import { ResponseCacheService } from '../cache/response-cache.service'; import { RequestService } from '../data/request.service'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model'; -import { Observable } from 'rxjs/Observable'; -import { ResponseCacheEntry } from '../cache/response-cache.reducer'; +import { Observable, of as observableOf, combineLatest as observableCombineLatest } from 'rxjs'; import { RequestEntry } from '../data/request.reducer'; import { RemoteData } from '../data/remote-data'; -import { PaginatedList } from '../data/paginated-list'; import { PageInfo } from '../shared/page-info.model'; -import { GetRequest } from '../data/request.models'; -import { URLCombiner } from '../url-combiner/url-combiner'; import { getMockRequestService } from '../../shared/mocks/mock-request.service'; -import { getMockResponseCacheService } from '../../shared/mocks/mock-response-cache.service'; + import { RegistryBitstreamformatsSuccessResponse, - RegistryMetadatafieldsSuccessResponse, RegistryMetadataschemasSuccessResponse, - SearchSuccessResponse -} from '../cache/response-cache.models'; -import { SearchQueryResponse } from '../../+search-page/search-service/search-query-response.model'; + RegistryMetadatafieldsSuccessResponse, + RegistryMetadataschemasSuccessResponse +} from '../cache/response.models'; import { Component } from '@angular/core'; import { RegistryMetadataschemasResponse } from './registry-metadataschemas-response.model'; import { RegistryMetadatafieldsResponse } from './registry-metadatafields-response.model'; import { RegistryBitstreamformatsResponse } from './registry-bitstreamformats-response.model'; +import { map } from 'rxjs/operators'; @Component({ template: '' }) class DummyComponent { @@ -125,29 +120,29 @@ describe('RegistryService', () => { const endpointWithParams = `${endpoint}?size=${pageInfo.elementsPerPage}&page=${pageInfo.currentPage - 1}`; const halServiceStub = { - getEndpoint: (link: string) => Observable.of(endpoint) + getEndpoint: (link: string) => observableOf(endpoint) }; const rdbStub = { - toRemoteDataObservable: (requestEntryObs: Observable, responseCacheObs: Observable, payloadObs: Observable) => { - return Observable.combineLatest(requestEntryObs, - responseCacheObs, payloadObs, (req, res, pay) => { - return { req, res, pay }; - }); + toRemoteDataObservable: (requestEntryObs: Observable, payloadObs: Observable) => { + return observableCombineLatest(requestEntryObs, + payloadObs).pipe(map(([req, pay]) => { + return { req, pay }; + }) + ); }, aggregate: (input: Array>>): Observable> => { - return Observable.of(new RemoteData(false, false, true, null, [])); + return observableOf(new RemoteData(false, false, true, null, [])); } }; beforeEach(() => { TestBed.configureTestingModule({ - imports: [ CommonModule ], + imports: [CommonModule], declarations: [ DummyComponent ], providers: [ - { provide: ResponseCacheService, useValue: getMockResponseCacheService() }, { provide: RequestService, useValue: getMockRequestService() }, { provide: RemoteDataBuildService, useValue: rdbStub }, { provide: HALEndpointService, useValue: halServiceStub }, @@ -156,16 +151,19 @@ describe('RegistryService', () => { }); registryService = TestBed.get(RegistryService); - spyOn((registryService as any).halService, 'getEndpoint').and.returnValue(Observable.of(endpoint)); + spyOn((registryService as any).halService, 'getEndpoint').and.returnValue(observableOf(endpoint)); }); describe('when requesting metadataschemas', () => { - const queryResponse = Object.assign(new RegistryMetadataschemasResponse(), { metadataschemas: mockSchemasList, page: pageInfo }); + const queryResponse = Object.assign(new RegistryMetadataschemasResponse(), { + metadataschemas: mockSchemasList, + page: pageInfo + }); const response = new RegistryMetadataschemasSuccessResponse(queryResponse, '200', pageInfo); - const responseEntry = Object.assign(new ResponseCacheEntry(), { response: response }); + const responseEntry = Object.assign(new RequestEntry(), { response: response }); beforeEach(() => { - (registryService as any).responseCache.get.and.returnValue(Observable.of(responseEntry)); + (registryService as any).requestService.getByHref.and.returnValue(observableOf(responseEntry)); /* tslint:disable:no-empty */ registryService.getMetadataSchemas(pagination).subscribe((value) => { }); @@ -183,19 +181,18 @@ describe('RegistryService', () => { it('should call getByHref on the request service with the correct request url', () => { expect((registryService as any).requestService.getByHref).toHaveBeenCalledWith(endpointWithParams); }); - - it('should call get on the request service with the correct request url', () => { - expect((registryService as any).responseCache.get).toHaveBeenCalledWith(endpointWithParams); - }); }); describe('when requesting metadataschema by name', () => { - const queryResponse = Object.assign(new RegistryMetadataschemasResponse(), { metadataschemas: mockSchemasList, page: pageInfo }); + const queryResponse = Object.assign(new RegistryMetadataschemasResponse(), { + metadataschemas: mockSchemasList, + page: pageInfo + }); const response = new RegistryMetadataschemasSuccessResponse(queryResponse, '200', pageInfo); - const responseEntry = Object.assign(new ResponseCacheEntry(), { response: response }); + const responseEntry = Object.assign(new RequestEntry(), { response: response }); beforeEach(() => { - (registryService as any).responseCache.get.and.returnValue(Observable.of(responseEntry)); + (registryService as any).requestService.getByHref.and.returnValue(observableOf(responseEntry)); /* tslint:disable:no-empty */ registryService.getMetadataSchemaByName(mockSchemasList[0].prefix).subscribe((value) => { }); @@ -213,19 +210,18 @@ describe('RegistryService', () => { it('should call getByHref on the request service with the correct request url', () => { expect((registryService as any).requestService.getByHref.calls.argsFor(0)[0]).toContain(endpoint); }); - - it('should call get on the request service with the correct request url', () => { - expect((registryService as any).responseCache.get.calls.argsFor(0)[0]).toContain(endpoint); - }); }); describe('when requesting metadatafields', () => { - const queryResponse = Object.assign(new RegistryMetadatafieldsResponse(), { metadatafields: mockFieldsList, page: pageInfo }); + const queryResponse = Object.assign(new RegistryMetadatafieldsResponse(), { + metadatafields: mockFieldsList, + page: pageInfo + }); const response = new RegistryMetadatafieldsSuccessResponse(queryResponse, '200', pageInfo); - const responseEntry = Object.assign(new ResponseCacheEntry(), { response: response }); + const responseEntry = Object.assign(new RequestEntry(), { response: response }); beforeEach(() => { - (registryService as any).responseCache.get.and.returnValue(Observable.of(responseEntry)); + (registryService as any).requestService.getByHref.and.returnValue(observableOf(responseEntry)); /* tslint:disable:no-empty */ registryService.getMetadataFieldsBySchema(mockSchemasList[0], pagination).subscribe((value) => { }); @@ -243,19 +239,18 @@ describe('RegistryService', () => { it('should call getByHref on the request service with the correct request url', () => { expect((registryService as any).requestService.getByHref).toHaveBeenCalledWith(endpointWithParams); }); - - it('should call get on the request service with the correct request url', () => { - expect((registryService as any).responseCache.get).toHaveBeenCalledWith(endpointWithParams); - }); }); describe('when requesting bitstreamformats', () => { - const queryResponse = Object.assign(new RegistryBitstreamformatsResponse(), { bitstreamformats: mockFieldsList, page: pageInfo }); + const queryResponse = Object.assign(new RegistryBitstreamformatsResponse(), { + bitstreamformats: mockFieldsList, + page: pageInfo + }); const response = new RegistryBitstreamformatsSuccessResponse(queryResponse, '200', pageInfo); - const responseEntry = Object.assign(new ResponseCacheEntry(), { response: response }); + const responseEntry = Object.assign(new RequestEntry(), { response: response }); beforeEach(() => { - (registryService as any).responseCache.get.and.returnValue(Observable.of(responseEntry)); + (registryService as any).requestService.getByHref.and.returnValue(observableOf(responseEntry)); /* tslint:disable:no-empty */ registryService.getBitstreamFormats(pagination).subscribe((value) => { }); @@ -273,9 +268,5 @@ describe('RegistryService', () => { it('should call getByHref on the request service with the correct request url', () => { expect((registryService as any).requestService.getByHref).toHaveBeenCalledWith(endpointWithParams); }); - - it('should call get on the request service with the correct request url', () => { - expect((registryService as any).responseCache.get).toHaveBeenCalledWith(endpointWithParams); - }); }); }); diff --git a/src/app/core/registry/registry.service.ts b/src/app/core/registry/registry.service.ts index 4359284158..ef92d42ce9 100644 --- a/src/app/core/registry/registry.service.ts +++ b/src/app/core/registry/registry.service.ts @@ -1,35 +1,34 @@ +import { combineLatest as observableCombineLatest, Observable } from 'rxjs'; import { Injectable } from '@angular/core'; -import { Observable } from 'rxjs/Observable'; import { RemoteData } from '../data/remote-data'; import { PaginatedList } from '../data/paginated-list'; import { PageInfo } from '../shared/page-info.model'; import { MetadataSchema } from '../metadata/metadataschema.model'; import { MetadataField } from '../metadata/metadatafield.model'; import { BitstreamFormat } from './mock-bitstream-format.model'; -import { flatMap, map, tap } from 'rxjs/operators'; +import { filter, flatMap, map, tap } from 'rxjs/operators'; import { GetRequest, RestRequest } from '../data/request.models'; import { GenericConstructor } from '../shared/generic-constructor'; import { ResponseParsingService } from '../data/parsing.service'; import { RegistryMetadataschemasResponseParsingService } from '../data/registry-metadataschemas-response-parsing.service'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { RequestService } from '../data/request.service'; -import { ResponseCacheService } from '../cache/response-cache.service'; import { RegistryMetadataschemasResponse } from './registry-metadataschemas-response.model'; -import { ResponseCacheEntry } from '../cache/response-cache.reducer'; import { - MetadataschemaSuccessResponse, RegistryBitstreamformatsSuccessResponse, RegistryMetadatafieldsSuccessResponse, + RegistryBitstreamformatsSuccessResponse, + RegistryMetadatafieldsSuccessResponse, RegistryMetadataschemasSuccessResponse -} from '../cache/response-cache.models'; +} from '../cache/response.models'; import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { MetadataschemaParsingService } from '../data/metadataschema-parsing.service'; -import { Res } from 'awesome-typescript-loader/dist/checker/protocol'; import { RegistryMetadatafieldsResponseParsingService } from '../data/registry-metadatafields-response-parsing.service'; import { RegistryMetadatafieldsResponse } from './registry-metadatafields-response.model'; -import { isNotEmpty } from '../../shared/empty.util'; +import { hasValue, isNotEmpty } from '../../shared/empty.util'; import { URLCombiner } from '../url-combiner/url-combiner'; import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model'; import { RegistryBitstreamformatsResponseParsingService } from '../data/registry-bitstreamformats-response-parsing.service'; import { RegistryBitstreamformatsResponse } from './registry-bitstreamformats-response.model'; +import { RequestEntry } from '../data/request.reducer'; +import { getResponseFromEntry } from '../shared/operators'; @Injectable() export class RegistryService { @@ -38,8 +37,7 @@ export class RegistryService { private metadataFieldsPath = 'metadatafields'; private bitstreamFormatsPath = 'bitstreamformats'; - constructor(protected responseCache: ResponseCacheService, - protected requestService: RequestService, + constructor(protected requestService: RequestService, private rdb: RemoteDataBuildService, private halService: HALEndpointService) { @@ -52,12 +50,8 @@ export class RegistryService { flatMap((request: RestRequest) => this.requestService.getByHref(request.href)) ); - const responseCacheObs = requestObs.pipe( - flatMap((request: RestRequest) => this.responseCache.get(request.href)) - ); - - const rmrObs: Observable = responseCacheObs.pipe( - map((entry: ResponseCacheEntry) => entry.response), + const rmrObs: Observable = requestEntryObs.pipe( + getResponseFromEntry(), map((response: RegistryMetadataschemasSuccessResponse) => response.metadataschemasResponse) ); @@ -65,16 +59,18 @@ export class RegistryService { map((rmr: RegistryMetadataschemasResponse) => rmr.metadataschemas) ); - const pageInfoObs: Observable = responseCacheObs.pipe( - map((entry: ResponseCacheEntry) => entry.response), + const pageInfoObs: Observable = requestEntryObs.pipe( + getResponseFromEntry(), map((response: RegistryMetadataschemasSuccessResponse) => response.pageInfo) ); - const payloadObs = Observable.combineLatest(metadataschemasObs, pageInfoObs, (metadataschemas, pageInfo) => { - return new PaginatedList(pageInfo, metadataschemas); - }); + const payloadObs = observableCombineLatest(metadataschemasObs, pageInfoObs).pipe( + map(([metadataschemas, pageInfo]) => { + return new PaginatedList(pageInfo, metadataschemas); + }) + ); - return this.rdb.toRemoteDataObservable(requestEntryObs, responseCacheObs, payloadObs); + return this.rdb.toRemoteDataObservable(requestEntryObs, payloadObs); } public getMetadataSchemaByName(schemaName: string): Observable> { @@ -89,12 +85,8 @@ export class RegistryService { flatMap((request: RestRequest) => this.requestService.getByHref(request.href)) ); - const responseCacheObs = requestObs.pipe( - flatMap((request: RestRequest) => this.responseCache.get(request.href)) - ); - - const rmrObs: Observable = responseCacheObs.pipe( - map((entry: ResponseCacheEntry) => entry.response), + const rmrObs: Observable = requestEntryObs.pipe( + getResponseFromEntry(), map((response: RegistryMetadataschemasSuccessResponse) => response.metadataschemasResponse) ); @@ -103,7 +95,7 @@ export class RegistryService { map((metadataSchemas: MetadataSchema[]) => metadataSchemas.filter((value) => value.prefix === schemaName)[0]) ); - return this.rdb.toRemoteDataObservable(requestEntryObs, responseCacheObs, metadataschemaObs); + return this.rdb.toRemoteDataObservable(requestEntryObs, metadataschemaObs); } public getMetadataFieldsBySchema(schema: MetadataSchema, pagination: PaginationComponentOptions): Observable>> { @@ -113,12 +105,8 @@ export class RegistryService { flatMap((request: RestRequest) => this.requestService.getByHref(request.href)) ); - const responseCacheObs = requestObs.pipe( - flatMap((request: RestRequest) => this.responseCache.get(request.href)) - ); - - const rmrObs: Observable = responseCacheObs.pipe( - map((entry: ResponseCacheEntry) => entry.response), + const rmrObs: Observable = requestEntryObs.pipe( + getResponseFromEntry(), map((response: RegistryMetadatafieldsSuccessResponse) => response.metadatafieldsResponse) ); @@ -127,16 +115,19 @@ export class RegistryService { map((metadataFields: MetadataField[]) => metadataFields.filter((field) => field.schema.id === schema.id)) ); - const pageInfoObs: Observable = responseCacheObs.pipe( - map((entry: ResponseCacheEntry) => entry.response), + const pageInfoObs: Observable = requestEntryObs.pipe( + getResponseFromEntry(), + map((response: RegistryMetadatafieldsSuccessResponse) => response.pageInfo) ); - const payloadObs = Observable.combineLatest(metadatafieldsObs, pageInfoObs, (metadatafields, pageInfo) => { - return new PaginatedList(pageInfo, metadatafields); - }); + const payloadObs = observableCombineLatest(metadatafieldsObs, pageInfoObs).pipe( + map(([metadatafields, pageInfo]) => { + return new PaginatedList(pageInfo, metadatafields); + }) + ); - return this.rdb.toRemoteDataObservable(requestEntryObs, responseCacheObs, payloadObs); + return this.rdb.toRemoteDataObservable(requestEntryObs, payloadObs); } public getBitstreamFormats(pagination: PaginationComponentOptions): Observable>> { @@ -146,12 +137,8 @@ export class RegistryService { flatMap((request: RestRequest) => this.requestService.getByHref(request.href)) ); - const responseCacheObs = requestObs.pipe( - flatMap((request: RestRequest) => this.responseCache.get(request.href)) - ); - - const rbrObs: Observable = responseCacheObs.pipe( - map((entry: ResponseCacheEntry) => entry.response), + const rbrObs: Observable = requestEntryObs.pipe( + getResponseFromEntry(), map((response: RegistryBitstreamformatsSuccessResponse) => response.bitstreamformatsResponse) ); @@ -159,16 +146,18 @@ export class RegistryService { map((rbr: RegistryBitstreamformatsResponse) => rbr.bitstreamformats) ); - const pageInfoObs: Observable = responseCacheObs.pipe( - map((entry: ResponseCacheEntry) => entry.response), + const pageInfoObs: Observable = requestEntryObs.pipe( + getResponseFromEntry(), map((response: RegistryBitstreamformatsSuccessResponse) => response.pageInfo) ); - const payloadObs = Observable.combineLatest(bitstreamformatsObs, pageInfoObs, (bitstreamformats, pageInfo) => { - return new PaginatedList(pageInfo, bitstreamformats); - }); + const payloadObs = observableCombineLatest(bitstreamformatsObs, pageInfoObs).pipe( + map(([bitstreamformats, pageInfo]) => { + return new PaginatedList(pageInfo, bitstreamformats); + }) + ); - return this.rdb.toRemoteDataObservable(requestEntryObs, responseCacheObs, payloadObs); + return this.rdb.toRemoteDataObservable(requestEntryObs, payloadObs); } private getMetadataSchemasRequestObs(pagination: PaginationComponentOptions): Observable { diff --git a/src/app/core/shared/bitstream.model.ts b/src/app/core/shared/bitstream.model.ts index 511c2c5cd2..794282e867 100644 --- a/src/app/core/shared/bitstream.model.ts +++ b/src/app/core/shared/bitstream.model.ts @@ -2,7 +2,7 @@ import { DSpaceObject } from './dspace-object.model'; import { RemoteData } from '../data/remote-data'; import { Item } from './item.model'; import { BitstreamFormat } from './bitstream-format.model'; -import { Observable } from 'rxjs/Observable'; +import { Observable } from 'rxjs'; export class Bitstream extends DSpaceObject { diff --git a/src/app/core/shared/bundle.model.ts b/src/app/core/shared/bundle.model.ts index 9a8afb2661..3f5b5df877 100644 --- a/src/app/core/shared/bundle.model.ts +++ b/src/app/core/shared/bundle.model.ts @@ -2,7 +2,7 @@ import { DSpaceObject } from './dspace-object.model'; import { Bitstream } from './bitstream.model'; import { Item } from './item.model'; import { RemoteData } from '../data/remote-data'; -import { Observable } from 'rxjs/Observable'; +import { Observable } from 'rxjs'; export class Bundle extends DSpaceObject { /** diff --git a/src/app/core/shared/collection.model.ts b/src/app/core/shared/collection.model.ts index b2f8d90a65..8fdc14bd6e 100644 --- a/src/app/core/shared/collection.model.ts +++ b/src/app/core/shared/collection.model.ts @@ -2,7 +2,7 @@ import { DSpaceObject } from './dspace-object.model'; import { Bitstream } from './bitstream.model'; import { Item } from './item.model'; import { RemoteData } from '../data/remote-data'; -import { Observable } from 'rxjs/Observable'; +import { Observable } from 'rxjs'; export class Collection extends DSpaceObject { diff --git a/src/app/core/shared/community.model.ts b/src/app/core/shared/community.model.ts index 20bd50f4a9..893a7e0b94 100644 --- a/src/app/core/shared/community.model.ts +++ b/src/app/core/shared/community.model.ts @@ -2,7 +2,7 @@ import { DSpaceObject } from './dspace-object.model'; import { Bitstream } from './bitstream.model'; import { Collection } from './collection.model'; import { RemoteData } from '../data/remote-data'; -import { Observable } from 'rxjs/Observable'; +import { Observable } from 'rxjs'; import { PaginatedList } from '../data/paginated-list'; export class Community extends DSpaceObject { diff --git a/src/app/core/shared/dspace-object.model.ts b/src/app/core/shared/dspace-object.model.ts index 042bfc01a0..68338143ba 100644 --- a/src/app/core/shared/dspace-object.model.ts +++ b/src/app/core/shared/dspace-object.model.ts @@ -4,13 +4,13 @@ import { CacheableObject } from '../cache/object-cache.reducer'; import { RemoteData } from '../data/remote-data'; import { ResourceType } from './resource-type'; import { ListableObject } from '../../shared/object-collection/shared/listable-object.model'; -import { Observable } from 'rxjs/Observable'; +import { Observable } from 'rxjs'; import { autoserialize } from 'cerialize'; /** * An abstract model class for a DSpaceObject. */ -export class DSpaceObject implements CacheableObject, ListableObject { +export class DSpaceObject implements CacheableObject, ListableObject { self: string; diff --git a/src/app/core/shared/hal-endpoint.service.spec.ts b/src/app/core/shared/hal-endpoint.service.spec.ts index 0c2afe938b..8b3011e7d7 100644 --- a/src/app/core/shared/hal-endpoint.service.spec.ts +++ b/src/app/core/shared/hal-endpoint.service.spec.ts @@ -1,44 +1,66 @@ -import { cold, hot } from 'jasmine-marbles'; +import { cold, getTestScheduler, hot } from 'jasmine-marbles'; import { GlobalConfig } from '../../../config/global-config.interface'; import { getMockRequestService } from '../../shared/mocks/mock-request.service'; -import { ResponseCacheService } from '../cache/response-cache.service'; import { RequestService } from '../data/request.service'; import { HALEndpointService } from './hal-endpoint.service'; import { EndpointMapRequest } from '../data/request.models'; +import { RequestEntry } from '../data/request.reducer'; +import { of as observableOf } from 'rxjs'; describe('HALEndpointService', () => { let service: HALEndpointService; - let responseCache: ResponseCacheService; let requestService: RequestService; let envConfig: GlobalConfig; + let requestEntry; const endpointMap = { test: 'https://rest.api/test', + foo: 'https://rest.api/foo', + bar: 'https://rest.api/bar', + endpoint: 'https://rest.api/endpoint', + link: 'https://rest.api/link', + another: 'https://rest.api/another', + }; + const start = 'http://start.com'; + const one = 'http://one.com'; + const two = 'http://two.com'; + const endpointMaps = { + [start]: { + one: one, + two: 'empty', + endpoint: 'https://rest.api/endpoint', + link: 'https://rest.api/link', + another: 'https://rest.api/another', + }, + [one]: { + one: 'empty', + two: two, + bar: 'https://rest.api/bar', + } }; const linkPath = 'test'; + beforeEach(() => { + requestEntry = { + request: { responseMsToLive: 1000 } as any, + requestPending: false, + responsePending: false, + completed: true, + response: { endpointMap: endpointMap } as any + } as RequestEntry; + requestService = getMockRequestService(observableOf(requestEntry)); + + envConfig = { + rest: { baseUrl: 'https://rest.api/' } + } as any; + + service = new HALEndpointService( + requestService, + envConfig + ); + }); + describe('getRootEndpointMap', () => { - beforeEach(() => { - responseCache = jasmine.createSpyObj('responseCache', { - get: hot('a-', { - a: { - response: { endpointMap: endpointMap } - } - }) - }); - - requestService = getMockRequestService(); - - envConfig = { - rest: { baseUrl: 'https://rest.api/' } - } as any; - - service = new HALEndpointService( - responseCache, - requestService, - envConfig - ); - }); it('should configure a new EndpointMapRequest', () => { (service as any).getRootEndpointMap(); @@ -48,8 +70,8 @@ describe('HALEndpointService', () => { it('should return an Observable of the endpoint map', () => { const result = (service as any).getRootEndpointMap(); - const expected = cold('b-', { b: endpointMap }); - expect(result).toBeObservable(expected); + const expected = '(b|)'; + getTestScheduler().expectObservable(result).toBe(expected, { b: endpointMap }); }); }); @@ -60,12 +82,6 @@ describe('HALEndpointService', () => { envConfig = { rest: { baseUrl: 'https://rest.api/' } } as any; - - service = new HALEndpointService( - responseCache, - requestService, - envConfig - ); }); it('should return the endpoint URL for the service\'s linkPath', () => { @@ -86,10 +102,53 @@ describe('HALEndpointService', () => { }); }); + describe('getEndpointAt', () => { + it('should throw an error when the list of hal endpoint names is empty', () => { + const endpointAtWithoutEndpointNames = () => { + (service as any).getEndpointAt('') + }; + expect(endpointAtWithoutEndpointNames).toThrow(); + }); + + it('should be at least called as many times as the length of halNames', () => { + spyOn(service as any, 'getEndpointMapAt').and.returnValue(observableOf(endpointMap)); + spyOn((service as any), 'getEndpointAt').and.callThrough(); + + (service as any).getEndpointAt('', 'endpoint').subscribe(); + + expect((service as any).getEndpointAt.calls.count()).toEqual(1); + + (service as any).getEndpointAt.calls.reset(); + + (service as any).getEndpointAt('', 'endpoint', 'another').subscribe(); + + expect((service as any).getEndpointAt.calls.count()).toBeGreaterThanOrEqual(2); + + (service as any).getEndpointAt.calls.reset(); + + (service as any).getEndpointAt('', 'endpoint', 'another', 'foo', 'bar', 'test').subscribe(); + + expect((service as any).getEndpointAt.calls.count()).toBeGreaterThanOrEqual(5); + }); + + it('should return the correct endpoint', () => { + spyOn(service as any, 'getEndpointMapAt').and.callFake((param) => { + return observableOf(endpointMaps[param]); + }); + + (service as any).getEndpointAt(start, 'one').subscribe((endpoint) => { + expect(endpoint).toEqual(one); + }); + + (service as any).getEndpointAt(start, 'one', 'two').subscribe((endpoint) => { + expect(endpoint).toEqual(two); + }); + }); + }); + describe('isEnabledOnRestApi', () => { beforeEach(() => { service = new HALEndpointService( - responseCache, requestService, envConfig ); diff --git a/src/app/core/shared/hal-endpoint.service.ts b/src/app/core/shared/hal-endpoint.service.ts index 3bedeb9915..a93d54db64 100644 --- a/src/app/core/shared/hal-endpoint.service.ts +++ b/src/app/core/shared/hal-endpoint.service.ts @@ -1,21 +1,27 @@ -import { Observable } from 'rxjs/Observable'; -import { distinctUntilChanged, map, flatMap, startWith, tap } from 'rxjs/operators'; +import { Observable, of as observableOf, combineLatest as observableCombineLatest } from 'rxjs'; +import { + distinctUntilChanged, first, + map, + mergeMap, + startWith, + switchMap, + tap +} from 'rxjs/operators'; import { RequestService } from '../data/request.service'; -import { ResponseCacheService } from '../cache/response-cache.service'; import { GlobalConfig } from '../../../config/global-config.interface'; -import { EndpointMap, EndpointMapSuccessResponse } from '../cache/response-cache.models'; import { EndpointMapRequest } from '../data/request.models'; -import { ResponseCacheEntry } from '../cache/response-cache.reducer'; -import { hasNoValue, hasValue, isEmpty, isNotEmpty } from '../../shared/empty.util'; +import { hasValue, isEmpty, isNotEmpty } from '../../shared/empty.util'; import { RESTURLCombiner } from '../url-combiner/rest-url-combiner'; import { Inject, Injectable } from '@angular/core'; import { GLOBAL_CONFIG } from '../../../config'; +import { EndpointMap, EndpointMapSuccessResponse } from '../cache/response.models'; +import { getResponseFromEntry } from './operators'; +import { URLCombiner } from '../url-combiner/url-combiner'; @Injectable() export class HALEndpointService { - constructor(private responseCache: ResponseCacheService, - private requestService: RequestService, + constructor(private requestService: RequestService, @Inject(GLOBAL_CONFIG) private EnvConfig: GlobalConfig) { } @@ -29,39 +35,48 @@ export class HALEndpointService { private getEndpointMapAt(href): Observable { const request = new EndpointMapRequest(this.requestService.generateRequestId(), href); + this.requestService.configure(request); - return this.responseCache.get(request.href) - .map((entry: ResponseCacheEntry) => entry.response) - .filter((response: EndpointMapSuccessResponse) => isNotEmpty(response)) - .map((response: EndpointMapSuccessResponse) => response.endpointMap) - .distinctUntilChanged(); + return this.requestService.getByHref(request.href).pipe( + getResponseFromEntry(), + map((response: EndpointMapSuccessResponse) => response.endpointMap), + ); } public getEndpoint(linkPath: string): Observable { - return this.getEndpointAt(...linkPath.split('/')); + return this.getEndpointAt(this.getRootHref(), ...linkPath.split('/')); } - private getEndpointAt(...path: string[]): Observable { - if (isEmpty(path)) { - path = ['/']; + /** + * Resolve the actual hal url based on a list of hal names + * @param {string} href The root url to start from + * @param {string} halNames List of hal names for which a url should be resolved + * @returns {Observable} Observable that emits the found hal url + */ + private getEndpointAt(href: string, ...halNames: string[]): Observable { + if (isEmpty(halNames)) { + throw new Error('cant\'t fetch the URL without the HAL link names') + } + + const nextHref$ = this.getEndpointMapAt(href).pipe( + map((endpointMap: EndpointMap): string => { + /*TODO remove if/else block once the rest response contains _links for facets*/ + const nextName = halNames[0]; + if (hasValue(endpointMap) && hasValue(endpointMap[nextName])) { + return endpointMap[nextName]; + } else { + return new URLCombiner(href, nextName).toString(); + } + }) + ) as Observable; + + if (halNames.length === 1) { + return nextHref$; + } else { + return nextHref$.pipe( + switchMap((nextHref) => this.getEndpointAt(nextHref, ...halNames.slice(1))) + ); } - let currentPath; - const pipeArguments = path - .map((subPath: string, index: number) => [ - flatMap((href: string) => this.getEndpointMapAt(href)), - map((endpointMap: EndpointMap) => { - if (hasValue(endpointMap) && hasValue(endpointMap[subPath])) { - currentPath = endpointMap[subPath]; - return endpointMap[subPath]; - } else { - /*TODO remove if/else block once the rest response contains _links for facets*/ - currentPath += '/' + subPath; - return currentPath; - } - }), - ]) - .reduce((combined, thisElement) => [...combined, ...thisElement], []); - return Observable.of(this.getRootHref()).pipe(...pipeArguments, distinctUntilChanged()); } public isEnabledOnRestApi(linkPath: string): Observable { diff --git a/src/app/core/shared/item.model.spec.ts b/src/app/core/shared/item.model.spec.ts index c020cd3454..2e5388dc4d 100644 --- a/src/app/core/shared/item.model.spec.ts +++ b/src/app/core/shared/item.model.spec.ts @@ -1,10 +1,10 @@ -import { Observable } from 'rxjs/Observable'; +import { Observable, of as observableOf } from 'rxjs'; import { Item } from './item.model'; import { RemoteData } from '../data/remote-data'; import { Bitstream } from './bitstream.model'; import { isEmpty } from '../../shared/empty.util'; -import { PageInfo } from './page-info.model'; +import { first, map } from 'rxjs/operators'; describe('Item', () => { @@ -56,30 +56,30 @@ describe('Item', () => { it('should return the bitstreams related to this item with the specified bundle name', () => { const bitObs: Observable = item.getBitstreamsByBundleName(thumbnailBundleName); - bitObs.take(1).subscribe((bs) => + bitObs.pipe(first()).subscribe((bs) => expect(bs.every((b) => b.name === thumbnailBundleName)).toBeTruthy()); }); it('should return an empty array when no bitstreams with this bundleName exist for this item', () => { const bs: Observable = item.getBitstreamsByBundleName(nonExistingBundleName); - bs.take(1).subscribe((b) => expect(isEmpty(b)).toBeTruthy()); + bs.pipe(first()).subscribe((b) => expect(isEmpty(b)).toBeTruthy()); }); describe('get thumbnail', () => { beforeEach(() => { - spyOn(item, 'getBitstreamsByBundleName').and.returnValue(Observable.of([remoteDataThumbnail])); + spyOn(item, 'getBitstreamsByBundleName').and.returnValue(observableOf([remoteDataThumbnail])); }); it('should return the thumbnail of this item', () => { const path: string = thumbnailPath; const bitstream: Observable = item.getThumbnail(); - bitstream.map((b) => expect(b.content).toBe(path)); + bitstream.pipe(map((b) => expect(b.content).toBe(path))); }); }); describe('get files', () => { beforeEach(() => { - spyOn(item, 'getBitstreamsByBundleName').and.returnValue(Observable.of(bitstreams)); + spyOn(item, 'getBitstreamsByBundleName').and.returnValue(observableOf(bitstreams)); }); it("should return all bitstreams with 'ORIGINAL' as bundleName", () => { @@ -87,7 +87,7 @@ describe('Item', () => { const files: Observable = item.getFiles(); let index = 0; - files.map((f) => expect(f.length).toBe(2)); + files.pipe(map((f) => expect(f.length).toBe(2))); files.subscribe( (array) => array.forEach( (file) => { @@ -103,7 +103,7 @@ describe('Item', () => { }); function createRemoteDataObject(object: any) { - return Observable.of(new RemoteData( + return observableOf(new RemoteData( false, false, true, diff --git a/src/app/core/shared/item.model.ts b/src/app/core/shared/item.model.ts index cc84694e84..69def7b969 100644 --- a/src/app/core/shared/item.model.ts +++ b/src/app/core/shared/item.model.ts @@ -1,4 +1,5 @@ -import { Observable } from 'rxjs/Observable'; +import {map, startWith, filter} from 'rxjs/operators'; +import { Observable } from 'rxjs'; import { DSpaceObject } from './dspace-object.model'; import { Collection } from './collection.model'; @@ -58,9 +59,9 @@ export class Item extends DSpaceObject { // TODO: currently this just picks the first thumbnail // should be adjusted when we have a way to determine // the primary thumbnail from rest - return this.getBitstreamsByBundleName('THUMBNAIL') - .filter((thumbnails) => isNotEmpty(thumbnails)) - .map((thumbnails) => thumbnails[0]) + return this.getBitstreamsByBundleName('THUMBNAIL').pipe( + filter((thumbnails) => isNotEmpty(thumbnails)), + map((thumbnails) => thumbnails[0]),) } /** @@ -68,10 +69,10 @@ export class Item extends DSpaceObject { * @returns {Observable} the primaryBitstream of the 'THUMBNAIL' bundle */ getThumbnailForOriginal(original: Bitstream): Observable { - return this.getBitstreamsByBundleName('THUMBNAIL') - .map((files) => { + return this.getBitstreamsByBundleName('THUMBNAIL').pipe( + map((files) => { return files.find((thumbnail) => thumbnail.name.startsWith(original.name)) - }).startWith(undefined); + }),startWith(undefined),); } /** @@ -88,15 +89,15 @@ export class Item extends DSpaceObject { * @returns {Observable} the bitstreams with the given bundleName */ getBitstreamsByBundleName(bundleName: string): Observable { - return this.bitstreams - .map((rd: RemoteData>) => rd.payload.page) - .filter((bitstreams: Bitstream[]) => hasValue(bitstreams)) - .startWith([]) - .map((bitstreams) => { + return this.bitstreams.pipe( + map((rd: RemoteData>) => rd.payload.page), + filter((bitstreams: Bitstream[]) => hasValue(bitstreams)), + startWith([]), + map((bitstreams) => { return bitstreams .filter((bitstream) => hasValue(bitstream)) .filter((bitstream) => bitstream.bundleName === bundleName) - }); + }),); } } diff --git a/src/app/core/shared/operators.spec.ts b/src/app/core/shared/operators.spec.ts index bb2fc263fd..6aeec230c4 100644 --- a/src/app/core/shared/operators.spec.ts +++ b/src/app/core/shared/operators.spec.ts @@ -1,44 +1,52 @@ import { cold, getTestScheduler, hot } from 'jasmine-marbles'; -import { TestScheduler } from '../../../../node_modules/rxjs'; +import { TestScheduler } from 'rxjs/testing'; import { getMockRequestService } from '../../shared/mocks/mock-request.service'; -import { getMockResponseCacheService } from '../../shared/mocks/mock-response-cache.service'; -import { ResponseCacheEntry } from '../cache/response-cache.reducer'; -import { ResponseCacheService } from '../cache/response-cache.service'; -import { GetRequest, RestRequest } from '../data/request.models'; +import { GetRequest } from '../data/request.models'; import { RequestEntry } from '../data/request.reducer'; import { RequestService } from '../data/request.service'; import { configureRequest, - filterSuccessfulResponses, getRemoteDataPayload, - getRequestFromSelflink, getResourceLinksFromResponse, - getResponseFromSelflink + filterSuccessfulResponses, + getRemoteDataPayload, getRequestFromRequestHref, getRequestFromRequestUUID, + getResourceLinksFromResponse, getResponseFromEntry, } from './operators'; describe('Core Module - RxJS Operators', () => { let scheduler: TestScheduler; let requestService: RequestService; - const testSelfLink = 'https://rest.api/'; + const testRequestHref = 'https://rest.api/'; + const testRequestUUID = 'https://rest.api/'; const testRCEs = { a: { response: { isSuccessful: true, resourceSelfLinks: ['a', 'b', 'c', 'd'] } }, b: { response: { isSuccessful: false, resourceSelfLinks: ['e', 'f'] } }, c: { response: { isSuccessful: undefined, resourceSelfLinks: ['g', 'h', 'i'] } }, d: { response: { isSuccessful: true, resourceSelfLinks: ['j', 'k', 'l', 'm', 'n'] } }, - e: { response: { isSuccessful: 1, resourceSelfLinks: [] } } + e: { response: { isSuccessful: 1, resourceSelfLinks: [] } }, + f: { response: undefined }, + g: undefined + }; + + const testResponses = { + a: testRCEs.a.response, + b: testRCEs.b.response, + c: testRCEs.c.response, + d: testRCEs.d.response, + e: testRCEs.e.response }; beforeEach(() => { scheduler = getTestScheduler(); }); - describe('getRequestFromSelflink', () => { + describe('getRequestFromRequestHref', () => { it('should return the RequestEntry corresponding to the self link in the source', () => { requestService = getMockRequestService(); - const source = hot('a', { a: testSelfLink }); - const result = source.pipe(getRequestFromSelflink(requestService)); - const expected = cold('a', { a: new RequestEntry()}); + const source = hot('a', { a: testRequestHref }); + const result = source.pipe(getRequestFromRequestHref(requestService)); + const expected = cold('a', { a: new RequestEntry() }); expect(result).toBeObservable(expected) }); @@ -46,56 +54,51 @@ describe('Core Module - RxJS Operators', () => { it('should use the requestService to fetch the request by its self link', () => { requestService = getMockRequestService(); - const source = hot('a', { a: testSelfLink }); - scheduler.schedule(() => source.pipe(getRequestFromSelflink(requestService)).subscribe()); + const source = hot('a', { a: testRequestHref }); + scheduler.schedule(() => source.pipe(getRequestFromRequestHref(requestService)).subscribe()); scheduler.flush(); - expect(requestService.getByHref).toHaveBeenCalledWith(testSelfLink) + expect(requestService.getByHref).toHaveBeenCalledWith(testRequestHref) }); it('shouldn\'t return anything if there is no request matching the self link', () => { requestService = getMockRequestService(cold('a', { a: undefined })); - const source = hot('a', { a: testSelfLink }); - const result = source.pipe(getRequestFromSelflink(requestService)); + const source = hot('a', { a: testRequestUUID }); + const result = source.pipe(getRequestFromRequestHref(requestService)); const expected = cold('-'); expect(result).toBeObservable(expected) }); }); - describe('getResponseFromSelflink', () => { - let responseCacheService: ResponseCacheService; + describe('getRequestFromRequestUUID', () => { - beforeEach(() => { - scheduler = getTestScheduler(); - }); + it('should return the RequestEntry corresponding to the request uuid in the source', () => { + requestService = getMockRequestService(); - it('should return the ResponseCacheEntry corresponding to the self link in the source', () => { - responseCacheService = getMockResponseCacheService(); - - const source = hot('a', { a: testSelfLink }); - const result = source.pipe(getResponseFromSelflink(responseCacheService)); - const expected = cold('a', { a: new ResponseCacheEntry()}); + const source = hot('a', { a: testRequestUUID }); + const result = source.pipe(getRequestFromRequestUUID(requestService)); + const expected = cold('a', { a: new RequestEntry() }); expect(result).toBeObservable(expected) }); - it('should use the responseCacheService to fetch the response by the request\'s link', () => { - responseCacheService = getMockResponseCacheService(); + it('should use the requestService to fetch the request by its request uuid', () => { + requestService = getMockRequestService(); - const source = hot('a', { a: testSelfLink }); - scheduler.schedule(() => source.pipe(getResponseFromSelflink(responseCacheService)).subscribe()); + const source = hot('a', { a: testRequestUUID }); + scheduler.schedule(() => source.pipe(getRequestFromRequestUUID(requestService)).subscribe()); scheduler.flush(); - expect(responseCacheService.get).toHaveBeenCalledWith(testSelfLink) + expect(requestService.getByUUID).toHaveBeenCalledWith(testRequestUUID) }); - it('shouldn\'t return anything if there is no response matching the request\'s link', () => { - responseCacheService = getMockResponseCacheService(undefined, cold('a', { a: undefined })); + it('shouldn\'t return anything if there is no request matching the request uuid', () => { + requestService = getMockRequestService(cold('a', { a: undefined })); - const source = hot('a', { a: testSelfLink }); - const result = source.pipe(getResponseFromSelflink(responseCacheService)); + const source = hot('a', { a: testRequestUUID }); + const result = source.pipe(getRequestFromRequestUUID(requestService)); const expected = cold('-'); expect(result).toBeObservable(expected) @@ -106,7 +109,7 @@ describe('Core Module - RxJS Operators', () => { it('should only return responses for which isSuccessful === true', () => { const source = hot('abcde', testRCEs); const result = source.pipe(filterSuccessfulResponses()); - const expected = cold('a--d-', testRCEs); + const expected = cold('a--d-', testResponses); expect(result).toBeObservable(expected) }); @@ -128,7 +131,7 @@ describe('Core Module - RxJS Operators', () => { describe('configureRequest', () => { it('should call requestService.configure with the source request', () => { requestService = getMockRequestService(); - const testRequest = new GetRequest('6b789e31-f026-4ff8-8993-4eb3b730c841', testSelfLink); + const testRequest = new GetRequest('6b789e31-f026-4ff8-8993-4eb3b730c841', testRequestHref); const source = hot('a', { a: testRequest }); scheduler.schedule(() => source.pipe(configureRequest(requestService)).subscribe()); scheduler.flush(); @@ -149,4 +152,20 @@ describe('Core Module - RxJS Operators', () => { expect(result).toBeObservable(expected) }); }); + + describe('getResponseFromEntry', () => { + it('should return the response for all not empty request entries, when they have a value', () => { + const source = hot('abcdefg', testRCEs); + const result = source.pipe(getResponseFromEntry()); + const expected = cold('abcde--', { + a: testRCEs.a.response, + b: testRCEs.b.response, + c: testRCEs.c.response, + d: testRCEs.d.response, + e: testRCEs.e.response + }); + + expect(result).toBeObservable(expected) + }); + }); }); diff --git a/src/app/core/shared/operators.ts b/src/app/core/shared/operators.ts index 07d64a83ab..4e6a95ff11 100644 --- a/src/app/core/shared/operators.ts +++ b/src/app/core/shared/operators.ts @@ -1,9 +1,7 @@ -import { Observable } from 'rxjs/Observable'; +import { Observable } from 'rxjs'; import { filter, first, flatMap, map, tap } from 'rxjs/operators'; -import { hasValueOperator, isNotEmpty } from '../../shared/empty.util'; -import { DSOSuccessResponse } from '../cache/response-cache.models'; -import { ResponseCacheEntry } from '../cache/response-cache.reducer'; -import { ResponseCacheService } from '../cache/response-cache.service'; +import { hasValue, hasValueOperator, isNotEmpty } from '../../shared/empty.util'; +import { DSOSuccessResponse, RestResponse } from '../cache/response.models'; import { RemoteData } from '../data/remote-data'; import { RestRequest } from '../data/request.models'; import { RequestEntry } from '../data/request.reducer'; @@ -17,29 +15,39 @@ import { SearchResult } from '../../+search-page/search-result.model'; * This file contains custom RxJS operators that can be used in multiple places */ -export const getRequestFromSelflink = (requestService: RequestService) => +export const getRequestFromRequestHref = (requestService: RequestService) => (source: Observable): Observable => source.pipe( flatMap((href: string) => requestService.getByHref(href)), hasValueOperator() ); -export const getResponseFromSelflink = (responseCache: ResponseCacheService) => - (source: Observable): Observable => +export const getRequestFromRequestUUID = (requestService: RequestService) => + (source: Observable): Observable => source.pipe( - flatMap((href: string) => responseCache.get(href)), + flatMap((uuid: string) => requestService.getByUUID(uuid)), hasValueOperator() ); export const filterSuccessfulResponses = () => - (source: Observable): Observable => - source.pipe(filter((entry: ResponseCacheEntry) => entry.response.isSuccessful === true)); + (source: Observable): Observable => + source.pipe( + getResponseFromEntry(), + filter((response: RestResponse) => response.isSuccessful === true), + ); + +export const getResponseFromEntry = () => + (source: Observable): Observable => + source.pipe( + filter((entry: RequestEntry) => hasValue(entry) && hasValue(entry.response)), + map((entry: RequestEntry) => entry.response) + ); export const getResourceLinksFromResponse = () => - (source: Observable): Observable => + (source: Observable): Observable => source.pipe( filterSuccessfulResponses(), - map((entry: ResponseCacheEntry) => (entry.response as DSOSuccessResponse).resourceSelfLinks), + map((response: DSOSuccessResponse) => response.resourceSelfLinks), ); export const configureRequest = (requestService: RequestService) => @@ -57,10 +65,11 @@ export const getSucceededRemoteData = () => export const toDSpaceObjectListRD = () => (source: Observable>>>): Observable>> => source.pipe( + filter((rd: RemoteData>>) => rd.hasSucceeded), map((rd: RemoteData>>) => { const dsoPage: T[] = rd.payload.page.map((searchResult: SearchResult) => searchResult.dspaceObject); const payload = Object.assign(rd.payload, { page: dsoPage }) as PaginatedList; - return Object.assign(rd, {payload: payload}); + return Object.assign(rd, { payload: payload }); }) ); diff --git a/src/app/header/header.component.spec.ts b/src/app/header/header.component.spec.ts index 9d0dd04e40..6c0047a1dd 100644 --- a/src/app/header/header.component.spec.ts +++ b/src/app/header/header.component.spec.ts @@ -1,24 +1,21 @@ -import { ComponentFixture, TestBed, async } from '@angular/core/testing'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { Store, StoreModule } from '@ngrx/store'; import { TranslateModule } from '@ngx-translate/core'; import { NgbCollapseModule } from '@ng-bootstrap/ng-bootstrap'; -import { Observable } from 'rxjs/Observable'; +import { of as observableOf } from 'rxjs'; import { HeaderComponent } from './header.component'; import { HeaderState } from './header.reducer'; import { HeaderToggleAction } from './header.actions'; -import { AuthNavMenuComponent } from '../shared/auth-nav-menu/auth-nav-menu.component'; -import { LogInComponent } from '../shared/log-in/log-in.component'; -import { LogOutComponent } from '../shared/log-out/log-out.component'; -import { LoadingComponent } from '../shared/loading/loading.component'; import { ReactiveFormsModule } from '@angular/forms'; import { HostWindowService } from '../shared/host-window.service'; import { HostWindowServiceStub } from '../shared/testing/host-window-service-stub'; import { RouterStub } from '../shared/testing/router-stub'; import { Router } from '@angular/router'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import * as ngrx from '@ngrx/store'; import { NO_ERRORS_SCHEMA } from '@angular/core'; let comp: HeaderComponent; @@ -52,7 +49,7 @@ describe('HeaderComponent', () => { comp = fixture.componentInstance; - store = fixture.debugElement.injector.get(Store); + store = fixture.debugElement.injector.get(Store) as Store; spyOn(store, 'dispatch'); }); @@ -74,7 +71,11 @@ describe('HeaderComponent', () => { beforeEach(() => { menu = fixture.debugElement.query(By.css('#collapsingNav')).nativeElement; - spyOn(store, 'select').and.returnValue(Observable.of({ navCollapsed: true })); + spyOnProperty(ngrx, 'select').and.callFake(() => { + return () => { + return () => observableOf({ navCollapsed: true }) + }; + }); fixture.detectChanges(); }); @@ -89,7 +90,11 @@ describe('HeaderComponent', () => { beforeEach(() => { menu = fixture.debugElement.query(By.css('#collapsingNav')).nativeElement; - spyOn(store, 'select').and.returnValue(Observable.of(false)); + spyOnProperty(ngrx, 'select').and.callFake(() => { + return () => { + return () => observableOf(false) + }; + }); fixture.detectChanges(); }); diff --git a/src/app/header/header.component.ts b/src/app/header/header.component.ts index 93cb329f4f..e1f8da0f9d 100644 --- a/src/app/header/header.component.ts +++ b/src/app/header/header.component.ts @@ -1,6 +1,6 @@ import { Component, OnInit } from '@angular/core'; -import { createSelector, Store } from '@ngrx/store'; -import { Observable } from 'rxjs/Observable'; +import { createSelector, select, Store } from '@ngrx/store'; +import { Observable } from 'rxjs'; import { RouterReducerState } from '@ngrx/router-store'; import { HeaderState } from './header.reducer'; @@ -33,7 +33,7 @@ export class HeaderComponent implements OnInit { ngOnInit(): void { // set loading - this.isNavBarCollapsed = this.store.select(navCollapsedSelector); + this.isNavBarCollapsed = this.store.pipe(select(navCollapsedSelector)); } public toggle(): void { diff --git a/src/app/header/header.effects.spec.ts b/src/app/header/header.effects.spec.ts index e67043dcba..97b428bf8c 100644 --- a/src/app/header/header.effects.spec.ts +++ b/src/app/header/header.effects.spec.ts @@ -2,7 +2,7 @@ import { TestBed } from '@angular/core/testing'; import { HeaderEffects } from './header.effects'; import { HeaderCollapseAction } from './header.actions'; import { HostWindowResizeAction } from '../shared/host-window.actions'; -import { Observable } from 'rxjs/Observable'; +import { Observable } from 'rxjs'; import { provideMockActions } from '@ngrx/effects/testing'; import { cold, hot } from 'jasmine-marbles'; import * as fromRouter from '@ngrx/router-store'; diff --git a/src/app/header/header.effects.ts b/src/app/header/header.effects.ts index e1d281958b..cdc018d2d9 100644 --- a/src/app/header/header.effects.ts +++ b/src/app/header/header.effects.ts @@ -1,5 +1,6 @@ +import { map } from 'rxjs/operators'; import { Injectable } from '@angular/core'; -import { Effect, Actions } from '@ngrx/effects' +import { Effect, Actions, ofType } from '@ngrx/effects' import * as fromRouter from '@ngrx/router-store'; import { HostWindowActionTypes } from '../shared/host-window.actions'; @@ -9,12 +10,16 @@ import { HeaderCollapseAction } from './header.actions'; export class HeaderEffects { @Effect() resize$ = this.actions$ - .ofType(HostWindowActionTypes.RESIZE) - .map(() => new HeaderCollapseAction()); + .pipe( + ofType(HostWindowActionTypes.RESIZE), + map(() => new HeaderCollapseAction()) + ); @Effect() routeChange$ = this.actions$ - .ofType(fromRouter.ROUTER_NAVIGATION) - .map(() => new HeaderCollapseAction()); + .pipe( + ofType(fromRouter.ROUTER_NAVIGATION), + map(() => new HeaderCollapseAction()) + ); constructor(private actions$: Actions) { diff --git a/src/app/shared/animations/slide.ts b/src/app/shared/animations/slide.ts index fa4a451863..ee16f9936f 100644 --- a/src/app/shared/animations/slide.ts +++ b/src/app/shared/animations/slide.ts @@ -1,4 +1,4 @@ -import { animate, state, transition, trigger, style, stagger, query } from '@angular/animations'; +import { animate, state, style, transition, trigger } from '@angular/animations'; export const slide = trigger('slide', [ diff --git a/src/app/shared/auth-nav-menu/auth-nav-menu.component.spec.ts b/src/app/shared/auth-nav-menu/auth-nav-menu.component.spec.ts index e1a82f4a33..5c5dd11d75 100644 --- a/src/app/shared/auth-nav-menu/auth-nav-menu.component.spec.ts +++ b/src/app/shared/auth-nav-menu/auth-nav-menu.component.spec.ts @@ -22,23 +22,28 @@ describe('AuthNavMenuComponent', () => { let deNavMenuItem: DebugElement; let fixture: ComponentFixture; - const notAuthState: AuthState = { - authenticated: false, - loaded: false, - loading: false - }; - const authState: AuthState = { - authenticated: true, - loaded: true, - loading: false, - authToken: new AuthTokenInfo('test_token'), - user: EPersonMock - }; + let notAuthState: AuthState; + let authState: AuthState; + let routerState = { url: '/home' }; - + function init() { + notAuthState = { + authenticated: false, + loaded: false, + loading: false + }; + authState = { + authenticated: true, + loaded: true, + loading: false, + authToken: new AuthTokenInfo('test_token'), + user: EPersonMock + }; + } describe('when is a not mobile view', () => { + beforeEach(async(() => { const window = new HostWindowServiceStub(800); @@ -53,8 +58,13 @@ describe('AuthNavMenuComponent', () => { AuthNavMenuComponent ], providers: [ - {provide: HostWindowService, useValue: window}, - {provide: AuthService, useValue: {setRedirectUrl: () => { /*empty*/ }}} + { provide: HostWindowService, useValue: window }, + { + provide: AuthService, useValue: { + setRedirectUrl: () => { /*empty*/ + } + } + } ], schemas: [ CUSTOM_ELEMENTS_SCHEMA @@ -64,11 +74,14 @@ describe('AuthNavMenuComponent', () => { })); + beforeEach(() => { + init(); + }); describe('when route is /login and user is not authenticated', () => { - routerState = { - url: '/login' - }; beforeEach(inject([Store], (store: Store) => { + routerState = { + url: '/login' + }; store .subscribe((state) => { (state as any).router = Object.create({}); @@ -91,7 +104,9 @@ describe('AuthNavMenuComponent', () => { const navMenuItemSelector = 'li'; deNavMenuItem = deNavMenu.query(By.css(navMenuItemSelector)); })); - + afterEach(() => { + fixture.destroy(); + }); it('should not render', () => { expect(component).toBeTruthy(); expect(deNavMenu.nativeElement).toBeDefined(); @@ -101,10 +116,10 @@ describe('AuthNavMenuComponent', () => { }); describe('when route is /logout and user is authenticated', () => { - routerState = { - url: '/logout' - }; beforeEach(inject([Store], (store: Store) => { + routerState = { + url: '/logout' + }; store .subscribe((state) => { (state as any).router = Object.create({}); @@ -128,6 +143,10 @@ describe('AuthNavMenuComponent', () => { deNavMenuItem = deNavMenu.query(By.css(navMenuItemSelector)); })); + afterEach(() => { + fixture.destroy(); + }); + it('should not render', () => { expect(component).toBeTruthy(); expect(deNavMenu.nativeElement).toBeDefined(); @@ -166,6 +185,11 @@ describe('AuthNavMenuComponent', () => { deNavMenuItem = deNavMenu.query(By.css(navMenuItemSelector)); })); + afterEach(() => { + fixture.destroy(); + component = null; + }); + it('should render login dropdown menu', () => { const loginDropdownMenu = deNavMenuItem.query(By.css('div[id=loginDropdownMenu]')); expect(loginDropdownMenu.nativeElement).toBeDefined(); @@ -200,6 +224,10 @@ describe('AuthNavMenuComponent', () => { deNavMenuItem = deNavMenu.query(By.css(navMenuItemSelector)); })); + afterEach(() => { + fixture.destroy(); + component = null; + }); it('should render logout dropdown menu', () => { const logoutDropdownMenu = deNavMenuItem.query(By.css('div[id=logoutDropdownMenu]')); expect(logoutDropdownMenu.nativeElement).toBeDefined(); @@ -223,8 +251,13 @@ describe('AuthNavMenuComponent', () => { AuthNavMenuComponent ], providers: [ - {provide: HostWindowService, useValue: window}, - {provide: AuthService, useValue: {setRedirectUrl: () => { /*empty*/ }}} + { provide: HostWindowService, useValue: window }, + { + provide: AuthService, useValue: { + setRedirectUrl: () => { /*empty*/ + } + } + } ], schemas: [ CUSTOM_ELEMENTS_SCHEMA @@ -234,6 +267,9 @@ describe('AuthNavMenuComponent', () => { })); + beforeEach(() => { + init(); + }); describe('when user is not authenticated', () => { beforeEach(inject([Store], (store: Store) => { @@ -260,6 +296,11 @@ describe('AuthNavMenuComponent', () => { deNavMenuItem = deNavMenu.query(By.css(navMenuItemSelector)); })); + afterEach(() => { + fixture.destroy(); + component = null; + }); + it('should render login link', () => { const loginDropdownMenu = deNavMenuItem.query(By.css('a[id=loginLink]')); expect(loginDropdownMenu.nativeElement).toBeDefined(); @@ -291,6 +332,11 @@ describe('AuthNavMenuComponent', () => { deNavMenuItem = deNavMenu.query(By.css(navMenuItemSelector)); })); + afterEach(() => { + fixture.destroy(); + component = null; + }); + it('should render logout link', inject([Store], (store: Store) => { const logoutDropdownMenu = deNavMenuItem.query(By.css('a[id=logoutLink]')); expect(logoutDropdownMenu.nativeElement).toBeDefined(); diff --git a/src/app/shared/auth-nav-menu/auth-nav-menu.component.ts b/src/app/shared/auth-nav-menu/auth-nav-menu.component.ts index c657071987..fc85616de9 100644 --- a/src/app/shared/auth-nav-menu/auth-nav-menu.component.ts +++ b/src/app/shared/auth-nav-menu/auth-nav-menu.component.ts @@ -1,7 +1,9 @@ +import { Observable, of as observableOf, Subscription } from 'rxjs'; + +import { filter, map } from 'rxjs/operators'; import { Component, OnInit } from '@angular/core'; -import { Observable } from 'rxjs/Observable'; import { RouterReducerState } from '@ngrx/router-store'; -import { Store } from '@ngrx/store'; +import { select, Store } from '@ngrx/store'; import { fadeInOut, fadeOut } from '../animations/fade'; import { HostWindowService } from '../host-window.service'; @@ -14,7 +16,6 @@ import { } from '../../core/auth/selectors'; import { EPerson } from '../../core/eperson/models/eperson.model'; import { AuthService, LOGIN_ROUTE, LOGOUT_ROUTE } from '../../core/auth/auth.service'; -import { Subscription } from 'rxjs/Subscription'; @Component({ selector: 'ds-auth-nav-menu', @@ -37,7 +38,7 @@ export class AuthNavMenuComponent implements OnInit { public isXsOrSm$: Observable; - public showAuth = Observable.of(false); + public showAuth = observableOf(false); public user: Observable; @@ -52,22 +53,24 @@ export class AuthNavMenuComponent implements OnInit { ngOnInit(): void { // set isAuthenticated - this.isAuthenticated = this.store.select(isAuthenticated); + this.isAuthenticated = this.store.pipe(select(isAuthenticated)); // set loading - this.loading = this.store.select(isAuthenticationLoading); + this.loading = this.store.pipe(select(isAuthenticationLoading)); - this.user = this.store.select(getAuthenticatedUser); + this.user = this.store.pipe(select(getAuthenticatedUser)); - this.showAuth = this.store.select(routerStateSelector) - .filter((router: RouterReducerState) => isNotUndefined(router) && isNotUndefined(router.state)) - .map((router: RouterReducerState) => { + this.showAuth = this.store.pipe( + select(routerStateSelector), + filter((router: RouterReducerState) => isNotUndefined(router) && isNotUndefined(router.state)), + map((router: RouterReducerState) => { const url = router.state.url; const show = !router.state.url.startsWith(LOGIN_ROUTE) && !router.state.url.startsWith(LOGOUT_ROUTE); if (show) { this.authService.setRedirectUrl(url); } return show; - }); + }) + ); } } diff --git a/src/app/shared/browse-by/browse-by.component.spec.ts b/src/app/shared/browse-by/browse-by.component.spec.ts index 883d61a221..2417dde7ca 100644 --- a/src/app/shared/browse-by/browse-by.component.spec.ts +++ b/src/app/shared/browse-by/browse-by.component.spec.ts @@ -3,7 +3,7 @@ import { async, ComponentFixture, TestBed } from '@angular/core/testing'; import { TranslateModule } from '@ngx-translate/core'; import { By } from '@angular/platform-browser'; import { NO_ERRORS_SCHEMA } from '@angular/core'; -import { Observable } from 'rxjs/Observable'; +import { of as observableOf } from 'rxjs'; import { SharedModule } from '../shared.module'; describe('BrowseByComponent', () => { @@ -30,7 +30,7 @@ describe('BrowseByComponent', () => { }); it('should display results when objects is not empty', () => { - (comp as any).objects = Observable.of({ + (comp as any).objects = observableOf({ payload: { page: { length: 1 diff --git a/src/app/shared/browse-by/browse-by.component.ts b/src/app/shared/browse-by/browse-by.component.ts index 062b41a440..94cf81f46e 100644 --- a/src/app/shared/browse-by/browse-by.component.ts +++ b/src/app/shared/browse-by/browse-by.component.ts @@ -4,7 +4,7 @@ import { PaginatedList } from '../../core/data/paginated-list'; import { PaginationComponentOptions } from '../pagination/pagination-component-options.model'; import { SortOptions } from '../../core/cache/models/sort-options.model'; import { fadeIn, fadeInOut } from '../animations/fade'; -import { Observable } from 'rxjs/Observable'; +import { Observable } from 'rxjs'; import { Item } from '../../core/shared/item.model'; import { ListableObject } from '../object-collection/shared/listable-object.model'; diff --git a/src/app/shared/chips/chips.component.spec.ts b/src/app/shared/chips/chips.component.spec.ts index 44092ce7d8..7a9461dfd7 100644 --- a/src/app/shared/chips/chips.component.spec.ts +++ b/src/app/shared/chips/chips.component.spec.ts @@ -1,7 +1,6 @@ // Load the implementations that should be tested import { ChangeDetectorRef, Component, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; import { async, ComponentFixture, fakeAsync, inject, TestBed, tick, } from '@angular/core/testing'; -import 'rxjs/add/observable/of'; import { Chips } from './models/chips.model'; import { UploaderService } from '../uploader/uploader.service'; diff --git a/src/app/shared/chips/models/chips.model.ts b/src/app/shared/chips/models/chips.model.ts index e133a416f4..9e6aa653e1 100644 --- a/src/app/shared/chips/models/chips.model.ts +++ b/src/app/shared/chips/models/chips.model.ts @@ -1,5 +1,5 @@ import { findIndex, isEqual, isObject } from 'lodash'; -import { BehaviorSubject } from 'rxjs/BehaviorSubject'; +import { BehaviorSubject } from 'rxjs'; import { ChipsItem, ChipsItemIcon } from './chips-item.model'; import { hasValue, isNotEmpty } from '../../empty.util'; diff --git a/src/app/shared/empty.util.ts b/src/app/shared/empty.util.ts index c1498d11af..d79c520fda 100644 --- a/src/app/shared/empty.util.ts +++ b/src/app/shared/empty.util.ts @@ -1,4 +1,4 @@ -import { Observable } from 'rxjs/Observable'; +import { Observable } from 'rxjs'; import { filter, map } from 'rxjs/operators'; /** diff --git a/src/app/shared/error/error.component.ts b/src/app/shared/error/error.component.ts index 08d06c31d6..6900869183 100644 --- a/src/app/shared/error/error.component.ts +++ b/src/app/shared/error/error.component.ts @@ -2,7 +2,7 @@ import { Component, Input } from '@angular/core'; import { TranslateService } from '@ngx-translate/core'; -import { Subscription } from 'rxjs/Subscription'; +import { Subscription } from 'rxjs'; @Component({ selector: 'ds-error', diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control.component.html b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control.component.html index db5bc92574..750ef721c2 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control.component.html +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control.component.html @@ -1,8 +1,8 @@ -
- @@ -12,429 +12,7 @@
- - - -
- -
- - - - -
-
- - - - - - -
- - -
- - -
- -
- - -
- - -
- -
-
- - -
- - -
- - -
- -
- -
- - - - - -
- -
- - - - -
- - -
- - - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- {{ message | translate:model.validators }} -
- -
- - - - - - - - - - - - - - - - - - - - - - - - - +
diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control.component.spec.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control.component.spec.ts index 7fc756c470..ca12a7a4b4 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control.component.spec.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control.component.spec.ts @@ -25,7 +25,7 @@ import { DynamicTextAreaModel, DynamicTimePickerModel } from '@ng-dynamic-forms/core'; -import { DsDynamicFormControlComponent, NGBootstrapFormControlType } from './ds-dynamic-form-control.component'; +import { DsDynamicFormControlComponent } from './ds-dynamic-form-control.component'; import { TranslateModule } from '@ngx-translate/core'; import { SharedModule } from '../../../shared.module'; import { DynamicDsDatePickerModel } from './models/date-picker/date-picker.model'; @@ -39,6 +39,27 @@ import { DynamicTagModel } from './models/tag/dynamic-tag.model'; import { DynamicTypeaheadModel } from './models/typeahead/dynamic-typeahead.model'; import { DynamicQualdropModel } from './models/ds-dynamic-qualdrop.model'; import { DynamicLookupNameModel } from './models/lookup/dynamic-lookup-name.model'; +import { BrowserDynamicTestingModule } from '@angular/platform-browser-dynamic/testing'; +import { + DynamicNGBootstrapCalendarComponent, + DynamicNGBootstrapCheckboxComponent, + DynamicNGBootstrapCheckboxGroupComponent, + DynamicNGBootstrapDatePickerComponent, + DynamicNGBootstrapFormArrayComponent, + DynamicNGBootstrapFormGroupComponent, + DynamicNGBootstrapInputComponent, + DynamicNGBootstrapRadioGroupComponent, + DynamicNGBootstrapSelectComponent, + DynamicNGBootstrapTextAreaComponent, + DynamicNGBootstrapTimePickerComponent +} from '@ng-dynamic-forms/ui-ng-bootstrap'; +import { DsDynamicTypeaheadComponent } from './models/typeahead/dynamic-typeahead.component'; +import { DsDynamicScrollableDropdownComponent } from './models/scrollable-dropdown/dynamic-scrollable-dropdown.component'; +import { DsDynamicTagComponent } from './models/tag/dynamic-tag.component'; +import { DsDynamicListComponent } from './models/list/dynamic-list.component'; +import { DsDynamicGroupComponent } from './models/dynamic-group/dynamic-group.components'; +import { DsDatePickerComponent } from './models/date-picker/date-picker.component'; +import { DsDynamicLookupComponent } from './models/lookup/dynamic-lookup.component'; describe('DsDynamicFormControlComponent test suite', () => { @@ -49,27 +70,42 @@ describe('DsDynamicFormControlComponent test suite', () => { scope: 'c1c16450-d56f-41bc-bb81-27f1d1eb5c23' }; const formModel = [ - new DynamicCheckboxModel({id: 'checkbox'}), - new DynamicCheckboxGroupModel({id: 'checkboxGroup', group: []}), - new DynamicColorPickerModel({id: 'colorpicker'}), - new DynamicDatePickerModel({id: 'datepicker'}), - new DynamicEditorModel({id: 'editor'}), - new DynamicFileUploadModel({id: 'upload', url: ''}), - new DynamicFormArrayModel({id: 'formArray', groupFactory: () => []}), - new DynamicFormGroupModel({id: 'formGroup', group: []}), - new DynamicInputModel({id: 'input', maxLength: 51}), - new DynamicRadioGroupModel({id: 'radioGroup'}), - new DynamicRatingModel({id: 'rating'}), - new DynamicSelectModel({id: 'select', options: [{value: 'One'}, {value: 'Two'}], value: 'One'}), - new DynamicSliderModel({id: 'slider'}), - new DynamicSwitchModel({id: 'switch'}), - new DynamicTextAreaModel({id: 'textarea'}), - new DynamicTimePickerModel({id: 'timepicker'}), - new DynamicTypeaheadModel({id: 'typeahead'}), - new DynamicScrollableDropdownModel({id: 'scrollableDropdown', authorityOptions: authorityOptions}), - new DynamicTagModel({id: 'tag'}), - new DynamicListCheckboxGroupModel({id: 'checkboxList', authorityOptions: authorityOptions, repeatable: true}), - new DynamicListRadioGroupModel({id: 'radioList', authorityOptions: authorityOptions, repeatable: false}), + new DynamicCheckboxModel({ id: 'checkbox' }), + new DynamicCheckboxGroupModel({ id: 'checkboxGroup', group: [] }), + new DynamicColorPickerModel({ id: 'colorpicker' }), + new DynamicDatePickerModel({ id: 'datepicker' }), + new DynamicEditorModel({ id: 'editor' }), + new DynamicFileUploadModel({ id: 'upload', url: '' }), + new DynamicFormArrayModel({ id: 'formArray', groupFactory: () => [] }), + new DynamicFormGroupModel({ id: 'formGroup', group: [] }), + new DynamicInputModel({ id: 'input', maxLength: 51 }), + new DynamicRadioGroupModel({ id: 'radioGroup' }), + new DynamicRatingModel({ id: 'rating' }), + new DynamicSelectModel({ + id: 'select', + options: [{ value: 'One' }, { value: 'Two' }], + value: 'One' + }), + new DynamicSliderModel({ id: 'slider' }), + new DynamicSwitchModel({ id: 'switch' }), + new DynamicTextAreaModel({ id: 'textarea' }), + new DynamicTimePickerModel({ id: 'timepicker' }), + new DynamicTypeaheadModel({ id: 'typeahead' }), + new DynamicScrollableDropdownModel({ + id: 'scrollableDropdown', + authorityOptions: authorityOptions + }), + new DynamicTagModel({ id: 'tag' }), + new DynamicListCheckboxGroupModel({ + id: 'checkboxList', + authorityOptions: authorityOptions, + repeatable: true + }), + new DynamicListRadioGroupModel({ + id: 'radioList', + authorityOptions: authorityOptions, + repeatable: false + }), new DynamicGroupModel({ id: 'relationGroup', formConfiguration: [], @@ -79,10 +115,10 @@ describe('DsDynamicFormControlComponent test suite', () => { scopeUUID: '', submissionScope: '' }), - new DynamicDsDatePickerModel({id: 'datepicker'}), - new DynamicLookupModel({id: 'lookup'}), - new DynamicLookupNameModel({id: 'lookupName'}), - new DynamicQualdropModel({id: 'combobox', readOnly: false}) + new DynamicDsDatePickerModel({ id: 'datepicker' }), + new DynamicLookupModel({ id: 'lookup' }), + new DynamicLookupNameModel({ id: 'lookupName' }), + new DynamicQualdropModel({ id: 'combobox', readOnly: false }) ]; const testModel = formModel[8]; let formGroup: FormGroup; @@ -93,6 +129,13 @@ describe('DsDynamicFormControlComponent test suite', () => { beforeEach(async(() => { + TestBed.overrideModule(BrowserDynamicTestingModule, { + + set: { + entryComponents: [DynamicNGBootstrapInputComponent] + } + }); + TestBed.configureTestingModule({ imports: [ @@ -102,8 +145,9 @@ describe('DsDynamicFormControlComponent test suite', () => { DynamicFormsCoreModule.forRoot(), SharedModule, TranslateModule.forRoot(), - TextMaskModule, + TextMaskModule ], + providers: [DsDynamicFormControlComponent, DynamicFormService], schemas: [CUSTOM_ELEMENTS_SCHEMA] }).compileComponents().then(() => { @@ -128,12 +172,10 @@ describe('DsDynamicFormControlComponent test suite', () => { }); fixture.detectChanges(); - testElement = debugElement.query(By.css(`input[id='${testModel.id}']`)); })); it('should initialize correctly', () => { - expect(component.context).toBeNull(); expect(component.control instanceof FormControl).toBe(true); expect(component.group instanceof FormGroup).toBe(true); @@ -149,15 +191,7 @@ describe('DsDynamicFormControlComponent test suite', () => { expect(component.change).toBeDefined(); expect(component.focus).toBeDefined(); - expect(component.onValueChange).toBeDefined(); - expect(component.onBlur).toBeDefined(); - expect(component.onFocus).toBeDefined(); - - expect(component.isValid).toBe(true); - expect(component.isInvalid).toBe(false); - expect(component.showErrorMessages).toBe(false); - - expect(component.type).toBe(NGBootstrapFormControlType.Input); + expect(component.componentType).toBe(DynamicNGBootstrapInputComponent); }); it('should have an input element', () => { @@ -185,11 +219,11 @@ describe('DsDynamicFormControlComponent test suite', () => { it('should listen to native change event', () => { - spyOn(component, 'onValueChange'); + spyOn(component, 'onChange'); testElement.triggerEventHandler('change', null); - expect(component.onValueChange).toHaveBeenCalled(); + expect(component.onChange).toHaveBeenCalled(); }); it('should update model value when control value changes', () => { @@ -219,63 +253,36 @@ describe('DsDynamicFormControlComponent test suite', () => { expect(component.onModelDisabledUpdates).toHaveBeenCalled(); }); - it('should determine correct form control type', () => { - + it('should map a form control model to a form control component', () => { const testFn = DsDynamicFormControlComponent.getFormControlType; - - expect(testFn(formModel[0])).toEqual(NGBootstrapFormControlType.Checkbox); - - expect(testFn(formModel[1])).toEqual(NGBootstrapFormControlType.CheckboxGroup); - + expect(testFn(formModel[0])).toBe(DynamicNGBootstrapCheckboxComponent); + expect(testFn(formModel[1])).toBe(DynamicNGBootstrapCheckboxGroupComponent); expect(testFn(formModel[2])).toBeNull(); - - expect(testFn(formModel[3])).toEqual(NGBootstrapFormControlType.DatePicker); - + expect(testFn(formModel[3])).toBe(DynamicNGBootstrapDatePickerComponent); (formModel[3] as DynamicDatePickerModel).inline = true; - expect(testFn(formModel[3])).toEqual(NGBootstrapFormControlType.Calendar); - + expect(testFn(formModel[3])).toBe(DynamicNGBootstrapCalendarComponent); expect(testFn(formModel[4])).toBeNull(); - expect(testFn(formModel[5])).toBeNull(); - - expect(testFn(formModel[6])).toEqual(NGBootstrapFormControlType.Array); - - expect(testFn(formModel[7])).toEqual(NGBootstrapFormControlType.Group); - - expect(testFn(formModel[8])).toEqual(NGBootstrapFormControlType.Input); - - expect(testFn(formModel[9])).toEqual(NGBootstrapFormControlType.RadioGroup); - + expect(testFn(formModel[6])).toBe(DynamicNGBootstrapFormArrayComponent); + expect(testFn(formModel[7])).toBe(DynamicNGBootstrapFormGroupComponent); + expect(testFn(formModel[8])).toBe(DynamicNGBootstrapInputComponent); + expect(testFn(formModel[9])).toBe(DynamicNGBootstrapRadioGroupComponent); expect(testFn(formModel[10])).toBeNull(); - - expect(testFn(formModel[11])).toEqual(NGBootstrapFormControlType.Select); - + expect(testFn(formModel[11])).toBe(DynamicNGBootstrapSelectComponent); expect(testFn(formModel[12])).toBeNull(); - expect(testFn(formModel[13])).toBeNull(); - - expect(testFn(formModel[14])).toEqual(NGBootstrapFormControlType.TextArea); - - expect(testFn(formModel[15])).toEqual(NGBootstrapFormControlType.TimePicker); - - expect(testFn(formModel[16])).toEqual(NGBootstrapFormControlType.TypeAhead); - - expect(testFn(formModel[17])).toEqual(NGBootstrapFormControlType.ScrollableDropdown); - - expect(testFn(formModel[18])).toEqual(NGBootstrapFormControlType.Tag); - - expect(testFn(formModel[19])).toEqual(NGBootstrapFormControlType.List); - - expect(testFn(formModel[20])).toEqual(NGBootstrapFormControlType.List); - - expect(testFn(formModel[21])).toEqual(NGBootstrapFormControlType.Relation); - - expect(testFn(formModel[22])).toEqual(NGBootstrapFormControlType.Date); - - expect(testFn(formModel[23])).toEqual(NGBootstrapFormControlType.Lookup); - - expect(testFn(formModel[24])).toEqual(NGBootstrapFormControlType.LookupName); - - expect(testFn(formModel[25])).toEqual(NGBootstrapFormControlType.Group); + expect(testFn(formModel[14])).toBe(DynamicNGBootstrapTextAreaComponent); + expect(testFn(formModel[15])).toBe(DynamicNGBootstrapTimePickerComponent); + expect(testFn(formModel[16])).toBe(DsDynamicTypeaheadComponent); + expect(testFn(formModel[17])).toBe(DsDynamicScrollableDropdownComponent); + expect(testFn(formModel[18])).toBe(DsDynamicTagComponent); + expect(testFn(formModel[19])).toBe(DsDynamicListComponent); + expect(testFn(formModel[20])).toBe(DsDynamicListComponent); + expect(testFn(formModel[21])).toBe(DsDynamicGroupComponent); + expect(testFn(formModel[22])).toBe(DsDatePickerComponent); + expect(testFn(formModel[23])).toBe(DsDynamicLookupComponent); + expect(testFn(formModel[24])).toBe(DsDynamicLookupComponent); + expect(testFn(formModel[25])).toBe(DynamicNGBootstrapFormGroupComponent); }); + }); diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control.component.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control.component.ts index 3a39d22bef..3544bce280 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control.component.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control.component.ts @@ -1,24 +1,19 @@ import { - ChangeDetectorRef, Component, + ComponentFactoryResolver, ContentChildren, EventEmitter, Input, OnChanges, Output, QueryList, - SimpleChanges + SimpleChanges, + Type, + ViewChild, + ViewContainerRef } from '@angular/core'; import { FormGroup } from '@angular/forms'; import { - DynamicDatePickerModel, - DynamicFormControlComponent, - DynamicFormControlEvent, - DynamicFormControlModel, - DynamicFormLayout, - DynamicFormLayoutService, - DynamicFormValidationService, - DynamicTemplateDirective, DYNAMIC_FORM_CONTROL_TYPE_ARRAY, DYNAMIC_FORM_CONTROL_TYPE_CHECKBOX, DYNAMIC_FORM_CONTROL_TYPE_CHECKBOX_GROUP, @@ -29,6 +24,14 @@ import { DYNAMIC_FORM_CONTROL_TYPE_SELECT, DYNAMIC_FORM_CONTROL_TYPE_TEXTAREA, DYNAMIC_FORM_CONTROL_TYPE_TIMEPICKER, + DynamicDatePickerModel, + DynamicFormControl, + DynamicFormControlContainerComponent, + DynamicFormControlEvent, + DynamicFormControlModel, DynamicFormLayout, + DynamicFormLayoutService, + DynamicFormValidationService, + DynamicTemplateDirective, } from '@ng-dynamic-forms/core'; import { DYNAMIC_FORM_CONTROL_TYPE_TYPEAHEAD } from './models/typeahead/dynamic-typeahead.model'; import { DYNAMIC_FORM_CONTROL_TYPE_SCROLLABLE_DROPDOWN } from './models/scrollable-dropdown/dynamic-scrollable-dropdown.model'; @@ -40,40 +43,37 @@ import { DynamicListCheckboxGroupModel } from './models/list/dynamic-list-checkb import { DynamicListRadioGroupModel } from './models/list/dynamic-list-radio-group.model'; import { isNotEmpty } from '../../../empty.util'; import { DYNAMIC_FORM_CONTROL_TYPE_LOOKUP_NAME } from './models/lookup/dynamic-lookup-name.model'; - -export const enum NGBootstrapFormControlType { - - Array = 1, // 'ARRAY', - Calendar = 2, // 'CALENDAR', - Checkbox = 3, // 'CHECKBOX', - CheckboxGroup = 4, // 'CHECKBOX_GROUP', - DatePicker = 5, // 'DATEPICKER', - Group = 6, // 'GROUP', - Input = 7, // 'INPUT', - RadioGroup = 8, // 'RADIO_GROUP', - Select = 9, // 'SELECT', - TextArea = 10, // 'TEXTAREA', - TimePicker = 11, // 'TIMEPICKER' - TypeAhead = 12, // 'TYPEAHEAD' - ScrollableDropdown = 13, // 'SCROLLABLE_DROPDOWN' - Tag = 14, // 'TAG' - List = 15, // 'TYPELIST' - Relation = 16, // 'RELATION' - Date = 17, // 'DATE' - Lookup = 18, // LOOKUP - LookupName = 19, // LOOKUP_NAME -} +import { DsDynamicTagComponent } from './models/tag/dynamic-tag.component'; +import { + DynamicNGBootstrapCalendarComponent, + DynamicNGBootstrapCheckboxComponent, + DynamicNGBootstrapCheckboxGroupComponent, + DynamicNGBootstrapDatePickerComponent, + DynamicNGBootstrapFormArrayComponent, + DynamicNGBootstrapFormGroupComponent, + DynamicNGBootstrapInputComponent, + DynamicNGBootstrapRadioGroupComponent, + DynamicNGBootstrapSelectComponent, + DynamicNGBootstrapTextAreaComponent, + DynamicNGBootstrapTimePickerComponent +} from '@ng-dynamic-forms/ui-ng-bootstrap'; +import { DsDatePickerComponent } from './models/date-picker/date-picker.component'; +import { DsDynamicListComponent } from './models/list/dynamic-list.component'; +import { DsDynamicTypeaheadComponent } from './models/typeahead/dynamic-typeahead.component'; +import { DsDynamicScrollableDropdownComponent } from './models/scrollable-dropdown/dynamic-scrollable-dropdown.component'; +import { DsDynamicGroupComponent } from './models/dynamic-group/dynamic-group.components'; +import { DsDynamicLookupComponent } from './models/lookup/dynamic-lookup.component'; @Component({ selector: 'ds-dynamic-form-control', styleUrls: ['../../form.component.scss', './ds-dynamic-form.component.scss'], templateUrl: './ds-dynamic-form-control.component.html' }) -export class DsDynamicFormControlComponent extends DynamicFormControlComponent implements OnChanges { +export class DsDynamicFormControlComponent extends DynamicFormControlContainerComponent implements OnChanges { - @ContentChildren(DynamicTemplateDirective) contentTemplateList: QueryList; - // tslint:disable-next-line:no-input-rename - @Input('templates') inputTemplateList: QueryList; + @ContentChildren(DynamicTemplateDirective) contentTemplateList: QueryList; + // tslint:disable-next-line:no-input-rename + @Input('templates') inputTemplateList: QueryList; @Input() formId: string; @Input() asBootstrapFormGroup = true; @@ -81,7 +81,7 @@ export class DsDynamicFormControlComponent extends DynamicFormControlComponent i @Input() context: any | null = null; @Input() group: FormGroup; @Input() hasErrorMessaging = false; - @Input() layout: DynamicFormLayout; + @Input() layout = null as DynamicFormLayout; @Input() model: any; /* tslint:disable:no-output-rename */ @@ -89,90 +89,89 @@ export class DsDynamicFormControlComponent extends DynamicFormControlComponent i @Output('dfChange') change: EventEmitter = new EventEmitter(); @Output('dfFocus') focus: EventEmitter = new EventEmitter(); /* tslint:enable:no-output-rename */ + @ViewChild('componentViewContainer', {read: ViewContainerRef}) componentViewContainerRef: ViewContainerRef; - type: NGBootstrapFormControlType | null; + get componentType(): Type | null { + return this.layoutService.getCustomComponentType(this.model) || DsDynamicFormControlComponent.getFormControlType(this.model); + } - static getFormControlType(model: DynamicFormControlModel): NGBootstrapFormControlType | null { + static getFormControlType(model: DynamicFormControlModel): Type | null { switch (model.type) { case DYNAMIC_FORM_CONTROL_TYPE_ARRAY: - return NGBootstrapFormControlType.Array; + return DynamicNGBootstrapFormArrayComponent; case DYNAMIC_FORM_CONTROL_TYPE_CHECKBOX: - return NGBootstrapFormControlType.Checkbox; + return DynamicNGBootstrapCheckboxComponent; case DYNAMIC_FORM_CONTROL_TYPE_CHECKBOX_GROUP: - return (model instanceof DynamicListCheckboxGroupModel) ? NGBootstrapFormControlType.List : NGBootstrapFormControlType.CheckboxGroup; + return (model instanceof DynamicListCheckboxGroupModel) ? DsDynamicListComponent : DynamicNGBootstrapCheckboxGroupComponent; case DYNAMIC_FORM_CONTROL_TYPE_DATEPICKER: const datepickerModel = model as DynamicDatePickerModel; - return datepickerModel.inline ? NGBootstrapFormControlType.Calendar : NGBootstrapFormControlType.DatePicker; + return datepickerModel.inline ? DynamicNGBootstrapCalendarComponent : DynamicNGBootstrapDatePickerComponent; case DYNAMIC_FORM_CONTROL_TYPE_GROUP: - return NGBootstrapFormControlType.Group; + return DynamicNGBootstrapFormGroupComponent; case DYNAMIC_FORM_CONTROL_TYPE_INPUT: - return NGBootstrapFormControlType.Input; + return DynamicNGBootstrapInputComponent; case DYNAMIC_FORM_CONTROL_TYPE_RADIO_GROUP: - return (model instanceof DynamicListRadioGroupModel) ? NGBootstrapFormControlType.List : NGBootstrapFormControlType.RadioGroup; + return (model instanceof DynamicListRadioGroupModel) ? DsDynamicListComponent : DynamicNGBootstrapRadioGroupComponent; case DYNAMIC_FORM_CONTROL_TYPE_SELECT: - return NGBootstrapFormControlType.Select; + return DynamicNGBootstrapSelectComponent; case DYNAMIC_FORM_CONTROL_TYPE_TEXTAREA: - return NGBootstrapFormControlType.TextArea; + return DynamicNGBootstrapTextAreaComponent; case DYNAMIC_FORM_CONTROL_TYPE_TIMEPICKER: - return NGBootstrapFormControlType.TimePicker; + return DynamicNGBootstrapTimePickerComponent; case DYNAMIC_FORM_CONTROL_TYPE_TYPEAHEAD: - return NGBootstrapFormControlType.TypeAhead; + return DsDynamicTypeaheadComponent; case DYNAMIC_FORM_CONTROL_TYPE_SCROLLABLE_DROPDOWN: - return NGBootstrapFormControlType.ScrollableDropdown; + return DsDynamicScrollableDropdownComponent; case DYNAMIC_FORM_CONTROL_TYPE_TAG: - return NGBootstrapFormControlType.Tag; + return DsDynamicTagComponent; case DYNAMIC_FORM_CONTROL_TYPE_RELATION_GROUP: - return NGBootstrapFormControlType.Relation; + return DsDynamicGroupComponent; case DYNAMIC_FORM_CONTROL_TYPE_DSDATEPICKER: - return NGBootstrapFormControlType.Date; + return DsDatePickerComponent; case DYNAMIC_FORM_CONTROL_TYPE_LOOKUP: - return NGBootstrapFormControlType.Lookup; + return DsDynamicLookupComponent; case DYNAMIC_FORM_CONTROL_TYPE_LOOKUP_NAME: - return NGBootstrapFormControlType.LookupName; + return DsDynamicLookupComponent; default: return null; } } - constructor(protected changeDetectorRef: ChangeDetectorRef, protected layoutService: DynamicFormLayoutService, + constructor(protected componentFactoryResolver: ComponentFactoryResolver, protected layoutService: DynamicFormLayoutService, protected validationService: DynamicFormValidationService) { - super(changeDetectorRef, layoutService, validationService); + super(componentFactoryResolver, layoutService, validationService); } ngOnChanges(changes: SimpleChanges) { if (changes) { super.ngOnChanges(changes); } - - if (changes.model) { - this.type = DsDynamicFormControlComponent.getFormControlType(this.model); - } } onChangeLanguage(event) { if (isNotEmpty((this.model as any).value)) { - this.onValueChange(event); + this.onChange(event); } } } diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form.component.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form.component.ts index 7789d910a8..c1b4ca71c8 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form.component.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form.component.ts @@ -9,13 +9,13 @@ import { } from '@angular/core'; import { FormGroup } from '@angular/forms'; import { - DynamicFormComponent, + DynamicFormComponent, DynamicFormControlContainerComponent, DynamicFormControlEvent, DynamicFormControlModel, - DynamicFormLayout, - DynamicFormLayoutService, - DynamicFormService, - DynamicTemplateDirective, + DynamicFormLayout, + DynamicFormLayoutService, + DynamicFormService, + DynamicTemplateDirective, } from '@ng-dynamic-forms/core'; import { DsDynamicFormControlComponent } from './ds-dynamic-form-control.component'; import { FormBuilderService } from '../form-builder.service'; @@ -29,7 +29,7 @@ export class DsDynamicFormComponent extends DynamicFormComponent { @Input() formId: string; @Input() formGroup: FormGroup; @Input() formModel: DynamicFormControlModel[]; - @Input() formLayout: DynamicFormLayout = null; + @Input() formLayout = null as DynamicFormLayout; /* tslint:disable:no-output-rename */ @Output('dfBlur') blur: EventEmitter = new EventEmitter(); @@ -39,9 +39,10 @@ export class DsDynamicFormComponent extends DynamicFormComponent { @ContentChildren(DynamicTemplateDirective) templates: QueryList; - @ViewChildren(DsDynamicFormControlComponent) components: QueryList; + @ViewChildren(DsDynamicFormControlComponent) components: QueryList; + + constructor(protected formService: FormBuilderService, protected layoutService: DynamicFormLayoutService) { + super(formService, layoutService); + } - constructor(protected formService: FormBuilderService, protected layoutService: DynamicFormLayoutService) { - super(formService, layoutService); - } } diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/date-picker/date-picker.component.spec.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/date-picker/date-picker.component.spec.ts index 02f1415e99..d11dbf664b 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/date-picker/date-picker.component.spec.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/date-picker/date-picker.component.spec.ts @@ -4,7 +4,7 @@ import { async, ComponentFixture, inject, TestBed, } from '@angular/core/testing import { FormControl, FormGroup } from '@angular/forms'; import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; -import { DynamicFormValidationService } from '@ng-dynamic-forms/core'; +import { DynamicFormLayoutService, DynamicFormValidationService } from '@ng-dynamic-forms/core'; import { DsDatePickerComponent } from './date-picker.component'; import { DynamicDsDatePickerModel } from './date-picker.model'; @@ -52,10 +52,8 @@ describe('DsDatePickerComponent test suite', () => { providers: [ ChangeDetectorRef, DsDatePickerComponent, - DynamicFormValidationService, - FormBuilderService, - FormComponent, - FormService + {provide: DynamicFormLayoutService, useValue: {}}, + {provide: DynamicFormValidationService, useValue: {}} ], schemas: [CUSTOM_ELEMENTS_SCHEMA] }); @@ -70,7 +68,6 @@ describe('DsDatePickerComponent test suite', () => { [bindId]='bindId' [group]='group' [model]='model' - [showErrorMessages]='showErrorMessages' (blur)='onBlur($event)' (change)='onValueChange($event)' (focus)='onFocus($event)'>`; diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/date-picker/date-picker.component.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/date-picker/date-picker.component.ts index 741d86fab9..2e22f314ed 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/date-picker/date-picker.component.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/date-picker/date-picker.component.ts @@ -1,7 +1,12 @@ -import { Component, EventEmitter, Inject, Input, OnInit, Output } from '@angular/core'; -import { FormControl, FormGroup } from '@angular/forms'; +import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; +import { FormGroup } from '@angular/forms'; import { DynamicDsDatePickerModel } from './date-picker.model'; -import { hasNoValue, hasValue, isNotEmpty } from '../../../../../empty.util'; +import { hasValue } from '../../../../../empty.util'; +import { + DynamicFormControlComponent, + DynamicFormLayoutService, + DynamicFormValidationService +} from '@ng-dynamic-forms/core'; export const DS_DATE_PICKER_SEPARATOR = '-'; @@ -11,11 +16,10 @@ export const DS_DATE_PICKER_SEPARATOR = '-'; templateUrl: './date-picker.component.html', }) -export class DsDatePickerComponent implements OnInit { +export class DsDatePickerComponent extends DynamicFormControlComponent implements OnInit { @Input() bindId = true; @Input() group: FormGroup; @Input() model: DynamicDsDatePickerModel; - @Input() showErrorMessages = false; // @Input() // minDate; // @Input() @@ -49,6 +53,12 @@ export class DsDatePickerComponent implements OnInit { disabledMonth = true; disabledDay = true; + constructor(protected layoutService: DynamicFormLayoutService, + protected validationService: DynamicFormValidationService + ) { + super(layoutService, validationService); + } + ngOnInit() { const now = new Date(); this.initialYear = now.getFullYear(); diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/date-picker/date-picker.model.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/date-picker/date-picker.model.ts index a75a1d2f1a..214e2d1907 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/date-picker/date-picker.model.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/date-picker/date-picker.model.ts @@ -1,6 +1,6 @@ import { DynamicDateControlModel, DynamicFormControlLayout, serializable } from '@ng-dynamic-forms/core'; import { DynamicDateControlModelConfig } from '@ng-dynamic-forms/core/src/model/dynamic-date-control.model'; -import { Subject } from 'rxjs/Subject'; +import { Subject } from 'rxjs'; export const DYNAMIC_FORM_CONTROL_TYPE_DSDATEPICKER = 'DATE'; diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-input.model.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-input.model.ts index f739c17cf3..860c481820 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-input.model.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-input.model.ts @@ -4,7 +4,7 @@ import { DynamicInputModelConfig, serializable } from '@ng-dynamic-forms/core'; -import { Subject } from 'rxjs/Subject'; +import { Subject } from 'rxjs'; import { LanguageCode } from '../../models/form-field-language-value.model'; import { AuthorityOptions } from '../../../../../core/integration/models/authority-options.model'; diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-qualdrop.model.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-qualdrop.model.ts index bae79cc348..6bd5a604a0 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-qualdrop.model.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-qualdrop.model.ts @@ -1,6 +1,6 @@ import { DynamicFormControlLayout, DynamicFormGroupModel, DynamicInputModelConfig, serializable } from '@ng-dynamic-forms/core'; import { DsDynamicInputModel, DsDynamicInputModelConfig } from './ds-dynamic-input.model'; -import { Subject } from 'rxjs/Subject'; +import { Subject } from 'rxjs'; import { DynamicFormGroupModelConfig } from '@ng-dynamic-forms/core/src/model/form-group/dynamic-form-group.model'; import { LanguageCode } from '../../models/form-field-language-value.model'; diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-row-array-model.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-row-array-model.ts index b38ea142f0..b91af8f0c9 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-row-array-model.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-row-array-model.ts @@ -6,16 +6,16 @@ import { import { DYNAMIC_FORM_CONTROL_TYPE_TAG } from './tag/dynamic-tag.model'; export interface DynamicRowArrayModelConfig extends DynamicFormArrayModelConfig { - notRepeteable: boolean; + notRepeatable: boolean; } export class DynamicRowArrayModel extends DynamicFormArrayModel { - @serializable() notRepeteable = false; + @serializable() notRepeatable = false; isRowArray = true; constructor(config: DynamicRowArrayModelConfig, layout?: DynamicFormControlLayout) { super(config, layout); - this.notRepeteable = config.notRepeteable; + this.notRepeatable = config.notRepeatable; } } diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/dynamic-group/dynamic-group.component.spec.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/dynamic-group/dynamic-group.component.spec.ts index d1e6f67287..42d8f4b6de 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/dynamic-group/dynamic-group.component.spec.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/dynamic-group/dynamic-group.component.spec.ts @@ -3,80 +3,88 @@ import { ChangeDetectorRef, Component, CUSTOM_ELEMENTS_SCHEMA } from '@angular/c import { async, ComponentFixture, inject, TestBed, } from '@angular/core/testing'; import { FormControl, FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'; -import { DynamicFormValidationService } from '@ng-dynamic-forms/core'; -import { Store } from '@ngrx/store'; import { TranslateModule } from '@ngx-translate/core'; -import { Observable } from 'rxjs/Observable'; -import 'rxjs/add/observable/of'; import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; import { DsDynamicGroupComponent } from './dynamic-group.components'; import { DynamicGroupModel, DynamicGroupModelConfig } from './dynamic-group.model'; -import { FormRowModel, SubmissionFormsModel } from '../../../../../../core/shared/config/config-submission-forms.model'; +import { + FormRowModel, + SubmissionFormsModel +} from '../../../../../../core/shared/config/config-submission-forms.model'; import { FormFieldModel } from '../../../models/form-field.model'; import { FormBuilderService } from '../../../form-builder.service'; import { FormService } from '../../../../form.service'; import { GLOBAL_CONFIG } from '../../../../../../../config'; import { FormComponent } from '../../../../form.component'; -import { AppState } from '../../../../../../app.reducer'; 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'; +import { DynamicFormLayoutService, DynamicFormValidationService } from '@ng-dynamic-forms/core'; +import { MockStore } from '../../../../../testing/mock-store'; +import { Store } from '@ngrx/store'; +import { AppState } from '../../../../../../app.reducer'; -export const FORM_GROUP_TEST_MODEL_CONFIG = { - disabled: false, - errorMessages: {required: 'You must specify at least one author.'}, - formConfiguration: [{ - fields: [{ - hints: 'Enter the name of the author.', - input: {type: 'onebox'}, - label: 'Author', - languageCodes: [], - mandatory: 'true', - mandatoryMessage: 'Required field!', - repeatable: false, - selectableMetadata: [{ - authority: 'RPAuthority', - closed: false, - metadata: 'dc.contributor.author' - }], - } as FormFieldModel] - } as FormRowModel, { - fields: [{ - hints: 'Enter the affiliation of the author.', - input: {type: 'onebox'}, - label: 'Affiliation', - languageCodes: [], - mandatory: 'false', - repeatable: false, - selectableMetadata: [{ - authority: 'OUAuthority', - closed: false, - metadata: 'local.contributor.affiliation' - }] - } as FormFieldModel] - } as FormRowModel], - id: 'dc_contributor_author', - label: 'Authors', - mandatoryField: 'dc.contributor.author', - name: 'dc.contributor.author', - placeholder: 'Authors', - readOnly: false, - relationFields: ['local.contributor.affiliation'], - required: true, - scopeUUID: '43fe1f8c-09a6-4fcf-9c78-5d4fed8f2c8f', - submissionScope: undefined, - validators: {required: null} -} as DynamicGroupModelConfig; +export let FORM_GROUP_TEST_MODEL_CONFIG; -export const FORM_GROUP_TEST_GROUP = new FormGroup({ - dc_contributor_author: new FormControl(), -}); +export let FORM_GROUP_TEST_GROUP; -describe('DsDynamicGroupComponent test suite', () => { - const config = { +let config; + +function init() { + FORM_GROUP_TEST_MODEL_CONFIG = { + disabled: false, + errorMessages: { required: 'You must specify at least one author.' }, + formConfiguration: [{ + fields: [{ + hints: 'Enter the name of the author.', + input: { type: 'onebox' }, + label: 'Author', + languageCodes: [], + mandatory: 'true', + mandatoryMessage: 'Required field!', + repeatable: false, + selectableMetadata: [{ + authority: 'RPAuthority', + closed: false, + metadata: 'dc.contributor.author' + }], + } as FormFieldModel] + } as FormRowModel, { + fields: [{ + hints: 'Enter the affiliation of the author.', + input: { type: 'onebox' }, + label: 'Affiliation', + languageCodes: [], + mandatory: 'false', + repeatable: false, + selectableMetadata: [{ + authority: 'OUAuthority', + closed: false, + metadata: 'local.contributor.affiliation' + }] + } as FormFieldModel] + } as FormRowModel], + id: 'dc_contributor_author', + label: 'Authors', + mandatoryField: 'dc.contributor.author', + name: 'dc.contributor.author', + placeholder: 'Authors', + readOnly: false, + relationFields: ['local.contributor.affiliation'], + required: true, + scopeUUID: '43fe1f8c-09a6-4fcf-9c78-5d4fed8f2c8f', + submissionScope: undefined, + validators: { required: null } + } as DynamicGroupModelConfig; + + FORM_GROUP_TEST_GROUP = new FormGroup({ + dc_contributor_author: new FormControl(), + }); + + config = { form: { validatorMap: { required: 'required', @@ -84,6 +92,10 @@ describe('DsDynamicGroupComponent test suite', () => { } } } as any; + +} + +describe('DsDynamicGroupComponent test suite', () => { let testComp: TestComponent; let groupComp: DsDynamicGroupComponent; let testFixture: ComponentFixture; @@ -95,14 +107,11 @@ describe('DsDynamicGroupComponent test suite', () => { let control2: FormControl; let model2: DsDynamicInputModel; - const store: Store = jasmine.createSpyObj('store', { - dispatch: {}, - select: Observable.of(true) - }); - // async beforeEach beforeEach(async(() => { - + init(); + const store = new MockStore(Object.create(null)); + /* TODO make sure these files use mocks instead of real services/components https://github.com/DSpace/dspace-angular/issues/281 */ TestBed.configureTestingModule({ imports: [ BrowserAnimationsModule, @@ -120,10 +129,11 @@ describe('DsDynamicGroupComponent test suite', () => { ChangeDetectorRef, DsDynamicGroupComponent, DynamicFormValidationService, + DynamicFormLayoutService, FormBuilderService, FormComponent, FormService, - {provide: GLOBAL_CONFIG, useValue: config}, + { provide: GLOBAL_CONFIG, useValue: config }, {provide: Store, useValue: store}, ], schemas: [CUSTOM_ELEMENTS_SCHEMA] @@ -138,7 +148,6 @@ describe('DsDynamicGroupComponent test suite', () => { `; @@ -147,6 +156,11 @@ describe('DsDynamicGroupComponent test suite', () => { testComp = testFixture.componentInstance; }); + afterEach(() => { + testFixture.destroy(); + testComp = null; + }); + it('should create DsDynamicGroupComponent', inject([DsDynamicGroupComponent], (app: DsDynamicGroupComponent) => { expect(app).toBeDefined(); @@ -161,9 +175,7 @@ describe('DsDynamicGroupComponent test suite', () => { groupComp.formId = 'testForm'; groupComp.group = FORM_GROUP_TEST_GROUP; groupComp.model = new DynamicGroupModel(FORM_GROUP_TEST_MODEL_CONFIG); - groupComp.showErrorMessages = false; groupFixture.detectChanges(); - control1 = service.getFormControlById('dc_contributor_author', (groupComp as any).formRef.formGroup, groupComp.formModel) as FormControl; model1 = service.findById('dc_contributor_author', groupComp.formModel) as DsDynamicInputModel; control2 = service.getFormControlById('local_contributor_affiliation', (groupComp as any).formRef.formGroup, groupComp.formModel) as FormControl; @@ -178,11 +190,12 @@ describe('DsDynamicGroupComponent test suite', () => { }); it('should init component properly', inject([FormBuilderService], (service: FormBuilderService) => { - const formConfig = {rows: groupComp.model.formConfiguration} as SubmissionFormsModel; + const formConfig = { rows: groupComp.model.formConfiguration } as SubmissionFormsModel; const formModel = service.modelFromConfiguration(formConfig, groupComp.model.scopeUUID, {}, groupComp.model.submissionScope, groupComp.model.readOnly); const chips = new Chips([], 'value', 'dc.contributor.author'); - - expect(groupComp.formCollapsed).toEqual(Observable.of(false)); + groupComp.formCollapsed.subscribe((value) => { + expect(value).toEqual(false); + }); expect(groupComp.formModel.length).toEqual(formModel.length); expect(groupComp.chips.getChipsItems()).toEqual(chips.getChipsItems()); })); @@ -203,7 +216,9 @@ describe('DsDynamicGroupComponent test suite', () => { btnEl.click(); expect(groupComp.chips.getChipsItems()).toEqual(modelValue); - expect(groupComp.formCollapsed).toEqual(Observable.of(true)); + groupComp.formCollapsed.subscribe((value) => { + expect(value).toEqual(true); + }) }); it('should clear form inputs', () => { @@ -220,7 +235,9 @@ describe('DsDynamicGroupComponent test suite', () => { expect(control1.value).toBeNull(); expect(control2.value).toBeNull(); - expect(groupComp.formCollapsed).toEqual(Observable.of(false)); + groupComp.formCollapsed.subscribe((value) => { + expect(value).toEqual(false); + }); }); }); @@ -237,7 +254,6 @@ describe('DsDynamicGroupComponent test suite', () => { 'local.contributor.affiliation': new FormFieldMetadataValueObject('test affiliation') }]; groupComp.model.value = modelValue; - groupComp.showErrorMessages = false; groupFixture.detectChanges(); }); @@ -248,11 +264,12 @@ describe('DsDynamicGroupComponent test suite', () => { }); it('should init component properly', inject([FormBuilderService], (service: FormBuilderService) => { - const formConfig = {rows: groupComp.model.formConfiguration} as SubmissionFormsModel; + const formConfig = { rows: groupComp.model.formConfiguration } as SubmissionFormsModel; const formModel = service.modelFromConfiguration(formConfig, groupComp.model.scopeUUID, {}, groupComp.model.submissionScope, groupComp.model.readOnly); const chips = new Chips(modelValue, 'value', 'dc.contributor.author'); - - expect(groupComp.formCollapsed).toEqual(Observable.of(true)); + groupComp.formCollapsed.subscribe((value) => { + expect(value).toEqual(true); + }) expect(groupComp.formModel.length).toEqual(formModel.length); expect(groupComp.chips.getChipsItems()).toEqual(chips.getChipsItems()); })); @@ -280,7 +297,9 @@ describe('DsDynamicGroupComponent test suite', () => { groupFixture.detectChanges(); expect(groupComp.chips.getChipsItems()).toEqual(modelValue); - expect(groupComp.formCollapsed).toEqual(Observable.of(true)); + groupComp.formCollapsed.subscribe((value) => { + expect(value).toEqual(true); + }) })); it('should delete existing chips item', () => { @@ -292,7 +311,9 @@ describe('DsDynamicGroupComponent test suite', () => { btnEl.click(); expect(groupComp.chips.getChipsItems()).toEqual([]); - expect(groupComp.formCollapsed).toEqual(Observable.of(false)); + groupComp.formCollapsed.subscribe((value) => { + expect(value).toEqual(false); + }) }); }); }); diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/dynamic-group/dynamic-group.components.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/dynamic-group/dynamic-group.components.ts index a55e7aff9d..40e337588a 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/dynamic-group/dynamic-group.components.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/dynamic-group/dynamic-group.components.ts @@ -1,3 +1,4 @@ +import { of as observableOf, Subscription } from 'rxjs'; import { ChangeDetectorRef, Component, @@ -9,9 +10,14 @@ import { Output, ViewChild } from '@angular/core'; - -import { Observable } from 'rxjs/Observable'; -import { DynamicFormControlModel, DynamicFormGroupModel, DynamicInputModel } from '@ng-dynamic-forms/core'; +import { + DynamicFormControlComponent, + DynamicFormControlModel, + DynamicFormGroupModel, + DynamicFormLayoutService, + DynamicFormValidationService, + DynamicInputModel +} from '@ng-dynamic-forms/core'; import { isEqual } from 'lodash'; import { DynamicGroupModel, PLACEHOLDER_PARENT_METADATA } from './dynamic-group.model'; @@ -26,10 +32,7 @@ import { ChipsItem } from '../../../../../chips/models/chips-item.model'; import { GlobalConfig } from '../../../../../../../config/global-config.interface'; import { GLOBAL_CONFIG } from '../../../../../../../config'; import { FormGroup } from '@angular/forms'; -import { Subscription } from 'rxjs/Subscription'; import { hasOnlyEmptyProperties } from '../../../../../object.util'; -import { FormFieldMetadataValueObject } from '../../../models/form-field-metadata-value.model'; -import { AuthorityValueModel } from '../../../../../../core/integration/models/authority-value.model'; @Component({ selector: 'ds-dynamic-group', @@ -37,19 +40,18 @@ import { AuthorityValueModel } from '../../../../../../core/integration/models/a templateUrl: './dynamic-group.component.html', animations: [shrinkInOut] }) -export class DsDynamicGroupComponent implements OnDestroy, OnInit { +export class DsDynamicGroupComponent extends DynamicFormControlComponent implements OnDestroy, OnInit { @Input() formId: string; @Input() group: FormGroup; @Input() model: DynamicGroupModel; - @Input() showErrorMessages = false; @Output() blur: EventEmitter = new EventEmitter(); @Output() change: EventEmitter = new EventEmitter(); @Output() focus: EventEmitter = new EventEmitter(); public chips: Chips; - public formCollapsed = Observable.of(false); + public formCollapsed = observableOf(false); public formModel: DynamicFormControlModel[]; public editMode = false; @@ -61,13 +63,18 @@ export class DsDynamicGroupComponent implements OnDestroy, OnInit { constructor(@Inject(GLOBAL_CONFIG) protected EnvConfig: GlobalConfig, private formBuilderService: FormBuilderService, private formService: FormService, - private cdr: ChangeDetectorRef) { + private cdr: ChangeDetectorRef, + protected layoutService: DynamicFormLayoutService, + protected validationService: DynamicFormValidationService + ) { + super(layoutService, validationService); + } ngOnInit() { - const config = {rows: this.model.formConfiguration} as SubmissionFormsModel; + const config = { rows: this.model.formConfiguration } as SubmissionFormsModel; if (!this.model.isEmpty()) { - this.formCollapsed = Observable.of(true); + this.formCollapsed = observableOf(true); } this.model.valueUpdates.subscribe((value: any[]) => { if ((isNotEmpty(value) && !(value.length === 1 && hasOnlyEmptyProperties(value[0])))) { @@ -75,7 +82,7 @@ export class DsDynamicGroupComponent implements OnDestroy, OnInit { } else { this.expandForm(); } - // this.formCollapsed = (isNotEmpty(value) && !(value.length === 1 && hasOnlyEmptyProperties(value[0]))) ? Observable.of(true) : Observable.of(false); + // this.formCollapsed = (isNotEmpty(value) && !(value.length === 1 && hasOnlyEmptyProperties(value[0]))) ? observableOf(true) : observableOf(false); }); this.formId = this.formService.getUniqueId(this.model.id); @@ -152,12 +159,12 @@ export class DsDynamicGroupComponent implements OnDestroy, OnInit { } collapseForm() { - this.formCollapsed = Observable.of(true); + this.formCollapsed = observableOf(true); this.clear(); } expandForm() { - this.formCollapsed = Observable.of(false); + this.formCollapsed = observableOf(false); } clear() { @@ -168,7 +175,7 @@ export class DsDynamicGroupComponent implements OnDestroy, OnInit { } this.resetForm(); if (!this.model.isEmpty()) { - this.formCollapsed = Observable.of(true); + this.formCollapsed = observableOf(true); } } diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/list/dynamic-list-checkbox-group.model.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/list/dynamic-list-checkbox-group.model.ts index 5fdc530ebd..ed9db8b1a5 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/list/dynamic-list-checkbox-group.model.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/list/dynamic-list-checkbox-group.model.ts @@ -1,4 +1,4 @@ -import { Subject } from 'rxjs/Subject'; +import { Subject } from 'rxjs'; import { DynamicCheckboxGroupModel, DynamicFormControlLayout, diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/list/dynamic-list.component.spec.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/list/dynamic-list.component.spec.ts index 6a765eba4a..adfd087033 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/list/dynamic-list.component.spec.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/list/dynamic-list.component.spec.ts @@ -9,7 +9,12 @@ import { DsDynamicListComponent } from './dynamic-list.component'; import { DynamicListCheckboxGroupModel } from './dynamic-list-checkbox-group.model'; import { AuthorityOptions } from '../../../../../../core/integration/models/authority-options.model'; import { FormBuilderService } from '../../../form-builder.service'; -import { DynamicFormControlLayout, DynamicFormsCoreModule, DynamicFormValidationService } from '@ng-dynamic-forms/core'; +import { + DynamicFormControlLayout, + DynamicFormLayoutService, + DynamicFormsCoreModule, + DynamicFormValidationService +} from '@ng-dynamic-forms/core'; import { DynamicFormsNGBootstrapUIModule } from '@ng-dynamic-forms/ui-ng-bootstrap'; import { AuthorityService } from '../../../../../../core/integration/authority.service'; import { AuthorityServiceStub } from '../../../../../testing/authority-service-stub'; @@ -90,12 +95,13 @@ describe('DsDynamicListComponent test suite', () => { TestComponent, ], // declare the test component providers: [ - AuthorityService, ChangeDetectorRef, DsDynamicListComponent, DynamicFormValidationService, FormBuilderService, {provide: AuthorityService, useValue: authorityServiceStub}, + {provide: DynamicFormLayoutService, useValue: {}}, + {provide: DynamicFormValidationService, useValue: {}} ], schemas: [CUSTOM_ELEMENTS_SCHEMA] }); @@ -110,7 +116,6 @@ describe('DsDynamicListComponent test suite', () => { [bindId]="bindId" [group]="group" [model]="model" - [showErrorMessages]="showErrorMessages" (blur)="onBlur($event)" (change)="onValueChange($event)" (focus)="onFocus($event)">`; diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/list/dynamic-list.component.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/list/dynamic-list.component.ts index ec0d3e343a..dc808f4759 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/list/dynamic-list.component.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/list/dynamic-list.component.ts @@ -7,7 +7,11 @@ import { IntegrationSearchOptions } from '../../../../../../core/integration/mod import { hasValue, isNotEmpty } from '../../../../../empty.util'; import { DynamicListCheckboxGroupModel } from './dynamic-list-checkbox-group.model'; import { FormBuilderService } from '../../../form-builder.service'; -import { DynamicCheckboxModel } from '@ng-dynamic-forms/core'; +import { + DynamicCheckboxModel, + DynamicFormControlComponent, DynamicFormLayoutService, + DynamicFormValidationService +} from '@ng-dynamic-forms/core'; import { AuthorityValueModel } from '../../../../../../core/integration/models/authority-value.model'; import { DynamicListRadioGroupModel } from './dynamic-list-radio-group.model'; import { IntegrationData } from '../../../../../../core/integration/integration-data'; @@ -25,11 +29,10 @@ export interface ListItem { templateUrl: './dynamic-list.component.html' }) -export class DsDynamicListComponent implements OnInit { +export class DsDynamicListComponent extends DynamicFormControlComponent implements OnInit { @Input() bindId = true; @Input() group: FormGroup; @Input() model: DynamicListCheckboxGroupModel | DynamicListRadioGroupModel; - @Input() showErrorMessages = false; @Output() blur: EventEmitter = new EventEmitter(); @Output() change: EventEmitter = new EventEmitter(); @@ -41,7 +44,11 @@ export class DsDynamicListComponent implements OnInit { constructor(private authorityService: AuthorityService, private cdr: ChangeDetectorRef, - private formBuilderService: FormBuilderService) { + private formBuilderService: FormBuilderService, + protected layoutService: DynamicFormLayoutService, + protected validationService: DynamicFormValidationService + ) { + super(layoutService, validationService); } ngOnInit() { @@ -99,7 +106,7 @@ export class DsDynamicListComponent implements OnInit { const value = option.id || option.value; const checked: boolean = isNotEmpty(findKey( this.model.value, - {value: option.value})); + (v) => v.value === option.value)); const item: ListItem = { id: value, @@ -110,7 +117,10 @@ export class DsDynamicListComponent implements OnInit { if (this.model.repeatable) { this.formBuilderService.addFormGroupControl(listGroup, (this.model as DynamicListCheckboxGroupModel), new DynamicCheckboxModel(item)); } else { - (this.model as DynamicListRadioGroupModel).options.push({label: item.label, value: option}); + (this.model as DynamicListRadioGroupModel).options.push({ + label: item.label, + value: option + }); } tempList.push(item); itemsPerGroup++; diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/lookup/dynamic-lookup.component.spec.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/lookup/dynamic-lookup.component.spec.ts index ce45453bee..62e9191893 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/lookup/dynamic-lookup.component.spec.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/lookup/dynamic-lookup.component.spec.ts @@ -6,7 +6,11 @@ import { async, ComponentFixture, fakeAsync, inject, TestBed, tick, } from '@ang import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; import { AuthorityOptions } from '../../../../../../core/integration/models/authority-options.model'; -import { DynamicFormsCoreModule, DynamicFormValidationService } from '@ng-dynamic-forms/core'; +import { + DynamicFormLayoutService, + DynamicFormsCoreModule, + DynamicFormValidationService +} from '@ng-dynamic-forms/core'; import { DynamicFormsNGBootstrapUIModule } from '@ng-dynamic-forms/ui-ng-bootstrap'; import { AuthorityService } from '../../../../../../core/integration/authority.service'; import { AuthorityServiceStub } from '../../../../../testing/authority-service-stub'; @@ -14,16 +18,13 @@ import { DsDynamicLookupComponent } from './dynamic-lookup.component'; import { DynamicLookupModel } from './dynamic-lookup.model'; import { InfiniteScrollModule } from 'ngx-infinite-scroll'; import { TranslateModule } from '@ngx-translate/core'; -import { FormBuilderService } from '../../../form-builder.service'; -import { FormService } from '../../../../form.service'; -import { FormComponent } from '../../../../form.component'; import { FormFieldMetadataValueObject } from '../../../models/form-field-metadata-value.model'; import { By } from '@angular/platform-browser'; import { AuthorityValueModel } from '../../../../../../core/integration/models/authority-value.model'; -import { DynamicLookupNameModel } from './dynamic-lookup-name.model'; import { createTestComponent } from '../../../../../testing/utils'; +import { DynamicLookupNameModel } from './dynamic-lookup-name.model'; -export const LOOKUP_TEST_MODEL_CONFIG = { +let LOOKUP_TEST_MODEL_CONFIG = { authorityOptions: { closed: false, metadata: 'lookup', @@ -31,7 +32,7 @@ export const LOOKUP_TEST_MODEL_CONFIG = { scope: 'c1c16450-d56f-41bc-bb81-27f1d1eb5c23' } as AuthorityOptions, disabled: false, - errorMessages: {required: 'Required field.'}, + errorMessages: { required: 'Required field.' }, id: 'lookup', label: 'Author', maxOptions: 10, @@ -41,11 +42,11 @@ export const LOOKUP_TEST_MODEL_CONFIG = { required: true, repeatable: true, separator: ',', - validators: {required: null}, + validators: { required: null }, value: undefined }; -export const LOOKUP_NAME_TEST_MODEL_CONFIG = { +let LOOKUP_NAME_TEST_MODEL_CONFIG = { authorityOptions: { closed: false, metadata: 'lookup-name', @@ -53,7 +54,7 @@ export const LOOKUP_NAME_TEST_MODEL_CONFIG = { scope: 'c1c16450-d56f-41bc-bb81-27f1d1eb5c23' } as AuthorityOptions, disabled: false, - errorMessages: {required: 'Required field.'}, + errorMessages: { required: 'Required field.' }, id: 'lookupName', label: 'Author', maxOptions: 10, @@ -63,16 +64,67 @@ export const LOOKUP_NAME_TEST_MODEL_CONFIG = { required: true, repeatable: true, separator: ',', - validators: {required: null}, + validators: { required: null }, value: undefined }; -export const LOOKUP_TEST_GROUP = new FormGroup({ +let LOOKUP_TEST_GROUP = new FormGroup({ lookup: new FormControl(), lookupName: new FormControl() }); describe('Dynamic Lookup component', () => { + function init() { + LOOKUP_TEST_MODEL_CONFIG = { + authorityOptions: { + closed: false, + metadata: 'lookup', + name: 'RPAuthority', + scope: 'c1c16450-d56f-41bc-bb81-27f1d1eb5c23' + } as AuthorityOptions, + disabled: false, + errorMessages: { required: 'Required field.' }, + id: 'lookup', + label: 'Author', + maxOptions: 10, + name: 'lookup', + placeholder: 'Author', + readOnly: false, + required: true, + repeatable: true, + separator: ',', + validators: { required: null }, + value: undefined + }; + + LOOKUP_NAME_TEST_MODEL_CONFIG = { + authorityOptions: { + closed: false, + metadata: 'lookup-name', + name: 'RPAuthority', + scope: 'c1c16450-d56f-41bc-bb81-27f1d1eb5c23' + } as AuthorityOptions, + disabled: false, + errorMessages: { required: 'Required field.' }, + id: 'lookupName', + label: 'Author', + maxOptions: 10, + name: 'lookupName', + placeholder: 'Author', + readOnly: false, + required: true, + repeatable: true, + separator: ',', + validators: { required: null }, + value: undefined + }; + + LOOKUP_TEST_GROUP = new FormGroup({ + lookup: new FormControl(), + lookupName: new FormControl() + }); + + } let testComp: TestComponent; let lookupComp: DsDynamicLookupComponent; @@ -80,11 +132,11 @@ describe('Dynamic Lookup component', () => { let lookupFixture: ComponentFixture; let html; - const authorityServiceStub = new AuthorityServiceStub(); - + let authorityServiceStub; // async beforeEach beforeEach(async(() => { - + const authorityService = new AuthorityServiceStub(); + authorityServiceStub = authorityService; TestBed.configureTestingModule({ imports: [ DynamicFormsCoreModule, @@ -102,18 +154,19 @@ describe('Dynamic Lookup component', () => { providers: [ ChangeDetectorRef, DsDynamicLookupComponent, - DynamicFormValidationService, - FormBuilderService, - FormComponent, - FormService, - {provide: AuthorityService, useValue: authorityServiceStub}, + { provide: AuthorityService, useValue: authorityService }, + { provide: DynamicFormLayoutService, useValue: {} }, + { provide: DynamicFormValidationService, useValue: {} } ], schemas: [CUSTOM_ELEMENTS_SCHEMA] }); - })); - describe('', () => { + beforeEach(() => { + init(); + }); + + describe('DynamicLookUpComponent', () => { // synchronous beforeEach beforeEach(() => { html = ` @@ -121,7 +174,6 @@ describe('Dynamic Lookup component', () => { [bindId]="bindId" [group]="group" [model]="model" - [showErrorMessages]="showErrorMessages" (blur)="onBlur($event)" (change)="onValueChange($event)" (focus)="onFocus($event)">`; @@ -129,200 +181,236 @@ describe('Dynamic Lookup component', () => { testFixture = createTestComponent(html, TestComponent) as ComponentFixture; testComp = testFixture.componentInstance; }); - + afterEach(() => { + testFixture.destroy(); + testComp = null; + }); it('should create DsDynamicLookupComponent', inject([DsDynamicLookupComponent], (app: DsDynamicLookupComponent) => { expect(app).toBeDefined(); })); - }); - describe('when model is DynamicLookupModel', () => { + describe('when model is DynamicLookupModel', () => { - describe('', () => { - beforeEach(() => { + describe('', () => { + beforeEach(() => { + + lookupFixture = TestBed.createComponent(DsDynamicLookupComponent); + lookupComp = lookupFixture.componentInstance; // FormComponent test instance + lookupComp.group = LOOKUP_TEST_GROUP; + lookupComp.model = new DynamicLookupModel(LOOKUP_TEST_MODEL_CONFIG); + lookupFixture.detectChanges(); + }); + afterEach(() => { + lookupFixture.destroy(); + lookupComp = null; + }); + it('should render only an input element', () => { + const de = lookupFixture.debugElement.queryAll(By.css('input.form-control')); + expect(de.length).toBe(1); + }); - lookupFixture = TestBed.createComponent(DsDynamicLookupComponent); - lookupComp = lookupFixture.componentInstance; // FormComponent test instance - lookupComp.group = LOOKUP_TEST_GROUP; - lookupComp.model = new DynamicLookupModel(LOOKUP_TEST_MODEL_CONFIG); - lookupFixture.detectChanges(); }); - it('should render only an input element', () => { - const de = lookupFixture.debugElement.queryAll(By.css('input.form-control')); - expect(de.length).toBe(1); + describe('and init model value is empty', () => { + beforeEach(() => { + lookupFixture = TestBed.createComponent(DsDynamicLookupComponent); + lookupComp = lookupFixture.componentInstance; // FormComponent test instance + lookupComp.group = LOOKUP_TEST_GROUP; + lookupComp.model = new DynamicLookupModel(LOOKUP_TEST_MODEL_CONFIG); + lookupFixture.detectChanges(); + }); + + afterEach(() => { + lookupFixture.destroy(); + lookupComp = null; + }); + + it('should init component properly', () => { + expect(lookupComp.firstInputValue).toBe(''); + }); + + it('should return search results', fakeAsync(() => { + const de = lookupFixture.debugElement.queryAll(By.css('button')); + const btnEl = de[0].nativeElement; + const results$ = authorityServiceStub.getEntriesByName({} as any); + + lookupComp.firstInputValue = 'test'; + lookupFixture.detectChanges(); + + btnEl.click(); + tick(); + lookupFixture.detectChanges(); + results$.subscribe((results) => { + expect(lookupComp.optionsList).toEqual(results.payload); + }); + + })); + + it('should select a results entry properly', fakeAsync(() => { + let de = lookupFixture.debugElement.queryAll(By.css('button')); + const btnEl = de[0].nativeElement; + const selectedValue = Object.assign(new AuthorityValueModel(), { + id: 1, + display: 'one', + value: 1 + }); + spyOn(lookupComp.change, 'emit'); + lookupComp.firstInputValue = 'test'; + lookupFixture.detectChanges(); + btnEl.click(); + tick(); + lookupFixture.detectChanges(); + de = lookupFixture.debugElement.queryAll(By.css('button.dropdown-item')); + const entryEl = de[0].nativeElement; + entryEl.click(); + lookupFixture.detectChanges(); + expect(lookupComp.firstInputValue).toEqual('one'); + expect(lookupComp.model.value).toEqual(selectedValue); + expect(lookupComp.change.emit).toHaveBeenCalled(); + })); + + it('should set model.value on input type when AuthorityOptions.closed is false', fakeAsync(() => { + lookupComp.firstInputValue = 'test'; + lookupFixture.detectChanges(); + + lookupComp.onInput(new Event('input')); + expect(lookupComp.model.value).toEqual(new FormFieldMetadataValueObject('test')) + + })); + + it('should not set model.value on input type when AuthorityOptions.closed is true', () => { + lookupComp.model.authorityOptions.closed = true; + lookupComp.firstInputValue = 'test'; + lookupFixture.detectChanges(); + + lookupComp.onInput(new Event('input')); + expect(lookupComp.model.value).not.toBeDefined(); + + }); }); - }); + describe('and init model value is not empty', () => { + beforeEach(() => { - describe('and init model value is empty', () => { - beforeEach(() => { - - lookupFixture = TestBed.createComponent(DsDynamicLookupComponent); - lookupComp = lookupFixture.componentInstance; // FormComponent test instance - lookupComp.group = LOOKUP_TEST_GROUP; - lookupComp.model = new DynamicLookupModel(LOOKUP_TEST_MODEL_CONFIG); - lookupFixture.detectChanges(); - }); - - it('should init component properly', () => { - expect(lookupComp.firstInputValue).toBe(''); - }); - - it('should return search results', fakeAsync(() => { - const de = lookupFixture.debugElement.queryAll(By.css('button')); - const btnEl = de[0].nativeElement; - const results$ = authorityServiceStub.getEntriesByName({} as any); - - lookupComp.firstInputValue = 'test'; - lookupFixture.detectChanges(); - - btnEl.click(); - tick(); - lookupFixture.detectChanges(); - results$.subscribe((results) => { - expect(lookupComp.optionsList).toEqual(results.payload); - }) - - })); - - it('should select a results entry properly', fakeAsync(() => { - let de = lookupFixture.debugElement.queryAll(By.css('button')); - const btnEl = de[0].nativeElement; - const selectedValue = Object.assign(new AuthorityValueModel(), {id: 1, display: 'one', value: 1}); - spyOn(lookupComp.change, 'emit'); - - lookupComp.firstInputValue = 'test'; - lookupFixture.detectChanges(); - btnEl.click(); - tick(); - lookupFixture.detectChanges(); - de = lookupFixture.debugElement.queryAll(By.css('button.dropdown-item')); - const entryEl = de[0].nativeElement; - entryEl.click(); - - expect(lookupComp.firstInputValue).toEqual('one'); - expect(lookupComp.model.value).toEqual(selectedValue); - expect(lookupComp.change.emit).toHaveBeenCalled(); - })); - - it('should set model.value on input type when AuthorityOptions.closed is false', fakeAsync(() => { - lookupComp.firstInputValue = 'test'; - lookupFixture.detectChanges(); - - lookupComp.onInput(new Event('input')); - expect(lookupComp.model.value).toEqual(new FormFieldMetadataValueObject('test')) - - })); - - it('should not set model.value on input type when AuthorityOptions.closed is true', () => { - lookupComp.model.authorityOptions.closed = true; - lookupComp.firstInputValue = 'test'; - lookupFixture.detectChanges(); - - lookupComp.onInput(new Event('input')); - expect(lookupComp.model.value).not.toBeDefined(); + lookupFixture = TestBed.createComponent(DsDynamicLookupComponent); + lookupComp = lookupFixture.componentInstance; // FormComponent test instance + lookupComp.group = LOOKUP_TEST_GROUP; + lookupComp.model = new DynamicLookupModel(LOOKUP_TEST_MODEL_CONFIG); + lookupComp.model.value = new FormFieldMetadataValueObject('test', null, 'test001'); + lookupFixture.detectChanges(); + // spyOn(store, 'dispatch'); + }); + afterEach(() => { + lookupFixture.destroy(); + lookupComp = null; + }); + it('should init component properly', () => { + expect(lookupComp.firstInputValue).toBe('test'); + }); }); }); - describe('and init model value is not empty', () => { - beforeEach(() => { + describe('when model is DynamicLookupNameModel', () => { - lookupFixture = TestBed.createComponent(DsDynamicLookupComponent); - lookupComp = lookupFixture.componentInstance; // FormComponent test instance - lookupComp.group = LOOKUP_TEST_GROUP; - lookupComp.model = new DynamicLookupModel(LOOKUP_TEST_MODEL_CONFIG); - lookupComp.model.value = new FormFieldMetadataValueObject('test', null, 'test001'); - lookupFixture.detectChanges(); + describe('', () => { + beforeEach(() => { + + lookupFixture = TestBed.createComponent(DsDynamicLookupComponent); + lookupComp = lookupFixture.componentInstance; // FormComponent test instance + lookupComp.group = LOOKUP_TEST_GROUP; + lookupComp.model = new DynamicLookupNameModel(LOOKUP_NAME_TEST_MODEL_CONFIG); + lookupFixture.detectChanges(); + + // spyOn(store, 'dispatch'); + }); + afterEach(() => { + lookupFixture.destroy(); + lookupComp = null; + }); + it('should render two input element', () => { + const de = lookupFixture.debugElement.queryAll(By.css('input.form-control')); + expect(de.length).toBe(2); + }); - // spyOn(store, 'dispatch'); }); - it('should init component properly', () => { - expect(lookupComp.firstInputValue).toBe('test') + describe('and init model value is empty', () => { + + beforeEach(() => { + + lookupFixture = TestBed.createComponent(DsDynamicLookupComponent); + lookupComp = lookupFixture.componentInstance; // FormComponent test instance + lookupComp.group = LOOKUP_TEST_GROUP; + lookupComp.model = new DynamicLookupNameModel(LOOKUP_NAME_TEST_MODEL_CONFIG); + lookupFixture.detectChanges(); + }); + + afterEach(() => { + lookupFixture.destroy(); + lookupComp = null; + }); + + it('should select a results entry properly', fakeAsync(() => { + const payload = [ + Object.assign(new AuthorityValueModel(), { + id: 1, + display: 'Name, Lastname', + value: 1 + }), + Object.assign(new AuthorityValueModel(), { + id: 2, + display: 'NameTwo, LastnameTwo', + value: 2 + }), + ]; + let de = lookupFixture.debugElement.queryAll(By.css('button')); + const btnEl = de[0].nativeElement; + const selectedValue = Object.assign(new AuthorityValueModel(), { + id: 1, + display: 'Name, Lastname', + value: 1 + }); + spyOn(lookupComp.change, 'emit'); + authorityServiceStub.setNewPayload(payload); + lookupComp.firstInputValue = 'test'; + lookupFixture.detectChanges(); + btnEl.click(); + tick(); + lookupFixture.detectChanges(); + de = lookupFixture.debugElement.queryAll(By.css('button.dropdown-item')); + const entryEl = de[0].nativeElement; + entryEl.click(); + + expect(lookupComp.firstInputValue).toEqual('Name'); + expect(lookupComp.secondInputValue).toEqual('Lastname'); + expect(lookupComp.model.value).toEqual(selectedValue); + expect(lookupComp.change.emit).toHaveBeenCalled(); + })); + }); + + describe('and init model value is not empty', () => { + beforeEach(() => { + + lookupFixture = TestBed.createComponent(DsDynamicLookupComponent); + lookupComp = lookupFixture.componentInstance; // FormComponent test instance + lookupComp.group = LOOKUP_TEST_GROUP; + lookupComp.model = new DynamicLookupNameModel(LOOKUP_NAME_TEST_MODEL_CONFIG); + lookupComp.model.value = new FormFieldMetadataValueObject('Name, Lastname', null, 'test001'); + lookupFixture.detectChanges(); + + }); + afterEach(() => { + lookupFixture.destroy(); + lookupComp = null; + }); + it('should init component properly', () => { + expect(lookupComp.firstInputValue).toBe('Name'); + expect(lookupComp.secondInputValue).toBe('Lastname'); + }); }); }); }); - - describe('when model is DynamicLookupNameModel', () => { - - describe('', () => { - beforeEach(() => { - - lookupFixture = TestBed.createComponent(DsDynamicLookupComponent); - lookupComp = lookupFixture.componentInstance; // FormComponent test instance - lookupComp.group = LOOKUP_TEST_GROUP; - lookupComp.model = new DynamicLookupNameModel(LOOKUP_NAME_TEST_MODEL_CONFIG); - lookupFixture.detectChanges(); - - // spyOn(store, 'dispatch'); - }); - - it('should render two input element', () => { - const de = lookupFixture.debugElement.queryAll(By.css('input.form-control')); - expect(de.length).toBe(2); - }); - - }); - - describe('and init model value is empty', () => { - - beforeEach(() => { - - lookupFixture = TestBed.createComponent(DsDynamicLookupComponent); - lookupComp = lookupFixture.componentInstance; // FormComponent test instance - lookupComp.group = LOOKUP_TEST_GROUP; - lookupComp.model = new DynamicLookupNameModel(LOOKUP_NAME_TEST_MODEL_CONFIG); - lookupFixture.detectChanges(); - }); - - it('should select a results entry properly', fakeAsync(() => { - const payload = [ - Object.assign(new AuthorityValueModel(), {id: 1, display: 'Name, Lastname', value: 1}), - Object.assign(new AuthorityValueModel(), {id: 2, display: 'NameTwo, LastnameTwo', value: 2}), - ]; - let de = lookupFixture.debugElement.queryAll(By.css('button')); - const btnEl = de[0].nativeElement; - const selectedValue = Object.assign(new AuthorityValueModel(), {id: 1, display: 'Name, Lastname', value: 1}); - - spyOn(lookupComp.change, 'emit'); - authorityServiceStub.setNewPayload(payload); - lookupComp.firstInputValue = 'test'; - lookupFixture.detectChanges(); - btnEl.click(); - tick(); - lookupFixture.detectChanges(); - de = lookupFixture.debugElement.queryAll(By.css('button.dropdown-item')); - const entryEl = de[0].nativeElement; - entryEl.click(); - - expect(lookupComp.firstInputValue).toEqual('Name'); - expect(lookupComp.secondInputValue).toEqual('Lastname'); - expect(lookupComp.model.value).toEqual(selectedValue); - expect(lookupComp.change.emit).toHaveBeenCalled(); - })); - - }); - - describe('and init model value is not empty', () => { - beforeEach(() => { - - lookupFixture = TestBed.createComponent(DsDynamicLookupComponent); - lookupComp = lookupFixture.componentInstance; // FormComponent test instance - lookupComp.group = LOOKUP_TEST_GROUP; - lookupComp.model = new DynamicLookupNameModel(LOOKUP_NAME_TEST_MODEL_CONFIG); - lookupComp.model.value = new FormFieldMetadataValueObject('Name, Lastname', null, 'test001'); - lookupFixture.detectChanges(); - - }); - - it('should init component properly', () => { - expect(lookupComp.firstInputValue).toBe('Name'); - expect(lookupComp.secondInputValue).toBe('Lastname'); - }); - }); - - }); }); // declare a test component @@ -337,7 +425,4 @@ class TestComponent { inputLookupModelConfig = LOOKUP_TEST_MODEL_CONFIG; model = new DynamicLookupModel(this.inputLookupModelConfig); - - showErrorMessages = false; - } diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/lookup/dynamic-lookup.component.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/lookup/dynamic-lookup.component.ts index 4e88e9c78e..2a2ee64e9e 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/lookup/dynamic-lookup.component.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/lookup/dynamic-lookup.component.ts @@ -1,3 +1,5 @@ + +import {distinctUntilChanged} from 'rxjs/operators'; import { ChangeDetectorRef, Component, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core'; import { FormGroup } from '@angular/forms'; @@ -7,21 +9,25 @@ import { IntegrationSearchOptions } from '../../../../../../core/integration/mod import { hasValue, isEmpty, isNotEmpty, isNull, isUndefined } from '../../../../../empty.util'; import { IntegrationData } from '../../../../../../core/integration/integration-data'; import { PageInfo } from '../../../../../../core/shared/page-info.model'; -import { Subscription } from 'rxjs/Subscription'; +import { Subscription } from 'rxjs'; import { FormFieldMetadataValueObject } from '../../../models/form-field-metadata-value.model'; import { AuthorityValueModel } from '../../../../../../core/integration/models/authority-value.model'; import { DynamicLookupNameModel } from './dynamic-lookup-name.model'; +import { + DynamicFormControlComponent, + DynamicFormLayoutService, + DynamicFormValidationService +} from '@ng-dynamic-forms/core'; @Component({ selector: 'ds-dynamic-lookup', styleUrls: ['./dynamic-lookup.component.scss'], templateUrl: './dynamic-lookup.component.html' }) -export class DsDynamicLookupComponent implements OnDestroy, OnInit { +export class DsDynamicLookupComponent extends DynamicFormControlComponent implements OnDestroy, OnInit { @Input() bindId = true; @Input() group: FormGroup; @Input() model: DynamicLookupModel | DynamicLookupNameModel; - @Input() showErrorMessages = false; @Output() blur: EventEmitter = new EventEmitter(); @Output() change: EventEmitter = new EventEmitter(); @@ -37,7 +43,11 @@ export class DsDynamicLookupComponent implements OnDestroy, OnInit { protected sub: Subscription; constructor(private authorityService: AuthorityService, - private cdr: ChangeDetectorRef) { + private cdr: ChangeDetectorRef, + protected layoutService: DynamicFormLayoutService, + protected validationService: DynamicFormValidationService + ) { + super(layoutService, validationService); } ngOnInit() { @@ -137,8 +147,8 @@ export class DsDynamicLookupComponent implements OnDestroy, OnInit { this.searchOptions.query = this.getCurrentValue(); this.loading = true; - this.authorityService.getEntriesByName(this.searchOptions) - .distinctUntilChanged() + this.authorityService.getEntriesByName(this.searchOptions).pipe( + distinctUntilChanged()) .subscribe((object: IntegrationData) => { this.optionsList = object.payload; this.pageInfo = object.pageInfo; diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component.spec.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component.spec.ts index 49cdb5d890..6902530956 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component.spec.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component.spec.ts @@ -6,7 +6,11 @@ import { async, ComponentFixture, fakeAsync, inject, TestBed, tick, } from '@ang import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; import { AuthorityOptions } from '../../../../../../core/integration/models/authority-options.model'; -import { DynamicFormsCoreModule } from '@ng-dynamic-forms/core'; +import { + DynamicFormLayoutService, + DynamicFormsCoreModule, + DynamicFormValidationService +} from '@ng-dynamic-forms/core'; import { DynamicFormsNGBootstrapUIModule } from '@ng-dynamic-forms/ui-ng-bootstrap'; import { AuthorityService } from '../../../../../../core/integration/authority.service'; import { AuthorityServiceStub } from '../../../../../testing/authority-service-stub'; @@ -77,6 +81,8 @@ describe('Dynamic Dynamic Scrollable Dropdown component', () => { ChangeDetectorRef, DsDynamicScrollableDropdownComponent, {provide: AuthorityService, useValue: authorityServiceStub}, + {provide: DynamicFormLayoutService, useValue: {}}, + {provide: DynamicFormValidationService, useValue: {}} ], schemas: [CUSTOM_ELEMENTS_SCHEMA] }); @@ -90,7 +96,6 @@ describe('Dynamic Dynamic Scrollable Dropdown component', () => { `; diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component.ts index 1c8bf15f1a..02468f9fbf 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component.ts @@ -1,3 +1,5 @@ + +import {tap} from 'rxjs/operators'; import { ChangeDetectorRef, Component, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core'; import { FormGroup } from '@angular/forms'; @@ -9,17 +11,21 @@ import { IntegrationSearchOptions } from '../../../../../../core/integration/mod import { IntegrationData } from '../../../../../../core/integration/integration-data'; import { AuthorityValueModel } from '../../../../../../core/integration/models/authority-value.model'; import { NgbDropdown } from '@ng-bootstrap/ng-bootstrap'; +import { + DynamicFormControlComponent, + DynamicFormLayoutService, + DynamicFormValidationService +} from '@ng-dynamic-forms/core'; @Component({ selector: 'ds-dynamic-scrollable-dropdown', styleUrls: ['./dynamic-scrollable-dropdown.component.scss'], templateUrl: './dynamic-scrollable-dropdown.component.html' }) -export class DsDynamicScrollableDropdownComponent implements OnInit { +export class DsDynamicScrollableDropdownComponent extends DynamicFormControlComponent implements OnInit { @Input() bindId = true; @Input() group: FormGroup; @Input() model: DynamicScrollableDropdownModel; - @Input() showErrorMessages = false; @Output() blur: EventEmitter = new EventEmitter(); @Output() change: EventEmitter = new EventEmitter(); @@ -31,7 +37,13 @@ export class DsDynamicScrollableDropdownComponent implements OnInit { protected searchOptions: IntegrationSearchOptions; - constructor(private authorityService: AuthorityService, private cdr: ChangeDetectorRef) {} + constructor(private authorityService: AuthorityService, + private cdr: ChangeDetectorRef, + protected layoutService: DynamicFormLayoutService, + protected validationService: DynamicFormValidationService + ) { + super(layoutService, validationService); + } ngOnInit() { this.searchOptions = new IntegrationSearchOptions( @@ -66,8 +78,8 @@ export class DsDynamicScrollableDropdownComponent implements OnInit { if (!this.loading && this.pageInfo.currentPage <= this.pageInfo.totalPages) { this.loading = true; this.searchOptions.currentPage++; - this.authorityService.getEntriesByName(this.searchOptions) - .do(() => this.loading = false) + this.authorityService.getEntriesByName(this.searchOptions).pipe( + tap(() => this.loading = false)) .subscribe((object: IntegrationData) => { this.optionsList = this.optionsList.concat(object.payload); this.pageInfo = object.pageInfo; diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/tag/dynamic-tag.component.spec.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/tag/dynamic-tag.component.spec.ts index 24959f4be4..9eaa23c004 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/tag/dynamic-tag.component.spec.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/tag/dynamic-tag.component.spec.ts @@ -2,12 +2,15 @@ import { ChangeDetectorRef, Component, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; import { FormControl, FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'; import { async, ComponentFixture, fakeAsync, flush, inject, TestBed, } from '@angular/core/testing'; +import { of as observableOf } from 'rxjs'; -import { DynamicFormsCoreModule } from '@ng-dynamic-forms/core'; +import { + DynamicFormLayoutService, + DynamicFormsCoreModule, + DynamicFormValidationService +} from '@ng-dynamic-forms/core'; import { DynamicFormsNGBootstrapUIModule } from '@ng-dynamic-forms/ui-ng-bootstrap'; import { NgbModule, NgbTypeaheadSelectItemEvent } from '@ng-bootstrap/ng-bootstrap'; -import { Observable } from 'rxjs/Observable'; -import 'rxjs/add/observable/of' import { AuthorityOptions } from '../../../../../../core/integration/models/authority-options.model'; import { AuthorityService } from '../../../../../../core/integration/authority.service'; @@ -34,27 +37,32 @@ function createKeyUpEvent(key: number) { return event; } -export const TAG_TEST_GROUP = new FormGroup({ - tag: new FormControl(), -}); +let TAG_TEST_GROUP; +let TAG_TEST_MODEL_CONFIG; -export const TAG_TEST_MODEL_CONFIG = { - authorityOptions: { - closed: false, - metadata: 'tag', - name: 'common_iso_languages', - scope: 'c1c16450-d56f-41bc-bb81-27f1d1eb5c23' - } as AuthorityOptions, - disabled: false, - id: 'tag', - label: 'Keywords', - minChars: 3, - name: 'tag', - placeholder: 'Keywords', - readOnly: false, - required: false, - repeatable: false -}; +function init() { + TAG_TEST_GROUP = new FormGroup({ + tag: new FormControl(), + }); + + TAG_TEST_MODEL_CONFIG = { + authorityOptions: { + closed: false, + metadata: 'tag', + name: 'common_iso_languages', + scope: 'c1c16450-d56f-41bc-bb81-27f1d1eb5c23' + } as AuthorityOptions, + disabled: false, + id: 'tag', + label: 'Keywords', + minChars: 3, + name: 'tag', + placeholder: 'Keywords', + readOnly: false, + required: false, + repeatable: false + }; +} describe('DsDynamicTagComponent test suite', () => { @@ -69,7 +77,7 @@ describe('DsDynamicTagComponent test suite', () => { // async beforeEach beforeEach(async(() => { const authorityServiceStub = new AuthorityServiceStub(); - + init(); TestBed.configureTestingModule({ imports: [ DynamicFormsCoreModule, @@ -85,8 +93,10 @@ describe('DsDynamicTagComponent test suite', () => { providers: [ ChangeDetectorRef, DsDynamicTagComponent, - {provide: AuthorityService, useValue: authorityServiceStub}, - {provide: GLOBAL_CONFIG, useValue: {} as GlobalConfig}, + { provide: AuthorityService, useValue: authorityServiceStub }, + { provide: GLOBAL_CONFIG, useValue: {} as GlobalConfig }, + { provide: DynamicFormLayoutService, useValue: {} }, + { provide: DynamicFormValidationService, useValue: {} } ], schemas: [CUSTOM_ELEMENTS_SCHEMA] }); @@ -100,7 +110,6 @@ describe('DsDynamicTagComponent test suite', () => { `; @@ -108,14 +117,16 @@ describe('DsDynamicTagComponent test suite', () => { testFixture = createTestComponent(html, TestComponent) as ComponentFixture; testComp = testFixture.componentInstance; }); - + afterEach(() => { + testFixture.destroy(); + }); it('should create DsDynamicTagComponent', inject([DsDynamicTagComponent], (app: DsDynamicTagComponent) => { expect(app).toBeDefined(); })); }); - describe('when authorityOptions are setted', () => { + describe('when authorityOptions are set', () => { describe('and init model value is empty', () => { beforeEach(() => { @@ -134,23 +145,28 @@ describe('DsDynamicTagComponent test suite', () => { it('should init component properly', () => { chips = new Chips([], 'display'); expect(tagComp.chips.getChipsItems()).toEqual(chips.getChipsItems()); + expect(tagComp.searchOptions).toBeDefined(); }); it('should search when 3+ characters typed', fakeAsync(() => { spyOn((tagComp as any).authorityService, 'getEntriesByName').and.callThrough(); - tagComp.search(Observable.of('test')).subscribe(() => { + tagComp.search(observableOf('test')).subscribe(() => { expect((tagComp as any).authorityService.getEntriesByName).toHaveBeenCalled(); }); })); it('should select a results entry properly', fakeAsync(() => { modelValue = [ - Object.assign(new AuthorityValueModel(), {id: 1, display: 'Name, Lastname', value: 1}) + Object.assign(new AuthorityValueModel(), { id: 1, display: 'Name, Lastname', value: 1 }) ]; const event: NgbTypeaheadSelectItemEvent = { - item: Object.assign(new AuthorityValueModel(), {id: 1, display: 'Name, Lastname', value: 1}), + item: Object.assign(new AuthorityValueModel(), { + id: 1, + display: 'Name, Lastname', + value: 1 + }), preventDefault: () => { return; } @@ -225,7 +241,7 @@ describe('DsDynamicTagComponent test suite', () => { }); - describe('when authorityOptions are not setted', () => { + describe('when authorityOptions are not set', () => { describe('and init model value is empty', () => { beforeEach(() => { diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/tag/dynamic-tag.component.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/tag/dynamic-tag.component.ts index ac23e665d0..b8ef84d48d 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/tag/dynamic-tag.component.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/tag/dynamic-tag.component.ts @@ -1,7 +1,8 @@ +import {of as observableOf, Observable } from 'rxjs'; + +import {catchError, debounceTime, distinctUntilChanged, tap, switchMap, map, merge} from 'rxjs/operators'; import { ChangeDetectorRef, Component, EventEmitter, Inject, Input, OnInit, Output } from '@angular/core'; import { FormGroup } from '@angular/forms'; - -import { Observable } from 'rxjs/Observable'; import { NgbTypeaheadSelectItemEvent } from '@ng-bootstrap/ng-bootstrap'; import { AuthorityService } from '../../../../../../core/integration/authority.service'; @@ -12,17 +13,21 @@ import { hasValue, isNotEmpty } from '../../../../../empty.util'; import { isEqual } from 'lodash'; import { GlobalConfig } from '../../../../../../../config/global-config.interface'; import { GLOBAL_CONFIG } from '../../../../../../../config'; +import { + DynamicFormControlComponent, + DynamicFormLayoutService, + DynamicFormValidationService +} from '@ng-dynamic-forms/core'; @Component({ selector: 'ds-dynamic-tag', styleUrls: ['./dynamic-tag.component.scss'], templateUrl: './dynamic-tag.component.html' }) -export class DsDynamicTagComponent implements OnInit { +export class DsDynamicTagComponent extends DynamicFormControlComponent implements OnInit { @Input() bindId = true; @Input() group: FormGroup; @Input() model: DynamicTagModel; - @Input() showErrorMessages = false; @Output() blur: EventEmitter = new EventEmitter(); @Output() change: EventEmitter = new EventEmitter(); @@ -40,41 +45,46 @@ export class DsDynamicTagComponent implements OnInit { formatter = (x: { display: string }) => x.display; search = (text$: Observable) => - text$ - .debounceTime(300) - .distinctUntilChanged() - .do(() => this.changeSearchingStatus(true)) - .switchMap((term) => { + text$.pipe( + debounceTime(300), + distinctUntilChanged(), + tap(() => this.changeSearchingStatus(true)), + switchMap((term) => { if (term === '' || term.length < this.model.minChars) { - return Observable.of({list: []}); + return observableOf({list: []}); } else { this.searchOptions.query = term; - return this.authorityService.getEntriesByName(this.searchOptions) - .map((authorities) => { + return this.authorityService.getEntriesByName(this.searchOptions).pipe( + map((authorities) => { // @TODO Pagination for authority is not working, to refactor when it will be fixed return { list: authorities.payload, pageInfo: authorities.pageInfo }; - }) - .do(() => this.searchFailed = false) - .catch(() => { + }), + tap(() => this.searchFailed = false), + catchError(() => { this.searchFailed = true; - return Observable.of({list: []}); - }); + return observableOf({list: []}); + }),); } - }) - .map((results) => results.list) - .do(() => this.changeSearchingStatus(false)) - .merge(this.hideSearchingWhenUnsubscribed); + }), + map((results) => results.list), + tap(() => this.changeSearchingStatus(false)), + merge(this.hideSearchingWhenUnsubscribed),); constructor(@Inject(GLOBAL_CONFIG) protected EnvConfig: GlobalConfig, private authorityService: AuthorityService, - private cdr: ChangeDetectorRef) { + private cdr: ChangeDetectorRef, + protected layoutService: DynamicFormLayoutService, + protected validationService: DynamicFormValidationService + ) { + super(layoutService, validationService); } ngOnInit() { this.hasAuthority = this.model.authorityOptions && hasValue(this.model.authorityOptions.name); + if (this.hasAuthority) { this.searchOptions = new IntegrationSearchOptions( this.model.authorityOptions.scope, diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/typeahead/dynamic-typeahead.component.spec.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/typeahead/dynamic-typeahead.component.spec.ts index 2ed145b03a..c950f8f4ef 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/typeahead/dynamic-typeahead.component.spec.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/typeahead/dynamic-typeahead.component.spec.ts @@ -3,12 +3,14 @@ import { ChangeDetectorRef, Component, CUSTOM_ELEMENTS_SCHEMA } from '@angular/c import { FormControl, FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'; import { async, ComponentFixture, fakeAsync, inject, TestBed, } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; - -import { Observable } from 'rxjs/Observable'; -import 'rxjs/add/observable/of'; +import { of as observableOf } from 'rxjs'; import { AuthorityOptions } from '../../../../../../core/integration/models/authority-options.model'; -import { DynamicFormsCoreModule } from '@ng-dynamic-forms/core'; +import { + DynamicFormLayoutService, + DynamicFormsCoreModule, + DynamicFormValidationService +} from '@ng-dynamic-forms/core'; import { DynamicFormsNGBootstrapUIModule } from '@ng-dynamic-forms/ui-ng-bootstrap'; import { AuthorityService } from '../../../../../../core/integration/authority.service'; import { AuthorityServiceStub } from '../../../../../testing/authority-service-stub'; @@ -20,29 +22,34 @@ import { DynamicTypeaheadModel } from './dynamic-typeahead.model'; import { FormFieldMetadataValueObject } from '../../../models/form-field-metadata-value.model'; import { createTestComponent } from '../../../../../testing/utils'; -export const TYPEAHEAD_TEST_GROUP = new FormGroup({ - typeahead: new FormControl(), -}); +export let TYPEAHEAD_TEST_GROUP; -export const TYPEAHEAD_TEST_MODEL_CONFIG = { - authorityOptions: { - closed: false, - metadata: 'typeahead', - name: 'EVENTAuthority', - scope: 'c1c16450-d56f-41bc-bb81-27f1d1eb5c23' - } as AuthorityOptions, - disabled: false, - id: 'typeahead', - label: 'Conference', - minChars: 3, - name: 'typeahead', - placeholder: 'Conference', - readOnly: false, - required: false, - repeatable: false, - value: undefined -}; +export let TYPEAHEAD_TEST_MODEL_CONFIG; +function init() { + TYPEAHEAD_TEST_GROUP = new FormGroup({ + typeahead: new FormControl(), + }); + + TYPEAHEAD_TEST_MODEL_CONFIG = { + authorityOptions: { + closed: false, + metadata: 'typeahead', + name: 'EVENTAuthority', + scope: 'c1c16450-d56f-41bc-bb81-27f1d1eb5c23' + } as AuthorityOptions, + disabled: false, + id: 'typeahead', + label: 'Conference', + minChars: 3, + name: 'typeahead', + placeholder: 'Conference', + readOnly: false, + required: false, + repeatable: false, + value: undefined + }; +} describe('DsDynamicTypeaheadComponent test suite', () => { let testComp: TestComponent; @@ -54,7 +61,7 @@ describe('DsDynamicTypeaheadComponent test suite', () => { // async beforeEach beforeEach(async(() => { const authorityServiceStub = new AuthorityServiceStub(); - + init() TestBed.configureTestingModule({ imports: [ DynamicFormsCoreModule, @@ -70,8 +77,9 @@ describe('DsDynamicTypeaheadComponent test suite', () => { providers: [ ChangeDetectorRef, DsDynamicTypeaheadComponent, - {provide: AuthorityService, useValue: authorityServiceStub}, - {provide: GLOBAL_CONFIG, useValue: {} as GlobalConfig}, + { provide: AuthorityService, useValue: authorityServiceStub }, + { provide: DynamicFormLayoutService, useValue: {} }, + { provide: DynamicFormValidationService, useValue: {} } ], schemas: [CUSTOM_ELEMENTS_SCHEMA] }); @@ -85,7 +93,6 @@ describe('DsDynamicTypeaheadComponent test suite', () => { `; @@ -94,6 +101,9 @@ describe('DsDynamicTypeaheadComponent test suite', () => { testComp = testFixture.componentInstance; }); + afterEach(() => { + testFixture.destroy(); + }); it('should create DsDynamicTypeaheadComponent', inject([DsDynamicTypeaheadComponent], (app: DsDynamicTypeaheadComponent) => { expect(app).toBeDefined(); @@ -123,7 +133,7 @@ describe('DsDynamicTypeaheadComponent test suite', () => { it('should search when 3+ characters typed', fakeAsync(() => { spyOn((typeaheadComp as any).authorityService, 'getEntriesByName').and.callThrough(); - typeaheadComp.search(Observable.of('test')).subscribe(() => { + typeaheadComp.search(observableOf('test')).subscribe(() => { expect((typeaheadComp as any).authorityService.getEntriesByName).toHaveBeenCalled(); }); @@ -219,6 +229,4 @@ class TestComponent { model = new DynamicTypeaheadModel(TYPEAHEAD_TEST_MODEL_CONFIG); - showErrorMessages = false; - } diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/typeahead/dynamic-typeahead.component.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/typeahead/dynamic-typeahead.component.ts index dade5d037a..58f8030bcc 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/typeahead/dynamic-typeahead.component.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/typeahead/dynamic-typeahead.component.ts @@ -1,7 +1,9 @@ + +import {of as observableOf, Observable } from 'rxjs'; + +import {distinctUntilChanged, switchMap, tap, filter, catchError, debounceTime, merge, map} from 'rxjs/operators'; import { ChangeDetectorRef, Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; import { FormGroup } from '@angular/forms'; - -import { Observable } from 'rxjs/Observable'; import { NgbTypeaheadSelectItemEvent } from '@ng-bootstrap/ng-bootstrap'; import { AuthorityService } from '../../../../../../core/integration/authority.service'; @@ -9,17 +11,21 @@ import { DynamicTypeaheadModel } from './dynamic-typeahead.model'; import { IntegrationSearchOptions } from '../../../../../../core/integration/models/integration-options.model'; import { isEmpty, isNotEmpty } from '../../../../../empty.util'; import { FormFieldMetadataValueObject } from '../../../models/form-field-metadata-value.model'; +import { + DynamicFormControlComponent, + DynamicFormLayoutService, + DynamicFormValidationService +} from '@ng-dynamic-forms/core'; @Component({ selector: 'ds-dynamic-typeahead', styleUrls: ['./dynamic-typeahead.component.scss'], templateUrl: './dynamic-typeahead.component.html' }) -export class DsDynamicTypeaheadComponent implements OnInit { +export class DsDynamicTypeaheadComponent extends DynamicFormControlComponent implements OnInit { @Input() bindId = true; @Input() group: FormGroup; @Input() model: DynamicTypeaheadModel; - @Input() showErrorMessages = false; @Output() blur: EventEmitter = new EventEmitter(); @Output() change: EventEmitter = new EventEmitter(); @@ -37,35 +43,40 @@ export class DsDynamicTypeaheadComponent implements OnInit { }; search = (text$: Observable) => - text$ - .debounceTime(300) - .distinctUntilChanged() - .do(() => this.changeSearchingStatus(true)) - .switchMap((term) => { + text$.pipe( + debounceTime(300), + distinctUntilChanged(), + tap(() => this.changeSearchingStatus(true)), + switchMap((term) => { if (term === '' || term.length < this.model.minChars) { - return Observable.of({list: []}); + return observableOf({list: []}); } else { this.searchOptions.query = term; - return this.authorityService.getEntriesByName(this.searchOptions) - .map((authorities) => { + return this.authorityService.getEntriesByName(this.searchOptions).pipe( + map((authorities) => { // @TODO Pagination for authority is not working, to refactor when it will be fixed return { list: authorities.payload, pageInfo: authorities.pageInfo }; - }) - .do(() => this.searchFailed = false) - .catch(() => { + }), + tap(() => this.searchFailed = false), + catchError(() => { this.searchFailed = true; - return Observable.of({list: []}); - }); + return observableOf({list: []}); + }),); } - }) - .map((results) => results.list) - .do(() => this.changeSearchingStatus(false)) - .merge(this.hideSearchingWhenUnsubscribed); + }), + map((results) => results.list), + tap(() => this.changeSearchingStatus(false)), + merge(this.hideSearchingWhenUnsubscribed),); - constructor(private authorityService: AuthorityService, private cdr: ChangeDetectorRef) { + constructor(private authorityService: AuthorityService, + private cdr: ChangeDetectorRef, + protected layoutService: DynamicFormLayoutService, + protected validationService: DynamicFormValidationService + ) { + super(layoutService, validationService); } ngOnInit() { @@ -74,8 +85,8 @@ export class DsDynamicTypeaheadComponent implements OnInit { this.model.authorityOptions.scope, this.model.authorityOptions.name, this.model.authorityOptions.metadata); - this.group.get(this.model.id).valueChanges - .filter((value) => this.currentValue !== value) + this.group.get(this.model.id).valueChanges.pipe( + filter((value) => this.currentValue !== value)) .subscribe((value) => { this.currentValue = value; }); diff --git a/src/app/shared/form/builder/form-builder.service.spec.ts b/src/app/shared/form/builder/form-builder.service.spec.ts index 12f51166b5..5266afabfd 100644 --- a/src/app/shared/form/builder/form-builder.service.spec.ts +++ b/src/app/shared/form/builder/form-builder.service.spec.ts @@ -13,13 +13,10 @@ import { DynamicColorPickerModel, DynamicDatePickerModel, DynamicEditorModel, - DynamicFileUploadModel, DynamicFormArrayGroupModel, + DynamicFileUploadModel, DynamicFormArrayModel, DynamicFormControlModel, - DynamicFormControlValue, - DynamicFormGroupModel, - DynamicFormService, - DynamicFormValidationService, + DynamicFormGroupModel, DynamicFormValidationService, DynamicFormValueControlModel, DynamicInputModel, DynamicRadioGroupModel, @@ -41,7 +38,10 @@ import { DynamicTypeaheadModel } from './ds-dynamic-form-ui/models/typeahead/dyn import { DynamicListRadioGroupModel } from './ds-dynamic-form-ui/models/list/dynamic-list-radio-group.model'; import { AuthorityOptions } from '../../../core/integration/models/authority-options.model'; import { FormFieldModel } from './models/form-field.model'; -import { FormRowModel, SubmissionFormsModel } from '../../../core/shared/config/config-submission-forms.model'; +import { + FormRowModel, + SubmissionFormsModel +} from '../../../core/shared/config/config-submission-forms.model'; import { FormBuilderService } from './form-builder.service'; import { DynamicRowGroupModel } from './ds-dynamic-form-ui/models/ds-dynamic-row-group-model'; import { DsDynamicInputModel } from './ds-dynamic-form-ui/models/ds-dynamic-input.model'; @@ -69,9 +69,8 @@ describe('FormBuilderService test suite', () => { TestBed.configureTestingModule({ imports: [ReactiveFormsModule], providers: [ - FormBuilderService, - DynamicFormService, - DynamicFormValidationService, + {provide: FormBuilderService, useClass: FormBuilderService}, + {provide: DynamicFormValidationService, useValue: {}}, {provide: NG_VALIDATORS, useValue: testValidator, multi: true}, {provide: NG_ASYNC_VALIDATORS, useValue: testAsyncValidator, multi: true} ] @@ -255,7 +254,7 @@ describe('FormBuilderService test suite', () => { { id: 'testFormRowArray', initialCount: 5, - notRepeteable: false, + notRepeatable: false, groupFactory: () => { return [ new DynamicInputModel({id: 'testFormRowArrayGroupInput'}) @@ -761,8 +760,8 @@ describe('FormBuilderService test suite', () => { (formArray.at(index) as FormGroup).controls.testFormArrayGroupInput.setValue('next test value 1'); (formArray.at(index + step) as FormGroup).controls.testFormArrayGroupInput.setValue('next test value 2'); - (model.get(index).get(0) as DynamicFormValueControlModel).valueUpdates.next('next test value 1'); - (model.get(index + step).get(0) as DynamicFormValueControlModel).valueUpdates.next('next test value 2'); + (model.get(index).get(0) as DynamicFormValueControlModel).valueUpdates.next('next test value 1'); + (model.get(index + step).get(0) as DynamicFormValueControlModel).valueUpdates.next('next test value 2'); service.moveFormArrayGroup(index, step, formArray, model); @@ -771,8 +770,8 @@ describe('FormBuilderService test suite', () => { expect((formArray.at(index) as FormGroup).controls.testFormArrayGroupInput.value).toEqual('next test value 2'); expect((formArray.at(index + step) as FormGroup).controls.testFormArrayGroupInput.value).toEqual('next test value 1'); - expect((model.get(index).get(0) as DynamicFormValueControlModel).value).toEqual('next test value 2'); - expect((model.get(index + step).get(0) as DynamicFormValueControlModel).value).toEqual('next test value 1'); + expect((model.get(index).get(0) as DynamicFormValueControlModel).value).toEqual('next test value 2'); + expect((model.get(index + step).get(0) as DynamicFormValueControlModel).value).toEqual('next test value 1'); }); it('should move down a form array group', () => { @@ -785,8 +784,8 @@ describe('FormBuilderService test suite', () => { (formArray.at(index) as FormGroup).controls.testFormArrayGroupInput.setValue('next test value 1'); (formArray.at(index + step) as FormGroup).controls.testFormArrayGroupInput.setValue('next test value 2'); - (model.get(index).get(0) as DynamicFormValueControlModel).valueUpdates.next('next test value 1'); - (model.get(index + step).get(0) as DynamicFormValueControlModel).valueUpdates.next('next test value 2'); + (model.get(index).get(0) as DynamicFormValueControlModel).valueUpdates.next('next test value 1'); + (model.get(index + step).get(0) as DynamicFormValueControlModel).valueUpdates.next('next test value 2'); service.moveFormArrayGroup(index, step, formArray, model); @@ -795,8 +794,8 @@ describe('FormBuilderService test suite', () => { expect((formArray.at(index) as FormGroup).controls.testFormArrayGroupInput.value).toEqual('next test value 2'); expect((formArray.at(index + step) as FormGroup).controls.testFormArrayGroupInput.value).toEqual('next test value 1'); - expect((model.get(index).get(0) as DynamicFormValueControlModel).value).toEqual('next test value 2'); - expect((model.get(index + step).get(0) as DynamicFormValueControlModel).value).toEqual('next test value 1'); + expect((model.get(index).get(0) as DynamicFormValueControlModel).value).toEqual('next test value 2'); + expect((model.get(index + step).get(0) as DynamicFormValueControlModel).value).toEqual('next test value 1'); }); it('should throw when form array group is to be moved out of bounds', () => { diff --git a/src/app/shared/form/builder/parsers/field-parser.ts b/src/app/shared/form/builder/parsers/field-parser.ts index f37b3868f3..3286b3fdbb 100644 --- a/src/app/shared/form/builder/parsers/field-parser.ts +++ b/src/app/shared/form/builder/parsers/field-parser.ts @@ -35,7 +35,7 @@ export abstract class FieldParser { id: uniqueId() + '_array', label: this.configData.label, initialCount: this.getInitArrayIndex(), - notRepeteable: !this.configData.repeatable, + notRepeatable: !this.configData.repeatable, groupFactory: () => { let model; if ((arrayCounter === 0)) { diff --git a/src/app/shared/form/form.component.html b/src/app/shared/form/form.component.html index 1b5f2ef72f..958c9a6c73 100644 --- a/src/app/shared/form/form.component.html +++ b/src/app/shared/form/form.component.html @@ -14,7 +14,7 @@ -