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 0936b27ea4..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,40 +63,42 @@ "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", - "angular2-moment": "^1.9.0", + "@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-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.1", + "bootstrap": "4.1.3", "cerialize": "0.1.18", "compression": "1.7.1", "cookie-parser": "1.4.3", - "core-js": "2.5.3", + "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", - "ng2-nouislider": "^1.7.11", + "ng-mocks": "^6.2.1", "ng2-file-upload": "1.2.1", - "ngx-infinite-scroll": "0.8.2", + "ng2-nouislider": "^1.7.11", + "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/cs.json b/resources/i18n/cs.json new file mode 100644 index 0000000000..1fdd02401b --- /dev/null +++ b/resources/i18n/cs.json @@ -0,0 +1,277 @@ +{ + "footer": { + "copyright": "copyright © 2002-{{ year }}", + "link.dspace": "software DSpace", + "link.duraspace": "DuraSpace" + }, + "collection": { + "page": { + "news": "Novinky", + "license": "Licence", + "browse": { + "recent": { + "head": "Poslední příspěvky" + } + } + } + }, + "community": { + "page": { + "news": "Novinky", + "license": "Licence" + }, + "sub-collection-list": { + "head": "Kolekce v této komunitě" + } + }, + "item": { + "page": { + "author": "Autor", + "abstract": "Abstract", + "date": "Datum", + "uri": "URI", + "files": "Soubory", + "collections": "Kolekce", + "filesection": { + "download": "Stáhnout", + "name": "Název:", + "format": "Formát:", + "size": "Velikost:", + "description": "Popis:" + }, + "link": { + "simple": "Minimální záznam", + "full": "Úplný záznam" + } + } + }, + "nav": { + "home": "Domů", + "login": "Přihlásit se", + "logout": "Odhlásit se" + }, + "pagination": { + "results-per-page": "Výsledků na stránku", + "sort-direction": "Seřazení", + "showing": { + "label": "Zobrazují se záznamy ", + "detail": "{{ range }} z {{ total }}" + } + }, + "sorting": { + "score": { + "DESC": "Relevance" + }, + "dc.title": { + "ASC": "Název vzestupně", + "DESC": "Název sestupně" + } + }, + "title": "DSpace", + "404": { + "help": "Nepodařilo se najít stránku, kterou hledáte. Je možné, že stránka byla přesunuta nebo smazána. Pomocí tlačítka níže můžete přejít na domovskou stránku. ", + "page-not-found": "stránka nenalezena", + "link": { + "home-page": "Přejít na domovskou stránku" + } + }, + "home": { + "title": "DSpace Angular :: Domů", + "description": "", + "top-level-communities": { + "head": "Komunity v DSpace", + "help": "Vybráním komunity můžete prohlížet její kolekce." + } + }, + "search": { + "title": "DSpace Angular :: Hledat", + "description": "", + "form": { + "search": "Hledat", + "search_dspace": "Hledat v DSpace" + }, + "results": { + "head": "Výsledky hledání", + "no-results": "Nebyli nalezeny žádné výsledky" + }, + "sidebar": { + "close": "Zpět na výsledky", + "open": "Vyhledávací nástroje", + "results": "výsledky", + "filters": { + "title": "Filtry" + }, + "settings": { + "title": "Nastavení", + "sort-by": "Řadit dle", + "rpp": "Výsledků na stránku" + } + }, + "view-switch": { + "show-list": "Zobrazit seznam", + "show-grid": "Zobrazit mřížku" + }, + "filters": { + "head": "Filtry", + "reset": "Obnovit filtry", + "applied": { + "f.author": "Autor", + "f.dateIssued.min": "Od data", + "f.dateIssued.max": "Do data", + "f.subject": "Předmět", + "f.has_content_in_original_bundle": "Má soubory" + }, + "filter": { + "show-more": "Zobrazit více", + "show-less": "Sbalit", + "author": { + "placeholder": "Jméno autora", + "head": "Autor" + }, + "scope": { + "placeholder": "Filtr rozsahu", + "head": "Rozsah" + }, + "subject": { + "placeholder": "Předmět", + "head": "Předmět" + }, + "dateIssued": { + "max": { + "placeholder": "Datum od" + }, + "min": { + "placeholder": "Datum do" + }, + "head": "Datum" + }, + "has_content_in_original_bundle": { + "head": "Má soubory" + } + } + } + }, + "browse": { + "title": "Prohlížíte {{ collection }} dle {{ field }} {{ value }}" + }, + "admin": { + "registries": { + "metadata": { + "title": "DSpace Angular :: Registr metadat", + "head": "Registr metadat", + "description": "Registr metadat je seznam všech metadatových polí dostupných v repozitáři. Tyto pole mohou být rozdělena do více schémat. DSpace však vyžaduje použití schématu kvalifikový Dublin Core.", + "schemas": { + "table": { + "id": "ID", + "namespace": "Jmenný prostor", + "name": "Název" + }, + "no-items": "Žádná schémata metadat." + } + }, + "schema": { + "title": "DSpace Angular :: Registr schémat metadat", + "head": "Metadata Schema", + "description": "Toto je schéma metadat pro „{{namespace}}“.", + "fields": { + "head": "Pole schématu metadat", + "table": { + "field": "Pole", + "scopenote": "Poznámka o rozsahu" + }, + "no-items": "Žádná metadatová pole." + } + }, + "bitstream-formats": { + "title": "DSpace Angular :: Registr formátů souborů", + "head": "Registr formátů souborů", + "description": "Tento seznam formátů souborů poskytuje informace o známých formátech a o úrovni jejich podpory.", + "formats": { + "table": { + "name": "Název", + "mimetype": "Typ MIME", + "supportLevel": { + "head": "Úroveň podpory", + "0": "Neznámá", + "1": "Známá", + "2": "Podpora" + }, + "internal": "interní" + }, + "no-items": "Žádné formáty souborů." + } + } + } + }, + "loading": { + "default": "Načítá se...", + "top-level-communities": "Načítají se komunity nejvyšší úrovně...", + "community": "Načítá se komunita...", + "collection": "Načítá se kolekce...", + "sub-collections": "Načítají se subkolekce...", + "recent-submissions": "Načítají se poslední příspěvky...", + "item": "Načítá se záznam...", + "objects": "Načítá se...", + "search-results": "Načítají se výsledky hledání...", + "browse-by": "Načítají se záznamy..." + }, + "error": { + "default": "Chyba", + "top-level-communities": "Chyba během stahování komunit nejvyšší úrovně", + "community": "Chyba během stahování komunity", + "collection": "Chyba během stahování kolekce", + "sub-collections": "Chyba během stahování subkolekcí", + "recent-submissions": "Chyba během stahování posledních příspěvků", + "item": "Chyba během stahování záznamu", + "objects": "Chyba během stahování objektů", + "search-results": "Chyba během stahování výsledků hledání", + "browse-by": "Chyba během stahování záznamů", + "validation": { + "pattern": "Tento vstup je omezen dle vzoru: {{ pattern }}.", + "license": { + "notgranted": "Pro dokončení zaslání Musíte udělit licenci. Pokud v tuto chvíli tuto licenci nemůžete udělit, můžete svou práci uložit a později se k svému příspěveku vrátit nebo jej smazat." + } + } + }, + "form": { + "submit": "Odeslat", + "cancel": "Zrušit", + "search": "Hledat", + "remove": "Smazat", + "first-name": "Křestní jméno", + "last-name": "Příjmení", + "loading": "Načítá se...", + "no-results": "Nebyli nalezeny žádné výsledky", + "no-value": "Nebyla zadána hodnota", + "group-collapse": "Sbalit", + "group-expand": "Rozbalit", + "group-collapse-help": "Kliknutím sem sbalíte", + "group-expand-help": "Kliknutím sem rozbalíte a přidáte další prvky" + }, + "login": { + "title": "Přihlásit se", + "form": { + "header": "Prosím, přihlaste se do DSpace", + "email": "E-mailová adresa", + "forgot-password": "Zapomněli jste své heslo?", + "new-user": "Nový uživatel? Zaregistrujte se kliknutím sem.", + "password": "Heslo", + "submit": "Přihlásit se" + } + }, + "logout": { + "title": "Odhlásit se", + "form": { + "header": "Odhlásit se z DSpace", + "submit": "Odhlásit se" + } + }, + "auth": { + "messages": { + "expired": "Vaše relace vypršela. Prosím, znova se přihlaste." + }, + "errors": { + "invalid-user": "Neplatná e-mailová adresa nebo heslo." + } + } +} diff --git a/resources/i18n/de.json b/resources/i18n/de.json new file mode 100644 index 0000000000..d6b02ff533 --- /dev/null +++ b/resources/i18n/de.json @@ -0,0 +1,277 @@ +{ + "footer": { + "copyright": "Copyright © 2002-{{ year }}", + "link.dspace": "DSpace Software", + "link.duraspace": "DuraSpace" + }, + "collection": { + "page": { + "news": "Neuigkeiten", + "license": "Lizenz", + "browse": { + "recent": { + "head": "Aktuellste Veröffentlichungen" + } + } + } + }, + "community": { + "page": { + "news": "Neuigkeiten", + "license": "Lizenz" + }, + "sub-collection-list": { + "head": "Sammlungen in diesem Bereich" + } + }, + "item": { + "page": { + "author": "Autor", + "abstract": "Kurzfassung", + "date": "Datum", + "uri": "URI", + "files": "Dateien", + "collections": "Sammlungen", + "filesection": { + "download": "Herunterladen", + "name": "Name:", + "format": "Format:", + "size": "Größe:", + "description": "Beschreibung:" + }, + "link": { + "simple": "Kurzanzeige", + "full": "Vollanzeige" + } + } + }, + "nav": { + "home": "Zur Startseite", + "login": "Anmelden", + "logout": "Abmelden" + }, + "pagination": { + "results-per-page": "Ergebnisse pro Seite", + "sort-direction": "Sortiermöglichkeiten", + "showing": { + "label": "Anzeige der Treffer ", + "detail": "{{ range }} bis {{ total }}" + } + }, + "sorting": { + "score": { + "DESC": "Relevanz" + }, + "dc.title": { + "ASC": "Titel aufsteigend", + "DESC": "Titel absteigend" + } + }, + "title": "DSpace", + "404": { + "help": "Die Seite, die Sie aufrufen wollten, konnte nicht gefunden werden. Sie könnte verschoben oder gelöscht worden sein. Mit dem Link unten kommen Sie zurück zur Startseite. ", + "page-not-found": "Seite nicht gefunden", + "link": { + "home-page": "Zurück zur Startseite" + } + }, + "home": { + "title": "DSpace Angular :: Startseite", + "description": "", + "top-level-communities": { + "head": "Bereiche in DSpace", + "help": "Wählen Sie einen Bereich, um seine Sammlungen einzusehen." + } + }, + "search": { + "title": "DSpace Angular :: Suche", + "description": "", + "form": { + "search": "Suche", + "search_dspace": "DSpace durchsuchen" + }, + "results": { + "head": "Suchergebnisse", + "no-results": "Zu dieser Suche gibt es keine Treffer." + }, + "sidebar": { + "close": "Zurück zu den Ergebnissen", + "open": "Suchwerkzeuge", + "results": "Ergebnisse", + "filters": { + "title": "Filter" + }, + "settings": { + "title": "Einstellungen", + "sort-by": "Sortiere nach", + "rpp": "Treffer pro Seite" + } + }, + "view-switch": { + "show-list": "Zeige als Liste", + "show-grid": "Zeige als Raster" + }, + "filters": { + "head": "Filter", + "reset": "Filter zurücksetzen", + "applied": { + "f.author": "Autor", + "f.dateIssued.min": "Anfangsdatum", + "f.dateIssued.max": "Enddatum", + "f.subject": "Thema", + "f.has_content_in_original_bundle": "Besitzt Dateien" + }, + "filter": { + "show-more": "Zeige mehr", + "show-less": "Zeige weniger", + "author": { + "placeholder": "Autor", + "head": "Autor" + }, + "scope": { + "placeholder": "Bereichsfilter", + "head": "Bereich" + }, + "subject": { + "placeholder": "Schlagwort", + "head": "Schlagwort" + }, + "dateIssued": { + "max": { + "placeholder": "Frühestes Datum" + }, + "min": { + "placeholder": "Ältestes Datum" + }, + "head": "Datum" + }, + "has_content_in_original_bundle": { + "head": "Besitzt Dateien" + } + } + } + }, + "browse": { + "title": "Anzeige {{ collection }} nach {{ field }} {{ value }}" + }, + "admin": { + "registries": { + "metadata": { + "title": "DSpace Angular :: Metadatenreferenzliste", + "head": "Metadatenreferenzliste", + "description": "Die Metadatenreferenzliste beinhaltet alle Metadatenfelder, die zur Verfügung stehen. Die Felder können in unterschiedlichen Schemata enthalten sein. Nichtsdestotrotz benötigt DSpace mindestens qualifiziertes Dublin Core.", + "schemas": { + "table": { + "id": "ID", + "namespace": "Namensraum", + "name": "Name" + }, + "no-items": "Es gbit keine Metadatenschemata." + } + }, + "schema": { + "title": "DSpace Angular :: Referenzliste der Metadatenschemata", + "head": "Metadatenschemata", + "description": "Dies ist das Metadatenschema für \"{{namespace}}\".", + "fields": { + "head": "Felder in diesem Schema", + "table": { + "field": "Feld", + "scopenote": "Gültigkeitsbereich" + }, + "no-items": "Es gibt keine Felder in diesem Schema." + } + }, + "bitstream-formats": { + "title": "DSpace Angular :: Referenzliste der Dateiformate", + "head": "Referenzliste der Dateiformate", + "description": "Diese Liste enhtält die in diesem Repositorium zulässigen Dateiformate und den jeweiligen Unterstützungsgrad.", + "formats": { + "table": { + "name": "Name", + "mimetype": "MIME Type", + "supportLevel": { + "head": "Unterstützungsgrad", + "0": "Unbekannt", + "1": "Bekannt", + "2": "Unterstützt" + }, + "internal": "intern" + }, + "no-items": "Es gibt keine Formate in dieser Referenzliste." + } + } + } + }, + "loading": { + "default": "Am Laden ...", + "top-level-communities": "Die Hauptbereiche werden geladen ...", + "community": "Der Bereich wird geladen ...", + "collection": "Die Sammlung wird geladen ...", + "sub-collections": "Die untergeordneten Sammlungen werden geladen ...", + "recent-submissions": "Die aktuellsten Veröffentlichungen werden geladen ...", + "item": "Die Ressource wird geladen ...", + "objects": "Am Laden ...", + "search-results": "Die Suchergebnisse werden geladen ...", + "browse-by": "Die Ressourcen werden geladen ..." + }, + "error": { + "default": "Fehler", + "top-level-communities": "Fehler beim Laden der Hauptbereiche.", + "community": "Fehler beim Laden des Bereiches.", + "collection": "Fehler beim Laden der Sammlung.", + "sub-collections": "Fehler beim Laden der untergeordneten Sammlungen.", + "recent-submissions": "Fehler beim Laden der aktuellsten Veröffentlichungen.", + "item": "Fehler beim Laden der Ressource.", + "objects": "Fehler beim Laden der Objekte.", + "search-results": "Fehler beim Laden der Suchergebnisse.", + "browse-by": "Fehler beim Laden der Ressourcen", + "validation": { + "pattern": "Die Eingabe kann nur folgendes Muster haben: {{ pattern }}.", + "license": { + "notgranted": "Sie müssen der Lizenz zustimmen, um die Ressource einzureichen. Wenn dies zur Zeit nicht geht, können Sie die Einreichung speichern und später wiederaufnehmen oder löschen." + } + } + }, + "form": { + "submit": "Los", + "cancel": "Abbrechen", + "search": "Suchen", + "remove": "Löschen", + "first-name": "Vorname", + "last-name": "Nachname", + "loading": "Am Laden ...", + "no-results": "Keine Ergebnisse gefunden", + "no-value": "Kein Wert eingegeben", + "group-collapse": "Weniger", + "group-expand": "Mehr", + "group-collapse-help": "Hier klicken, um die Anzeige zu reduzieren", + "group-expand-help": "Hier klicken, um mehr Elemente anzuzeigen" + }, + "login": { + "title": "Einloggen", + "form": { + "header": "Bitte Loggen Sie sich ein.", + "email": "E-Mail-Adresse", + "forgot-password": "Haben Sie Ihr Passwort vergessen?", + "new-user": "Sind Sie neu hier? Klicken Sie hier, um sich zu registrieren.", + "password": "Passwort", + "submit": "Einloggen" + } + }, + "logout": { + "title": "Ausloggen", + "form": { + "header": "Ausloggen aus DSpace", + "submit": "Ausloggen" + } + }, + "auth": { + "messages": { + "expired": "Ihre Sitzung ist abgelaufen, bitte melden Sie sich erneut an." + }, + "errors": { + "invalid-user": "Ungültige E-Mail-Adresse oder Passwort." + } + } +} diff --git a/resources/i18n/en.json b/resources/i18n/en.json index bb01849380..e58c7cadff 100644 --- a/resources/i18n/en.json +++ b/resources/i18n/en.json @@ -185,6 +185,9 @@ } } }, + "browse": { + "title": "Browsing {{ collection }} by {{ field }} {{ value }}" + }, "admin": { "registries": { "metadata": { @@ -236,18 +239,19 @@ }, "loading": { "default": "Loading...", - "top-level-communities": "Loading top level communities...", + "top-level-communities": "Loading top-level communities...", "community": "Loading community...", "collection": "Loading collection...", "sub-collections": "Loading sub-collections...", "recent-submissions": "Loading recent submissions...", "item": "Loading item...", "objects": "Loading...", - "search-results": "Loading search results..." + "search-results": "Loading search results...", + "browse-by": "Loading items..." }, "error": { "default": "Error", - "top-level-communities": "Error fetching top level communities", + "top-level-communities": "Error fetching top-level communities", "community": "Error fetching community", "collection": "Error fetching collection", "sub-collections": "Error fetching sub-collections", @@ -255,6 +259,7 @@ "item": "Error fetching item", "objects": "Error fetching objects", "search-results": "Error fetching search results", + "browse-by": "Error fetching items", "validation": { "pattern": "This input is restricted by the current pattern: {{ pattern }}.", "license": { @@ -275,7 +280,7 @@ "group-collapse": "Collapse", "group-expand": "Expand", "group-collapse-help": "Click here to collapse", - "group-expand-help": "Click here to expand and add more element" + "group-expand-help": "Click here to expand and add more elements" }, "login": { "title": "Login", @@ -300,7 +305,7 @@ "expired": "Your session has expired. Please log in again." }, "errors": { - "invalid-user": "Invalid email or password." + "invalid-user": "Invalid email address or password." } } } diff --git a/resources/i18n/nl.json b/resources/i18n/nl.json new file mode 100644 index 0000000000..6c3b1fe401 --- /dev/null +++ b/resources/i18n/nl.json @@ -0,0 +1,277 @@ +{ + "footer": { + "copyright": "copyright © 2002-{{ year }}", + "link.dspace": "DSpace software", + "link.duraspace": "DuraSpace" + }, + "collection": { + "page": { + "news": "Nieuws", + "license": "Licentie", + "browse": { + "recent": { + "head": "Recent toegevoegd" + } + } + } + }, + "community": { + "page": { + "news": "Nieuws", + "license": "Licentie" + }, + "sub-collection-list": { + "head": "Collecties in deze Community" + } + }, + "item": { + "page": { + "author": "Auteur", + "abstract": "Abstract", + "date": "Datum", + "uri": "URI", + "files": "Bestanden", + "collections": "Collecties", + "filesection": { + "download": "Download", + "name": "Naam:", + "format": "Formaat:", + "size": "Grootte:", + "description": "Beschrijving:" + }, + "link": { + "simple": "Eenvoudige item weergave", + "full": "Volledige item weergave" + } + } + }, + "nav": { + "home": "Home", + "login": "Log In", + "logout": "Log Uit" + }, + "pagination": { + "results-per-page": "Resultaten per pagina", + "sort-direction": "Sorteer mogelijkheden", + "showing": { + "label": "Getoonde items ", + "detail": "{{ range }} tot {{ total }}" + } + }, + "sorting": { + "score": { + "DESC": "Relevantie" + }, + "dc.title": { + "ASC": "Oplopend op titel", + "DESC": "Aflopend op titel" + } + }, + "title": "DSpace", + "404": { + "help": "De pagina die u zoekt kan niet gevonden worden. De pagina werd mogelijk verplaatst of verwijderd. U kan onderstaande knop gebruiken om terug naar de homepagina te gaan. ", + "page-not-found": "Pagina niet gevonden", + "link": { + "home-page": "Terug naar de homepagina" + } + }, + "home": { + "title": "DSpace Angular :: Home", + "description": "", + "top-level-communities": { + "head": "Communities in DSpace", + "help": "Selecteer een community om diens collecties te verkennen." + } + }, + "search": { + "title": "DSpace Angular :: Zoek", + "description": "", + "form": { + "search": "Zoek", + "search_dspace": "Zoek in DSpace" + }, + "results": { + "head": "Zoekresultaten", + "no-results": "Er waren geen resultaten voor deze zoekopdracht" + }, + "sidebar": { + "close": "Terug naar de resultaten", + "open": "Zoek Tools", + "results": "resultaten", + "filters": { + "title": "Filters" + }, + "settings": { + "title": "Instellingen", + "sort-by": "Sorteer volgens", + "rpp": "Resultaten per pagina" + } + }, + "view-switch": { + "show-list": "Toon als lijst", + "show-grid": "Toon in raster" + }, + "filters": { + "head": "Filters", + "reset": "Filters verwijderen", + "applied": { + "f.author": "Auteur", + "f.dateIssued.min": "Start datum", + "f.dateIssued.max": "Eind datum", + "f.subject": "Sleutelwoord", + "f.has_content_in_original_bundle": "Heeft bestanden" + }, + "filter": { + "show-more": "Toon meer", + "show-less": "Inklappen", + "author": { + "placeholder": "Auteursnaam", + "head": "Auteur" + }, + "scope": { + "placeholder": "Bereik filter", + "head": "Bereik" + }, + "subject": { + "placeholder": "Onderwerp", + "head": "Onderwerp" + }, + "dateIssued": { + "max": { + "placeholder": "Vroegste Datum" + }, + "min": { + "placeholder": "Laatste Datum" + }, + "head": "Datum" + }, + "has_content_in_original_bundle": { + "head": "Heeft bestanden" + } + } + } + }, + "browse": { + "title": "Verken {{ collection }} volgens {{ field }} {{ value }}" + }, + "admin": { + "registries": { + "metadata": { + "title": "DSpace Angular :: Metadata Register", + "head": "Metadata Register", + "description": "Het metadata register omvat de lijst van alle metadata velden die beschikbaar zijn in het systeem. Deze velden kunnen verspreid zijn over verschillende metadata schema's. Het qualified Dublin Core schema (dc) is een verplicht schema en kan niet worden verwijderd.", + "schemas": { + "table": { + "id": "ID", + "namespace": "Naamruimte", + "name": "Naam" + }, + "no-items": "Er kunnen geen metadata schema's getoond worden." + } + }, + "schema": { + "title": "DSpace Angular :: Metadata Schema Register", + "head": "Metadata Schema", + "description": "Dit is het metadata schema voor \"{{namespace}}\".", + "fields": { + "head": "Schema metadata velden", + "table": { + "field": "Veld", + "scopenote": "Opmerking over bereik" + }, + "no-items": "Er kunnen geen metadata velden getoond worden." + } + }, + "bitstream-formats": { + "title": "DSpace Angular :: Bitstream Formaat Register", + "head": "Bitstream Formaat Register", + "description": "Deze lijst van Bitstream formaten biedt informatie over de formaten die in deze repository zijn toegelaten en op welke manier ze ondersteund worden. De term Bitstream wordt in DSpace gebruikt om een bestand aan te duiden dat samen met metadata onderdeel uitmaakt van een item. De naam bitstream duidt op het feit dat het bestand achterliggend wordt opgeslaan zonder bestandsextensie.", + "formats": { + "table": { + "name": "Naam", + "mimetype": "MIME Type", + "supportLevel": { + "head": "Ondersteuning", + "0": "Onbekend", + "1": "Gekend", + "2": "Ondersteund" + }, + "internal": "intern" + }, + "no-items": "Er kunnen geen bitstream formaten getoond worden." + } + } + } + }, + "loading": { + "default": "Laden...", + "top-level-communities": "Inladen van de Communities op het hoogste niveau...", + "community": "Community wordt ingeladen...", + "collection": "Collectie wordt ingeladen...", + "sub-collections": "De sub-collecties worden ingeladen...", + "recent-submissions": "Recent toegevoegde items worden ingeladen...", + "item": "Item wordt ingeladen...", + "objects": "Laden...", + "search-results": "Zoekresultaten worden ingeladen...", + "browse-by": "Items worden ingeladen..." + }, + "error": { + "default": "Fout", + "top-level-communities": "Fout bij het inladen van communities op het hoogste niveau", + "community": "Fout bij het ophalen van een community", + "collection": "Fout bij het ophalen van een collectie", + "sub-collections": "Fout bij het ophalen van sub-collecties", + "recent-submissions": "Fout bij het ophalen van recent toegevoegde items", + "item": "Fout bij het ophalen van items", + "objects": "Fout bij het ophalen van objecten", + "search-results": "Fout bij het ophalen van zoekresultaten", + "browse-by": "Fout bij het ophalen van items", + "validation": { + "pattern": "Deze invoer is niet toegelaten volgens dit patroon: {{ pattern }}.", + "license": { + "notgranted": "U moet de invoerlicentie goedkeuren om de invoer af te werken. Indien u deze licentie momenteel niet kan of mag goedkeuren, kan u uw werk opslaan en de invoer later afwerken. U kan dit nieuwe item ook verwijderen indien u niet voldoet aan de vereisten van de invoer licentie." + } + } + }, + "form": { + "submit": "Verstuur", + "cancel": "Annuleer", + "search": "Zoek", + "remove": "Verwijder", + "first-name": "Voornaam", + "last-name": "Achternaam", + "loading": "Inladen...", + "no-results": "Geen resultaten gevonden", + "no-value": "Geen waarde ingevoerd", + "group-collapse": "Inklappen", + "group-expand": "Uitklappen", + "group-collapse-help": "Klik hier op in te klappen", + "group-expand-help": "Klik hier om uit te klappen en om meer onderdelen toe te voegen" + }, + "login": { + "title": "Aanmelden", + "form": { + "header": "Gelieve in te loggen in DSpace", + "email": "Email adres", + "forgot-password": "Bent u uw wachtwoord vergeten?", + "new-user": "Nieuwe gebruiker? Gelieve u hier te registreren", + "password": "Wachtwoord", + "submit": "Aanmelden" + } + }, + "logout": { + "title": "Afmelden", + "form": { + "header": "Afmelden in DSpace", + "submit": "Afmelden" + } + }, + "auth": { + "messages": { + "expired": "Uw sessie is vervallen. Gelieve opnieuw aan te melden." + }, + "errors": { + "invalid-user": "Ongeldig email adres of wachtwoord." + } + } +} 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.html b/src/app/+browse-by/+browse-by-author-page/browse-by-author-page.component.html new file mode 100644 index 0000000000..438c318994 --- /dev/null +++ b/src/app/+browse-by/+browse-by-author-page/browse-by-author-page.component.html @@ -0,0 +1,11 @@ +
+
+ + +
+
diff --git a/src/app/+browse-by/+browse-by-author-page/browse-by-author-page.component.scss b/src/app/+browse-by/+browse-by-author-page/browse-by-author-page.component.scss new file mode 100644 index 0000000000..e69de29bb2 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 new file mode 100644 index 0000000000..813ee8a32f --- /dev/null +++ b/src/app/+browse-by/+browse-by-author-page/browse-by-author-page.component.ts @@ -0,0 +1,107 @@ + +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 { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model'; +import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model'; +import { ActivatedRoute } from '@angular/router'; +import { hasValue, isNotEmpty } from '../../shared/empty.util'; +import { BrowseService } from '../../core/browse/browse.service'; +import { BrowseEntry } from '../../core/shared/browse-entry.model'; +import { Item } from '../../core/shared/item.model'; + +@Component({ + selector: 'ds-browse-by-author-page', + styleUrls: ['./browse-by-author-page.component.scss'], + templateUrl: './browse-by-author-page.component.html' +}) +/** + * Component for browsing (items) by author (dc.contributor.author) + */ +export class BrowseByAuthorPageComponent implements OnInit { + + authors$: Observable>>; + items$: Observable>>; + paginationConfig: PaginationComponentOptions = Object.assign(new PaginationComponentOptions(), { + id: 'browse-by-author-pagination', + currentPage: 1, + pageSize: 20 + }); + sortConfig: SortOptions = new SortOptions('dc.contributor.author', SortDirection.ASC); + subs: Subscription[] = []; + currentUrl: string; + value = ''; + + public constructor(private itemDataService: ItemDataService, private route: ActivatedRoute, private browseService: BrowseService) { + } + + ngOnInit(): void { + this.currentUrl = this.route.snapshot.pathFromRoot + .map((snapshot) => (snapshot.routeConfig) ? snapshot.routeConfig.path : '') + .join('/'); + this.updatePage({ + pagination: this.paginationConfig, + sort: this.sortConfig + }); + this.subs.push( + observableCombineLatest( + this.route.params, + this.route.queryParams, + (params, queryParams, ) => { + return Object.assign({}, params, queryParams); + }) + .subscribe((params) => { + const page = +params.page || this.paginationConfig.currentPage; + const pageSize = +params.pageSize || this.paginationConfig.pageSize; + const sortDirection = params.sortDirection || this.sortConfig.direction; + const sortField = params.sortField || this.sortConfig.field; + this.value = +params.value || params.value || ''; + const pagination = Object.assign({}, + this.paginationConfig, + { currentPage: page, pageSize: pageSize } + ); + const sort = Object.assign({}, + this.sortConfig, + { direction: sortDirection, field: sortField } + ); + const searchOptions = { + pagination: pagination, + sort: sort + }; + if (isNotEmpty(this.value)) { + this.updatePageWithItems(searchOptions, this.value); + } else { + this.updatePage(searchOptions); + } + })); + } + + /** + * Updates the current page with searchOptions + * @param searchOptions Options to narrow down your search: + * { pagination: PaginationComponentOptions, + * sort: SortOptions } + */ + updatePage(searchOptions) { + this.authors$ = this.browseService.getBrowseEntriesFor('author', searchOptions); + this.items$ = undefined; + } + + /** + * Updates the current page with searchOptions and display items linked to author + * @param searchOptions Options to narrow down your search: + * { pagination: PaginationComponentOptions, + * sort: SortOptions } + * @param author The author's name for displaying items + */ + updatePageWithItems(searchOptions, author: string) { + this.items$ = this.browseService.getBrowseItemsFor('author', author, searchOptions); + } + + ngOnDestroy(): void { + this.subs.filter((sub) => hasValue(sub)).forEach((sub) => sub.unsubscribe()); + } + +} diff --git a/src/app/+browse-by/+browse-by-title-page/browse-by-title-page.component.html b/src/app/+browse-by/+browse-by-title-page/browse-by-title-page.component.html new file mode 100644 index 0000000000..d37727be36 --- /dev/null +++ b/src/app/+browse-by/+browse-by-title-page/browse-by-title-page.component.html @@ -0,0 +1,11 @@ +
+
+ + +
+
diff --git a/src/app/+browse-by/+browse-by-title-page/browse-by-title-page.component.scss b/src/app/+browse-by/+browse-by-title-page/browse-by-title-page.component.scss new file mode 100644 index 0000000000..e69de29bb2 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 new file mode 100644 index 0000000000..e9127dbbab --- /dev/null +++ b/src/app/+browse-by/+browse-by-title-page/browse-by-title-page.component.ts @@ -0,0 +1,92 @@ + +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 { 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 { ActivatedRoute, PRIMARY_OUTLET, UrlSegmentGroup } from '@angular/router'; +import { hasValue } from '../../shared/empty.util'; +import { Collection } from '../../core/shared/collection.model'; + +@Component({ + selector: 'ds-browse-by-title-page', + styleUrls: ['./browse-by-title-page.component.scss'], + templateUrl: './browse-by-title-page.component.html' +}) +/** + * Component for browsing items by title (dc.title) + */ +export class BrowseByTitlePageComponent implements OnInit { + + items$: Observable>>; + paginationConfig: PaginationComponentOptions = Object.assign(new PaginationComponentOptions(), { + id: 'browse-by-title-pagination', + currentPage: 1, + pageSize: 20 + }); + sortConfig: SortOptions = new SortOptions('dc.title', SortDirection.ASC); + subs: Subscription[] = []; + currentUrl: string; + + public constructor(private itemDataService: ItemDataService, private route: ActivatedRoute) { + + } + + ngOnInit(): void { + this.currentUrl = this.route.snapshot.pathFromRoot + .map((snapshot) => (snapshot.routeConfig) ? snapshot.routeConfig.path : '') + .join('/'); + this.updatePage({ + pagination: this.paginationConfig, + sort: this.sortConfig + }); + this.subs.push( + observableCombineLatest( + this.route.params, + this.route.queryParams, + (params, queryParams, ) => { + return Object.assign({}, params, queryParams); + }) + .subscribe((params) => { + const page = +params.page || this.paginationConfig.currentPage; + const pageSize = +params.pageSize || this.paginationConfig.pageSize; + const sortDirection = params.sortDirection || this.sortConfig.direction; + const sortField = params.sortField || this.sortConfig.field; + const pagination = Object.assign({}, + this.paginationConfig, + { currentPage: page, pageSize: pageSize } + ); + const sort = Object.assign({}, + this.sortConfig, + { direction: sortDirection, field: sortField } + ); + this.updatePage({ + pagination: pagination, + sort: sort + }); + })); + } + + /** + * Updates the current page with searchOptions + * @param searchOptions Options to narrow down your search: + * { pagination: PaginationComponentOptions, + * sort: SortOptions } + */ + updatePage(searchOptions) { + this.items$ = this.itemDataService.findAll({ + currentPage: searchOptions.pagination.currentPage, + elementsPerPage: searchOptions.pagination.pageSize, + sort: searchOptions.sort + }); + } + + ngOnDestroy(): void { + this.subs.filter((sub) => hasValue(sub)).forEach((sub) => sub.unsubscribe()); + } + +} diff --git a/src/app/+browse-by/browse-by-routing.module.ts b/src/app/+browse-by/browse-by-routing.module.ts new file mode 100644 index 0000000000..630a7c0db5 --- /dev/null +++ b/src/app/+browse-by/browse-by-routing.module.ts @@ -0,0 +1,16 @@ +import { RouterModule } from '@angular/router'; +import { NgModule } from '@angular/core'; +import { BrowseByTitlePageComponent } from './+browse-by-title-page/browse-by-title-page.component'; +import { BrowseByAuthorPageComponent } from './+browse-by-author-page/browse-by-author-page.component'; + +@NgModule({ + imports: [ + RouterModule.forChild([ + { path: 'title', component: BrowseByTitlePageComponent }, + { path: 'author', component: BrowseByAuthorPageComponent } + ]) + ] +}) +export class BrowseByRoutingModule { + +} diff --git a/src/app/+browse-by/browse-by.module.ts b/src/app/+browse-by/browse-by.module.ts new file mode 100644 index 0000000000..51843a13d8 --- /dev/null +++ b/src/app/+browse-by/browse-by.module.ts @@ -0,0 +1,27 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { BrowseByTitlePageComponent } from './+browse-by-title-page/browse-by-title-page.component'; +import { ItemDataService } from '../core/data/item-data.service'; +import { SharedModule } from '../shared/shared.module'; +import { BrowseByRoutingModule } from './browse-by-routing.module'; +import { BrowseByAuthorPageComponent } from './+browse-by-author-page/browse-by-author-page.component'; +import { BrowseService } from '../core/browse/browse.service'; + +@NgModule({ + imports: [ + BrowseByRoutingModule, + CommonModule, + SharedModule + ], + declarations: [ + BrowseByTitlePageComponent, + BrowseByAuthorPageComponent + ], + providers: [ + ItemDataService, + BrowseService + ] +}) +export class BrowseByModule { + +} 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/+collection-page/create-collection-page/create-collection-page.component.ts b/src/app/+collection-page/create-collection-page/create-collection-page.component.ts index 6429e623bf..f3dbff9c1f 100644 --- a/src/app/+collection-page/create-collection-page/create-collection-page.component.ts +++ b/src/app/+collection-page/create-collection-page/create-collection-page.component.ts @@ -1,23 +1,14 @@ import { Component } from '@angular/core'; import { Community } from '../../core/shared/community.model'; -import { ComColDataService } from '../../core/data/comcol-data.service'; -import { NormalizedCommunity } from '../../core/cache/models/normalized-community.model'; import { CommunityDataService } from '../../core/data/community-data.service'; import { CollectionDataService } from '../../core/data/collection-data.service'; import { Collection } from '../../core/shared/collection.model'; import { RouteService } from '../../shared/services/route.service'; import { Router } from '@angular/router'; -import { DSOSuccessResponse, ErrorResponse } from '../../core/cache/response-cache.models'; -import { Observable } from 'rxjs/Observable'; -import { ResponseCacheEntry } from '../../core/cache/response-cache.reducer'; -import { first, flatMap, map, take } from 'rxjs/operators'; +import { Observable } from 'rxjs'; +import { take } from 'rxjs/operators'; import { RemoteData } from '../../core/data/remote-data'; -import { hasValueOperator, isNotEmpty, isNotEmptyOperator } from '../../shared/empty.util'; -import { DSpaceObject } from '../../core/shared/dspace-object.model'; -import { HttpEvent } from '@angular/common/http'; -import { getSucceededRemoteData } from '../../core/shared/operators'; -import { ObjectCacheService } from '../../core/cache/object-cache.service'; -import { NormalizedCollection } from '../../core/cache/models/normalized-collection.model'; +import { isNotEmpty } from '../../shared/empty.util'; @Component({ selector: 'ds-create-collection', 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/create-community-page/create-community-page.component.ts b/src/app/+community-page/create-community-page/create-community-page.component.ts index a991bf7127..8c75af0d64 100644 --- a/src/app/+community-page/create-community-page/create-community-page.component.ts +++ b/src/app/+community-page/create-community-page/create-community-page.component.ts @@ -1,8 +1,7 @@ import { Component } from '@angular/core'; import { Community } from '../../core/shared/community.model'; import { CommunityDataService } from '../../core/data/community-data.service'; -import { ErrorResponse } from '../../core/cache/response-cache.models'; -import { Observable } from 'rxjs/Observable'; +import { Observable } from 'rxjs'; import { RouteService } from '../../shared/services/route.service'; import { Router } from '@angular/router'; import { RemoteData } from '../../core/data/remote-data'; 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 f4b63c332f..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,23 +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 { - // console.log(filter.name); - 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-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-routing.module.ts b/src/app/app-routing.module.ts index 4bc8c43152..7de83651ff 100644 --- a/src/app/app-routing.module.ts +++ b/src/app/app-routing.module.ts @@ -12,6 +12,7 @@ import { PageNotFoundComponent } from './pagenotfound/pagenotfound.component'; { path: 'collections', loadChildren: './+collection-page/collection-page.module#CollectionPageModule' }, { path: 'items', loadChildren: './+item-page/item-page.module#ItemPageModule' }, { path: 'search', loadChildren: './+search-page/search-page.module#SearchPageModule' }, + { path: 'browse', loadChildren: './+browse-by/browse-by.module#BrowseByModule' }, { path: 'admin', loadChildren: './+admin/admin.module#AdminModule' }, { path: 'login', loadChildren: './+login-page/login-page.module#LoginPageModule' }, { path: 'logout', loadChildren: './+logout-page/logout-page.module#LogoutPageModule' }, 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..8469911406 100755 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -7,7 +7,6 @@ import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; import { EffectsModule } from '@ngrx/effects'; import { RouterStateSerializer, StoreRouterConnectingModule } from '@ngrx/router-store'; import { META_REDUCERS, MetaReducer, StoreModule } from '@ngrx/store'; -import { StoreDevtoolsModule } from '@ngrx/store-devtools'; import { TranslateModule } from '@ngx-translate/core'; @@ -47,10 +46,6 @@ export function getMetaReducers(config: GlobalConfig): Array { + public static getConstructor(type): GenericConstructor { switch (type) { - case AuthType.Eperson: { - return NormalizedEpersonModel + case AuthType.EPerson: { + return NormalizedEPerson } case AuthType.Status: { diff --git a/src/app/core/auth/auth-request.service.ts b/src/app/core/auth/auth-request.service.ts index a57ad48865..f957d807c1 100644 --- a/src/app/core/auth/auth-request.service.ts +++ b/src/app/core/auth/auth-request.service.ts @@ -1,15 +1,16 @@ +import { Observable, of as observableOf, throwError as observableThrowError } from 'rxjs'; +import { distinctUntilChanged, filter, map, mergeMap, tap } from 'rxjs/operators'; import { Inject, Injectable } from '@angular/core'; import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { ResponseCacheService } from '../cache/response-cache.service'; import { RequestService } from '../data/request.service'; import { GLOBAL_CONFIG } from '../../../config'; import { GlobalConfig } from '../../../config/global-config.interface'; -import { Observable } from 'rxjs/Observable'; import { isNotEmpty } from '../../shared/empty.util'; import { AuthGetRequest, AuthPostRequest, PostRequest, RestRequest } from '../data/request.models'; -import { ResponseCacheEntry } from '../cache/response-cache.reducer'; -import { AuthStatusResponse, ErrorResponse, RestResponse } from '../cache/response-cache.models'; +import { AuthStatusResponse, ErrorResponse } from '../cache/response.models'; import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service'; +import { RequestEntry } from '../data/request.reducer'; +import { getResponseFromEntry } from '../shared/operators'; @Injectable() export class AuthRequestService { @@ -18,23 +19,22 @@ export class AuthRequestService { constructor(@Inject(GLOBAL_CONFIG) protected EnvConfig: GlobalConfig, protected halService: HALEndpointService, - protected responseCache: ResponseCacheService, protected requestService: RequestService) { } protected fetchRequest(request: RestRequest): Observable { - 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 f7d899a9bc..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,20 +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 { MockStore } from '../../shared/testing/mock-store'; +import { ObjectCacheState } from '../cache/object-cache.reducer'; -describe('ConfigResponseParsingService', () => { +describe('AuthResponseParsingService', () => { let service: AuthResponseParsingService; - const EnvConfig = {} as GlobalConfig; - const store = {} 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); @@ -86,13 +84,19 @@ describe('ConfigResponseParsingService', () => { type: 'eperson', uuid: '4dc70ab5-cd73-492f-b007-3179d2d9296b', _links: { - self: 'https://hasselt-dspace.dev01.4science.it/dspace-spring-rest/api/eperson/epersons/4dc70ab5-cd73-492f-b007-3179d2d9296b' + self: { + href: 'https://hasselt-dspace.dev01.4science.it/dspace-spring-rest/api/eperson/epersons/4dc70ab5-cd73-492f-b007-3179d2d9296b' + } } } }, _links: { - eperson: 'https://hasselt-dspace.dev01.4science.it/dspace-spring-rest/api/eperson/epersons/4dc70ab5-cd73-492f-b007-3179d2d9296b', - self: 'https://hasselt-dspace.dev01.4science.it/dspace-spring-rest/api/authn/status' + eperson: { + href: 'https://hasselt-dspace.dev01.4science.it/dspace-spring-rest/api/eperson/epersons/4dc70ab5-cd73-492f-b007-3179d2d9296b' + }, + self: { + href: 'https://hasselt-dspace.dev01.4science.it/dspace-spring-rest/api/authn/status' + } } }, statusCode: '200' diff --git a/src/app/core/auth/auth-response-parsing.service.ts b/src/app/core/auth/auth-response-parsing.service.ts index f024035c06..65d093de61 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'; @@ -12,22 +12,22 @@ import { ResponseParsingService } from '../data/parsing.service'; import { RestRequest } from '../data/request.models'; import { AuthType } from './auth-type'; import { AuthStatus } from './models/auth-status.model'; +import { NormalizedAuthStatus } from './models/normalized-auth-status.model'; @Injectable() export class AuthResponseParsingService extends BaseResponseParsingService implements ResponseParsingService { protected objectFactory = AuthObjectFactory; - protected toCache = false; + protected toCache = true; constructor(@Inject(GLOBAL_CONFIG) protected EnvConfig: GlobalConfig, - protected objectCache: ObjectCacheService,) { + protected objectCache: ObjectCacheService) { super(); } parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse { if (isNotEmpty(data.payload) && isNotEmpty(data.payload._links) && (data.statusCode === '200' || data.statusCode === 'OK')) { - const response: AuthStatus = this.process(data.payload, request.href); - response.eperson = data.payload._embedded.eperson; + const response = this.process(data.payload, request.href); return new AuthStatusResponse(response, data.statusCode); } else { return new AuthStatusResponse(data.payload as AuthStatus, data.statusCode); diff --git a/src/app/core/auth/auth-type.ts b/src/app/core/auth/auth-type.ts index b8879ae445..9a248da91f 100644 --- a/src/app/core/auth/auth-type.ts +++ b/src/app/core/auth/auth-type.ts @@ -1,4 +1,4 @@ export enum AuthType { - Eperson = 'eperson', + EPerson = 'eperson', Status = 'status' } diff --git a/src/app/core/auth/auth.actions.ts b/src/app/core/auth/auth.actions.ts index 207e8fae70..d0969d38d4 100644 --- a/src/app/core/auth/auth.actions.ts +++ b/src/app/core/auth/auth.actions.ts @@ -5,7 +5,7 @@ import { Action } from '@ngrx/store'; import { type } from '../../shared/ngrx/type'; // import models -import { Eperson } from '../eperson/models/eperson.model'; +import { EPerson } from '../eperson/models/eperson.model'; import { AuthTokenInfo } from './models/auth-token-info.model'; export const AuthActionTypes = { @@ -76,10 +76,10 @@ export class AuthenticatedSuccessAction implements Action { payload: { authenticated: boolean; authToken: AuthTokenInfo; - user: Eperson + user: EPerson }; - constructor(authenticated: boolean, authToken: AuthTokenInfo, user: Eperson) { + constructor(authenticated: boolean, authToken: AuthTokenInfo, user: EPerson) { this.payload = { authenticated, authToken, user }; } } @@ -250,9 +250,9 @@ export class RefreshTokenErrorAction implements Action { */ export class RegistrationAction implements Action { public type: string = AuthActionTypes.REGISTRATION; - payload: Eperson; + payload: EPerson; - constructor(user: Eperson) { + constructor(user: EPerson) { this.payload = user; } } @@ -278,9 +278,9 @@ export class RegistrationErrorAction implements Action { */ export class RegistrationSuccessAction implements Action { public type: string = AuthActionTypes.REGISTRATION_SUCCESS; - payload: Eperson; + payload: EPerson; - constructor(user: Eperson) { + constructor(user: EPerson) { this.payload = user; } } diff --git a/src/app/core/auth/auth.effects.spec.ts b/src/app/core/auth/auth.effects.spec.ts index 3b569e523f..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 { @@ -25,22 +24,26 @@ import { AuthServiceStub } from '../../shared/testing/auth-service-stub'; import { AuthService } from './auth.service'; import { TruncatablesState } from '../../shared/truncatable/truncatable.reducer'; -import { EpersonMock } from '../../shared/testing/eperson-mock'; +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, @@ -72,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: { @@ -105,7 +108,7 @@ describe('AuthEffects', () => { it('should return a AUTHENTICATED_SUCCESS action in response to a AUTHENTICATED action', () => { actions = hot('--a-', {a: {type: AuthActionTypes.AUTHENTICATED, payload: token}}); - const expected = cold('--b-', {b: new AuthenticatedSuccessAction(true, token, EpersonMock)}); + const expected = cold('--b-', {b: new AuthenticatedSuccessAction(true, token, EPersonMock)}); expect(authEffects.authenticated$).toBeObservable(expected); }); @@ -113,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}}); @@ -139,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}}); @@ -165,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}}); @@ -191,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 e2d6c80b5e..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'; @@ -28,7 +28,7 @@ import { RegistrationErrorAction, RegistrationSuccessAction } from './auth.actions'; -import { Eperson } from '../eperson/models/eperson.model'; +import { EPerson } from '../eperson/models/eperson.model'; import { AuthStatus } from './models/auth-status.model'; import { AuthTokenInfo } from './models/auth-token-info.model'; import { AppState } from '../../app.reducer'; @@ -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 651e2fd096..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'; @@ -35,7 +38,7 @@ export class AuthInterceptor implements HttpInterceptor { } private isSuccess(response: HttpResponseBase): boolean { - return response.status === 200; + return (response.status === 200 || response.status === 204); } private isAuthRequest(http: HttpRequest | HttpResponseBase): boolean { @@ -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.reducer.spec.ts b/src/app/core/auth/auth.reducer.spec.ts index f148f3ac8d..ca2ba00036 100644 --- a/src/app/core/auth/auth.reducer.spec.ts +++ b/src/app/core/auth/auth.reducer.spec.ts @@ -21,7 +21,7 @@ import { SetRedirectUrlAction } from './auth.actions'; import { AuthTokenInfo } from './models/auth-token-info.model'; -import { EpersonMock } from '../../shared/testing/eperson-mock'; +import { EPersonMock } from '../../shared/testing/eperson-mock'; describe('authReducer', () => { @@ -107,7 +107,7 @@ describe('authReducer', () => { loading: true, info: undefined }; - const action = new AuthenticatedSuccessAction(true, mockTokenInfo, EpersonMock); + const action = new AuthenticatedSuccessAction(true, mockTokenInfo, EPersonMock); const newState = authReducer(initialState, action); state = { authenticated: true, @@ -116,7 +116,7 @@ describe('authReducer', () => { error: undefined, loading: false, info: undefined, - user: EpersonMock + user: EPersonMock }; expect(newState).toEqual(state); }); @@ -182,7 +182,7 @@ describe('authReducer', () => { error: undefined, loading: false, info: undefined, - user: EpersonMock + user: EPersonMock }; const action = new LogOutAction(); @@ -199,7 +199,7 @@ describe('authReducer', () => { error: undefined, loading: false, info: undefined, - user: EpersonMock + user: EPersonMock }; const action = new LogOutSuccessAction(); @@ -225,7 +225,7 @@ describe('authReducer', () => { error: undefined, loading: false, info: undefined, - user: EpersonMock + user: EPersonMock }; const action = new LogOutErrorAction(mockError); @@ -237,7 +237,7 @@ describe('authReducer', () => { error: 'Test error message', loading: false, info: undefined, - user: EpersonMock + user: EPersonMock }; expect(newState).toEqual(state); }); @@ -250,7 +250,7 @@ describe('authReducer', () => { error: undefined, loading: false, info: undefined, - user: EpersonMock + user: EPersonMock }; const newTokenInfo = new AuthTokenInfo('Refreshed token'); const action = new RefreshTokenAction(newTokenInfo); @@ -262,7 +262,7 @@ describe('authReducer', () => { error: undefined, loading: false, info: undefined, - user: EpersonMock, + user: EPersonMock, refreshing: true }; expect(newState).toEqual(state); @@ -276,7 +276,7 @@ describe('authReducer', () => { error: undefined, loading: false, info: undefined, - user: EpersonMock, + user: EPersonMock, refreshing: true }; const newTokenInfo = new AuthTokenInfo('Refreshed token'); @@ -289,7 +289,7 @@ describe('authReducer', () => { error: undefined, loading: false, info: undefined, - user: EpersonMock, + user: EPersonMock, refreshing: false }; expect(newState).toEqual(state); @@ -303,7 +303,7 @@ describe('authReducer', () => { error: undefined, loading: false, info: undefined, - user: EpersonMock, + user: EPersonMock, refreshing: true }; const action = new RefreshTokenErrorAction(); @@ -329,7 +329,7 @@ describe('authReducer', () => { error: undefined, loading: false, info: undefined, - user: EpersonMock + user: EPersonMock }; state = { diff --git a/src/app/core/auth/auth.reducer.ts b/src/app/core/auth/auth.reducer.ts index 0c5e36ce91..98827d842e 100644 --- a/src/app/core/auth/auth.reducer.ts +++ b/src/app/core/auth/auth.reducer.ts @@ -12,7 +12,7 @@ import { SetRedirectUrlAction } from './auth.actions'; // import models -import { Eperson } from '../eperson/models/eperson.model'; +import { EPerson } from '../eperson/models/eperson.model'; import { AuthTokenInfo } from './models/auth-token-info.model'; /** @@ -46,7 +46,7 @@ export interface AuthState { refreshing?: boolean; // the authenticated user - user?: Eperson; + user?: EPerson; } /** diff --git a/src/app/core/auth/auth.service.spec.ts b/src/app/core/auth/auth.service.spec.ts index b54f65078e..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'; @@ -18,49 +17,64 @@ import { AuthRequestServiceStub } from '../../shared/testing/auth-request-servic import { AuthRequestService } from './auth-request.service'; import { AuthStatus } from './models/auth-status.model'; import { AuthTokenInfo } from './models/auth-token-info.model'; -import { Eperson } from '../eperson/models/eperson.model'; -import { EpersonMock } from '../../shared/testing/eperson-mock'; +import { EPerson } from '../eperson/models/eperson.model'; +import { EPersonMock } from '../../shared/testing/eperson-mock'; import { AppState } from '../../app.reducer'; import { ClientCookieService } from '../../shared/services/client-cookie.service'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { getMockRemoteDataBuildService } from '../../shared/mocks/mock-remote-data-build.service'; 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(); + + 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 }, CookieService, AuthService ], @@ -79,7 +93,7 @@ describe('AuthService test', () => { }); it('should return the authenticated user object when user token is valid', () => { - authService.authenticatedUser(new AuthTokenInfo('test_token')).subscribe((user: Eperson) => { + authService.authenticatedUser(new AuthTokenInfo('test_token')).subscribe((user: EPerson) => { expect(user).toBeDefined(); }); }); @@ -113,14 +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: AuthRequestService, useValue: authRequest }, + { provide: REQUEST, useValue: {} }, + { provide: Router, useValue: routerStub }, + { provide: RemoteDataBuildService, useValue: rdbService }, CookieService ] }).compileComponents(); @@ -132,7 +148,7 @@ describe('AuthService test', () => { (state as any).core = Object.create({}); (state as any).core.auth = authenticatedState; }); - authService = new AuthService({}, window, authReqService, router, cookieService, store); + authService = new AuthService({}, window, authReqService, router, cookieService, store, rdbService); })); it('should return true when user is logged in', () => { @@ -164,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 ] @@ -184,14 +200,14 @@ describe('AuthService test', () => { loaded: true, loading: false, authToken: expiredToken, - user: EpersonMock + user: EPersonMock }; store .subscribe((state) => { (state as any).core = Object.create({}); (state as any).core.auth = authenticatedState; }); - authService = new AuthService({}, window, authReqService, router, cookieService, store); + authService = new AuthService({}, window, authReqService, router, cookieService, store, rdbService); storage = (authService as any).storage; spyOn(storage, 'get'); spyOn(storage, 'remove'); diff --git a/src/app/core/auth/auth.service.ts b/src/app/core/auth/auth.service.ts index 2eb6736d89..4c520e8f30 100644 --- a/src/app/core/auth/auth.service.ts +++ b/src/app/core/auth/auth.service.ts @@ -1,15 +1,24 @@ +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, withLatestFrom } from 'rxjs/operators'; -import { Eperson } from '../eperson/models/eperson.model'; +import { EPerson } from '../eperson/models/eperson.model'; import { AuthRequestService } from './auth-request.service'; import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service'; @@ -17,11 +26,18 @@ import { AuthStatus } from './models/auth-status.model'; import { AuthTokenInfo, TOKENITEM } from './models/auth-token-info.model'; import { isEmpty, isNotEmpty, isNotNull, isNotUndefined } from '../../shared/empty.util'; import { CookieService } from '../../shared/services/cookie.service'; -import { getAuthenticationToken, getRedirectUrl, isAuthenticated, isTokenRefreshing } from './selectors'; +import { + getAuthenticationToken, + getRedirectUrl, + isAuthenticated, + isTokenRefreshing +} from './selectors'; import { AppState, routerStateSelector } from '../../app.reducer'; import { ResetAuthenticationMessagesAction, SetRedirectUrlAction } from './auth.actions'; import { NativeWindowRef, NativeWindowService } from '../../shared/services/window.service'; import { Base64EncodeUrl } from '../../shared/utils/encode-decode.util'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { NormalizedEPerson } from '../eperson/models/normalized-eperson.model'; export const LOGIN_ROUTE = '/login'; export const LOGOUT_ROUTE = '/logout'; @@ -45,22 +61,27 @@ export class AuthService { protected authRequestService: AuthRequestService, protected router: Router, protected storage: CookieService, - protected store: Store) { - this.store.select(isAuthenticated) - .startWith(false) - .subscribe((authenticated: boolean) => this._authenticated = authenticated); + protected store: Store, + protected rdbService: RemoteDataBuildService + ) { + 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(); }); @@ -93,14 +114,15 @@ 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) => { + console.log('yey response'); if (status.authenticated) { return status; } else { throw(new Error('Invalid email or password')); } - }) + })) } @@ -109,28 +131,33 @@ export class AuthService { * @returns {Observable} */ public isAuthenticated(): Observable { - return this.store.select(isAuthenticated); + return this.store.pipe(select(isAuthenticated)); } /** * Returns the authenticated user * @returns {User} */ - public authenticatedUser(token: AuthTokenInfo): Observable { + public authenticatedUser(token: AuthTokenInfo): Observable { // Determine if the user has an existing auth session on the server const options: HttpOptions = Object.create({}); let headers = new HttpHeaders(); headers = headers.append('Accept', 'application/json'); headers = headers.append('Authorization', `Bearer ${token.accessToken}`); options.headers = headers; - return this.authRequestService.getRequest('status', options) - .map((status: AuthStatus) => { + return this.authRequestService.getRequest('status', options).pipe( + switchMap((status: AuthStatus) => { + if (status.authenticated) { - return status.eperson; + // TODO this should be cleaned up, AuthStatus could be parsed by the RemoteDataService as a whole... + // Review when https://jira.duraspace.org/browse/DS-4006 is fixed + // See https://github.com/DSpace/dspace-angular/issues/292 + const person$ = this.rdbService.buildSingle(status.eperson.toString()); + return person$.pipe(map((eperson) => eperson.payload)); } else { throw(new Error('Not authenticated')); } - }); + })) } /** @@ -144,9 +171,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); @@ -155,7 +183,8 @@ export class AuthService { } else { throw false; } - }); + }) + ); } /** @@ -167,14 +196,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')); } - }); + })); } /** @@ -188,12 +217,12 @@ export class AuthService { * Create a new user * @returns {User} */ - public create(user: Eperson): Observable { + public create(user: EPerson): Observable { // Normally you would do an HTTP request to POST the user // 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); } /** @@ -204,15 +233,15 @@ export class AuthService { // Send a request that sign end the session 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) => { + const options: HttpOptions = Object.create({ headers, responseType: 'text' }); + return this.authRequestService.getRequest('logout', options).pipe( + map((status: AuthStatus) => { if (!status.authenticated) { return true; } else { throw(new Error('auth.errors.invalid-user')); } - }) + })) } @@ -233,7 +262,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; @@ -246,9 +275,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 { @@ -256,6 +286,7 @@ export class AuthService { return token.expires - (60 * 5 * 1000) < Date.now(); } }) + ) } /** @@ -279,7 +310,7 @@ export class AuthService { // Set the cookie expire date const expires = new Date(expireDate); - const options: CookieAttributes = {expires: expires}; + const options: CookieAttributes = { expires: expires }; // Save cookie with the token return this.storage.set(TOKENITEM, token, options); @@ -324,8 +355,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(); @@ -337,8 +368,12 @@ export class AuthService { this.router.navigated = false; const url = decodeURIComponent(redirectUrl); this.router.navigateByUrl(url); + /* TODO Reenable hard redirect when REST API can handle x-forwarded-for, see https://github.com/DSpace/DSpace/pull/2207 */ + // this._window.nativeWindow.location.href = url; } else { this.router.navigate(['/']); + /* TODO Reenable hard redirect when REST API can handle x-forwarded-for, see https://github.com/DSpace/DSpace/pull/2207 */ + // this._window.nativeWindow.location.href = '/'; } }) @@ -359,9 +394,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)); } } @@ -374,7 +409,7 @@ export class AuthService { // Set the cookie expire date const expires = new Date(expireDate); - const options: CookieAttributes = {expires: expires}; + const options: CookieAttributes = { expires: expires }; this.storage.set(REDIRECT_COOKIE, url, options); this.store.dispatch(new SetRedirectUrlAction(isNotUndefined(url) ? url : '')); } 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 9d69c18388..37f8d76672 100644 --- a/src/app/core/auth/models/auth-status.model.ts +++ b/src/app/core/auth/models/auth-status.model.ts @@ -1,7 +1,8 @@ import { AuthError } from './auth-error.model'; import { AuthTokenInfo } from './auth-token-info.model'; -import { DSpaceObject } from '../../shared/dspace-object.model'; -import { Eperson } from '../../eperson/models/eperson.model'; +import { EPerson } from '../../eperson/models/eperson.model'; +import { RemoteData } from '../../data/remote-data'; +import { Observable } from 'rxjs'; export class AuthStatus { @@ -13,7 +14,7 @@ export class AuthStatus { error?: AuthError; - eperson: Eperson; + eperson: Observable>; token?: AuthTokenInfo; diff --git a/src/app/core/auth/models/normalized-auth-status.model.ts b/src/app/core/auth/models/normalized-auth-status.model.ts index c63c611a75..b8dd2aa23e 100644 --- a/src/app/core/auth/models/normalized-auth-status.model.ts +++ b/src/app/core/auth/models/normalized-auth-status.model.ts @@ -1,12 +1,18 @@ import { AuthStatus } from './auth-status.model'; import { autoserialize, autoserializeAs, inheritSerialization } from 'cerialize'; -import { mapsTo } from '../../cache/builders/build-decorators'; -import { NormalizedDSpaceObject } from '../../cache/models/normalized-dspace-object.model'; -import { Eperson } from '../../eperson/models/eperson.model'; +import { mapsTo, relationship } from '../../cache/builders/build-decorators'; +import { ResourceType } from '../../shared/resource-type'; +import { NormalizedObject } from '../../cache/models/normalized-object.model'; +import { IDToUUIDSerializer } from '../../cache/id-to-uuid-serializer'; @mapsTo(AuthStatus) -@inheritSerialization(NormalizedDSpaceObject) -export class NormalizedAuthStatus extends NormalizedDSpaceObject { +@inheritSerialization(NormalizedObject) +export class NormalizedAuthStatus extends NormalizedObject { + @autoserialize + id: string; + + @autoserializeAs(new IDToUUIDSerializer('auth-status'), 'id') + uuid: string; /** * True if REST API is up and running, should never return false @@ -20,7 +26,7 @@ export class NormalizedAuthStatus extends NormalizedDSpaceObject { @autoserialize authenticated: boolean; - @autoserializeAs(Eperson) - eperson: Eperson; - + @relationship(ResourceType.EPerson, false) + @autoserialize + eperson: string; } diff --git a/src/app/core/auth/server-auth.service.ts b/src/app/core/auth/server-auth.service.ts index 00dfbc5615..9ab2d84c20 100644 --- a/src/app/core/auth/server-auth.service.ts +++ b/src/app/core/auth/server-auth.service.ts @@ -1,6 +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'; @@ -8,7 +9,8 @@ import { isNotEmpty } from '../../shared/empty.util'; import { AuthService } from './auth.service'; import { AuthTokenInfo } from './models/auth-token-info.model'; import { CheckAuthenticationTokenAction } from './auth.actions'; -import { Eperson } from '../eperson/models/eperson.model'; +import { EPerson } from '../eperson/models/eperson.model'; +import { NormalizedEPerson } from '../eperson/models/normalized-eperson.model'; /** * The auth service. @@ -20,7 +22,7 @@ export class ServerAuthService extends AuthService { * Returns the authenticated user * @returns {User} */ - public authenticatedUser(token: AuthTokenInfo): Observable { + public authenticatedUser(token: AuthTokenInfo): Observable { // Determine if the user has an existing auth session on the server const options: HttpOptions = Object.create({}); let headers = new HttpHeaders(); @@ -32,14 +34,21 @@ export class ServerAuthService extends AuthService { headers = headers.append('X-Forwarded-For', clientIp); options.headers = headers; - return this.authRequestService.getRequest('status', options) - .map((status: AuthStatus) => { + return this.authRequestService.getRequest('status', options).pipe( + switchMap((status: AuthStatus) => { + if (status.authenticated) { - return status.eperson; + + // 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) + ); } else { throw(new Error('Not authenticated')); } - }); + })); } /** @@ -53,8 +62,8 @@ export class ServerAuthService extends AuthService { * Redirect to the route navigated before the login */ public redirectToPreviousUrl() { - this.getRedirectUrl() - .first() + this.getRedirectUrl().pipe( + first()) .subscribe((redirectUrl) => { if (isNotEmpty(redirectUrl)) { // override the route reuse strategy diff --git a/src/app/core/browse/browse.service.spec.ts b/src/app/core/browse/browse.service.spec.ts index 5118ea7ecc..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 } from '../data/request.models'; +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 @@ -143,10 +133,11 @@ describe('BrowseService', () => { }); - describe('getBrowseEntriesFor', () => { + describe('getBrowseEntriesFor and getBrowseItemsFor', () => { + const mockAuthorName = 'Donald Smith'; + beforeEach(() => { - responseCache = initMockResponseCacheService(true); - requestService = getMockRequestService(); + requestService = getMockRequestService(getRequestEntry$(true)); rdbService = getMockRemoteDataBuildService(); service = initTestService(); spyOn(service, 'getBrowseDefinitions').and @@ -156,7 +147,7 @@ describe('BrowseService', () => { spyOn(rdbService, 'toRemoteDataObservable').and.callThrough(); }); - describe('when called with a valid browse definition id', () => { + describe('when getBrowseEntriesFor is called with a valid browse definition id', () => { it('should configure a new BrowseEntriesRequest', () => { const expected = new BrowseEntriesRequest(requestService.generateRequestId(), browseDefinitions[1]._links.entries); @@ -175,7 +166,26 @@ describe('BrowseService', () => { }); - describe('when called with an invalid browse definition id', () => { + describe('when getBrowseItemsFor is called with a valid browse definition id', () => { + it('should configure a new BrowseItemsRequest', () => { + const expected = new BrowseItemsRequest(requestService.generateRequestId(), browseDefinitions[1]._links.items + '?filterValue=' + mockAuthorName); + + scheduler.schedule(() => service.getBrowseItemsFor(browseDefinitions[1].id, mockAuthorName).subscribe()); + scheduler.flush(); + + expect(requestService.configure).toHaveBeenCalledWith(expected); + }); + + it('should call RemoteDataBuildService to create the RemoteData Observable', () => { + service.getBrowseItemsFor(browseDefinitions[1].id, mockAuthorName); + + expect(rdbService.toRemoteDataObservable).toHaveBeenCalled(); + + }); + + }); + + describe('when getBrowseEntriesFor is called with an invalid browse definition id', () => { it('should throw an Error', () => { const definitionID = 'invalidID'; @@ -184,14 +194,23 @@ describe('BrowseService', () => { expect(service.getBrowseEntriesFor(definitionID)).toBeObservable(expected); }); }); + + describe('when getBrowseItemsFor is called with an invalid browse definition id', () => { + it('should throw an Error', () => { + + const definitionID = 'invalidID'; + const expected = cold('--#-', undefined, new Error(`No metadata browse definition could be found for id '${definitionID}'`)) + + expect(service.getBrowseItemsFor(definitionID, mockAuthorName)).toBeObservable(expected); + }); + }); }); describe('getBrowseURLFor', () => { describe('if getBrowseDefinitions fires', () => { beforeEach(() => { - responseCache = initMockResponseCacheService(true); - requestService = getMockRequestService(); + requestService = getMockRequestService(getRequestEntry$(true)); rdbService = getMockRemoteDataBuildService(); service = initTestService(); spyOn(service, 'getBrowseDefinitions').and @@ -246,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 836014a110..ca56c0d267 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,12 +11,15 @@ 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, RestRequest } from '../data/request.models'; +import { + BrowseEndpointRequest, + BrowseEntriesRequest, + BrowseItemsRequest, + RestRequest +} from '../data/request.models'; import { RequestService } from '../data/request.service'; import { BrowseDefinition } from '../shared/browse-definition.model'; import { BrowseEntry } from '../shared/browse-entry.model'; @@ -24,11 +27,13 @@ import { HALEndpointService } from '../shared/hal-endpoint.service'; import { configureRequest, filterSuccessfulResponses, + getBrowseDefinitionLinks, getRemoteDataPayload, - getRequestFromSelflink, - getResponseFromSelflink + getRequestFromSelflink } from '../shared/operators'; import { URLCombiner } from '../url-combiner/url-combiner'; +import { Item } from '../shared/item.model'; +import { DSpaceObject } from '../shared/dspace-object.model'; @Injectable() export class BrowseService { @@ -48,7 +53,6 @@ export class BrowseService { } constructor( - protected responseCache: ResponseCacheService, protected requestService: RequestService, protected halService: HALEndpointService, private rdb: RemoteDataBuildService, @@ -65,16 +69,16 @@ 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 payload$ = requestEntry$.pipe( filterSuccessfulResponses(), - map((entry: ResponseCacheEntry) => entry.response), map((response: GenericSuccessResponse) => response.payload), ensureArrayHasValue(), + map((definitions: BrowseDefinition[]) => definitions + .map((definition: BrowseDefinition) => Object.assign(new BrowseDefinition(), definition))), distinctUntilChanged() ); - return this.rdb.toRemoteDataObservable(requestEntry$, responseCache$, payload$); + return this.rdb.toRemoteDataObservable(requestEntry$, payload$); } getBrowseEntriesFor(definitionID: string, options: { @@ -82,17 +86,7 @@ export class BrowseService { sort?: SortOptions; } = {}): Observable>> { const request$ = this.getBrowseDefinitions().pipe( - getRemoteDataPayload(), - map((browseDefinitions: BrowseDefinition[]) => browseDefinitions - .find((def: BrowseDefinition) => def.id === definitionID && def.metadataBrowse === true) - ), - map((def: BrowseDefinition) => { - if (isNotEmpty(def)) { - return def._links; - } else { - throw new Error(`No metadata browse definition could be found for id '${definitionID}'`); - } - }), + getBrowseDefinitionLinks(definitionID), hasValueOperator(), map((_links: any) => _links.entries), hasValueOperator(), @@ -118,16 +112,72 @@ 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 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 + })), distinctUntilChanged() ); - return this.rdb.toRemoteDataObservable(requestEntry$, responseCache$, payload$); + return this.rdb.toRemoteDataObservable(requestEntry$, payload$); + } + + /** + * Get all items linked to a certain metadata value + * @param {string} definitionID definition ID to define the metadata-field (e.g. author) + * @param {string} filterValue metadata value to filter by (e.g. author's name) + * @param options Options to narrow down your search: + * { pagination: PaginationComponentOptions, + * sort: SortOptions } + * @returns {Observable>>} + */ + getBrowseItemsFor(definitionID: string, filterValue: string, options: { + pagination?: PaginationComponentOptions; + sort?: SortOptions; + } = {}): Observable>> { + const request$ = this.getBrowseDefinitions().pipe( + getBrowseDefinitionLinks(definitionID), + hasValueOperator(), + map((_links: any) => _links.items), + hasValueOperator(), + map((href: string) => { + const args = []; + if (isNotEmpty(options.sort)) { + args.push(`sort=${options.sort.field},${options.sort.direction}`); + } + if (isNotEmpty(options.pagination)) { + args.push(`page=${options.pagination.currentPage - 1}`); + args.push(`size=${options.pagination.pageSize}`); + } + if (isNotEmpty(filterValue)) { + args.push(`filterValue=${filterValue}`); + } + if (isNotEmpty(args)) { + href = new URLCombiner(href, `?${args.join('&')}`).toString(); + } + return href; + }), + map((endpointURL: string) => new BrowseItemsRequest(this.requestService.generateRequestId(), endpointURL)), + configureRequest(this.requestService) + ); + + const href$ = request$.pipe(map((request: RestRequest) => request.href)); + + const requestEntry$ = href$.pipe(getRequestFromSelflink(this.requestService)); + + const payload$ = requestEntry$.pipe( + filterSuccessfulResponses(), + map((response: GenericSuccessResponse) => new PaginatedList(response.pageInfo, response.payload)), + map((list: PaginatedList) => Object.assign(list, { + page: list.page ? list.page.map((item: DSpaceObject) => Object.assign(new Item(), item)) : list.page + })), + distinctUntilChanged() + ); + + return this.rdb.toRemoteDataObservable(requestEntry$, payload$); } getBrowseURLFor(metadatumKey: string, linkPath: string): Observable { 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 841a4483ca..3877c19ff9 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,19 @@ +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, + takeUntil, tap +} 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,72 +24,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 { + filterSuccessfulResponses, getRequestFromSelflink, - getResourceLinksFromResponse, - getResponseFromSelflink, - filterSuccessfulResponses + getResourceLinksFromResponse } from '../../shared/operators'; -import { ReplaySubject } from 'rxjs/ReplaySubject'; @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$); } - href$ = href$.multicast(new ReplaySubject(1)).refCount(); - - 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 requestHref$ = href$.pipe( + switchMap((href: string) => + this.objectCache.getRequestHrefBySelfLink(href)), ); - const responseCache$ = Observable.race( - href$.pipe(getResponseFromSelflink(this.responseCache)), - requestHref$.pipe(getResponseFromSelflink(this.responseCache)) + const requestEntry$ = observableRace( + href$.pipe(getRequestFromSelflink(this.requestService)), + requestHref$.pipe(getRequestFromSelflink(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); @@ -84,21 +88,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( @@ -108,37 +112,34 @@ export class RemoteDataBuildService { error, payload ); - }); + }) + ); } buildList(href$: string | Observable): Observable>> { if (typeof href$ === 'string') { - href$ = Observable.of(href$); + href$ = observableOf(href$); } - href$ = href$.shareReplay(); const requestEntry$ = href$.pipe(getRequestFromSelflink(this.requestService)); - const responseCache$ = href$.pipe(getResponseFromSelflink(this.responseCache)); - - const tDomainList$ = responseCache$.pipe( + 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 { @@ -146,18 +147,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) => { @@ -200,7 +202,6 @@ export class RemoteDataBuildService { } } }); - const domainModel = getMapsTo(normalized.constructor); return Object.assign(new domainModel(), normalized, links); } @@ -208,12 +209,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); @@ -255,11 +255,11 @@ export class RemoteDataBuildService { error, payload ); - }) + })) } aggregatePaginatedList(input: Observable>, pageInfo: PageInfo): Observable>> { - return input.map((rd) => Object.assign(rd, {payload: new PaginatedList(pageInfo, rd.payload)})); + return input.pipe(map((rd) => Object.assign(rd, { payload: new PaginatedList(pageInfo, rd.payload) }))); } } diff --git a/src/app/core/cache/models/normalized-object-factory.ts b/src/app/core/cache/models/normalized-object-factory.ts index df67a1f2ce..5c5ebf50aa 100644 --- a/src/app/core/cache/models/normalized-object-factory.ts +++ b/src/app/core/cache/models/normalized-object-factory.ts @@ -8,6 +8,8 @@ import { ResourceType } from '../../shared/resource-type'; import { NormalizedObject } from './normalized-object.model'; import { NormalizedBitstreamFormat } from './normalized-bitstream-format.model'; import { NormalizedResourcePolicy } from './normalized-resource-policy.model'; +import { NormalizedEPerson } from '../../eperson/models/normalized-eperson.model'; +import { NormalizedGroup } from '../../eperson/models/normalized-group.model'; export class NormalizedObjectFactory { public static getConstructor(type: ResourceType): GenericConstructor { @@ -33,6 +35,12 @@ export class NormalizedObjectFactory { case ResourceType.ResourcePolicy: { return NormalizedResourcePolicy } + case ResourceType.EPerson: { + return NormalizedEPerson + } + case ResourceType.Group: { + return NormalizedGroup + } default: { return undefined; } diff --git a/src/app/core/cache/object-cache.actions.ts b/src/app/core/cache/object-cache.actions.ts index a136b04248..024a0e7061 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 */ @@ -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..311f11c2ad 100644 --- a/src/app/core/cache/object-cache.reducer.spec.ts +++ b/src/app/core/cache/object-cache.reducer.spec.ts @@ -2,8 +2,11 @@ import * as deepFreeze from 'deep-freeze'; import { objectCacheReducer } from './object-cache.reducer'; import { + AddPatchObjectCacheAction, AddToObjectCacheAction, - RemoveFromObjectCacheAction, ResetObjectCacheTimestampsAction + ApplyPatchObjectCacheAction, + RemoveFromObjectCacheAction, + ResetObjectCacheTimestampsAction } from './object-cache.actions'; class NullAction extends RemoveFromObjectCacheAction { @@ -26,7 +29,9 @@ describe('objectCacheReducer', () => { }, timeAdded: new Date().getTime(), msToLive: 900000, - requestHref: selfLink1 + requestHref: selfLink1, + patches: [], + isDirty: false }, [selfLink2]: { data: { @@ -35,7 +40,9 @@ describe('objectCacheReducer', () => { }, timeAdded: new Date().getTime(), msToLive: 900000, - requestHref: selfLink2 + requestHref: selfLink2, + patches: [], + isDirty: false } }; deepFreeze(testState); @@ -132,4 +139,16 @@ 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 perform the APPLY_PATCH action without affecting the previous state', () => { + const action = new ApplyPatchObjectCacheAction(selfLink1); + // testState has already been frozen above + objectCacheReducer(testState, action); + }); + }); diff --git a/src/app/core/cache/object-cache.reducer.ts b/src/app/core/cache/object-cache.reducer.ts index 3a1830e14a..4424bb2142 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 @@ -36,6 +46,8 @@ export class ObjectCacheEntry implements CacheEntry { timeAdded: number; msToLive: number; requestHref: 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 + requestHref: action.payload.requestHref, + 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..5a1a3c5ac6 100644 --- a/src/app/core/cache/object-cache.service.spec.ts +++ b/src/app/core/cache/object-cache.service.spec.ts @@ -1,11 +1,21 @@ 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; @@ -14,18 +24,29 @@ describe('ObjectCacheService', () => { const selfLink = 'https://rest.api/endpoint/1698f1d3-be98-4c51-9fd8-6bfedcbd59b7'; 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); @@ -51,10 +72,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 +88,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 +105,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 +116,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..3ac644a045 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); } /** @@ -47,10 +55,10 @@ export class ObjectCacheService { } /** - * 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 +81,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(); + return this.getEntry(selfLink).pipe( + map((entry: ObjectCacheEntry) => entry.requestHref), + distinctUntilChanged()); } getRequestHrefByUUID(uuid: string): Observable { - return this.store.select(selfLinkFromUuidSelector(uuid)) - .flatMap((selfLink: string) => this.getRequestHrefBySelfLink(selfLink)); + return this.store.pipe( + select(selfLinkFromUuidSelector(uuid)), + mergeMap((selfLink: string) => this.getRequestHrefBySelfLink(selfLink)) + ); } /** @@ -122,7 +148,7 @@ export class ObjectCacheService { * @return Observable> */ getList(selfLinks: string[]): Observable { - return Observable.combineLatest( + return observableCombineLatest( selfLinks.map((selfLink: string) => this.getBySelfLink(selfLink)) ); } @@ -139,9 +165,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 +185,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 +214,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 94ddd48f83..90eb621ba4 100644 --- a/src/app/core/cache/response-cache.models.ts +++ b/src/app/core/cache/response.models.ts @@ -14,7 +14,7 @@ import { DSpaceObject } from '../shared/dspace-object.model'; /* tslint:disable:max-classes-per-file */ export class RestResponse { - public toCache = true; + public timeAdded: number; constructor( public isSuccessful: boolean, @@ -141,7 +141,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..638d837bea --- /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's 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..db2263c52a --- /dev/null +++ b/src/app/core/cache/server-sync-buffer.effects.ts @@ -0,0 +1,119 @@ +import { delay, exhaustMap, first, map, switchMap } 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()), + 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..666144104b --- /dev/null +++ b/src/app/core/cache/server-sync-buffer.reducer.spec.ts @@ -0,0 +1,93 @@ +import * as deepFreeze from 'deep-freeze'; + +import { objectCacheReducer } from './object-cache.reducer'; +import { + AddPatchObjectCacheAction, + AddToObjectCacheAction, ApplyPatchObjectCacheAction, + RemoveFromObjectCacheAction, + ResetObjectCacheTimestampsAction +} from './object-cache.actions'; +import { Operation } from '../../../../node_modules/fast-json-patch'; +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 dabdfba0ab..dcbdbd0049 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'; @@ -62,6 +61,7 @@ import { RegistryMetadatafieldsResponseParsingService } from './data/registry-me import { RegistryBitstreamformatsResponseParsingService } from './data/registry-bitstreamformats-response-parsing.service'; import { NotificationsService } from '../shared/notifications/notifications.service'; import { UploaderService } from '../shared/uploader/uploader.service'; +import { BrowseItemsResponseParsingService } from './data/browse-items-response-parsing-service'; import { DSpaceObjectDataService } from './data/dspace-object-data.service'; const IMPORTS = [ @@ -101,7 +101,6 @@ const PROVIDERS = [ RegistryService, RemoteDataBuildService, RequestService, - ResponseCacheService, EndpointMapResponseParsingService, FacetValueResponseParsingService, FacetValueMapResponseParsingService, @@ -115,6 +114,7 @@ const PROVIDERS = [ ServerResponseService, BrowseResponseParsingService, BrowseEntriesResponseParsingService, + BrowseItemsResponseParsingService, BrowseService, ConfigResponseParsingService, RouteService, 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 050b3c2da5..6075d605fd 100644 --- a/src/app/core/data/base-response-parsing.service.ts +++ b/src/app/core/data/base-response-parsing.service.ts @@ -7,6 +7,8 @@ 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'; function isObjectLevel(halObj: any) { return isNotEmpty(halObj._links) && hasValue(halObj._links.self); @@ -25,7 +27,6 @@ export abstract class BaseResponseParsingService { protected abstract toCache: boolean; protected process(data: any, requestHref: string): any { - if (isNotEmpty(data)) { if (hasNoValue(data) || (typeof data !== 'object')) { return data; @@ -34,6 +35,7 @@ export abstract class BaseResponseParsingService { } else if (Array.isArray(data)) { return this.processArray(data, requestHref); } else if (isObjectLevel(data)) { + data = this.fixBadEPersonRestResponse(data); const object = this.deserialize(data); if (isNotEmpty(data._embedded)) { Object @@ -53,6 +55,7 @@ export abstract class BaseResponseParsingService { } }); } + this.cache(object, requestHref); return object; } @@ -122,7 +125,7 @@ export abstract class BaseResponseParsingService { 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, requestHref); } processPageInfo(payload: any): PageInfo { @@ -145,4 +148,23 @@ export abstract class BaseResponseParsingService { } return obj[keys[0]]; } + + // TODO Remove when https://jira.duraspace.org/browse/DS-4006 is fixed + // See https://github.com/DSpace/dspace-angular/issues/292 + private fixBadEPersonRestResponse(obj: any): any { + if (obj.type === ResourceType.EPerson) { + const groups = obj.groups; + const normGroups = []; + if (isNotEmpty(groups)) { + groups.forEach((group) => { + const parts = ['eperson', 'groups', group.uuid]; + const href = new RESTURLCombiner(this.EnvConfig, ...parts).toString(); + normGroups.push(href); + } + ) + } + return Object.assign({}, obj, { groups: normGroups }); + } + return obj; + } } 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 new file mode 100644 index 0000000000..99ea474dc6 --- /dev/null +++ b/src/app/core/data/browse-items-response-parsing-service.spec.ts @@ -0,0 +1,168 @@ +import { getMockObjectCacheService } from '../../shared/mocks/mock-object-cache.service'; +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'; +import { BrowseItemsResponseParsingService } from './browse-items-response-parsing-service'; + +describe('BrowseItemsResponseParsingService', () => { + let service: BrowseItemsResponseParsingService; + + beforeEach(() => { + service = new BrowseItemsResponseParsingService(undefined, getMockObjectCacheService()); + }); + + describe('parse', () => { + const request = new BrowseItemsRequest('client/f5b4ccb8-fbb0-4548-b558-f234d9fdfad6', 'https://rest.api/discover/browses/author/items'); + + const validResponse = { + payload: { + _embedded: { + items: [ + { + id: 'd7b6bc6f-ff6c-444a-a0d3-0cd9b68043e7', + uuid: 'd7b6bc6f-ff6c-444a-a0d3-0cd9b68043e7', + name: 'Development of Local Supply Chain : A Critical Link for Concentrated Solar Power in India', + handle: '10986/17472', + metadata: [ + { + key: 'dc.creator', + value: 'World Bank', + language: null + } + ], + inArchive: true, + discoverable: true, + withdrawn: false, + lastModified: '2018-05-25T09:32:58.005+0000', + type: 'item', + _links: { + bitstreams: { + href: 'https://dspace7-internal.atmire.com/rest/api/core/items/d7b6bc6f-ff6c-444a-a0d3-0cd9b68043e7/bitstreams' + }, + owningCollection: { + href: 'https://dspace7-internal.atmire.com/rest/api/core/items/d7b6bc6f-ff6c-444a-a0d3-0cd9b68043e7/owningCollection' + }, + templateItemOf: { + href: 'https://dspace7-internal.atmire.com/rest/api/core/items/d7b6bc6f-ff6c-444a-a0d3-0cd9b68043e7/templateItemOf' + }, + self: { + href: 'https://dspace7-internal.atmire.com/rest/api/core/items/d7b6bc6f-ff6c-444a-a0d3-0cd9b68043e7' + } + } + }, + { + id: '27c6f976-257c-4ad0-a0ef-c5e34ffe4d5b', + uuid: '27c6f976-257c-4ad0-a0ef-c5e34ffe4d5b', + name: 'Development of Local Supply Chain : The Missing Link for Concentrated Solar Power Projects in India', + handle: '10986/17475', + metadata: [ + { + key: 'dc.creator', + value: 'World Bank', + language: null + } + ], + inArchive: true, + discoverable: true, + withdrawn: false, + lastModified: '2018-05-25T09:33:42.526+0000', + type: 'item', + _links: { + bitstreams: { + href: 'https://dspace7-internal.atmire.com/rest/api/core/items/27c6f976-257c-4ad0-a0ef-c5e34ffe4d5b/bitstreams' + }, + owningCollection: { + href: 'https://dspace7-internal.atmire.com/rest/api/core/items/27c6f976-257c-4ad0-a0ef-c5e34ffe4d5b/owningCollection' + }, + templateItemOf: { + href: 'https://dspace7-internal.atmire.com/rest/api/core/items/27c6f976-257c-4ad0-a0ef-c5e34ffe4d5b/templateItemOf' + }, + self: { + href: 'https://dspace7-internal.atmire.com/rest/api/core/items/27c6f976-257c-4ad0-a0ef-c5e34ffe4d5b' + } + } + } + ] + }, + _links: { + first: { + href: 'https://dspace7-internal.atmire.com/rest/api/discover/browses/author/items?page=0&size=2' + }, + self: { + href: 'https://dspace7-internal.atmire.com/rest/api/discover/browses/author/items' + }, + next: { + href: 'https://dspace7-internal.atmire.com/rest/api/discover/browses/author/items?page=1&size=2' + }, + last: { + href: 'https://dspace7-internal.atmire.com/rest/api/discover/browses/author/items?page=7&size=2' + } + }, + page: { + size: 2, + totalElements: 16, + totalPages: 8, + number: 0 + } + }, + statusCode: '200' + } as DSpaceRESTV2Response; + + const invalidResponseNotAList = { + payload: { + id: 'd7b6bc6f-ff6c-444a-a0d3-0cd9b68043e7', + uuid: 'd7b6bc6f-ff6c-444a-a0d3-0cd9b68043e7', + name: 'Development of Local Supply Chain : A Critical Link for Concentrated Solar Power in India', + handle: '10986/17472', + metadata: [ + { + key: 'dc.creator', + value: 'World Bank', + language: null + } + ], + inArchive: true, + discoverable: true, + withdrawn: false, + lastModified: '2018-05-25T09:32:58.005+0000', + type: 'item', + _links: { + bitstreams: { + href: 'https://dspace7-internal.atmire.com/rest/api/core/items/d7b6bc6f-ff6c-444a-a0d3-0cd9b68043e7/bitstreams' + }, + owningCollection: { + href: 'https://dspace7-internal.atmire.com/rest/api/core/items/d7b6bc6f-ff6c-444a-a0d3-0cd9b68043e7/owningCollection' + }, + templateItemOf: { + href: 'https://dspace7-internal.atmire.com/rest/api/core/items/d7b6bc6f-ff6c-444a-a0d3-0cd9b68043e7/templateItemOf' + }, + self: { + href: 'https://dspace7-internal.atmire.com/rest/api/core/items/d7b6bc6f-ff6c-444a-a0d3-0cd9b68043e7' + } + } + }, + statusCode: '200' + } as DSpaceRESTV2Response; + + const invalidResponseStatusCode = { + payload: {}, statusCode: '500' + } as DSpaceRESTV2Response; + + it('should return a GenericSuccessResponse if data contains a valid browse items response', () => { + const response = service.parse(request, validResponse); + expect(response.constructor).toBe(GenericSuccessResponse); + }); + + it('should return an ErrorResponse if data contains an invalid browse entries response', () => { + const response = service.parse(request, invalidResponseNotAList); + expect(response.constructor).toBe(ErrorResponse); + }); + + it('should return an ErrorResponse if data contains a statuscode other than 200', () => { + const response = service.parse(request, invalidResponseStatusCode); + expect(response.constructor).toBe(ErrorResponse); + }); + + }); +}); diff --git a/src/app/core/data/browse-items-response-parsing-service.ts b/src/app/core/data/browse-items-response-parsing-service.ts new file mode 100644 index 0000000000..218c25bac6 --- /dev/null +++ b/src/app/core/data/browse-items-response-parsing-service.ts @@ -0,0 +1,58 @@ +import { Inject, Injectable } from '@angular/core'; +import { GLOBAL_CONFIG } from '../../../config'; +import { GlobalConfig } from '../../../config/global-config.interface'; +import { isNotEmpty } from '../../shared/empty.util'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { + ErrorResponse, + GenericSuccessResponse, + RestResponse +} from '../cache/response.models'; +import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; +import { DSpaceRESTv2Serializer } from '../dspace-rest-v2/dspace-rest-v2.serializer'; +import { BaseResponseParsingService } from './base-response-parsing.service'; +import { ResponseParsingService } from './parsing.service'; +import { RestRequest } from './request.models'; +import { Item } from '../shared/item.model'; +import { DSpaceObject } from '../shared/dspace-object.model'; + +/** + * A ResponseParsingService used to parse DSpaceRESTV2Response coming from the REST API to Browse Items (DSpaceObject[]) + */ +@Injectable() +export class BrowseItemsResponseParsingService extends BaseResponseParsingService implements ResponseParsingService { + + protected objectFactory = { + getConstructor: () => DSpaceObject + }; + protected toCache = false; + + constructor( + @Inject(GLOBAL_CONFIG) protected EnvConfig: GlobalConfig, + protected objectCache: ObjectCacheService, + ) { super(); + } + + /** + * Parses data from the browse endpoint to a list of DSpaceObjects + * @param {RestRequest} request + * @param {DSpaceRESTV2Response} data + * @returns {RestResponse} + */ + parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse { + if (isNotEmpty(data.payload) && isNotEmpty(data.payload._embedded) + && Array.isArray(data.payload._embedded[Object.keys(data.payload._embedded)[0]])) { + const serializer = new DSpaceRESTv2Serializer(DSpaceObject); + const items = serializer.deserializeArray(data.payload._embedded[Object.keys(data.payload._embedded)[0]]); + return new GenericSuccessResponse(items, data.statusCode, this.processPageInfo(data.payload)); + } else { + return new ErrorResponse( + Object.assign( + new Error('Unexpected response from browse endpoint'), + { statusText: data.statusCode } + ) + ); + } + } + +} 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 4157d96a9e..b936d71c30 100644 --- a/src/app/core/data/collection-data.service.ts +++ b/src/app/core/data/collection-data.service.ts @@ -1,10 +1,8 @@ import { Inject, Injectable } from '@angular/core'; import { Store } from '@ngrx/store'; -import { GLOBAL_CONFIG, GlobalConfig } from '../../../config'; 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'; @@ -21,7 +19,6 @@ export class CollectionDataService extends ComColDataService, diff --git a/src/app/core/data/comcol-data.service.spec.ts b/src/app/core/data/comcol-data.service.spec.ts index 0c03b64a1d..3325dc6ac3 100644 --- a/src/app/core/data/comcol-data.service.spec.ts +++ b/src/app/core/data/comcol-data.service.spec.ts @@ -1,19 +1,19 @@ import { Store } from '@ngrx/store'; import { cold, getTestScheduler, hot } from 'jasmine-marbles'; -import { Observable, 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'; -import { FindByIDRequest } from './request.models'; +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'; import { Community } from '../shared/community.model'; import { AuthService } from '../auth/auth.service'; import { NotificationsService } from '../../shared/notifications/notifications.service'; @@ -28,7 +28,6 @@ class NormalizedTestObject extends NormalizedObject { class TestService extends ComColDataService { constructor( - protected responseCache: ResponseCacheService, protected requestService: RequestService, protected rdbService: RemoteDataBuildService, protected store: Store, @@ -44,12 +43,12 @@ class TestService extends ComColDataService { 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; @@ -63,6 +62,15 @@ describe('ComColDataService', () => { const http = {} as HttpClient; const scopeID = 'd9d30c0c-69b7-4369-8397-ca67c888974d'; + 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}`; const scopedEndpoint = `${communityEndpoint}/${LINK_NAME}`; @@ -80,14 +88,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-', { @@ -110,7 +110,6 @@ describe('ComColDataService', () => { function initTestService(): TestService { return new TestService( - responseCache, requestService, rdbService, store, @@ -129,52 +128,62 @@ describe('ComColDataService', () => { cds = initMockCommunityDataService(); requestService = getMockRequestService(); objectCache = initMockObjectCacheService(); - responseCache = initMockResponseCacheService(true); halService = mockHalService; authService = initMockAuthService(); service = initTestService(); }); - describe('getScopedEndpoint', () => { + describe('getBrowseEndpoint', () => { beforeEach(() => { scheduler = getTestScheduler(); }); it('should configure a new FindByIDRequest for the scope Community', () => { + cds = initMockCommunityDataService(); + requestService = getMockRequestService(getRequestEntry$(true)); + objectCache = initMockObjectCacheService(); + service = initTestService(); + const expected = new FindByIDRequest(requestService.generateRequestId(), communityEndpoint, scopeID); - scheduler.schedule(() => service.getScopedEndpoint(scopeID).subscribe()); + scheduler.schedule(() => service.getBrowseEndpoint(options).subscribe()); scheduler.flush(); expect(requestService.configure).toHaveBeenCalledWith(expected); }); describe('if the scope Community can be found', () => { + beforeEach(() => { + cds = initMockCommunityDataService(); + requestService = getMockRequestService(getRequestEntry$(true)); + objectCache = initMockObjectCacheService(); + service = initTestService(); + }); + it('should fetch the scope Community from the cache', () => { - scheduler.schedule(() => service.getScopedEndpoint(scopeID).subscribe()); + scheduler.schedule(() => service.getBrowseEndpoint(options).subscribe()); scheduler.flush(); expect(objectCache.getByUUID).toHaveBeenCalledWith(scopeID); }); it('should return the endpoint to fetch resources within the given scope', () => { - const result = service.getScopedEndpoint(scopeID); - const expected = cold('--e-', { e: scopedEndpoint }); + const result = service.getBrowseEndpoint(options); + 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(); }); it('should throw an error', () => { - const result = service.getScopedEndpoint(scopeID); + const result = service.getBrowseEndpoint(options); const expected = cold('--#-', undefined, new Error(`The Community with scope ${scopeID} couldn't be retrieved`)); expect(result).toBeObservable(expected); diff --git a/src/app/core/data/comcol-data.service.ts b/src/app/core/data/comcol-data.service.ts index 0859d075ca..63c11dd8cb 100644 --- a/src/app/core/data/comcol-data.service.ts +++ b/src/app/core/data/comcol-data.service.ts @@ -1,27 +1,27 @@ -import { Observable } from 'rxjs/Observable'; -import { isEmpty, isNotEmpty, isNotEmptyOperator } 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 { FindByIDRequest, PostRequest, PutRequest, RequestError, RestRequest } from './request.models'; +import { FindAllOptions, FindByIDRequest } from './request.models'; import { NormalizedObject } from '../cache/models/normalized-object.model'; import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { DSpaceObject } from '../shared/dspace-object.model'; -import { Community } from '../shared/community.model'; -import { Collection } from '../shared/collection.model'; -import { catchError, distinctUntilChanged, map } from 'rxjs/operators'; -import { configureRequest, getResponseFromSelflink } from '../shared/operators'; -import { AuthService } from '../auth/auth.service'; -import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service'; -import { HttpHeaders } from '@angular/common/http'; -import { RemoteData } from './remote-data'; +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; @@ -36,34 +36,52 @@ export abstract class ComColDataService } * an Observable containing the scoped URL */ - public getScopedEndpoint(scopeID: string): Observable { - if (isEmpty(scopeID)) { - return this.halService.getEndpoint(this.linkPath); + public getBrowseEndpoint(options: FindAllOptions = {}, linkPath: string = this.linkPath): Observable { + if (isEmpty(options.scopeID)) { + return this.halService.getEndpoint(linkPath); } else { - const scopeCommunityHrefObs = this.cds.getEndpoint() - .flatMap((endpoint: string) => this.cds.getFindByIDHref(endpoint, scopeID)) - .filter((href: string) => isNotEmpty(href)) - .take(1) - .do((href: string) => { - const request = new FindByIDRequest(this.requestService.generateRequestId(), href, scopeID); + 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 ${scopeID} couldn't be retrieved`))), - successResponse - .flatMap((response: DSOSuccessResponse) => this.objectCache.getByUUID(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 49638629e6..9645a970c6 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'; @@ -14,7 +14,7 @@ import { AuthService } from '../auth/auth.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'; import { NotificationsService } from '../../shared/notifications/notifications.service'; import { HttpClient } from '@angular/common/http'; @@ -26,7 +26,6 @@ export class CommunityDataService extends ComColDataService, @@ -44,12 +43,10 @@ export class CommunityDataService extends ComColDataService>> { - const hrefObs = this.halService.getEndpoint(this.topLinkPath).filter((href: string) => isNotEmpty(href)) - .flatMap((endpoint: string) => this.getFindAllHref(endpoint, 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..ddf884e02b 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'; diff --git a/src/app/core/data/data.service.spec.ts b/src/app/core/data/data.service.spec.ts index 8a2f5897c2..7da709abd5 100644 --- a/src/app/core/data/data.service.spec.ts +++ b/src/app/core/data/data.service.spec.ts @@ -1,223 +1,181 @@ 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 { NotificationsService } from '../../shared/notifications/notifications.service'; -import { HttpClient } from '@angular/common/http'; -import { AuthService } from '../auth/auth.service'; -import { getMockRequestService } from '../../shared/mocks/mock-request.service'; -import { getMockResponseCacheService } from '../../shared/mocks/mock-response-cache.service'; +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'; -import { RemoteData } from './remote-data'; -import { RequestEntry } from './request.reducer'; -import { getMockRemoteDataBuildService } from '../../shared/mocks/mock-remote-data-build.service'; -import { EmptyError } from 'rxjs/util/EmptyError'; -import { ResponseCacheEntry } from '../cache/response-cache.reducer'; -import { DSOSuccessResponse, ErrorResponse, RestResponse } from '../cache/response-cache.models'; -import { hasValue } from '../../shared/empty.util'; -import { map } from 'rxjs/operators'; -import { RemoteDataError } from './remote-data-error'; -const LINK_NAME = 'test'; +const endpoint = 'https://rest.api/core'; // tslint:disable:max-classes-per-file 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, - protected authService: AuthService, - protected notificationsService: NotificationsService, - protected http: HttpClient - ) { - super(); - } - - public getScopedEndpoint(scope: string): Observable { - throw new Error('getScopedEndpoint is abstract in DataService'); - } + constructor( + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected store: Store, + protected linkPath: string, + protected halService: HALEndpointService, + protected objectCache: ObjectCacheService + ) { + super(); + } + public getBrowseEndpoint(options: FindAllOptions = {}, linkPath: string = this.linkPath): Observable { + return observableOf(endpoint); + } } describe('DataService', () => { - let service: TestService; - let options: FindAllOptions; - let responseCache = getMockResponseCacheService(); - let rdbService = {} as RemoteDataBuildService; - const authService = {} as AuthService; - const notificationsService = {} as NotificationsService; - const http = {} as HttpClient; - const store = {} as Store; - const endpoint = 'https://rest.api/core'; - const halService = Object.assign({ - getEndpoint: (linkpath) => Observable.of(endpoint) - }); - const requestService = Object.assign(getMockRequestService(), { - getByUUID: () => Observable.of(new RequestEntry()), - configure: (request) => request - }); + 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; - const dso = new DSpaceObject(); - const successfulRd$ = Observable.of(new RemoteData(false, false, true, undefined, dso)); - const successfulResponseCacheEntry = Object.assign({ - response: { - isSuccessful: true, - payload: dso, - toCache: true, - statusCode: '200', - resourceSelfLinks: [ - endpoint - ] - } as DSOSuccessResponse - }) as ResponseCacheEntry; + function initTestService(): TestService { + return new TestService( + requestService, + rdbService, + store, + endpoint, + halService, + objectCache + ); + } - function initSuccessfulRemoteDataBuildService(): RemoteDataBuildService { - return { - buildSingle: (selfLinks$: Observable) => { - selfLinks$.subscribe(); - return successfulRd$; + service = initTestService(); + + describe('getFindAllHref', () => { + + it('should return an observable with the endpoint', () => { + options = {}; + + (service as any).getFindAllHref(options).subscribe((value) => { + expect(value).toBe(endpoint); } - } as RemoteDataBuildService; - } - function initSuccessfulResponseCacheService(): ResponseCacheService { - return getMockResponseCacheService(Observable.of(new ResponseCacheEntry()), Observable.of(successfulResponseCacheEntry)); - } - - function initTestService(): TestService { - return new TestService( - responseCache, - requestService, - rdbService, - store, - LINK_NAME, - halService, - authService, - notificationsService, - http - ); - } - - service = initTestService(); - - describe('getFindAllHref', () => { - - it('should return an observable with the endpoint', () => { - options = {}; - - (service as any).getFindAllHref(endpoint).subscribe((value) => { - expect(value).toBe(endpoint); - } - ); - }); - - // getScopedEndpoint is not implemented in abstract DataService - it('should throw error if scopeID provided in options', () => { - options = { scopeID: 'somevalue' }; - - expect(() => { (service as any).getFindAllHref(endpoint, options) }) - .toThrowError('getScopedEndpoint is abstract in DataService'); - }); - - 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(endpoint, 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(endpoint, 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(endpoint, 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(endpoint, 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(endpoint, options).subscribe((value) => { - expect(value).toBe(expected); - }); - }) + ); }); - describe('create', () => { + it('should include page in href if currentPage provided in options', () => { + options = { currentPage: 2 }; + const expected = `${endpoint}?page=${options.currentPage - 1}`; - describe('when the request was successful', () => { - beforeEach(() => { - responseCache = initSuccessfulResponseCacheService(); - rdbService = initSuccessfulRemoteDataBuildService(); - service = initTestService(); - }); - - it('should return a RemoteData of a DSpaceObject', () => { - service.create(dso, undefined).subscribe((rd: RemoteData) => { - expect(rd.payload).toBe(dso); - }); - }); - - it('should get the response from cache with the correct url when parent is empty', () => { - const expectedUrl = endpoint; - - service.create(dso, undefined).subscribe((value) => { - expect(responseCache.get).toHaveBeenCalledWith(expectedUrl); - }); - }); - - it('should get the response from cache with the correct url when parent is not empty', () => { - const parent = 'fake-parent-uuid'; - const expectedUrl = `${endpoint}?parent=${parent}`; - - service.create(dso, parent).subscribe((value) => { - expect(responseCache.get).toHaveBeenCalledWith(expectedUrl); - }); - }); + (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 53327952c1..d591b1a8d5 100644 --- a/src/app/core/data/data.service.ts +++ b/src/app/core/data/data.service.ts @@ -1,8 +1,8 @@ +import { distinctUntilChanged, filter, first, map, take } from 'rxjs/operators'; +import { Observable } from 'rxjs'; import { Store } from '@ngrx/store'; -import { Observable } from 'rxjs/Observable'; import { hasValue, isNotEmpty, isNotEmptyOperator } 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'; @@ -13,78 +13,68 @@ import { FindAllOptions, FindAllRequest, FindByIDRequest, - GetRequest, - RestRequest + GetRequest } from './request.models'; import { RequestService } from './request.service'; import { NormalizedObject } from '../cache/models/normalized-object.model'; -import { distinctUntilChanged, first, map, take, tap } from 'rxjs/operators'; +import { compare, Operation } from 'fast-json-patch'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { DSpaceObject } from '../shared/dspace-object.model'; +import { AuthService } from '../auth/auth.service'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { HttpClient } from '@angular/common/http'; import { configureRequest, filterSuccessfulResponses, - getResponseFromSelflink + getResponseFromEntry } from '../shared/operators'; -import { ResponseCacheEntry } from '../cache/response-cache.reducer'; -import { HttpClient } from '@angular/common/http'; -import { DSOSuccessResponse, ErrorResponse } from '../cache/response-cache.models'; -import { AuthService } from '../auth/auth.service'; -import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { DSOSuccessResponse, ErrorResponse, RestResponse } from '../cache/response.models'; import { NotificationOptions } from '../../shared/notifications/models/notification-options.model'; 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; protected abstract authService: AuthService; protected abstract notificationsService: NotificationsService; protected abstract http: HttpClient; - public abstract getScopedEndpoint(scope: string): Observable + public abstract getBrowseEndpoint(options: FindAllOptions, linkPath?: string): Observable - protected getFindAllHref(endpoint, options: FindAllOptions = {}): Observable { + protected getFindAllHref(options: FindAllOptions = {}, linkPath?: string): Observable { let result: Observable; const args = []; - if (hasValue(options.scopeID)) { - result = this.getScopedEndpoint(options.scopeID).distinctUntilChanged(); - } else { - result = Observable.of(endpoint); - } - + 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; } } findAll(options: FindAllOptions = {}): Observable>> { - const hrefObs = this.halService.getEndpoint(this.linkPath).filter((href: string) => isNotEmpty(href)) - .flatMap((endpoint: string) => this.getFindAllHref(endpoint, options)); + const hrefObs = this.getFindAllHref(options); - hrefObs - .filter((href: string) => hasValue(href)) - .take(1) + 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); @@ -98,11 +88,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); @@ -116,6 +106,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); + } + } + create(dso: TDomain, parentUUID: string): Observable> { const requestId = this.requestService.generateRequestId(); const endpoint$ = this.halService.getEndpoint(this.linkPath).pipe( @@ -131,22 +143,20 @@ export abstract class DataService ); const selfLink$ = request$.pipe( - map((request: RestRequest) => request.href), - getResponseFromSelflink(this.responseCache), - map((response: ResponseCacheEntry) => { - if (!response.response.isSuccessful && response.response instanceof ErrorResponse) { - const errorResponse: ErrorResponse = response.response; - this.notificationsService.error('Server Error:', errorResponse.errorMessage, new NotificationOptions(-1)); + getResponseFromEntry(), + map((response: RestResponse) => { + if (!response.isSuccessful && response instanceof ErrorResponse) { + this.notificationsService.error('Server Error:', response.errorMessage, new NotificationOptions(-1)); + } else { + return response; } - return response; }), filterSuccessfulResponses(), - map((entry: ResponseCacheEntry) => entry.response), map((response: DSOSuccessResponse) => { return response.resourceSelfLinks[0]; }), distinctUntilChanged() - ); + ) as Observable; return this.rdbService.buildSingle(selfLink$) as Observable>; } 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..568114be1a 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.href); 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 d14d7f8a0b..3e8d8bdc04 100644 --- a/src/app/core/data/dspace-object-data.service.ts +++ b/src/app/core/data/dspace-object-data.service.ts @@ -1,15 +1,16 @@ 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'; 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'; import { AuthService } from '../auth/auth.service'; import { NotificationsService } from '../../shared/notifications/notifications.service'; import { HttpClient } from '@angular/common/http'; @@ -19,10 +20,10 @@ class DataServiceImpl extends DataService protected linkPath = 'dso'; constructor( - protected responseCache: ResponseCacheService, protected requestService: RequestService, protected rdbService: RemoteDataBuildService, protected store: Store, + protected objectCache: ObjectCacheService, protected halService: HALEndpointService, protected authService: AuthService, protected notificationsService: NotificationsService, @@ -30,8 +31,8 @@ class DataServiceImpl extends DataService super(); } - getScopedEndpoint(scope: string): Observable { - return undefined; + getBrowseEndpoint(options: FindAllOptions = {}, linkPath: string = this.linkPath): Observable { + return this.halService.getEndpoint(linkPath); } getFindByIDHref(endpoint, resourceID): string { @@ -47,11 +48,12 @@ export class DSpaceObjectDataService { constructor( protected requestService: RequestService, protected rdbService: RemoteDataBuildService, + protected objectCache: ObjectCacheService, protected halService: HALEndpointService, protected authService: AuthService, protected notificationsService: NotificationsService, protected http: HttpClient) { - this.dataService = new DataServiceImpl(null, requestService, rdbService, null, halService, authService, notificationsService, http); + this.dataService = new DataServiceImpl(requestService, rdbService, null, objectCache, halService, authService, notificationsService, http); } 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..0fc5917847 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,7 +4,7 @@ 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'; 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..585172c22e 100644 --- a/src/app/core/data/facet-value-response-parsing.service.ts +++ b/src/app/core/data/facet-value-response-parsing.service.ts @@ -4,7 +4,7 @@ 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'; diff --git a/src/app/core/data/item-data.service.spec.ts b/src/app/core/data/item-data.service.spec.ts index 4d0dc8aec3..bb67fc8412 100644 --- a/src/app/core/data/item-data.service.spec.ts +++ b/src/app/core/data/item-data.service.spec.ts @@ -1,25 +1,34 @@ 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', () => { let scheduler: TestScheduler; 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; const scopeID = '4af28e99-6a9c-4036-a199-e1b587046d39'; + const options = Object.assign(new FindAllOptions(), { + scopeID: scopeID, + sort: { + field: '', + direction: undefined + } + }); + const browsesEndpoint = 'https://rest.api/discover/browses'; const itemBrowseEndpoint = `${browsesEndpoint}/author/items`; const scopedEndpoint = `${itemBrowseEndpoint}?scope=${scopeID}`; @@ -37,25 +46,25 @@ describe('ItemDataService', () => { function initTestService() { return new ItemDataService( - responseCache, requestService, rdbService, store, bs, - halEndpointService + halEndpointService, + objectCache ); } - describe('getScopedEndpoint', () => { + describe('getBrowseEndpoint', () => { beforeEach(() => { scheduler = getTestScheduler(); }); - it('should return the endpoint to fetch Items within the given scope', () => { + it('should return the endpoint to fetch Items within the given scope and starting with the given string', () => { bs = initMockBrowseService(true); service = initTestService(); - const result = service.getScopedEndpoint(scopeID); + const result = service.getBrowseEndpoint(options); const expected = cold('--b-', { b: scopedEndpoint }); expect(result).toBeObservable(expected); @@ -67,7 +76,7 @@ describe('ItemDataService', () => { service = initTestService(); }); it('should throw an error', () => { - const result = service.getScopedEndpoint(scopeID); + const result = service.getBrowseEndpoint(options); const expected = cold('--#-', undefined, browseError); expect(result).toBeObservable(expected); diff --git a/src/app/core/data/item-data.service.ts b/src/app/core/data/item-data.service.ts index 6b62fe6688..68380ddaa2 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} 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 { isEmpty, isNotEmpty } from '../../shared/empty.util'; +import { Observable } from 'rxjs'; +import { isNotEmpty } 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,6 +14,8 @@ 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 { FindAllOptions } from './request.models'; +import { ObjectCacheService } from '../cache/object-cache.service'; import { NotificationsService } from '../../shared/notifications/notifications.service'; import { AuthService } from '../auth/auth.service'; import { HttpClient } from '@angular/common/http'; @@ -24,11 +25,11 @@ 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 objectCache: ObjectCacheService, protected halService: HALEndpointService, protected authService: AuthService, protected notificationsService: NotificationsService, @@ -36,15 +37,21 @@ export class ItemDataService extends DataService { super(); } - public getScopedEndpoint(scopeID: string): Observable { - if (isEmpty(scopeID)) { - return this.halService.getEndpoint(this.linkPath); - } else { - return this.bs.getBrowseURLFor('dc.date.issued', this.linkPath) - .filter((href: string) => isNotEmpty(href)) - .map((href: string) => new URLCombiner(href, `?scope=${scopeID}`).toString()) - .distinctUntilChanged(); + /** + * Get the endpoint for browsing items + * (When options.sort.field is empty, the default field to browse by will be 'dc.date.issued') + * @param {FindAllOptions} options + * @returns {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, linkPath).pipe( + filter((href: string) => isNotEmpty(href)), + map((href: string) => new URLCombiner(href, `?scope=${options.scopeID}`).toString()), + distinctUntilChanged(),); } } 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..537a0b69b6 100644 --- a/src/app/core/data/request.effects.ts +++ b/src/app/core/data/request.effects.ts @@ -1,42 +1,48 @@ +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), + tap((entry: RequestEntry) => console.log(entry)), flatMap((request: RestRequest) => { let body; if (isNotEmpty(request.body)) { @@ -45,20 +51,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 7ca152a204..6c4c89e492 100644 --- a/src/app/core/data/request.models.ts +++ b/src/app/core/data/request.models.ts @@ -9,51 +9,41 @@ 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 { IntegrationResponseParsingService } from '../integration/integration-response-parsing.service'; +import { RestRequestMethod } from './rest-request-method'; +import { BrowseItemsResponseParsingService } from './browse-items-response-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) } } @@ -64,7 +54,7 @@ export class PostRequest extends RestRequest { public body?: any, public options?: HttpOptions ) { - super(uuid, href, RestRequestMethod.Post, body) + super(uuid, href, RestRequestMethod.POST, body) } } @@ -75,7 +65,7 @@ export class PutRequest extends RestRequest { public body?: any, public options?: HttpOptions ) { - super(uuid, href, RestRequestMethod.Put, body) + super(uuid, href, RestRequestMethod.PUT, body) } } @@ -86,7 +76,7 @@ export class DeleteRequest extends RestRequest { public body?: any, public options?: HttpOptions ) { - super(uuid, href, RestRequestMethod.Delete, body) + super(uuid, href, RestRequestMethod.DELETE, body) } } @@ -97,7 +87,7 @@ export class OptionsRequest extends RestRequest { public body?: any, public options?: HttpOptions ) { - super(uuid, href, RestRequestMethod.Options, body) + super(uuid, href, RestRequestMethod.OPTIONS, body) } } @@ -108,7 +98,7 @@ export class HeadRequest extends RestRequest { public body?: any, public options?: HttpOptions ) { - super(uuid, href, RestRequestMethod.Head, body) + super(uuid, href, RestRequestMethod.HEAD, body) } } @@ -119,7 +109,7 @@ export class PatchRequest extends RestRequest { public body?: any, public options?: HttpOptions ) { - super(uuid, href, RestRequestMethod.Patch, body) + super(uuid, href, RestRequestMethod.PATCH, body) } } @@ -181,6 +171,12 @@ export class BrowseEntriesRequest extends GetRequest { } } +export class BrowseItemsRequest extends GetRequest { + getResponseParser(): GenericConstructor { + return BrowseItemsResponseParsingService; + } +} + export class ConfigRequest extends GetRequest { constructor(uuid: string, href: string) { super(uuid, href); 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..debddb748c 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,7 +448,11 @@ 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(); }); diff --git a/src/app/core/data/request.service.ts b/src/app/core/data/request.service.ts index 12933f83fc..4b7a7c9b49 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,51 +76,69 @@ 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)) + ); } // 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)) + } + ) } } 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; } @@ -121,11 +156,45 @@ 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) + }); + } + + commit(method?: RestRequestMethod) { + this.store.dispatch(new CommitSSBAction(method)) + } + + /** + * Check whether a Response 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 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; + } + }) + ); + return observableOf(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 8c5b5f1ac6..59038228bb 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'; import { isNotEmpty } from '../../shared/empty.util'; import { DSpaceObject } from '../shared/dspace-object.model'; @@ -38,12 +38,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); + })); } /** @@ -61,7 +61,7 @@ export class DSpaceRESTv2Service { request(method: RestRequestMethod, url: string, body?: any, options?: HttpOptions): Observable { const requestOptions: HttpOptions = {}; requestOptions.body = body; - if (method === RestRequestMethod.Post && isNotEmpty(body) && isNotEmpty(body.name)) { + if (method === RestRequestMethod.POST && isNotEmpty(body) && isNotEmpty(body.name)) { requestOptions.body = this.buildFormData(body); } requestOptions.observe = 'response'; @@ -71,12 +71,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); + })); } buildFormData(dso: DSpaceObject): FormData { diff --git a/src/app/core/eperson/models/eperson.model.ts b/src/app/core/eperson/models/eperson.model.ts index 373fb42792..45d26761b0 100644 --- a/src/app/core/eperson/models/eperson.model.ts +++ b/src/app/core/eperson/models/eperson.model.ts @@ -1,7 +1,7 @@ import { DSpaceObject } from '../../shared/dspace-object.model'; import { Group } from './group.model'; -export class Eperson extends DSpaceObject { +export class EPerson extends DSpaceObject { public handle: string; diff --git a/src/app/core/eperson/models/NormalizedEperson.model.ts b/src/app/core/eperson/models/normalized-eperson.model.ts similarity index 84% rename from src/app/core/eperson/models/NormalizedEperson.model.ts rename to src/app/core/eperson/models/normalized-eperson.model.ts index 0c0b2490d6..9d0fa428e9 100644 --- a/src/app/core/eperson/models/NormalizedEperson.model.ts +++ b/src/app/core/eperson/models/normalized-eperson.model.ts @@ -2,13 +2,13 @@ import { autoserialize, inheritSerialization } from 'cerialize'; import { CacheableObject } from '../../cache/object-cache.reducer'; import { ListableObject } from '../../../shared/object-collection/shared/listable-object.model'; import { NormalizedDSpaceObject } from '../../cache/models/normalized-dspace-object.model'; -import { Eperson } from './eperson.model'; +import { EPerson } from './eperson.model'; import { mapsTo, relationship } from '../../cache/builders/build-decorators'; import { ResourceType } from '../../shared/resource-type'; -@mapsTo(Eperson) +@mapsTo(EPerson) @inheritSerialization(NormalizedDSpaceObject) -export class NormalizedEpersonModel extends NormalizedDSpaceObject implements CacheableObject, ListableObject { +export class NormalizedEPerson extends NormalizedDSpaceObject implements CacheableObject, ListableObject { @autoserialize public handle: string; diff --git a/src/app/core/eperson/models/NormalizedGroup.model.ts b/src/app/core/eperson/models/normalized-group.model.ts similarity index 79% rename from src/app/core/eperson/models/NormalizedGroup.model.ts rename to src/app/core/eperson/models/normalized-group.model.ts index 24f7da8eab..be5995d9c5 100644 --- a/src/app/core/eperson/models/NormalizedGroup.model.ts +++ b/src/app/core/eperson/models/normalized-group.model.ts @@ -2,13 +2,12 @@ import { autoserialize, inheritSerialization } from 'cerialize'; import { CacheableObject } from '../../cache/object-cache.reducer'; import { ListableObject } from '../../../shared/object-collection/shared/listable-object.model'; import { NormalizedDSpaceObject } from '../../cache/models/normalized-dspace-object.model'; -import { Eperson } from './eperson.model'; import { mapsTo } from '../../cache/builders/build-decorators'; import { Group } from './group.model'; @mapsTo(Group) @inheritSerialization(NormalizedDSpaceObject) -export class NormalizedGroupModel extends NormalizedDSpaceObject implements CacheableObject, ListableObject { +export class NormalizedGroup extends NormalizedDSpaceObject implements CacheableObject, ListableObject { @autoserialize public handle: string; 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..6eff7ab792 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'; 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 ca3d3b0e95..2b47239729 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'; @@ -68,7 +65,6 @@ describe('MetadataService', () => { let store: Store; let objectCacheService: ObjectCacheService; - let responseCacheService: ResponseCacheService; let requestService: RequestService; let uuidService: UUIDService; let remoteDataBuildService: RemoteDataBuildService; @@ -89,10 +85,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: [ @@ -115,7 +110,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 }, @@ -191,7 +185,7 @@ describe('MetadataService', () => { })); 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..ede66f6952 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 { 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) => { + }),).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,11 +259,11 @@ 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[]) => { + item.getFiles().pipe(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)) + bitstream.format.pipe(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); 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/browse-entry.model.ts b/src/app/core/shared/browse-entry.model.ts index fede195a39..932c6946d1 100644 --- a/src/app/core/shared/browse-entry.model.ts +++ b/src/app/core/shared/browse-entry.model.ts @@ -1,6 +1,7 @@ import { autoserialize, autoserializeAs } from 'cerialize'; +import { ListableObject } from '../../shared/object-collection/shared/listable-object.model'; -export class BrowseEntry { +export class BrowseEntry implements ListableObject { @autoserialize type: string; 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 5e62e3e321..68338143ba 100644 --- a/src/app/core/shared/dspace-object.model.ts +++ b/src/app/core/shared/dspace-object.model.ts @@ -4,23 +4,26 @@ 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; /** * The human-readable identifier of this DSpaceObject */ + @autoserialize id: string; /** * The universally unique identifier of this DSpaceObject */ + @autoserialize uuid: string; /** @@ -31,11 +34,13 @@ export class DSpaceObject implements CacheableObject, ListableObject { /** * The name for this DSpaceObject */ + @autoserialize name: string; /** * An array containing all metadata of this DSpaceObject */ + @autoserialize metadata: Metadatum[]; /** diff --git a/src/app/core/shared/hal-endpoint.service.spec.ts b/src/app/core/shared/hal-endpoint.service.spec.ts index 0c2afe938b..b65b3f905b 100644 --- a/src/app/core/shared/hal-endpoint.service.spec.ts +++ b/src/app/core/shared/hal-endpoint.service.spec.ts @@ -1,44 +1,44 @@ -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', }; 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 +48,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 +60,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', () => { @@ -89,7 +83,6 @@ describe('HALEndpointService', () => { 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 a0be10f454..a0bf7aabd5 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, switchMap } 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,42 @@ 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 = ['/']; + 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) => [ - switchMap((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..5f29de9e93 100644 --- a/src/app/core/shared/operators.spec.ts +++ b/src/app/core/shared/operators.spec.ts @@ -1,17 +1,15 @@ 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, + getRequestFromSelflink, + getResourceLinksFromResponse, } from './operators'; describe('Core Module - RxJS Operators', () => { @@ -27,6 +25,14 @@ describe('Core Module - RxJS Operators', () => { e: { response: { isSuccessful: 1, resourceSelfLinks: [] } } }; + 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(); }); @@ -64,49 +70,11 @@ describe('Core Module - RxJS Operators', () => { }); }); - describe('getResponseFromSelflink', () => { - let responseCacheService: ResponseCacheService; - - beforeEach(() => { - scheduler = getTestScheduler(); - }); - - 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()}); - - expect(result).toBeObservable(expected) - }); - - it('should use the responseCacheService to fetch the response by the request\'s link', () => { - responseCacheService = getMockResponseCacheService(); - - const source = hot('a', { a: testSelfLink }); - scheduler.schedule(() => source.pipe(getResponseFromSelflink(responseCacheService)).subscribe()); - scheduler.flush(); - - expect(responseCacheService.get).toHaveBeenCalledWith(testSelfLink) - }); - - it('shouldn\'t return anything if there is no response matching the request\'s link', () => { - responseCacheService = getMockResponseCacheService(undefined, cold('a', { a: undefined })); - - const source = hot('a', { a: testSelfLink }); - const result = source.pipe(getResponseFromSelflink(responseCacheService)); - const expected = cold('-'); - - expect(result).toBeObservable(expected) - }); - }); - describe('filterSuccessfulResponses', () => { 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) }); diff --git a/src/app/core/shared/operators.ts b/src/app/core/shared/operators.ts index b44be403b8..a6335ebb5d 100644 --- a/src/app/core/shared/operators.ts +++ b/src/app/core/shared/operators.ts @@ -1,13 +1,12 @@ -import { Observable } from 'rxjs/Observable'; +import { Observable } from 'rxjs'; import { filter, first, flatMap, map, tap } from 'rxjs/operators'; -import { hasValueOperator } 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'; import { RequestService } from '../data/request.service'; +import { BrowseDefinition } from './browse-definition.model'; import { DSpaceObject } from './dspace-object.model'; import { PaginatedList } from '../data/paginated-list'; import { SearchResult } from '../../+search-page/search-result.model'; @@ -23,29 +22,25 @@ export const getRequestFromSelflink = (requestService: RequestService) => hasValueOperator() ); -export const getRequestFromUUID = (requestService: RequestService) => - (source: Observable): Observable => - source.pipe( - flatMap((uuid: string) => requestService.getByUUID(uuid)), - hasValueOperator() - ); - -export const getResponseFromSelflink = (responseCache: ResponseCacheService) => - (source: Observable): Observable => - source.pipe( - flatMap((href: string) => responseCache.get(href)), - 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) => @@ -58,14 +53,36 @@ export const getRemoteDataPayload = () => export const getSucceededRemoteData = () => (source: Observable>): Observable> => - source.pipe(first((rd: RemoteData) => rd.hasSucceeded && !rd.isLoading)); + source.pipe(first((rd: RemoteData) => rd.hasSucceeded)); 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 }); + }) + ); + +/** + * Get the browse links from a definition by ID given an array of all definitions + * @param {string} definitionID + * @returns {(source: Observable>) => Observable} + */ +export const getBrowseDefinitionLinks = (definitionID: string) => + (source: Observable>): Observable => + source.pipe( + getRemoteDataPayload(), + map((browseDefinitions: BrowseDefinition[]) => browseDefinitions + .find((def: BrowseDefinition) => def.id === definitionID && def.metadataBrowse === true) + ), + map((def: BrowseDefinition) => { + if (isNotEmpty(def)) { + return def._links; + } else { + throw new Error(`No metadata browse definition could be found for id '${definitionID}'`); + } }) ); diff --git a/src/app/core/shared/resource-type.ts b/src/app/core/shared/resource-type.ts index 71053f628b..e67f3339de 100644 --- a/src/app/core/shared/resource-type.ts +++ b/src/app/core/shared/resource-type.ts @@ -6,7 +6,7 @@ export enum ResourceType { Item = 'item', Collection = 'collection', Community = 'community', - Eperson = 'eperson', + EPerson = 'eperson', Group = 'group', ResourcePolicy = 'resourcePolicy' } diff --git a/src/app/header/header.component.spec.ts b/src/app/header/header.component.spec.ts index 87fa2995d6..6c0047a1dd 100644 --- a/src/app/header/header.component.spec.ts +++ b/src/app/header/header.component.spec.ts @@ -1,24 +1,22 @@ -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; let fixture: ComponentFixture; @@ -35,11 +33,12 @@ describe('HeaderComponent', () => { NgbCollapseModule.forRoot(), NoopAnimationsModule, ReactiveFormsModule], - declarations: [HeaderComponent, AuthNavMenuComponent, LoadingComponent, LogInComponent, LogOutComponent], + declarations: [HeaderComponent], providers: [ { provide: HostWindowService, useValue: new HostWindowServiceStub(800) }, { provide: Router, useClass: RouterStub }, - ] + ], + schemas: [NO_ERRORS_SCHEMA] }) .compileComponents(); // compile template and css })); @@ -50,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'); }); @@ -72,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(); }); @@ -87,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 8b9f7c8775..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 @@ -5,7 +5,7 @@ import { By } from '@angular/platform-browser'; import { Store, StoreModule } from '@ngrx/store'; import { authReducer, AuthState } from '../../core/auth/auth.reducer'; -import { EpersonMock } from '../testing/eperson-mock'; +import { EPersonMock } from '../testing/eperson-mock'; import { TranslateModule } from '@ngx-translate/core'; import { AppState } from '../../app.reducer'; import { AuthNavMenuComponent } from './auth-nav-menu.component'; @@ -13,6 +13,7 @@ import { HostWindowServiceStub } from '../testing/host-window-service-stub'; import { HostWindowService } from '../host-window.service'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { AuthTokenInfo } from '../../core/auth/models/auth-token-info.model'; +import { AuthService } from '../../core/auth/auth.service'; describe('AuthNavMenuComponent', () => { @@ -21,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); @@ -52,7 +58,13 @@ describe('AuthNavMenuComponent', () => { AuthNavMenuComponent ], providers: [ - {provide: HostWindowService, useValue: window}, + { provide: HostWindowService, useValue: window }, + { + provide: AuthService, useValue: { + setRedirectUrl: () => { /*empty*/ + } + } + } ], schemas: [ CUSTOM_ELEMENTS_SCHEMA @@ -62,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({}); @@ -89,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(); @@ -99,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({}); @@ -126,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(); @@ -164,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(); @@ -198,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(); @@ -221,7 +251,13 @@ describe('AuthNavMenuComponent', () => { AuthNavMenuComponent ], providers: [ - {provide: HostWindowService, useValue: window}, + { provide: HostWindowService, useValue: window }, + { + provide: AuthService, useValue: { + setRedirectUrl: () => { /*empty*/ + } + } + } ], schemas: [ CUSTOM_ELEMENTS_SCHEMA @@ -231,6 +267,9 @@ describe('AuthNavMenuComponent', () => { })); + beforeEach(() => { + init(); + }); describe('when user is not authenticated', () => { beforeEach(inject([Store], (store: Store) => { @@ -257,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(); @@ -288,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 1c376258fb..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,15 +1,21 @@ +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'; import { AppState, routerStateSelector } from '../../app.reducer'; import { isNotUndefined } from '../empty.util'; -import { getAuthenticatedUser, isAuthenticated, isAuthenticationLoading } from '../../core/auth/selectors'; -import { Eperson } from '../../core/eperson/models/eperson.model'; -import { LOGIN_ROUTE, LOGOUT_ROUTE } from '../../core/auth/auth.service'; +import { + getAuthenticatedUser, + isAuthenticated, + isAuthenticationLoading +} from '../../core/auth/selectors'; +import { EPerson } from '../../core/eperson/models/eperson.model'; +import { AuthService, LOGIN_ROUTE, LOGOUT_ROUTE } from '../../core/auth/auth.service'; @Component({ selector: 'ds-auth-nav-menu', @@ -32,28 +38,39 @@ export class AuthNavMenuComponent implements OnInit { public isXsOrSm$: Observable; - public showAuth = Observable.of(false); + public showAuth = observableOf(false); - public user: Observable; + public user: Observable; + + public sub: Subscription; constructor(private store: Store, - private windowService: HostWindowService) { + private windowService: HostWindowService, + private authService: AuthService + ) { this.isXsOrSm$ = this.windowService.isXsOrSm(); } 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) => { - return !router.state.url.startsWith(LOGIN_ROUTE) && !router.state.url.startsWith(LOGOUT_ROUTE); - }); + 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.html b/src/app/shared/browse-by/browse-by.component.html new file mode 100644 index 0000000000..f30c5b905c --- /dev/null +++ b/src/app/shared/browse-by/browse-by.component.html @@ -0,0 +1,12 @@ + +

{{title}}

+
+ + +
+ + +
diff --git a/src/app/shared/browse-by/browse-by.component.scss b/src/app/shared/browse-by/browse-by.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/shared/browse-by/browse-by.component.spec.ts b/src/app/shared/browse-by/browse-by.component.spec.ts new file mode 100644 index 0000000000..2417dde7ca --- /dev/null +++ b/src/app/shared/browse-by/browse-by.component.spec.ts @@ -0,0 +1,44 @@ +import { BrowseByComponent } from './browse-by.component'; +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 { of as observableOf } from 'rxjs'; +import { SharedModule } from '../shared.module'; + +describe('BrowseByComponent', () => { + let comp: BrowseByComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot(), SharedModule], + declarations: [], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(BrowseByComponent); + comp = fixture.componentInstance; + }); + + it('should display a loading message when objects is empty',() => { + (comp as any).objects = undefined; + fixture.detectChanges(); + expect(fixture.debugElement.query(By.css('ds-loading'))).toBeDefined(); + }); + + it('should display results when objects is not empty', () => { + (comp as any).objects = observableOf({ + payload: { + page: { + length: 1 + } + } + }); + fixture.detectChanges(); + expect(fixture.debugElement.query(By.css('ds-viewable-collection'))).toBeDefined(); + }); + +}); diff --git a/src/app/shared/browse-by/browse-by.component.ts b/src/app/shared/browse-by/browse-by.component.ts new file mode 100644 index 0000000000..94cf81f46e --- /dev/null +++ b/src/app/shared/browse-by/browse-by.component.ts @@ -0,0 +1,30 @@ +import { Component, Input } from '@angular/core'; +import { RemoteData } from '../../core/data/remote-data'; +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'; +import { Item } from '../../core/shared/item.model'; +import { ListableObject } from '../object-collection/shared/listable-object.model'; + +@Component({ + selector: 'ds-browse-by', + styleUrls: ['./browse-by.component.scss'], + templateUrl: './browse-by.component.html', + animations: [ + fadeIn, + fadeInOut + ] +}) +/** + * Component to display a browse-by page for any ListableObject + */ +export class BrowseByComponent { + @Input() title: string; + @Input() objects$: Observable>>; + @Input() paginationConfig: PaginationComponentOptions; + @Input() sortConfig: SortOptions; + @Input() currentUrl: string; + query: string; +} 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 @@ -