diff --git a/.travis.yml b/.travis.yml index 403a10b770..901dee8186 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,10 +1,44 @@ sudo: required dist: trusty + +env: + # Install the latest docker-compose version for ci testing. + # The default installation in travis is not compatible with the latest docker-compose file version. + COMPOSE_VERSION: 1.24.1 + # The ci step will test the dspace-angular code against DSpace REST. + # Direct that step to utilize a DSpace REST service that has been started in docker. + DSPACE_REST_HOST: localhost + DSPACE_REST_PORT: 8080 + DSPACE_REST_NAMESPACE: '/server/api' + DSPACE_REST_SSL: false + +before_install: + # Docker Compose Install + - curl -L https://github.com/docker/compose/releases/download/${COMPOSE_VERSION}/docker-compose-`uname -s`-`uname -m` > docker-compose + - chmod +x docker-compose + - sudo mv docker-compose /usr/local/bin + +install: + # Start up DSpace 7 using the entities database dump + - docker-compose -f ./docker/docker-compose-travis.yml up -d + # Use the dspace-cli image to populate the assetstore. Trigger a discovery and oai update + - docker-compose -f ./docker/cli.yml -f ./docker/cli.assetstore.yml run --rm dspace-cli + - travis_retry yarn install + +before_script: + # The following line could be enabled to verify that the rest server is responding. + # Currently, "yarn run build" takes enough time to run to allow the service to be available + #- curl http://localhost:8080/ + +after_script: + - docker-compose -f ./docker/docker-compose-travis.yml down + addons: apt: sources: - google-chrome packages: + - dpkg - google-chrome-stable language: node_js @@ -18,9 +52,6 @@ cache: bundler_args: --retry 5 -install: - - travis_retry yarn install - script: # Use Chromium instead of Chrome. - export CHROME_BIN=chromium-browser diff --git a/README.md b/README.md index 1b3ed9b7cb..a9f2b0861b 100644 --- a/README.md +++ b/README.md @@ -131,6 +131,11 @@ yarn run clean:prod yarn run clean:dist ``` +Running the application with Docker +----------------------------------- +See [Docker Runtime Options](docker/README.md) + + Testing ------- diff --git a/config/environment.test.js b/config/environment.test.js index f4d625303f..6897e29aa7 100644 --- a/config/environment.test.js +++ b/config/environment.test.js @@ -1,3 +1,4 @@ +// This configuration is currently only being used for unit tests, end-to-end tests use environment.dev.ts module.exports = { }; diff --git a/docker/README.md b/docker/README.md new file mode 100644 index 0000000000..f7b4b04848 --- /dev/null +++ b/docker/README.md @@ -0,0 +1,79 @@ +# Docker Compose files + +## docker directory +- docker-compose.yml + - Starts DSpace Angular with Docker Compose from the current branch. This file assumes that a DSpace 7 REST instance will also be started in Docker. +- docker-compose-rest.yml + - Runs a published instance of the DSpace 7 REST API - persists data in Docker volumes +- docker-compose-travis.yml + - Runs a published instance of the DSpace 7 REST API for CI testing. The database is re-populated from a SQL dump on each startup. +- cli.yml + - Docker compose file that provides a DSpace CLI container to work with a running DSpace REST container. +- cli.assetstore.yml + - Docker compose file that will download and install data into a DSpace REST assetstore. This script points to a default dataset that will be utilized for CI testing. +- environment.dev.js + - Environment file for running DSpace Angular in Docker +- local.cfg + - Environment file for running the DSpace 7 REST API in Docker. + + +## To refresh / pull DSpace images from Dockerhub +``` +docker-compose -f docker/docker-compose.yml pull +``` + +## To build DSpace images using code in your branch +``` +docker-compose -f docker/docker-compose.yml build +``` + +## To start DSpace (REST and Angular) from your branch + +``` +docker-compose -p d7 -f docker/docker-compose.yml -f docker/docker-compose-rest.yml up -d +``` + +## Run DSpace REST and DSpace Angular from local branches. +_The system will be started in 2 steps. Each step shares the same docker network._ + +From DSpace/DSpace (build as needed) +``` +docker-compose -p d7 up -d +``` + +From DSpace/DSpace-angular +``` +docker-compose -p d7 -f docker/docker-compose.yml up -d +``` + +## Ingest test data from AIPDIR + +Create an administrator +``` +docker-compose -p d7 -f docker/cli.yml run --rm dspace-cli create-administrator -e test@test.edu -f admin -l user -p admin -c en +``` + +Load content from AIP files +``` +docker-compose -p d7 -f docker/cli.yml -f ./docker/cli.ingest.yml run --rm dspace-cli +``` + +## Alternative Ingest - Use Entities dataset +_Delete your docker volumes or use a unique project (-p) name_ + +Start DSpace with Database Content from a database dump +``` +docker-compose -p d7 -f docker/docker-compose.yml -f docker/docker-compose-rest.yml -f docker/db.entities.yml up -d +``` + +Load assetstore content and trigger a re-index of the repository +``` +docker-compose -p d7 -f docker/cli.yml -f docker/cli.assetstore.yml run --rm dspace-cli +``` + +## End to end testing of the rest api (runs in travis). +_In this instance, only the REST api runs in Docker using the Entities dataset. Travis will perform CI testing of Angular using Node to drive the tests._ + +``` +docker-compose -p d7ci -f docker/docker-compose-travis.yml up -d +``` diff --git a/docker/cli.assetstore.yml b/docker/cli.assetstore.yml new file mode 100644 index 0000000000..075c494a6c --- /dev/null +++ b/docker/cli.assetstore.yml @@ -0,0 +1,23 @@ +version: "3.7" + +networks: + dspacenet: + +services: + dspace-cli: + networks: + dspacenet: {} + environment: + - LOADASSETS=https://www.dropbox.com/s/zv7lj8j2lp3egjs/assetstore.tar.gz?dl=1 + entrypoint: + - /bin/bash + - '-c' + - | + if [ ! -z $${LOADASSETS} ] + then + curl $${LOADASSETS} -L -s --output /tmp/assetstore.tar.gz + cd /dspace + tar xvfz /tmp/assetstore.tar.gz + fi + + /dspace/bin/dspace index-discovery diff --git a/docker/cli.ingest.yml b/docker/cli.ingest.yml new file mode 100644 index 0000000000..f5ec7eb90d --- /dev/null +++ b/docker/cli.ingest.yml @@ -0,0 +1,32 @@ +# +# The contents of this file are subject to the license and copyright +# detailed in the LICENSE and NOTICE files at the root of the source +# tree and available online at +# +# http://www.dspace.org/license/ +# + +version: "3.7" + +services: + dspace-cli: + environment: + - AIPZIP=https://github.com/DSpace-Labs/AIP-Files/raw/master/dogAndReport.zip + - ADMIN_EMAIL=test@test.edu + - AIPDIR=/tmp/aip-dir + entrypoint: + - /bin/bash + - '-c' + - | + rm -rf $${AIPDIR} + mkdir $${AIPDIR} /dspace/upload + cd $${AIPDIR} + pwd + curl $${AIPZIP} -L -s --output aip.zip + unzip aip.zip + cd $${AIPDIR} + + /dspace/bin/dspace packager -r -a -t AIP -e $${ADMIN_EMAIL} -f -u SITE*.zip + /dspace/bin/dspace database update-sequences + + /dspace/bin/dspace index-discovery diff --git a/docker/cli.yml b/docker/cli.yml new file mode 100644 index 0000000000..ea5e3e0595 --- /dev/null +++ b/docker/cli.yml @@ -0,0 +1,22 @@ +version: "3.7" + +services: + dspace-cli: + image: "${DOCKER_OWNER:-dspace}/dspace-cli:${DSPACE_VER:-dspace-7_x}" + container_name: dspace-cli + #environment: + volumes: + - "assetstore:/dspace/assetstore" + - "./local.cfg:/dspace/config/local.cfg" + entrypoint: /dspace/bin/dspace + command: help + networks: + - dspacenet + tty: true + stdin_open: true + +volumes: + assetstore: + +networks: + dspacenet: diff --git a/docker/db.entities.yml b/docker/db.entities.yml new file mode 100644 index 0000000000..91d96bd72b --- /dev/null +++ b/docker/db.entities.yml @@ -0,0 +1,16 @@ +# +# The contents of this file are subject to the license and copyright +# detailed in the LICENSE and NOTICE files at the root of the source +# tree and available online at +# +# http://www.dspace.org/license/ +# + +version: "3.7" + +services: + dspacedb: + image: dspace/dspace-postgres-pgcrypto:loadsql + environment: + # Double underbars in env names will be replaced with periods for apache commons + - LOADSQL=https://www.dropbox.com/s/xh3ack0vg0922p2/configurable-entities-2019-05-08.sql?dl=1 diff --git a/docker/docker-compose-rest.yml b/docker/docker-compose-rest.yml new file mode 100644 index 0000000000..222557bc81 --- /dev/null +++ b/docker/docker-compose-rest.yml @@ -0,0 +1,59 @@ +networks: + dspacenet: +services: + dspace: + container_name: dspace + depends_on: + - dspacedb + image: dspace/dspace:dspace-7_x-jdk8-test + networks: + dspacenet: + ports: + - published: 8080 + target: 8080 + stdin_open: true + tty: true + volumes: + - assetstore:/dspace/assetstore + - "./local.cfg:/dspace/config/local.cfg" + # Ensure that the database is ready before starting tomcat + entrypoint: + - /bin/bash + - '-c' + - | + /dspace/bin/dspace database migrate + catalina.sh run + dspacedb: + container_name: dspacedb + image: dspace/dspace-postgres-pgcrypto + environment: + PGDATA: /pgdata + networks: + dspacenet: + stdin_open: true + tty: true + volumes: + - pgdata:/pgdata + dspacesolr: + container_name: dspacesolr + image: dspace/dspace-solr + networks: + dspacenet: + ports: + - published: 8983 + target: 8983 + stdin_open: true + tty: true + volumes: + - solr_authority:/opt/solr/server/solr/authority/data + - solr_oai:/opt/solr/server/solr/oai/data + - solr_search:/opt/solr/server/solr/search/data + - solr_statistics:/opt/solr/server/solr/statistics/data +version: '3.7' +volumes: + assetstore: + pgdata: + solr_authority: + solr_oai: + solr_search: + solr_statistics: diff --git a/docker/docker-compose-travis.yml b/docker/docker-compose-travis.yml new file mode 100644 index 0000000000..6ca44e4e47 --- /dev/null +++ b/docker/docker-compose-travis.yml @@ -0,0 +1,53 @@ +networks: + dspacenet: +services: + dspace: + container_name: dspace + depends_on: + - dspacedb + image: dspace/dspace:dspace-7_x-jdk8-test + networks: + dspacenet: + ports: + - published: 8080 + target: 8080 + stdin_open: true + tty: true + volumes: + - assetstore:/dspace/assetstore + - "./local.cfg:/dspace/config/local.cfg" + dspacedb: + container_name: dspacedb + environment: + LOADSQL: https://www.dropbox.com/s/xh3ack0vg0922p2/configurable-entities-2019-05-08.sql?dl=1 + PGDATA: /pgdata + image: dspace/dspace-postgres-pgcrypto:loadsql + networks: + dspacenet: + stdin_open: true + tty: true + volumes: + - pgdata:/pgdata + dspacesolr: + container_name: dspacesolr + image: dspace/dspace-solr + networks: + dspacenet: + ports: + - published: 8983 + target: 8983 + stdin_open: true + tty: true + volumes: + - solr_authority:/opt/solr/server/solr/authority/data + - solr_oai:/opt/solr/server/solr/oai/data + - solr_search:/opt/solr/server/solr/search/data + - solr_statistics:/opt/solr/server/solr/statistics/data +version: '3.7' +volumes: + assetstore: + pgdata: + solr_authority: + solr_oai: + solr_search: + solr_statistics: diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml new file mode 100644 index 0000000000..23f0615a1f --- /dev/null +++ b/docker/docker-compose.yml @@ -0,0 +1,26 @@ +version: '3.7' +networks: + dspacenet: +services: + dspace-angular: + container_name: dspace-angular + environment: + DSPACE_HOST: dspace-angular + DSPACE_NAMESPACE: / + DSPACE_PORT: '3000' + DSPACE_SSL: "false" + image: dspace/dspace-angular:latest + build: + context: .. + dockerfile: Dockerfile + networks: + dspacenet: + ports: + - published: 3000 + target: 3000 + - published: 9876 + target: 9876 + stdin_open: true + tty: true + volumes: + - ./environment.dev.js:/app/config/environment.dev.js diff --git a/docker/environment.dev.js b/docker/environment.dev.js new file mode 100644 index 0000000000..f88506012f --- /dev/null +++ b/docker/environment.dev.js @@ -0,0 +1,16 @@ +/* + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ +module.exports = { + rest: { + ssl: false, + host: 'localhost', + port: 8080, + // NOTE: Space is capitalized because 'namespace' is a reserved string in TypeScript + nameSpace: '/server/api' + } +}; diff --git a/docker/local.cfg b/docker/local.cfg new file mode 100644 index 0000000000..6692b13658 --- /dev/null +++ b/docker/local.cfg @@ -0,0 +1,6 @@ +dspace.dir=/dspace +db.url=jdbc:postgresql://dspacedb:5432/dspace +dspace.hostname=dspace +dspace.baseUrl=http://localhost:8080 +dspace.name=DSpace Started with Docker Compose +solr.server=http://dspacesolr:8983/solr diff --git a/package.json b/package.json index 7916379039..66cc55087f 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,9 @@ "engines": { "node": "8.* || >= 10.*" }, + "resolutions": { + "set-value": ">= 2.0.1" + }, "scripts": { "global": "npm install -g @angular/cli marked node-gyp nodemon node-nightly npm-check-updates npm-run-all rimraf typescript ts-node typedoc webpack webpack-bundle-analyzer pm2 rollup", "clean:coverage": "rimraf coverage", @@ -22,10 +25,10 @@ "clean:prod": "yarn run clean:coverage && yarn run clean:doc && yarn run clean:dist && yarn run clean:log && yarn run clean:json && yarn run clean:bld", "clean": "yarn run clean:prod && yarn run clean:node", "prebuild": "yarn run clean:bld && yarn run clean:dist", - "prebuild:aot": "yarn run prebuild", + "prebuild:ci": "yarn run prebuild", "prebuild:prod": "yarn run prebuild", "build": "node ./scripts/webpack.js --progress --mode development", - "build:aot": "yarn run syncbuilddir && node ./scripts/webpack.js --env.aot --env.server --mode development && node ./scripts/webpack.js --env.aot --env.client --mode development", + "build:ci": "yarn run syncbuilddir && node ./scripts/webpack.js --env.aot --env.server --mode development && node ./scripts/webpack.js --env.aot --env.client --mode development", "build:prod": "yarn run syncbuilddir && node ./scripts/webpack.js --env.aot --env.server --mode production && node ./scripts/webpack.js --env.aot --env.client --mode production", "postbuild:prod": "yarn run rollup", "rollup": "rollup -c rollup.config.js", @@ -51,10 +54,13 @@ "debug:server": "node-nightly --inspect --debug-brk dist/server.js", "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", + "ci": "yarn run lint && yarn run build:ci && yarn run test:headless && npm-run-all -p -r server e2e", "protractor": "node node_modules/protractor/bin/protractor", "pree2e": "yarn run webdriver:update", "e2e": "yarn run protractor", + "pretest": "yarn run clean:bld", + "pretest:headless": "yarn run pretest", + "pretest:watch": "yarn run pretest", "test": "karma start --single-run", "test:headless": "karma start --single-run --browsers ChromeHeadless", "test:watch": "karma start --no-single-run --auto-watch", @@ -109,6 +115,7 @@ "https": "1.0.0", "js-cookie": "2.2.0", "js.clone": "0.0.3", + "json5": "^2.1.0", "jsonschema": "1.2.2", "jwt-decode": "^2.2.0", "methods": "1.1.2", @@ -154,6 +161,7 @@ "@types/hammerjs": "2.0.35", "@types/jasmine": "^2.8.6", "@types/js-cookie": "2.1.0", + "@types/json5": "^0.0.30", "@types/lodash": "^4.14.110", "@types/memory-cache": "0.2.0", "@types/mime": "2.0.0", diff --git a/resources/i18n/cs.json b/resources/i18n/cs.json5 similarity index 97% rename from resources/i18n/cs.json rename to resources/i18n/cs.json5 index d658030b6b..bd4363409b 100644 --- a/resources/i18n/cs.json +++ b/resources/i18n/cs.json5 @@ -2,6 +2,7 @@ "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. ", "404.link.home-page": "Přejít na domovskou stránku", "404.page-not-found": "stránka nenalezena", + "admin.registries.bitstream-formats.description": "Tento seznam formátů souborů poskytuje informace o známých formátech a o úrovni jejich podpory.", "admin.registries.bitstream-formats.formats.no-items": "Žádné formáty souborů.", "admin.registries.bitstream-formats.formats.table.internal": "interní", @@ -13,29 +14,36 @@ "admin.registries.bitstream-formats.formats.table.supportLevel.head": "Úroveň podpory", "admin.registries.bitstream-formats.head": "Registr formátů souborů", "admin.registries.bitstream-formats.title": "DSpace Angular :: Registr formátů souborů", - "admin.registries.metadata.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.", + + "admin.registries.metadata.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 Kvalifikovaný Dublin Core.", "admin.registries.metadata.head": "Registr metadat", "admin.registries.metadata.schemas.no-items": "Žádná schémata metadat.", "admin.registries.metadata.schemas.table.id": "ID", "admin.registries.metadata.schemas.table.name": "Název", "admin.registries.metadata.schemas.table.namespace": "Jmenný prostor", "admin.registries.metadata.title": "DSpace Angular :: Registr metadat", + "admin.registries.schema.description": "Toto je schéma metadat pro „{{namespace}}“.", "admin.registries.schema.fields.head": "Pole schématu metadat", "admin.registries.schema.fields.no-items": "Žádná metadatová pole.", "admin.registries.schema.fields.table.field": "Pole", "admin.registries.schema.fields.table.scopenote": "Poznámka o rozsahu", - "admin.registries.schema.head": "Metadata Schema", + "admin.registries.schema.head": "Schéma metadat", "admin.registries.schema.title": "DSpace Angular :: Registr schémat metadat", + "auth.errors.invalid-user": "Neplatná e-mailová adresa nebo heslo.", "auth.messages.expired": "Vaše relace vypršela. Prosím, znova se přihlaste.", + "browse.title": "Prohlížíte {{ collection }} dle {{ field }} {{ value }}", + "collection.page.browse.recent.head": "Poslední příspěvky", "collection.page.license": "Licence", "collection.page.news": "Novinky", + "community.page.license": "Licence", "community.page.news": "Novinky", "community.sub-collection-list.head": "Kolekce v této komunitě", + "error.browse-by": "Chyba během stahování záznamů", "error.collection": "Chyba během stahování kolekce", "error.community": "Chyba během stahování komunity", @@ -48,9 +56,11 @@ "error.top-level-communities": "Chyba během stahování komunit nejvyšší úrovně", "error.validation.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.", "error.validation.pattern": "Tento vstup je omezen dle vzoru: {{ pattern }}.", + "footer.copyright": "copyright © 2002-{{ year }}", "footer.link.dspace": "software DSpace", "footer.link.duraspace": "DuraSpace", + "form.cancel": "Zrušit", "form.first-name": "Křestní jméno", "form.group-collapse": "Sbalit", @@ -64,11 +74,13 @@ "form.remove": "Smazat", "form.search": "Hledat", "form.submit": "Odeslat", + "home.description": "", "home.title": "DSpace Angular :: Domů", "home.top-level-communities.head": "Komunity v DSpace", "home.top-level-communities.help": "Vybráním komunity můžete prohlížet její kolekce.", - "item.page.abstract": "Abstract", + + "item.page.abstract": "Abstrakt", "item.page.author": "Autor", "item.page.collections": "Kolekce", "item.page.date": "Datum", @@ -81,6 +93,7 @@ "item.page.link.full": "Úplný záznam", "item.page.link.simple": "Minimální záznam", "item.page.uri": "URI", + "loading.browse-by": "Načítají se záznamy...", "loading.collection": "Načítá se kolekce...", "loading.community": "Načítá se komunita...", @@ -91,6 +104,7 @@ "loading.search-results": "Načítají se výsledky hledání...", "loading.sub-collections": "Načítají se subkolekce...", "loading.top-level-communities": "Načítají se komunity nejvyšší úrovně...", + "login.form.email": "E-mailová adresa", "login.form.forgot-password": "Zapomněli jste své heslo?", "login.form.header": "Prosím, přihlaste se do DSpace", @@ -98,22 +112,29 @@ "login.form.password": "Heslo", "login.form.submit": "Přihlásit se", "login.title": "Přihlásit se", + "logout.form.header": "Odhlásit se z DSpace", "logout.form.submit": "Odhlásit se", "logout.title": "Odhlásit se", + "nav.home": "Domů", "nav.login": "Přihlásit se", "nav.logout": "Odhlásit se", + "pagination.results-per-page": "Výsledků na stránku", "pagination.showing.detail": "{{ range }} z {{ total }}", "pagination.showing.label": "Zobrazují se záznamy ", "pagination.sort-direction": "Seřazení", + "search.description": "", + "search.title": "DSpace Angular :: Hledat", + "search.filters.applied.f.author": "Autor", "search.filters.applied.f.dateIssued.max": "Do data", "search.filters.applied.f.dateIssued.min": "Od data", "search.filters.applied.f.has_content_in_original_bundle": "Má soubory", "search.filters.applied.f.subject": "Předmět", + "search.filters.filter.author.head": "Autor", "search.filters.filter.author.placeholder": "Jméno autora", "search.filters.filter.dateIssued.head": "Datum", @@ -126,12 +147,16 @@ "search.filters.filter.show-more": "Zobrazit více", "search.filters.filter.subject.head": "Předmět", "search.filters.filter.subject.placeholder": "Předmět", + "search.filters.head": "Filtry", "search.filters.reset": "Obnovit filtry", + "search.form.search": "Hledat", "search.form.search_dspace": "Hledat v DSpace", + "search.results.head": "Výsledky hledání", "search.results.no-results": "Nebyli nalezeny žádné výsledky", + "search.sidebar.close": "Zpět na výsledky", "search.sidebar.filters.title": "Filtry", "search.sidebar.open": "Vyhledávací nástroje", @@ -139,11 +164,13 @@ "search.sidebar.settings.rpp": "Výsledků na stránku", "search.sidebar.settings.sort-by": "Řadit dle", "search.sidebar.settings.title": "Nastavení", - "search.title": "DSpace Angular :: Hledat", + "search.view-switch.show-grid": "Zobrazit mřížku", "search.view-switch.show-list": "Zobrazit seznam", + "sorting.dc.title.ASC": "Název vzestupně", "sorting.dc.title.DESC": "Název sestupně", "sorting.score.DESC": "Relevance", - "title": "DSpace" + + "title": "DSpace", } diff --git a/resources/i18n/de.json b/resources/i18n/de.json5 similarity index 99% rename from resources/i18n/de.json rename to resources/i18n/de.json5 index a3ac45b82f..5cf018bef3 100644 --- a/resources/i18n/de.json +++ b/resources/i18n/de.json5 @@ -2,6 +2,7 @@ "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. ", "404.link.home-page": "Zurück zur Startseite", "404.page-not-found": "Seite nicht gefunden", + "admin.registries.bitstream-formats.description": "Diese Liste enhtält die in diesem Repositorium zulässigen Dateiformate und den jeweiligen Unterstützungsgrad.", "admin.registries.bitstream-formats.formats.no-items": "Es gibt keine Formate in dieser Referenzliste.", "admin.registries.bitstream-formats.formats.table.internal": "intern", @@ -13,6 +14,7 @@ "admin.registries.bitstream-formats.formats.table.supportLevel.head": "Unterstützungsgrad", "admin.registries.bitstream-formats.head": "Referenzliste der Dateiformate", "admin.registries.bitstream-formats.title": "DSpace Angular :: Referenzliste der Dateiformate", + "admin.registries.metadata.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.", "admin.registries.metadata.head": "Metadatenreferenzliste", "admin.registries.metadata.schemas.no-items": "Es gbit keine Metadatenschemata.", @@ -20,6 +22,7 @@ "admin.registries.metadata.schemas.table.name": "Name", "admin.registries.metadata.schemas.table.namespace": "Namensraum", "admin.registries.metadata.title": "DSpace Angular :: Metadatenreferenzliste", + "admin.registries.schema.description": "Dies ist das Metadatenschema für \"{{namespace}}\".", "admin.registries.schema.fields.head": "Felder in diesem Schema", "admin.registries.schema.fields.no-items": "Es gibt keine Felder in diesem Schema.", @@ -27,15 +30,19 @@ "admin.registries.schema.fields.table.scopenote": "Gültigkeitsbereich", "admin.registries.schema.head": "Metadatenschemata", "admin.registries.schema.title": "DSpace Angular :: Referenzliste der Metadatenschemata", + "auth.errors.invalid-user": "Ungültige E-Mail-Adresse oder Passwort.", "auth.messages.expired": "Ihre Sitzung ist abgelaufen, bitte melden Sie sich erneut an.", + "browse.title": "Anzeige {{ collection }} nach {{ field }} {{ value }}", "collection.page.browse.recent.head": "Aktuellste Veröffentlichungen", "collection.page.license": "Lizenz", "collection.page.news": "Neuigkeiten", + "community.page.license": "Lizenz", "community.page.news": "Neuigkeiten", "community.sub-collection-list.head": "Sammlungen in diesem Bereich", + "error.browse-by": "Fehler beim Laden der Ressourcen", "error.collection": "Fehler beim Laden der Sammlung.", "error.community": "Fehler beim Laden des Bereiches.", @@ -48,9 +55,11 @@ "error.top-level-communities": "Fehler beim Laden der Hauptbereiche.", "error.validation.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.", "error.validation.pattern": "Die Eingabe kann nur folgendes Muster haben: {{ pattern }}.", + "footer.copyright": "Copyright © 2002-{{ year }}", "footer.link.dspace": "DSpace Software", "footer.link.duraspace": "DuraSpace", + "form.cancel": "Abbrechen", "form.first-name": "Vorname", "form.group-collapse": "Weniger", @@ -64,10 +73,12 @@ "form.remove": "Löschen", "form.search": "Suchen", "form.submit": "Los", + "home.description": "", "home.title": "DSpace Angular :: Startseite", "home.top-level-communities.head": "Bereiche in DSpace", "home.top-level-communities.help": "Wählen Sie einen Bereich, um seine Sammlungen einzusehen.", + "item.page.abstract": "Kurzfassung", "item.page.author": "Autor", "item.page.collections": "Sammlungen", @@ -81,6 +92,7 @@ "item.page.link.full": "Vollanzeige", "item.page.link.simple": "Kurzanzeige", "item.page.uri": "URI", + "loading.browse-by": "Die Ressourcen werden geladen ...", "loading.collection": "Die Sammlung wird geladen ...", "loading.community": "Der Bereich wird geladen ...", @@ -91,6 +103,7 @@ "loading.search-results": "Die Suchergebnisse werden geladen ...", "loading.sub-collections": "Die untergeordneten Sammlungen werden geladen ...", "loading.top-level-communities": "Die Hauptbereiche werden geladen ...", + "login.form.email": "E-Mail-Adresse", "login.form.forgot-password": "Haben Sie Ihr Passwort vergessen?", "login.form.header": "Bitte Loggen Sie sich ein.", @@ -99,22 +112,29 @@ "login.form.submit": "Einloggen", "login.form.ssoLogin": "Shibboleth", "login.title": "Einloggen", + "logout.form.header": "Ausloggen aus DSpace", "logout.form.submit": "Ausloggen", "logout.title": "Ausloggen", + "nav.home": "Zur Startseite", "nav.login": "Anmelden", "nav.logout": "Abmelden", + "pagination.results-per-page": "Ergebnisse pro Seite", "pagination.showing.detail": "{{ range }} bis {{ total }}", "pagination.showing.label": "Anzeige der Treffer ", "pagination.sort-direction": "Sortiermöglichkeiten", + "search.description": "", + "search.title": "DSpace Angular :: Suche", + "search.filters.applied.f.author": "Autor", "search.filters.applied.f.dateIssued.max": "Enddatum", "search.filters.applied.f.dateIssued.min": "Anfangsdatum", "search.filters.applied.f.has_content_in_original_bundle": "Besitzt Dateien", "search.filters.applied.f.subject": "Thema", + "search.filters.filter.author.head": "Autor", "search.filters.filter.author.placeholder": "Autor", "search.filters.filter.dateIssued.head": "Datum", @@ -127,12 +147,16 @@ "search.filters.filter.show-more": "Zeige mehr", "search.filters.filter.subject.head": "Schlagwort", "search.filters.filter.subject.placeholder": "Schlagwort", + "search.filters.head": "Filter", "search.filters.reset": "Filter zurücksetzen", + "search.form.search": "Suche", "search.form.search_dspace": "DSpace durchsuchen", + "search.results.head": "Suchergebnisse", "search.results.no-results": "Zu dieser Suche gibt es keine Treffer.", + "search.sidebar.close": "Zurück zu den Ergebnissen", "search.sidebar.filters.title": "Filter", "search.sidebar.open": "Suchwerkzeuge", @@ -140,11 +164,13 @@ "search.sidebar.settings.rpp": "Treffer pro Seite", "search.sidebar.settings.sort-by": "Sortiere nach", "search.sidebar.settings.title": "Einstellungen", - "search.title": "DSpace Angular :: Suche", + "search.view-switch.show-grid": "Zeige als Raster", "search.view-switch.show-list": "Zeige als Liste", + "sorting.dc.title.ASC": "Titel aufsteigend", "sorting.dc.title.DESC": "Titel absteigend", "sorting.score.DESC": "Relevanz", - "title": "DSpace" + + "title": "DSpace", } diff --git a/resources/i18n/en.json b/resources/i18n/en.json5 similarity index 95% rename from resources/i18n/en.json rename to resources/i18n/en.json5 index 877a96a63f..6570d5bf3a 100644 --- a/resources/i18n/en.json +++ b/resources/i18n/en.json5 @@ -2,6 +2,7 @@ "404.help": "We can't find the page you're looking for. The page may have been moved or deleted. You can use the button below to get back to the home page. ", "404.link.home-page": "Take me to the home page", "404.page-not-found": "page not found", + "admin.registries.bitstream-formats.create.failure.content": "An error occurred while creating the new bitstream format.", "admin.registries.bitstream-formats.create.failure.head": "Failure", "admin.registries.bitstream-formats.create.head": "Create Bitstream format", @@ -44,6 +45,7 @@ "admin.registries.bitstream-formats.table.supportLevel.UNKNOWN": "Unknown", "admin.registries.bitstream-formats.table.supportLevel.head": "Support Level", "admin.registries.bitstream-formats.title": "DSpace Angular :: Bitstream Format Registry", + "admin.registries.metadata.description": "The metadata registry maintains a list of all metadata fields available in the repository. These fields may be divided amongst multiple schemas. However, DSpace requires the qualified Dublin Core schema.", "admin.registries.metadata.form.create": "Create metadata schema", "admin.registries.metadata.form.edit": "Edit metadata schema", @@ -56,6 +58,7 @@ "admin.registries.metadata.schemas.table.name": "Name", "admin.registries.metadata.schemas.table.namespace": "Namespace", "admin.registries.metadata.title": "DSpace Angular :: Metadata Registry", + "admin.registries.schema.description": "This is the metadata schema for \"{{namespace}}\".", "admin.registries.schema.fields.head": "Schema metadata fields", "admin.registries.schema.fields.no-items": "No metadata fields to show.", @@ -80,8 +83,10 @@ "admin.registries.schema.notification.success": "Success", "admin.registries.schema.return": "Return", "admin.registries.schema.title": "DSpace Angular :: Metadata Schema Registry", + "auth.errors.invalid-user": "Invalid email address or password.", "auth.messages.expired": "Your session has expired. Please log in again.", + "browse.comcol.by.author": "By Author", "browse.comcol.by.dateissued": "By Issue Date", "browse.comcol.by.subject": "By Subject", @@ -112,7 +117,9 @@ "browse.startsWith.type_date": "Or type in a date (year-month):", "browse.startsWith.type_text": "Or enter first few letters:", "browse.title": "Browsing {{ collection }} by {{ field }} {{ value }}", + "chips.remove": "Remove chip", + "collection.create.head": "Create a Collection", "collection.create.sub-head": "Create a Collection for Community {{ parent }}", "collection.delete.cancel": "Cancel", @@ -132,8 +139,10 @@ "collection.form.tableofcontents": "News (HTML)", "collection.form.title": "Name", "collection.page.browse.recent.head": "Recent Submissions", + "collection.page.browse.recent.empty": "No items to show", "collection.page.license": "License", "collection.page.news": "News", + "community.create.head": "Create a Community", "community.create.sub-head": "Create a Sub-Community for Community {{ parent }}", "community.delete.cancel": "Cancel", @@ -154,6 +163,7 @@ "community.page.news": "News", "community.sub-collection-list.head": "Collections of this Community", "community.sub-community-list.head": "Communities of this Community", + "dso-selector.create.collection.head": "New collection", "dso-selector.create.community.head": "New community", "dso-selector.create.community.sub-level": "Create a new community in", @@ -164,6 +174,7 @@ "dso-selector.edit.item.head": "Edit item", "dso-selector.no-results": "No {{ type }} found", "dso-selector.placeholder": "Search for a {{ type }}", + "error.browse-by": "Error fetching items", "error.collection": "Error fetching collection", "error.community": "Error fetching community", @@ -178,9 +189,11 @@ "error.top-level-communities": "Error fetching top-level communities", "error.validation.license.notgranted": "You must grant this license to complete your submission. If you are unable to grant this license at this time you may save your work and return later or remove the submission.", "error.validation.pattern": "This input is restricted by the current pattern: {{ pattern }}.", + "footer.copyright": "copyright © 2002-{{ year }}", "footer.link.dspace": "DSpace software", "footer.link.duraspace": "DuraSpace", + "form.cancel": "Cancel", "form.clear": "Clear", "form.clear-help": "Click here to remove the selected value", @@ -202,10 +215,12 @@ "form.search": "Search", "form.search-help": "Click here to looking for an existing correspondence", "form.submit": "Submit", + "home.description": "", "home.title": "DSpace Angular :: Home", "home.top-level-communities.head": "Communities in DSpace", "home.top-level-communities.help": "Select a community to browse its collections.", + "item.edit.delete.cancel": "Cancel", "item.edit.delete.confirm": "Delete", "item.edit.delete.description": "Are you sure this item should be completely deleted? Caution: At present, no tombstone would be left.", @@ -213,6 +228,7 @@ "item.edit.delete.header": "Delete item: {{ id }}", "item.edit.delete.success": "The item has been deleted", "item.edit.head": "Edit Item", + "item.edit.metadata.add-button": "Add", "item.edit.metadata.discard-button": "Discard", "item.edit.metadata.edit.buttons.edit": "Edit", @@ -234,33 +250,65 @@ "item.edit.metadata.notifications.saved.title": "Metadata saved", "item.edit.metadata.reinstate-button": "Undo", "item.edit.metadata.save-button": "Save", + "item.edit.modify.overview.field": "Field", "item.edit.modify.overview.language": "Language", "item.edit.modify.overview.value": "Value", + + "item.edit.move.cancel": "Cancel", + "item.edit.move.description": "Select the collection you wish to move this item to. To narrow down the list of displayed collections, you can enter a search query in the box.", + "item.edit.move.error": "An error occured when attempting to move the item", + "item.edit.move.head": "Move item: {{id}}", + "item.edit.move.inheritpolicies.checkbox": "Inherit policies", + "item.edit.move.inheritpolicies.description": "Inherit the default policies of the destination collection", + "item.edit.move.move": "Move", + "item.edit.move.processing": "Moving...", + "item.edit.move.search.placeholder": "Enter a search query to look for collections", + "item.edit.move.success": "The item has been moved succesfully", + "item.edit.move.title": "Move item", + "item.edit.private.cancel": "Cancel", "item.edit.private.confirm": "Make it Private", "item.edit.private.description": "Are you sure this item should be made private in the archive?", "item.edit.private.error": "An error occurred while making the item private", "item.edit.private.header": "Make item private: {{ id }}", "item.edit.private.success": "The item is now private", + "item.edit.public.cancel": "Cancel", "item.edit.public.confirm": "Make it Public", "item.edit.public.description": "Are you sure this item should be made public in the archive?", "item.edit.public.error": "An error occurred while making the item public", "item.edit.public.header": "Make item public: {{ id }}", "item.edit.public.success": "The item is now public", + "item.edit.reinstate.cancel": "Cancel", "item.edit.reinstate.confirm": "Reinstate", "item.edit.reinstate.description": "Are you sure this item should be reinstated to the archive?", "item.edit.reinstate.error": "An error occurred while reinstating the item", "item.edit.reinstate.header": "Reinstate item: {{ id }}", "item.edit.reinstate.success": "The item was reinstated successfully", + + "item.edit.relationships.discard-button": "Discard", + "item.edit.relationships.edit.buttons.remove": "Remove", + "item.edit.relationships.edit.buttons.undo": "Undo changes", + "item.edit.relationships.notifications.discarded.content": "Your changes were discarded. To reinstate your changes click the 'Undo' button", + "item.edit.relationships.notifications.discarded.title": "Changes discarded", + "item.edit.relationships.notifications.failed.title": "Error deleting relationship", + "item.edit.relationships.notifications.outdated.content": "The item you're currently working on has been changed by another user. Your current changes are discarded to prevent conflicts", + "item.edit.relationships.notifications.outdated.title": "Changes outdated", + "item.edit.relationships.notifications.saved.content": "Your changes to this item's relationships were saved.", + "item.edit.relationships.notifications.saved.title": "Relationships saved", + "item.edit.relationships.reinstate-button": "Undo", + "item.edit.relationships.save-button": "Save", + "item.edit.tabs.bitstreams.head": "Item Bitstreams", "item.edit.tabs.bitstreams.title": "Item Edit - Bitstreams", "item.edit.tabs.curate.head": "Curate", "item.edit.tabs.curate.title": "Item Edit - Curate", "item.edit.tabs.metadata.head": "Item Metadata", "item.edit.tabs.metadata.title": "Item Edit - Metadata", + "item.edit.tabs.relationships.head": "Item Relationships", + "item.edit.tabs.relationships.title": "Item Edit - Relationships", "item.edit.tabs.status.buttons.authorizations.button": "Authorizations...", "item.edit.tabs.status.buttons.authorizations.label": "Edit item's authorization policies", "item.edit.tabs.status.buttons.delete.button": "Permanently delete", @@ -286,12 +334,14 @@ "item.edit.tabs.status.title": "Item Edit - Status", "item.edit.tabs.view.head": "View Item", "item.edit.tabs.view.title": "Item Edit - View", + "item.edit.withdraw.cancel": "Cancel", "item.edit.withdraw.confirm": "Withdraw", "item.edit.withdraw.description": "Are you sure this item should be withdrawn from the archive?", "item.edit.withdraw.error": "An error occurred while withdrawing the item", "item.edit.withdraw.header": "Withdraw item: {{ id }}", "item.edit.withdraw.success": "The item was withdrawn successfully", + "item.page.abstract": "Abstract", "item.page.author": "Authors", "item.page.citation": "Citation", @@ -309,10 +359,12 @@ "item.page.person.search.title": "Articles by this author", "item.page.subject": "Keywords", "item.page.uri": "URI", + "item.select.confirm": "Confirm selected", "item.select.table.author": "Author", "item.select.table.collection": "Collection", "item.select.table.title": "Title", + "journal.listelement.badge": "Journal", "journal.page.description": "Description", "journal.page.editor": "Editor-in-Chief", @@ -321,6 +373,7 @@ "journal.page.titleprefix": "Journal: ", "journal.search.results.head": "Journal Search Results", "journal.search.title": "DSpace Angular :: Journal Search", + "journalissue.listelement.badge": "Journal Issue", "journalissue.page.description": "Description", "journalissue.page.issuedate": "Issue Date", @@ -329,11 +382,13 @@ "journalissue.page.keyword": "Keywords", "journalissue.page.number": "Number", "journalissue.page.titleprefix": "Journal Issue: ", + "journalvolume.listelement.badge": "Journal Volume", "journalvolume.page.description": "Description", "journalvolume.page.issuedate": "Issue Date", "journalvolume.page.titleprefix": "Journal Volume: ", "journalvolume.page.volume": "Volume", + "loading.browse-by": "Loading items...", "loading.browse-by-page": "Loading page...", "loading.collection": "Loading collection...", @@ -347,23 +402,27 @@ "loading.sub-collections": "Loading sub-collections...", "loading.sub-communities": "Loading sub-communities...", "loading.top-level-communities": "Loading top-level communities...", + "login.form.email": "Email address", "login.form.forgot-password": "Have you forgotten your password?", "login.form.header": "Please log in to DSpace", "login.form.new-user": "New user? Click here to register.", "login.form.password": "Password", "login.form.submit": "Log in", - "login.form.ssoLogin": "Shibboleth", "login.title": "Login", + "logout.form.header": "Log out from DSpace", "logout.form.submit": "Log out", "logout.title": "Logout", + "menu.header.admin": "Admin", "menu.header.image.logo": "Repository logo", + "menu.section.access_control": "Access Control", "menu.section.access_control_authorizations": "Authorizations", "menu.section.access_control_groups": "Groups", "menu.section.access_control_people": "People", + "menu.section.browse_community": "This Community", "menu.section.browse_community_by_author": "By Author", "menu.section.browse_community_by_issue_date": "By Issue Date", @@ -374,21 +433,26 @@ "menu.section.browse_global_by_subject": "By Subject", "menu.section.browse_global_by_title": "By Title", "menu.section.browse_global_communities_and_collections": "Communities & Collections", + "menu.section.control_panel": "Control Panel", "menu.section.curation_task": "Curation Task", + "menu.section.edit": "Edit", "menu.section.edit_collection": "Collection", "menu.section.edit_community": "Community", "menu.section.edit_item": "Item", + "menu.section.export": "Export", "menu.section.export_collection": "Collection", "menu.section.export_community": "Community", "menu.section.export_item": "Item", "menu.section.export_metadata": "Metadata", + "menu.section.find": "Find", "menu.section.find_items": "Items", "menu.section.find_private_items": "Private Items", "menu.section.find_withdrawn_items": "Withdrawn Items", + "menu.section.icon.access_control": "Access Control menu section", "menu.section.icon.control_panel": "Control Panel menu section", "menu.section.icon.curation_task": "Curation Task menu section", @@ -401,20 +465,27 @@ "menu.section.icon.registries": "Registries menu section", "menu.section.icon.statistics_task": "Statistics Task menu section", "menu.section.icon.unpin": "Unpin sidebar", + "menu.section.import": "Import", "menu.section.import_batch": "Batch Import (ZIP)", "menu.section.import_metadata": "Metadata", + "menu.section.new": "New", "menu.section.new_collection": "Collection", "menu.section.new_community": "Community", "menu.section.new_item": "Item", "menu.section.new_item_version": "Item Version", + "menu.section.pin": "Pin sidebar", + "menu.section.unpin": "Unpin sidebar", + "menu.section.registries": "Registries", "menu.section.registries_format": "Format", "menu.section.registries_metadata": "Metadata", + "menu.section.statistics": "Statistics", "menu.section.statistics_task": "Statistics Task", + "menu.section.toggle.access_control": "Toggle Access Control section", "menu.section.toggle.control_panel": "Toggle Control Panel section", "menu.section.toggle.curation_task": "Toggle Curation Task section", @@ -425,7 +496,7 @@ "menu.section.toggle.new": "Toggle New section", "menu.section.toggle.registries": "Toggle Registries section", "menu.section.toggle.statistics_task": "Toggle Statistics Task section", - "menu.section.unpin": "Unpin sidebar", + "mydspace.description": "", "mydspace.general.text-here": "HERE", "mydspace.messages.controller-help": "Select this option to send a message to item's submitter.", @@ -463,6 +534,7 @@ "mydspace.upload.upload-multiple-successful": "{{qty}} new workspace items created.", "mydspace.upload.upload-successful": "New workspace item created. Click {{here}} for edit it.", "mydspace.view-btn": "View", + "nav.browse.header": "All of DSpace", "nav.community-browse.header": "By Community", "nav.language": "Language switch", @@ -471,6 +543,7 @@ "nav.mydspace": "MyDSpace", "nav.search": "Search", "nav.statistics.header": "Statistics", + "orgunit.listelement.badge": "Organizational Unit", "orgunit.page.city": "City", "orgunit.page.country": "Country", @@ -478,10 +551,12 @@ "orgunit.page.description": "Description", "orgunit.page.id": "ID", "orgunit.page.titleprefix": "Organizational Unit: ", + "pagination.results-per-page": "Results Per Page", "pagination.showing.detail": "{{ range }} of {{ total }}", "pagination.showing.label": "Now showing ", "pagination.sort-direction": "Sort Options", + "person.listelement.badge": "Person", "person.page.birthdate": "Birth Date", "person.page.email": "Email Address", @@ -494,6 +569,7 @@ "person.page.titleprefix": "Person: ", "person.search.results.head": "Person Search Results", "person.search.title": "DSpace Angular :: Person Search", + "project.listelement.badge": "Research Project", "project.page.contributor": "Contributors", "project.page.description": "Description", @@ -503,6 +579,7 @@ "project.page.keyword": "Keywords", "project.page.status": "Status", "project.page.titleprefix": "Research Project: ", + "publication.listelement.badge": "Publication", "publication.page.description": "Description", "publication.page.journal-issn": "Journal ISSN", @@ -512,6 +589,7 @@ "publication.page.volume-title": "Volume Title", "publication.search.results.head": "Publication Search Results", "publication.search.title": "DSpace Angular :: Publication Search", + "relationships.isAuthorOf": "Authors", "relationships.isIssueOf": "Journal Issues", "relationships.isJournalIssueOf": "Journal Issue", @@ -524,7 +602,11 @@ "relationships.isSingleJournalOf": "Journal", "relationships.isSingleVolumeOf": "Journal Volume", "relationships.isVolumeOf": "Journal Volumes", + "search.description": "", + "search.switch-configuration.title": "Show", + "search.title": "DSpace Angular :: Search", + "search.filters.applied.f.author": "Author", "search.filters.applied.f.dateIssued.max": "End date", "search.filters.applied.f.dateIssued.min": "Start date", @@ -535,6 +617,7 @@ "search.filters.applied.f.namedresourcetype": "Status", "search.filters.applied.f.subject": "Subject", "search.filters.applied.f.submitter": "Submitter", + "search.filters.filter.author.head": "Author", "search.filters.filter.author.placeholder": "Author name", "search.filters.filter.birthDate.head": "Birth Date", @@ -579,14 +662,18 @@ "search.filters.filter.subject.placeholder": "Subject", "search.filters.filter.submitter.head": "Submitter", "search.filters.filter.submitter.placeholder": "Submitter", + "search.filters.head": "Filters", "search.filters.reset": "Reset filters", + "search.form.search": "Search", "search.form.search_dspace": "Search DSpace", "search.form.search_mydspace": "Search MyDSpace", + "search.results.head": "Search Results", "search.results.no-results": "Your search returned no results. Having trouble finding what you're looking for? Try putting", "search.results.no-results-link": "quotes around it", + "search.sidebar.close": "Back to results", "search.sidebar.filters.title": "Filters", "search.sidebar.open": "Search Tools", @@ -594,14 +681,15 @@ "search.sidebar.settings.rpp": "Results per page", "search.sidebar.settings.sort-by": "Sort By", "search.sidebar.settings.title": "Settings", - "search.switch-configuration.title": "Show", - "search.title": "DSpace Angular :: Search", + "search.view-switch.show-detail": "Show detail", "search.view-switch.show-grid": "Show as grid", "search.view-switch.show-list": "Show as list", + "sorting.dc.title.ASC": "Title Ascending", "sorting.dc.title.DESC": "Title Descending", "sorting.score.DESC": "Relevance", + "submission.edit.title": "Edit Submission", "submission.general.cannot_submit": "You have not the privilege to make a new submission.", "submission.general.deposit": "Deposit", @@ -612,7 +700,7 @@ "submission.general.discard.submit": "Discard", "submission.general.save": "Save", "submission.general.save-later": "Save for later", - "submission.mydspace": {}, + "submission.sections.general.add-more": "Add more", "submission.sections.general.collection": "Collection", "submission.sections.general.deposit_error_notice": "There was an issue when submitting the item, please try again later.", @@ -627,6 +715,7 @@ "submission.sections.general.save_success_notice": "Submission saved successfully.", "submission.sections.general.search-collection": "Search for a collection", "submission.sections.general.sections_not_valid": "There are incomplete sections.", + "submission.sections.submit.progressbar.cclicense": "Creative commons license", "submission.sections.submit.progressbar.describe.recycle": "Recycle", "submission.sections.submit.progressbar.describe.stepcustom": "Describe", @@ -635,6 +724,7 @@ "submission.sections.submit.progressbar.detect-duplicate": "Potential duplicates", "submission.sections.submit.progressbar.license": "Deposit license", "submission.sections.submit.progressbar.upload": "Upload files", + "submission.sections.upload.delete.confirm.cancel": "Cancel", "submission.sections.upload.delete.confirm.info": "This operation can't be undone. Are you sure?", "submission.sections.upload.delete.confirm.submit": "Yes, I'm sure", @@ -658,13 +748,16 @@ "submission.sections.upload.undo": "Cancel", "submission.sections.upload.upload-failed": "Upload failed", "submission.sections.upload.upload-successful": "Upload successful", + "submission.submit.title": "Submission", + "submission.workflow.generic.delete": "Delete", "submission.workflow.generic.delete-help": "If you would to discard this item, select \"Delete\". You will then be asked to confirm it.", "submission.workflow.generic.edit": "Edit", "submission.workflow.generic.edit-help": "Select this option to change the item's metadata.", "submission.workflow.generic.view": "View", "submission.workflow.generic.view-help": "Select this option to view the item's metadata.", + "submission.workflow.tasks.claimed.approve": "Approve", "submission.workflow.tasks.claimed.approve_help": "If you have reviewed the item and it is suitable for inclusion in the collection, select \"Approve\".", "submission.workflow.tasks.claimed.edit": "Edit", @@ -677,18 +770,22 @@ "submission.workflow.tasks.claimed.reject_help": "If you have reviewed the item and found it is not suitable for inclusion in the collection, select \"Reject\". You will then be asked to enter a message indicating why the item is unsuitable, and whether the submitter should change something and resubmit.", "submission.workflow.tasks.claimed.return": "Return to pool", "submission.workflow.tasks.claimed.return_help": "Return the task to the pool so that another user may perform the task.", + "submission.workflow.tasks.generic.error": "Error occurred during operation...", "submission.workflow.tasks.generic.processing": "Processing...", "submission.workflow.tasks.generic.submitter": "Submitter", "submission.workflow.tasks.generic.success": "Operation successful", + "submission.workflow.tasks.pool.claim": "Claim", "submission.workflow.tasks.pool.claim_help": "Assign this task to yourself.", "submission.workflow.tasks.pool.hide-detail": "Hide detail", "submission.workflow.tasks.pool.show-detail": "Show detail", + "title": "DSpace", + "uploader.browse": "browse", "uploader.drag-message": "Drag & Drop your files here", "uploader.or": ", or", "uploader.processing": "Processing", - "uploader.queue-lenght": "Queue length" + "uploader.queue-length": "Queue length", } diff --git a/resources/i18n/nl.json b/resources/i18n/nl.json5 similarity index 99% rename from resources/i18n/nl.json rename to resources/i18n/nl.json5 index da12ff0518..a195e13e01 100644 --- a/resources/i18n/nl.json +++ b/resources/i18n/nl.json5 @@ -2,6 +2,7 @@ "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. ", "404.link.home-page": "Terug naar de homepagina", "404.page-not-found": "Pagina niet gevonden", + "admin.registries.bitstream-formats.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.", "admin.registries.bitstream-formats.formats.no-items": "Er kunnen geen bitstreamformaten getoond worden.", "admin.registries.bitstream-formats.formats.table.internal": "intern", @@ -13,6 +14,7 @@ "admin.registries.bitstream-formats.formats.table.supportLevel.head": "Ondersteuning", "admin.registries.bitstream-formats.head": "Bitstream Formaat Register", "admin.registries.bitstream-formats.title": "DSpace Angular :: Bitstream Formaat Register", + "admin.registries.metadata.description": "Het metadataregister omvat de lijst van alle metadatavelden die beschikbaar zijn in het systeem. Deze velden kunnen verspreid zijn over verschillende metadataschema's. Het qualified Dublin Core schema (dc) is een verplicht schema en kan niet worden verwijderd.", "admin.registries.metadata.head": "Metadata Register", "admin.registries.metadata.schemas.no-items": "Er kunnen geen metadataschema's getoond worden.", @@ -20,6 +22,7 @@ "admin.registries.metadata.schemas.table.name": "Naam", "admin.registries.metadata.schemas.table.namespace": "Naamruimte", "admin.registries.metadata.title": "DSpace Angular :: Metadata Register", + "admin.registries.schema.description": "Dit is het metadataschema voor \"{{namespace}}\".", "admin.registries.schema.fields.head": "Schema metadatavelden", "admin.registries.schema.fields.no-items": "Er kunnen geen metadatavelden getoond worden.", @@ -27,15 +30,20 @@ "admin.registries.schema.fields.table.scopenote": "Opmerking over bereik", "admin.registries.schema.head": "Metadata Schema", "admin.registries.schema.title": "DSpace Angular :: Metadata Schema Register", + "auth.errors.invalid-user": "Ongeldig e-mailadres of wachtwoord.", "auth.messages.expired": "Uw sessie is vervallen. Gelieve opnieuw aan te melden.", + "browse.title": "Verken {{ collection }} volgens {{ field }} {{ value }}", + "collection.page.browse.recent.head": "Recent toegevoegd", "collection.page.license": "Licentie", "collection.page.news": "Nieuws", + "community.page.license": "Licentie", "community.page.news": "Nieuws", "community.sub-collection-list.head": "Collecties in deze Community", + "error.browse-by": "Fout bij het ophalen van items", "error.collection": "Fout bij het ophalen van een collectie", "error.community": "Fout bij het ophalen van een community", @@ -48,9 +56,11 @@ "error.top-level-communities": "Fout bij het inladen van communities op het hoogste niveau", "error.validation.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 kunt dit nieuwe item ook verwijderen indien u niet voldoet aan de vereisten van de invoerlicentie.", "error.validation.pattern": "Deze invoer is niet toegelaten volgens dit patroon: {{ pattern }}.", + "footer.copyright": "copyright © 2002-{{ year }}", "footer.link.dspace": "DSpace software", "footer.link.duraspace": "DuraSpace", + "form.cancel": "Annuleer", "form.first-name": "Voornaam", "form.group-collapse": "Inklappen", @@ -64,10 +74,12 @@ "form.remove": "Verwijder", "form.search": "Zoek", "form.submit": "Verstuur", + "home.description": "", "home.title": "DSpace Angular :: Home", "home.top-level-communities.head": "Communities in DSpace", "home.top-level-communities.help": "Selecteer een community om diens collecties te verkennen.", + "item.page.abstract": "Abstract", "item.page.author": "Auteur", "item.page.collections": "Collecties", @@ -81,6 +93,7 @@ "item.page.link.full": "Volledige itemweergave", "item.page.link.simple": "Eenvoudige itemweergave", "item.page.uri": "URI", + "loading.browse-by": "Items worden ingeladen...", "loading.collection": "Collectie wordt ingeladen...", "loading.community": "Community wordt ingeladen...", @@ -91,6 +104,7 @@ "loading.search-results": "Zoekresultaten worden ingeladen...", "loading.sub-collections": "De sub-collecties worden ingeladen...", "loading.top-level-communities": "Inladen van de Communities op het hoogste niveau...", + "login.form.email": "Email adres", "login.form.forgot-password": "Bent u uw wachtwoord vergeten?", "login.form.header": "Gelieve in te loggen in DSpace", @@ -98,17 +112,23 @@ "login.form.password": "Wachtwoord", "login.form.submit": "Aanmelden", "login.title": "Aanmelden", + "logout.form.header": "Afmelden in DSpace", "logout.form.submit": "Afmelden", "logout.title": "Afmelden", + "nav.home": "Home", "nav.login": "Log In", "nav.logout": "Log Uit", + "pagination.results-per-page": "Resultaten per pagina", "pagination.showing.detail": "{{ range }} van {{ total }}", "pagination.showing.label": "Resultaten ", "pagination.sort-direction": "Sorteermogelijkheden", + "search.description": "", + "search.title": "DSpace Angular :: Zoek", + "search.filters.applied.f.author": "Auteur", "search.filters.applied.f.dateIssued.max": "Einddatum", "search.filters.applied.f.dateIssued.min": "Startdatum", @@ -126,12 +146,16 @@ "search.filters.filter.show-more": "Toon meer", "search.filters.filter.subject.head": "Onderwerp", "search.filters.filter.subject.placeholder": "Onderwerp", + "search.filters.head": "Filters", "search.filters.reset": "Filters verwijderen", + "search.form.search": "Zoek", "search.form.search_dspace": "Zoek in DSpace", + "search.results.head": "Zoekresultaten", "search.results.no-results": "Er waren geen resultaten voor deze zoekopdracht", + "search.sidebar.close": "Terug naar de resultaten", "search.sidebar.filters.title": "Filters", "search.sidebar.open": "Zoek Tools", @@ -139,11 +163,13 @@ "search.sidebar.settings.rpp": "Resultaten per pagina", "search.sidebar.settings.sort-by": "Sorteer volgens", "search.sidebar.settings.title": "Instellingen", - "search.title": "DSpace Angular :: Zoek", + "search.view-switch.show-grid": "Toon in raster", "search.view-switch.show-list": "Toon als lijst", + "sorting.dc.title.ASC": "Oplopend op titel", "sorting.dc.title.DESC": "Aflopend op titel", "sorting.score.DESC": "Relevantie", - "title": "DSpace" + + "title": "DSpace", } diff --git a/src/app/+collection-page/collection-page.component.html b/src/app/+collection-page/collection-page.component.html index 91239de17c..2b16bc1ca6 100644 --- a/src/app/+collection-page/collection-page.component.html +++ b/src/app/+collection-page/collection-page.component.html @@ -52,6 +52,9 @@ message="{{'error.recent-submissions' | translate}}"> + ; + /** + * The current url of this page + */ + url: string; + /** + * Prefix for this component's notification translate keys + * Should be initialized in the initializeNotificationsPrefix method of the child component + */ + notificationsPrefix; + /** + * The time span for being able to undo discarding changes + */ + discardTimeOut: number; + + constructor( + protected itemService: ItemDataService, + protected objectUpdatesService: ObjectUpdatesService, + protected router: Router, + protected notificationsService: NotificationsService, + protected translateService: TranslateService, + @Inject(GLOBAL_CONFIG) protected EnvConfig: GlobalConfig, + protected route: ActivatedRoute + ) { + + } + + /** + * Initialize common properties between item-update components + */ + ngOnInit(): void { + this.route.parent.data.pipe(map((data) => data.item)) + .pipe( + first(), + map((data: RemoteData) => data.payload) + ).subscribe((item: Item) => { + this.item = item; + }); + + this.discardTimeOut = this.EnvConfig.item.edit.undoTimeout; + this.url = this.router.url; + if (this.url.indexOf('?') > 0) { + this.url = this.url.substr(0, this.url.indexOf('?')); + } + this.hasChanges().pipe(first()).subscribe((hasChanges) => { + if (!hasChanges) { + this.initializeOriginalFields(); + } else { + this.checkLastModified(); + } + }); + + this.initializeNotificationsPrefix(); + this.initializeUpdates(); + } + + /** + * Initialize the values and updates of the current item's fields + */ + abstract initializeUpdates(): void; + + /** + * Initialize the prefix for notification messages + */ + abstract initializeNotificationsPrefix(): void; + + /** + * Sends all initial values of this item to the object updates service + */ + abstract initializeOriginalFields(): void; + + /** + * Prevent unnecessary rerendering so fields don't lose focus + */ + trackUpdate(index, update: FieldUpdate) { + return update && update.field ? update.field.uuid : undefined; + } + + /** + * Checks whether or not there are currently updates for this item + */ + hasChanges(): Observable { + return this.objectUpdatesService.hasUpdates(this.url); + } + + /** + * Check if the current page is entirely valid + */ + protected isValid() { + return this.objectUpdatesService.isValidPage(this.url); + } + + /** + * Checks if the current item is still in sync with the version in the store + * If it's not, a notification is shown and the changes are removed + */ + private checkLastModified() { + const currentVersion = this.item.lastModified; + this.objectUpdatesService.getLastModified(this.url).pipe(first()).subscribe( + (updateVersion: Date) => { + if (updateVersion.getDate() !== currentVersion.getDate()) { + this.notificationsService.warning(this.getNotificationTitle('outdated'), this.getNotificationContent('outdated')); + this.initializeOriginalFields(); + } + } + ); + } + + /** + * Submit the current changes + */ + abstract submit(): void; + + /** + * Request the object updates service to discard all current changes to this item + * Shows a notification to remind the user that they can undo this + */ + discard() { + const undoNotification = this.notificationsService.info(this.getNotificationTitle('discarded'), this.getNotificationContent('discarded'), { timeOut: this.discardTimeOut }); + this.objectUpdatesService.discardFieldUpdates(this.url, undoNotification); + } + + /** + * Request the object updates service to undo discarding all changes to this item + */ + reinstate() { + this.objectUpdatesService.reinstateFieldUpdates(this.url); + } + + /** + * Checks whether or not the item is currently reinstatable + */ + isReinstatable(): Observable { + return this.objectUpdatesService.isReinstatable(this.url); + } + + /** + * Get translated notification title + * @param key + */ + protected getNotificationTitle(key: string) { + return this.translateService.instant(this.notificationsPrefix + key + '.title'); + } + + /** + * Get translated notification content + * @param key + */ + protected getNotificationContent(key: string) { + return this.translateService.instant(this.notificationsPrefix + key + '.content'); + + } +} diff --git a/src/app/+item-page/edit-item-page/edit-item-page.module.ts b/src/app/+item-page/edit-item-page/edit-item-page.module.ts index 0c1de642ce..236388109e 100644 --- a/src/app/+item-page/edit-item-page/edit-item-page.module.ts +++ b/src/app/+item-page/edit-item-page/edit-item-page.module.ts @@ -15,6 +15,10 @@ import { ItemDeleteComponent } from './item-delete/item-delete.component'; import { ItemMetadataComponent } from './item-metadata/item-metadata.component'; import { EditInPlaceFieldComponent } from './item-metadata/edit-in-place-field/edit-in-place-field.component'; import { ItemBitstreamsComponent } from './item-bitstreams/item-bitstreams.component'; +import { ItemRelationshipsComponent } from './item-relationships/item-relationships.component'; +import { EditRelationshipComponent } from './item-relationships/edit-relationship/edit-relationship.component'; +import { EditRelationshipListComponent } from './item-relationships/edit-relationship-list/edit-relationship-list.component'; +import { ItemMoveComponent } from './item-move/item-move.component'; /** * Module that contains all components related to the Edit Item page administrator functionality @@ -37,8 +41,12 @@ import { ItemBitstreamsComponent } from './item-bitstreams/item-bitstreams.compo ItemDeleteComponent, ItemStatusComponent, ItemMetadataComponent, + ItemRelationshipsComponent, ItemBitstreamsComponent, - EditInPlaceFieldComponent + EditInPlaceFieldComponent, + EditRelationshipComponent, + EditRelationshipListComponent, + ItemMoveComponent, ] }) export class EditItemPageModule { diff --git a/src/app/+item-page/edit-item-page/edit-item-page.routing.module.ts b/src/app/+item-page/edit-item-page/edit-item-page.routing.module.ts index 811965e145..65e2a36fd1 100644 --- a/src/app/+item-page/edit-item-page/edit-item-page.routing.module.ts +++ b/src/app/+item-page/edit-item-page/edit-item-page.routing.module.ts @@ -10,12 +10,15 @@ import { ItemDeleteComponent } from './item-delete/item-delete.component'; import { ItemStatusComponent } from './item-status/item-status.component'; import { ItemMetadataComponent } from './item-metadata/item-metadata.component'; import { ItemBitstreamsComponent } from './item-bitstreams/item-bitstreams.component'; +import { ItemMoveComponent } from './item-move/item-move.component'; +import { ItemRelationshipsComponent } from './item-relationships/item-relationships.component'; const ITEM_EDIT_WITHDRAW_PATH = 'withdraw'; const ITEM_EDIT_REINSTATE_PATH = 'reinstate'; const ITEM_EDIT_PRIVATE_PATH = 'private'; const ITEM_EDIT_PUBLIC_PATH = 'public'; const ITEM_EDIT_DELETE_PATH = 'delete'; +const ITEM_EDIT_MOVE_PATH = 'move'; /** * Routing module that handles the routing for the Edit Item page administrator functionality @@ -50,6 +53,11 @@ const ITEM_EDIT_DELETE_PATH = 'delete'; component: ItemMetadataComponent, data: { title: 'item.edit.tabs.metadata.title' } }, + { + path: 'relationships', + component: ItemRelationshipsComponent, + data: { title: 'item.edit.tabs.relationships.title' } + }, { path: 'view', /* TODO - change when view page exists */ @@ -98,6 +106,14 @@ const ITEM_EDIT_DELETE_PATH = 'delete'; resolve: { item: ItemPageResolver } + }, + { + path: ITEM_EDIT_MOVE_PATH, + component: ItemMoveComponent, + data: { title: 'item.edit.move.title' }, + resolve: { + item: ItemPageResolver + } }]) ], providers: [ diff --git a/src/app/+item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.html b/src/app/+item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.html index e9c5de95ca..e8ffc28920 100644 --- a/src/app/+item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.html +++ b/src/app/+item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.html @@ -4,7 +4,7 @@ {{metadata?.key?.split('.').join('.​')}}
- + >
{{"item.edit.metadata.metadatafield.invalid" | translate}} diff --git a/src/app/+item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.spec.ts b/src/app/+item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.spec.ts index b8d122d4f6..e07df15651 100644 --- a/src/app/+item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.spec.ts +++ b/src/app/+item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.spec.ts @@ -10,7 +10,6 @@ import { By } from '@angular/platform-browser'; import { FormsModule } from '@angular/forms'; import { SharedModule } from '../../../../shared/shared.module'; import { getTestScheduler } from 'jasmine-marbles'; -import { InputSuggestion } from '../../../../shared/input-suggestions/input-suggestions.model'; import { TestScheduler } from 'rxjs/testing'; import { FieldChangeType } from '../../../../core/data/object-updates/object-updates.actions'; import { TranslateModule } from '@ngx-translate/core'; @@ -18,6 +17,7 @@ import { MetadatumViewModel } from '../../../../core/shared/metadata.models'; import { MetadataSchema } from '../../../../core/metadata/metadata-schema.model'; import { MetadataField } from '../../../../core/metadata/metadata-field.model'; import { createSuccessfulRemoteDataObject$ } from '../../../../shared/testing/utils'; +import { InputSuggestion } from '../../../../shared/input-suggestions/input-suggestions.model'; let comp: EditInPlaceFieldComponent; let fixture: ComponentFixture; diff --git a/src/app/+item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.ts b/src/app/+item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.ts index 1722cde8bc..7dce025a73 100644 --- a/src/app/+item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.ts +++ b/src/app/+item-page/edit-item-page/item-metadata/edit-in-place-field/edit-in-place-field.component.ts @@ -4,13 +4,13 @@ import { RegistryService } from '../../../../core/registry/registry.service'; import { cloneDeep } from 'lodash'; import { BehaviorSubject, Observable, of as observableOf } from 'rxjs'; import { map, take } from 'rxjs/operators'; -import { InputSuggestion } from '../../../../shared/input-suggestions/input-suggestions.model'; import { FieldChangeType } from '../../../../core/data/object-updates/object-updates.actions'; import { FieldUpdate } from '../../../../core/data/object-updates/object-updates.reducer'; import { ObjectUpdatesService } from '../../../../core/data/object-updates/object-updates.service'; import { NgModel } from '@angular/forms'; import { MetadatumViewModel } from '../../../../core/shared/metadata.models'; import { MetadataField } from '../../../../core/metadata/metadata-field.model'; +import { InputSuggestion } from '../../../../shared/input-suggestions/input-suggestions.model'; @Component({ // tslint:disable-next-line:component-selector diff --git a/src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.spec.ts b/src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.spec.ts index e51182e7b3..0c164b7bc2 100644 --- a/src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.spec.ts +++ b/src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.spec.ts @@ -31,7 +31,7 @@ import { createSuccessfulRemoteDataObject$ } from '../../../shared/testing/utils'; -let comp: ItemMetadataComponent; +let comp: any; let fixture: ComponentFixture; let de: DebugElement; let el: HTMLElement; diff --git a/src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.ts b/src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.ts index 8148b89bd4..be657d71dc 100644 --- a/src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.ts +++ b/src/app/+item-page/edit-item-page/item-metadata/item-metadata.component.ts @@ -1,4 +1,4 @@ -import { Component, Inject, Input, OnInit } from '@angular/core'; +import { Component, Inject } from '@angular/core'; import { Item } from '../../../core/shared/item.model'; import { ItemDataService } from '../../../core/data/item-data.service'; import { ObjectUpdatesService } from '../../../core/data/object-updates/object-updates.service'; @@ -6,8 +6,6 @@ import { ActivatedRoute, Router } from '@angular/router'; import { cloneDeep } from 'lodash'; import { Observable } from 'rxjs'; import { - FieldUpdate, - FieldUpdates, Identifiable } from '../../../core/data/object-updates/object-updates.reducer'; import { first, map, switchMap, take, tap } from 'rxjs/operators'; @@ -19,6 +17,7 @@ import { TranslateService } from '@ngx-translate/core'; import { RegistryService } from '../../../core/registry/registry.service'; import { MetadatumViewModel } from '../../../core/shared/metadata.models'; import { Metadata } from '../../../core/shared/metadata.utils'; +import { AbstractItemUpdateComponent } from '../abstract-item-update/abstract-item-update.component'; import { MetadataField } from '../../../core/metadata/metadata-field.model'; @Component({ @@ -29,28 +28,7 @@ import { MetadataField } from '../../../core/metadata/metadata-field.model'; /** * Component for displaying an item's metadata edit page */ -export class ItemMetadataComponent implements OnInit { - - /** - * The item to display the edit page for - */ - item: Item; - /** - * The current values and updates for all this item's metadata fields - */ - updates$: Observable; - /** - * The current url of this page - */ - url: string; - /** - * The time span for being able to undo discarding changes - */ - private discardTimeOut: number; - /** - * Prefix for this component's notification translate keys - */ - private notificationsPrefix = 'item.edit.metadata.notifications.'; +export class ItemMetadataComponent extends AbstractItemUpdateComponent { /** * Observable with a list of strings with all existing metadata field keys @@ -58,44 +36,38 @@ export class ItemMetadataComponent implements OnInit { metadataFields$: Observable; constructor( - private itemService: ItemDataService, - private objectUpdatesService: ObjectUpdatesService, - private router: Router, - private notificationsService: NotificationsService, - private translateService: TranslateService, + protected itemService: ItemDataService, + protected objectUpdatesService: ObjectUpdatesService, + protected router: Router, + protected notificationsService: NotificationsService, + protected translateService: TranslateService, @Inject(GLOBAL_CONFIG) protected EnvConfig: GlobalConfig, - private route: ActivatedRoute, - private metadataFieldService: RegistryService, + protected route: ActivatedRoute, + protected metadataFieldService: RegistryService, ) { - + super(itemService, objectUpdatesService, router, notificationsService, translateService, EnvConfig, route); } /** * Set up and initialize all fields */ ngOnInit(): void { + super.ngOnInit(); this.metadataFields$ = this.findMetadataFields(); - this.route.parent.data.pipe(map((data) => data.item)) - .pipe( - first(), - map((data: RemoteData) => data.payload) - ).subscribe((item: Item) => { - this.item = item; - }); + } - this.discardTimeOut = this.EnvConfig.item.edit.undoTimeout; - this.url = this.router.url; - if (this.url.indexOf('?') > 0) { - this.url = this.url.substr(0, this.url.indexOf('?')); - } - this.hasChanges().pipe(first()).subscribe((hasChanges) => { - if (!hasChanges) { - this.initializeOriginalFields(); - } else { - this.checkLastModified(); - } - }); - this.updates$ = this.objectUpdatesService.getFieldUpdates(this.url, this.item.metadataAsList); + /** + * Initialize the values and updates of the current item's metadata fields + */ + public initializeUpdates(): void { + this.updates$ = this.objectUpdatesService.getFieldUpdates(this.url, this.getMetadataAsListExcludingRelationships()); + } + + /** + * Initialize the prefix for notification messages + */ + public initializeNotificationsPrefix(): void { + this.notificationsPrefix = 'item.edit.metadata.notifications.'; } /** @@ -104,47 +76,23 @@ export class ItemMetadataComponent implements OnInit { */ add(metadata: MetadatumViewModel = new MetadatumViewModel()) { this.objectUpdatesService.saveAddFieldUpdate(this.url, metadata); - - } - - /** - * Request the object updates service to discard all current changes to this item - * Shows a notification to remind the user that they can undo this - */ - discard() { - const undoNotification = this.notificationsService.info(this.getNotificationTitle('discarded'), this.getNotificationContent('discarded'), { timeOut: this.discardTimeOut }); - this.objectUpdatesService.discardFieldUpdates(this.url, undoNotification); - } - - /** - * Request the object updates service to undo discarding all changes to this item - */ - reinstate() { - this.objectUpdatesService.reinstateFieldUpdates(this.url); } /** * Sends all initial values of this item to the object updates service */ - private initializeOriginalFields() { - this.objectUpdatesService.initialize(this.url, this.item.metadataAsList, this.item.lastModified); - } - - /** - * Prevent unnecessary rerendering so fields don't lose focus - */ - trackUpdate(index, update: FieldUpdate) { - return update && update.field ? update.field.uuid : undefined; + public initializeOriginalFields() { + this.objectUpdatesService.initialize(this.url, this.getMetadataAsListExcludingRelationships(), this.item.lastModified); } /** * Requests all current metadata for this item and requests the item service to update the item * Makes sure the new version of the item is rendered on the page */ - submit() { + public submit() { this.isValid().pipe(first()).subscribe((isValid) => { if (isValid) { - const metadata$: Observable = this.objectUpdatesService.getUpdatedFields(this.url, this.item.metadataAsList) as Observable; + const metadata$: Observable = this.objectUpdatesService.getUpdatedFields(this.url, this.getMetadataAsListExcludingRelationships()) as Observable; metadata$.pipe( first(), switchMap((metadata: MetadatumViewModel[]) => { @@ -157,7 +105,7 @@ export class ItemMetadataComponent implements OnInit { (rd: RemoteData) => { this.item = rd.payload; this.initializeOriginalFields(); - this.updates$ = this.objectUpdatesService.getFieldUpdates(this.url, this.item.metadataAsList); + this.updates$ = this.objectUpdatesService.getFieldUpdates(this.url, this.getMetadataAsListExcludingRelationships()); this.notificationsService.success(this.getNotificationTitle('saved'), this.getNotificationContent('saved')); } ) @@ -167,60 +115,6 @@ export class ItemMetadataComponent implements OnInit { }); } - /** - * Checks whether or not there are currently updates for this item - */ - hasChanges(): Observable { - return this.objectUpdatesService.hasUpdates(this.url); - } - - /** - * Checks whether or not the item is currently reinstatable - */ - isReinstatable(): Observable { - return this.objectUpdatesService.isReinstatable(this.url); - } - - /** - * Checks if the current item is still in sync with the version in the store - * If it's not, a notification is shown and the changes are removed - */ - private checkLastModified() { - const currentVersion = this.item.lastModified; - this.objectUpdatesService.getLastModified(this.url).pipe(first()).subscribe( - (updateVersion: Date) => { - if (updateVersion.getDate() !== currentVersion.getDate()) { - this.notificationsService.warning(this.getNotificationTitle('outdated'), this.getNotificationContent('outdated')); - this.initializeOriginalFields(); - } - } - ); - } - - /** - * Check if the current page is entirely valid - */ - private isValid() { - return this.objectUpdatesService.isValidPage(this.url); - } - - /** - * Get translated notification title - * @param key - */ - private getNotificationTitle(key: string) { - return this.translateService.instant(this.notificationsPrefix + key + '.title'); - } - - /** - * Get translated notification content - * @param key - */ - private getNotificationContent(key: string) { - return this.translateService.instant(this.notificationsPrefix + key + '.content'); - - } - /** * Method to request all metadata fields and convert them to a list of strings */ @@ -230,4 +124,8 @@ export class ItemMetadataComponent implements OnInit { take(1), map((remoteData$) => remoteData$.payload.page.map((field: MetadataField) => field.toString()))); } + + getMetadataAsListExcludingRelationships(): MetadatumViewModel[] { + return this.item.metadataAsList.filter((metadata: MetadatumViewModel) => !metadata.key.startsWith('relation.') && !metadata.key.startsWith('relationship.')); + } } diff --git a/src/app/+item-page/edit-item-page/item-move/item-move.component.html b/src/app/+item-page/edit-item-page/item-move/item-move.component.html new file mode 100644 index 0000000000..cf5ada77cf --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-move/item-move.component.html @@ -0,0 +1,48 @@ +
+
+
+

{{'item.edit.move.head' | translate: {id: (itemRD$ | async)?.payload?.handle} }}

+

{{'item.edit.move.description' | translate}}

+
+
+ + + +
+
+
+
+

+ + +

+

+ {{'item.edit.move.inheritpolicies.description' | translate}} +

+
+
+ + + +
+
+
diff --git a/src/app/+item-page/edit-item-page/item-move/item-move.component.spec.ts b/src/app/+item-page/edit-item-page/item-move/item-move.component.spec.ts new file mode 100644 index 0000000000..e73b4b6f9a --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-move/item-move.component.spec.ts @@ -0,0 +1,172 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { Item } from '../../../core/shared/item.model'; +import { RouterStub } from '../../../shared/testing/router-stub'; +import { CommonModule } from '@angular/common'; +import { RouterTestingModule } from '@angular/router/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; +import { ActivatedRoute, Router } from '@angular/router'; +import { ItemMoveComponent } from './item-move.component'; +import { NotificationsServiceStub } from '../../../shared/testing/notifications-service-stub'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { SearchService } from '../../../+search-page/search-service/search.service'; +import { of as observableOf } from 'rxjs'; +import { FormsModule } from '@angular/forms'; +import { ItemDataService } from '../../../core/data/item-data.service'; +import { RemoteData } from '../../../core/data/remote-data'; +import { PaginatedList } from '../../../core/data/paginated-list'; +import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; +import { RestResponse } from '../../../core/cache/response.models'; +import { Collection } from '../../../core/shared/collection.model'; + +describe('ItemMoveComponent', () => { + let comp: ItemMoveComponent; + let fixture: ComponentFixture; + + const mockItem = Object.assign(new Item(), { + id: 'fake-id', + handle: 'fake/handle', + lastModified: '2018' + }); + + const itemPageUrl = `fake-url/${mockItem.id}`; + const routerStub = Object.assign(new RouterStub(), { + url: `${itemPageUrl}/edit` + }); + + const mockItemDataService = jasmine.createSpyObj({ + moveToCollection: observableOf(new RestResponse(true, 200, 'Success')) + }); + + const mockItemDataServiceFail = jasmine.createSpyObj({ + moveToCollection: observableOf(new RestResponse(false, 500, 'Internal server error')) + }); + + const routeStub = { + data: observableOf({ + item: new RemoteData(false, false, true, null, { + id: 'item1' + }) + }) + }; + + const collection1 = Object.assign(new Collection(),{ + uuid: 'collection-uuid-1', + name: 'Test collection 1', + self: 'self-link-1', + }); + + const collection2 = Object.assign(new Collection(),{ + uuid: 'collection-uuid-2', + name: 'Test collection 2', + self: 'self-link-2', + }); + + const mockSearchService = { + search: () => { + return observableOf(new RemoteData(false, false, true, null, + new PaginatedList(null, [ + { + indexableObject: collection1, + hitHighlights: {} + }, { + indexableObject: collection2, + hitHighlights: {} + } + ]))); + } + }; + + const notificationsServiceStub = new NotificationsServiceStub(); + + describe('ItemMoveComponent success', () => { + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [CommonModule, FormsModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule.forRoot()], + declarations: [ItemMoveComponent], + providers: [ + {provide: ActivatedRoute, useValue: routeStub}, + {provide: Router, useValue: routerStub}, + {provide: ItemDataService, useValue: mockItemDataService}, + {provide: NotificationsService, useValue: notificationsServiceStub}, + {provide: SearchService, useValue: mockSearchService}, + ], schemas: [ + CUSTOM_ELEMENTS_SCHEMA + ] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(ItemMoveComponent); + comp = fixture.componentInstance; + fixture.detectChanges(); + }); + it('should load suggestions', () => { + const expected = [ + collection1, + collection2 + ]; + + comp.collectionSearchResults.subscribe((value) => { + expect(value).toEqual(expected); + } + ); + }); + it('should get current url ', () => { + expect(comp.getCurrentUrl()).toEqual('fake-url/fake-id/edit'); + }); + it('should on click select the correct collection name and id', () => { + const data = collection1; + + comp.onClick(data); + + expect(comp.selectedCollectionName).toEqual('Test collection 1'); + expect(comp.selectedCollection).toEqual(collection1); + }); + describe('moveCollection', () => { + it('should call itemDataService.moveToCollection', () => { + comp.itemId = 'item-id'; + comp.selectedCollectionName = 'selected-collection-id'; + comp.selectedCollection = collection1; + comp.moveCollection(); + + expect(mockItemDataService.moveToCollection).toHaveBeenCalledWith('item-id', collection1); + }); + it('should call notificationsService success message on success', () => { + comp.moveCollection(); + + expect(notificationsServiceStub.success).toHaveBeenCalled(); + }); + }); + }); + + describe('ItemMoveComponent fail', () => { + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [CommonModule, FormsModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule.forRoot()], + declarations: [ItemMoveComponent], + providers: [ + {provide: ActivatedRoute, useValue: routeStub}, + {provide: Router, useValue: routerStub}, + {provide: ItemDataService, useValue: mockItemDataServiceFail}, + {provide: NotificationsService, useValue: notificationsServiceStub}, + {provide: SearchService, useValue: mockSearchService}, + ], schemas: [ + CUSTOM_ELEMENTS_SCHEMA + ] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(ItemMoveComponent); + comp = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should call notificationsService error message on fail', () => { + comp.moveCollection(); + + expect(notificationsServiceStub.error).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/app/+item-page/edit-item-page/item-move/item-move.component.ts b/src/app/+item-page/edit-item-page/item-move/item-move.component.ts new file mode 100644 index 0000000000..113ee97b3f --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-move/item-move.component.ts @@ -0,0 +1,139 @@ +import { Component, OnInit } from '@angular/core'; +import { SearchService } from '../../../+search-page/search-service/search.service'; +import { first, map } from 'rxjs/operators'; +import { DSpaceObjectType } from '../../../core/shared/dspace-object-type.model'; +import { SearchOptions } from '../../../+search-page/search-options.model'; +import { RemoteData } from '../../../core/data/remote-data'; +import { DSpaceObject } from '../../../core/shared/dspace-object.model'; +import { PaginatedList } from '../../../core/data/paginated-list'; +import { SearchResult } from '../../../+search-page/search-result.model'; +import { Item } from '../../../core/shared/item.model'; +import { ActivatedRoute, Router } from '@angular/router'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { TranslateService } from '@ngx-translate/core'; +import { getSucceededRemoteData } from '../../../core/shared/operators'; +import { ItemDataService } from '../../../core/data/item-data.service'; +import { getItemEditPath } from '../../item-page-routing.module'; +import { Observable, of as observableOf } from 'rxjs'; +import { RestResponse } from '../../../core/cache/response.models'; +import { Collection } from '../../../core/shared/collection.model'; +import { tap } from 'rxjs/internal/operators/tap'; +import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model'; +import { PaginatedSearchOptions } from '../../../+search-page/paginated-search-options.model'; + +@Component({ + selector: 'ds-item-move', + templateUrl: './item-move.component.html' +}) +/** + * Component that handles the moving of an item to a different collection + */ +export class ItemMoveComponent implements OnInit { + /** + * TODO: There is currently no backend support to change the owningCollection and inherit policies, + * TODO: when this is added, the inherit policies option should be used. + */ + + selectorType = DSpaceObjectType.COLLECTION; + + inheritPolicies = false; + itemRD$: Observable>; + collectionSearchResults: Observable = observableOf([]); + selectedCollectionName: string; + selectedCollection: Collection; + canSubmit = false; + + itemId: string; + processing = false; + + pagination = new PaginationComponentOptions(); + + constructor(private route: ActivatedRoute, + private router: Router, + private notificationsService: NotificationsService, + private itemDataService: ItemDataService, + private searchService: SearchService, + private translateService: TranslateService) { + } + + ngOnInit(): void { + this.itemRD$ = this.route.data.pipe(map((data) => data.item), getSucceededRemoteData()) as Observable>; + this.itemRD$.subscribe((rd) => { + this.itemId = rd.payload.id; + } + ); + this.pagination.pageSize = 5; + this.loadSuggestions(''); + } + + /** + * Find suggestions based on entered query + * @param query - Search query + */ + findSuggestions(query): void { + this.loadSuggestions(query); + } + + /** + * Load all available collections to move the item to. + * TODO: When the API support it, only fetch collections where user has ADD rights to. + */ + loadSuggestions(query): void { + this.collectionSearchResults = this.searchService.search(new PaginatedSearchOptions({ + pagination: this.pagination, + dsoType: DSpaceObjectType.COLLECTION, + query: query + })).pipe( + first(), + map((rd: RemoteData>>) => { + return rd.payload.page.map((searchResult) => { + return searchResult.indexableObject + }) + }) , + ); + + } + + /** + * Set the collection name and id based on the selected value + * @param data - obtained from the ds-input-suggestions component + */ + onClick(data: any): void { + this.selectedCollection = data; + this.selectedCollectionName = data.name; + this.canSubmit = true; + } + + /** + * @returns {string} the current URL + */ + getCurrentUrl() { + return this.router.url; + } + + /** + * Moves the item to a new collection based on the selected collection + */ + moveCollection() { + this.processing = true; + this.itemDataService.moveToCollection(this.itemId, this.selectedCollection).pipe(first()).subscribe( + (response: RestResponse) => { + this.router.navigate([getItemEditPath(this.itemId)]); + if (response.isSuccessful) { + this.notificationsService.success(this.translateService.get('item.edit.move.success')); + } else { + this.notificationsService.error(this.translateService.get('item.edit.move.error')); + } + this.processing = false; + } + ); + } + + /** + * Resets the can submit when the user changes the content of the input field + * @param data + */ + resetCollection(data: any) { + this.canSubmit = false; + } +} diff --git a/src/app/+item-page/edit-item-page/item-operation/item-operation.component.html b/src/app/+item-page/edit-item-page/item-operation/item-operation.component.html index 4623195437..3a52fd0d12 100644 --- a/src/app/+item-page/edit-item-page/item-operation/item-operation.component.html +++ b/src/app/+item-page/edit-item-page/item-operation/item-operation.component.html @@ -4,7 +4,7 @@ diff --git a/src/app/+item-page/edit-item-page/item-operation/item-operation.component.spec.ts b/src/app/+item-page/edit-item-page/item-operation/item-operation.component.spec.ts index 1901bf5fb4..7122dbaf42 100644 --- a/src/app/+item-page/edit-item-page/item-operation/item-operation.component.spec.ts +++ b/src/app/+item-page/edit-item-page/item-operation/item-operation.component.spec.ts @@ -1,8 +1,9 @@ -import {ItemOperation} from './itemOperation.model'; -import {async, TestBed} from '@angular/core/testing'; -import {ItemOperationComponent} from './item-operation.component'; -import {TranslateModule} from '@ngx-translate/core'; -import {By} from '@angular/platform-browser'; +import { ItemOperation } from './itemOperation.model'; +import { async, TestBed } from '@angular/core/testing'; +import { ItemOperationComponent } from './item-operation.component'; +import { TranslateModule } from '@ngx-translate/core'; +import { By } from '@angular/platform-browser'; +import { RouterTestingModule } from '@angular/router/testing'; describe('ItemOperationComponent', () => { let itemOperation: ItemOperation; @@ -12,7 +13,7 @@ describe('ItemOperationComponent', () => { beforeEach(async(() => { TestBed.configureTestingModule({ - imports: [TranslateModule.forRoot()], + imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([])], declarations: [ItemOperationComponent] }).compileComponents(); })); diff --git a/src/app/+item-page/edit-item-page/item-relationships/edit-relationship-list/edit-relationship-list.component.html b/src/app/+item-page/edit-item-page/item-relationships/edit-relationship-list/edit-relationship-list.component.html new file mode 100644 index 0000000000..ba5164e81a --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-relationships/edit-relationship-list/edit-relationship-list.component.html @@ -0,0 +1,15 @@ + +
+
{{getRelationshipMessageKey(relationshipLabel) | translate}}
+ +
+
+ +
+
+
diff --git a/src/app/+item-page/edit-item-page/item-relationships/edit-relationship-list/edit-relationship-list.component.scss b/src/app/+item-page/edit-item-page/item-relationships/edit-relationship-list/edit-relationship-list.component.scss new file mode 100644 index 0000000000..ec6cd2ba78 --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-relationships/edit-relationship-list/edit-relationship-list.component.scss @@ -0,0 +1,12 @@ +@import '../../../../../styles/variables.scss'; + +.relationship-row:not(.alert-danger) { + padding: $alert-padding-y 0; +} + +.relationship-row.alert-danger { + margin-left: -$alert-padding-x; + margin-right: -$alert-padding-x; + margin-top: -1px; + margin-bottom: -1px; +} diff --git a/src/app/+item-page/edit-item-page/item-relationships/edit-relationship-list/edit-relationship-list.component.spec.ts b/src/app/+item-page/edit-item-page/item-relationships/edit-relationship-list/edit-relationship-list.component.spec.ts new file mode 100644 index 0000000000..3293711d73 --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-relationships/edit-relationship-list/edit-relationship-list.component.spec.ts @@ -0,0 +1,136 @@ +import { EditRelationshipListComponent } from './edit-relationship-list.component'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { RelationshipType } from '../../../../core/shared/item-relationships/relationship-type.model'; +import { ResourceType } from '../../../../core/shared/resource-type'; +import { Relationship } from '../../../../core/shared/item-relationships/relationship.model'; +import { of as observableOf } from 'rxjs/internal/observable/of'; +import { RemoteData } from '../../../../core/data/remote-data'; +import { Item } from '../../../../core/shared/item.model'; +import { PaginatedList } from '../../../../core/data/paginated-list'; +import { PageInfo } from '../../../../core/shared/page-info.model'; +import { FieldChangeType } from '../../../../core/data/object-updates/object-updates.actions'; +import { SharedModule } from '../../../../shared/shared.module'; +import { TranslateModule } from '@ngx-translate/core'; +import { ObjectUpdatesService } from '../../../../core/data/object-updates/object-updates.service'; +import { RelationshipService } from '../../../../core/data/relationship.service'; +import { DebugElement, NO_ERRORS_SCHEMA } from '@angular/core'; +import { By } from '@angular/platform-browser'; + +let comp: EditRelationshipListComponent; +let fixture: ComponentFixture; +let de: DebugElement; + +let objectUpdatesService; +let relationshipService; + +const url = 'http://test-url.com/test-url'; + +let item; +let author1; +let author2; +let fieldUpdate1; +let fieldUpdate2; +let relationships; +let relationshipType; + +describe('EditRelationshipListComponent', () => { + beforeEach(async(() => { + relationshipType = Object.assign(new RelationshipType(), { + id: '1', + uuid: '1', + leftwardType: 'isAuthorOfPublication', + rightwardType: 'isPublicationOfAuthor' + }); + + relationships = [ + Object.assign(new Relationship(), { + self: url + '/2', + id: '2', + uuid: '2', + leftId: 'author1', + rightId: 'publication', + relationshipType: observableOf(new RemoteData(false, false, true, undefined, relationshipType)) + }), + Object.assign(new Relationship(), { + self: url + '/3', + id: '3', + uuid: '3', + leftId: 'author2', + rightId: 'publication', + relationshipType: observableOf(new RemoteData(false, false, true, undefined, relationshipType)) + }) + ]; + + item = Object.assign(new Item(), { + self: 'fake-item-url/publication', + id: 'publication', + uuid: 'publication', + relationships: observableOf(new RemoteData(false, false, true, undefined, new PaginatedList(new PageInfo(), relationships))) + }); + + author1 = Object.assign(new Item(), { + id: 'author1', + uuid: 'author1' + }); + author2 = Object.assign(new Item(), { + id: 'author2', + uuid: 'author2' + }); + + fieldUpdate1 = { + field: author1, + changeType: undefined + }; + fieldUpdate2 = { + field: author2, + changeType: FieldChangeType.REMOVE + }; + + objectUpdatesService = jasmine.createSpyObj('objectUpdatesService', + { + getFieldUpdatesExclusive: observableOf({ + [author1.uuid]: fieldUpdate1, + [author2.uuid]: fieldUpdate2 + }) + } + ); + + relationshipService = jasmine.createSpyObj('relationshipService', + { + getRelatedItemsByLabel: observableOf([author1, author2]), + } + ); + + TestBed.configureTestingModule({ + imports: [SharedModule, TranslateModule.forRoot()], + declarations: [EditRelationshipListComponent], + providers: [ + { provide: ObjectUpdatesService, useValue: objectUpdatesService }, + { provide: RelationshipService, useValue: relationshipService } + ], schemas: [ + NO_ERRORS_SCHEMA + ] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(EditRelationshipListComponent); + comp = fixture.componentInstance; + de = fixture.debugElement; + comp.item = item; + comp.url = url; + comp.relationshipLabel = relationshipType.leftwardType; + fixture.detectChanges(); + }); + + describe('changeType is REMOVE', () => { + beforeEach(() => { + fieldUpdate1.changeType = FieldChangeType.REMOVE; + fixture.detectChanges(); + }); + it('the div should have class alert-danger', () => { + const element = de.queryAll(By.css('.relationship-row'))[1].nativeElement; + expect(element.classList).toContain('alert-danger'); + }); + }); +}); diff --git a/src/app/+item-page/edit-item-page/item-relationships/edit-relationship-list/edit-relationship-list.component.ts b/src/app/+item-page/edit-item-page/item-relationships/edit-relationship-list/edit-relationship-list.component.ts new file mode 100644 index 0000000000..765d6484d4 --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-relationships/edit-relationship-list/edit-relationship-list.component.ts @@ -0,0 +1,99 @@ +import { Component, Input, OnChanges, OnInit, SimpleChanges } from '@angular/core'; +import { ObjectUpdatesService } from '../../../../core/data/object-updates/object-updates.service'; +import { Observable } from 'rxjs/internal/Observable'; +import { FieldUpdate, FieldUpdates } from '../../../../core/data/object-updates/object-updates.reducer'; +import { RelationshipService } from '../../../../core/data/relationship.service'; +import { Item } from '../../../../core/shared/item.model'; +import { switchMap } from 'rxjs/operators'; +import { hasValue } from '../../../../shared/empty.util'; + +@Component({ + selector: 'ds-edit-relationship-list', + styleUrls: ['./edit-relationship-list.component.scss'], + templateUrl: './edit-relationship-list.component.html', +}) +/** + * A component creating a list of editable relationships of a certain type + * The relationships are rendered as a list of related items + */ +export class EditRelationshipListComponent implements OnInit, OnChanges { + /** + * The item to display related items for + */ + @Input() item: Item; + + /** + * The URL to the current page + * Used to fetch updates for the current item from the store + */ + @Input() url: string; + + /** + * The label of the relationship-type we're rendering a list for + */ + @Input() relationshipLabel: string; + + /** + * The FieldUpdates for the relationships in question + */ + updates$: Observable; + + constructor( + protected objectUpdatesService: ObjectUpdatesService, + protected relationshipService: RelationshipService + ) { + } + + ngOnInit(): void { + this.initUpdates(); + } + + ngOnChanges(changes: SimpleChanges): void { + this.initUpdates(); + } + + /** + * Initialize the FieldUpdates using the related items + */ + initUpdates() { + this.updates$ = this.getUpdatesByLabel(this.relationshipLabel); + } + + /** + * Transform the item's relationships of a specific type into related items + * @param label The relationship type's label + */ + public getRelatedItemsByLabel(label: string): Observable { + return this.relationshipService.getRelatedItemsByLabel(this.item, label); + } + + /** + * Get FieldUpdates for the relationships of a specific type + * @param label The relationship type's label + */ + public getUpdatesByLabel(label: string): Observable { + return this.getRelatedItemsByLabel(label).pipe( + switchMap((items: Item[]) => this.objectUpdatesService.getFieldUpdatesExclusive(this.url, items)) + ) + } + + /** + * Get the i18n message key for a relationship + * @param label The relationship type's label + */ + public getRelationshipMessageKey(label: string): string { + if (hasValue(label) && label.indexOf('Of') > -1) { + return `relationships.${label.substring(0, label.indexOf('Of') + 2)}` + } else { + return label; + } + } + + /** + * Prevent unnecessary rerendering so fields don't lose focus + */ + trackUpdate(index, update: FieldUpdate) { + return update && update.field ? update.field.uuid : undefined; + } + +} diff --git a/src/app/+item-page/edit-item-page/item-relationships/edit-relationship/edit-relationship.component.html b/src/app/+item-page/edit-item-page/item-relationships/edit-relationship/edit-relationship.component.html new file mode 100644 index 0000000000..e25c3c204f --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-relationships/edit-relationship/edit-relationship.component.html @@ -0,0 +1,19 @@ +
+
+ +
+
+
+ + +
+
+
diff --git a/src/app/+item-page/edit-item-page/item-relationships/edit-relationship/edit-relationship.component.scss b/src/app/+item-page/edit-item-page/item-relationships/edit-relationship/edit-relationship.component.scss new file mode 100644 index 0000000000..808a8344ba --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-relationships/edit-relationship/edit-relationship.component.scss @@ -0,0 +1,15 @@ +@import '../../../../../styles/variables.scss'; + +.btn[disabled] { + color: $gray-600; + border-color: $gray-600; + z-index: 0; // prevent border colors jumping on hover +} + +.relationship-action-buttons { + margin: 0; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); +} diff --git a/src/app/+item-page/edit-item-page/item-relationships/edit-relationship/edit-relationship.component.spec.ts b/src/app/+item-page/edit-item-page/item-relationships/edit-relationship/edit-relationship.component.spec.ts new file mode 100644 index 0000000000..e98da94c9d --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-relationships/edit-relationship/edit-relationship.component.spec.ts @@ -0,0 +1,179 @@ +import { async, TestBed } from '@angular/core/testing'; +import { of as observableOf } from 'rxjs/internal/observable/of'; +import { TranslateModule } from '@ngx-translate/core'; +import { ObjectUpdatesService } from '../../../../core/data/object-updates/object-updates.service'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { EditRelationshipComponent } from './edit-relationship.component'; +import { RelationshipType } from '../../../../core/shared/item-relationships/relationship-type.model'; +import { ResourceType } from '../../../../core/shared/resource-type'; +import { Relationship } from '../../../../core/shared/item-relationships/relationship.model'; +import { RemoteData } from '../../../../core/data/remote-data'; +import { Item } from '../../../../core/shared/item.model'; +import { PaginatedList } from '../../../../core/data/paginated-list'; +import { PageInfo } from '../../../../core/shared/page-info.model'; +import { FieldChangeType } from '../../../../core/data/object-updates/object-updates.actions'; + +let objectUpdatesService: ObjectUpdatesService; +const url = 'http://test-url.com/test-url'; + +let item; +let author1; +let author2; +let fieldUpdate1; +let fieldUpdate2; +let relationships; +let relationshipType; + +let fixture; +let comp: EditRelationshipComponent; +let de; +let el; + +describe('EditRelationshipComponent', () => { + beforeEach(async(() => { + relationshipType = Object.assign(new RelationshipType(), { + id: '1', + uuid: '1', + leftwardType: 'isAuthorOfPublication', + rightwardType: 'isPublicationOfAuthor' + }); + + relationships = [ + Object.assign(new Relationship(), { + self: url + '/2', + id: '2', + uuid: '2', + leftId: 'author1', + rightId: 'publication', + relationshipType: observableOf(new RemoteData(false, false, true, undefined, relationshipType)) + }), + Object.assign(new Relationship(), { + self: url + '/3', + id: '3', + uuid: '3', + leftId: 'author2', + rightId: 'publication', + relationshipType: observableOf(new RemoteData(false, false, true, undefined, relationshipType)) + }) + ]; + + item = Object.assign(new Item(), { + self: 'fake-item-url/publication', + id: 'publication', + uuid: 'publication', + relationships: observableOf(new RemoteData(false, false, true, undefined, new PaginatedList(new PageInfo(), relationships))) + }); + + author1 = Object.assign(new Item(), { + id: 'author1', + uuid: 'author1' + }); + author2 = Object.assign(new Item(), { + id: 'author2', + uuid: 'author2' + }); + + fieldUpdate1 = { + field: author1, + changeType: undefined + }; + fieldUpdate2 = { + field: author2, + changeType: FieldChangeType.REMOVE + }; + + objectUpdatesService = jasmine.createSpyObj('objectUpdatesService', + { + saveChangeFieldUpdate: {}, + saveRemoveFieldUpdate: {}, + setEditableFieldUpdate: {}, + setValidFieldUpdate: {}, + removeSingleFieldUpdate: {}, + isEditable: observableOf(false), // should always return something --> its in ngOnInit + isValid: observableOf(true) // should always return something --> its in ngOnInit + } + ); + + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot()], + declarations: [EditRelationshipComponent], + providers: [ + { provide: ObjectUpdatesService, useValue: objectUpdatesService } + ], schemas: [ + NO_ERRORS_SCHEMA + ] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(EditRelationshipComponent); + comp = fixture.componentInstance; + de = fixture.debugElement; + el = de.nativeElement; + + comp.url = url; + comp.fieldUpdate = fieldUpdate1; + comp.item = item; + + fixture.detectChanges(); + }); + + describe('when fieldUpdate has no changeType', () => { + beforeEach(() => { + comp.fieldUpdate = fieldUpdate1; + fixture.detectChanges(); + }); + + describe('canRemove', () => { + it('should return true', () => { + expect(comp.canRemove()).toBe(true); + }); + }); + + describe('canUndo', () => { + it('should return false', () => { + expect(comp.canUndo()).toBe(false); + }); + }); + }); + + describe('when fieldUpdate has DELETE as changeType', () => { + beforeEach(() => { + comp.fieldUpdate = fieldUpdate2; + fixture.detectChanges(); + }); + + describe('canRemove', () => { + it('should return false', () => { + expect(comp.canRemove()).toBe(false); + }); + }); + + describe('canUndo', () => { + it('should return true', () => { + expect(comp.canUndo()).toBe(true); + }); + }); + }); + + describe('remove', () => { + beforeEach(() => { + comp.remove(); + }); + + it('should call saveRemoveFieldUpdate with the correct arguments', () => { + expect(objectUpdatesService.saveRemoveFieldUpdate).toHaveBeenCalledWith(url, item); + }); + }); + + describe('undo', () => { + beforeEach(() => { + comp.undo(); + }); + + it('should call removeSingleFieldUpdate with the correct arguments', () => { + expect(objectUpdatesService.removeSingleFieldUpdate).toHaveBeenCalledWith(url, item.uuid); + }); + }); + +}); diff --git a/src/app/+item-page/edit-item-page/item-relationships/edit-relationship/edit-relationship.component.ts b/src/app/+item-page/edit-item-page/item-relationships/edit-relationship/edit-relationship.component.ts new file mode 100644 index 0000000000..497b06775a --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-relationships/edit-relationship/edit-relationship.component.ts @@ -0,0 +1,74 @@ +import { Component, Input, OnChanges } from '@angular/core'; +import { FieldUpdate } from '../../../../core/data/object-updates/object-updates.reducer'; +import { cloneDeep } from 'lodash'; +import { Item } from '../../../../core/shared/item.model'; +import { ObjectUpdatesService } from '../../../../core/data/object-updates/object-updates.service'; +import { FieldChangeType } from '../../../../core/data/object-updates/object-updates.actions'; +import { ItemViewMode } from '../../../../shared/items/item-type-decorator'; + +@Component({ + // tslint:disable-next-line:component-selector + selector: '[ds-edit-relationship]', + styleUrls: ['./edit-relationship.component.scss'], + templateUrl: './edit-relationship.component.html', +}) +export class EditRelationshipComponent implements OnChanges { + /** + * The current field, value and state of the relationship + */ + @Input() fieldUpdate: FieldUpdate; + + /** + * The current url of this page + */ + @Input() url: string; + + /** + * The related item of this relationship + */ + item: Item; + + /** + * The view-mode we're currently on + */ + viewMode = ItemViewMode.Element; + + constructor(private objectUpdatesService: ObjectUpdatesService) { + } + + /** + * Sets the current relationship based on the fieldUpdate input field + */ + ngOnChanges(): void { + this.item = cloneDeep(this.fieldUpdate.field) as Item; + } + + /** + * Sends a new remove update for this field to the object updates service + */ + remove(): void { + this.objectUpdatesService.saveRemoveFieldUpdate(this.url, this.item); + } + + /** + * Cancels the current update for this field in the object updates service + */ + undo(): void { + this.objectUpdatesService.removeSingleFieldUpdate(this.url, this.item.uuid); + } + + /** + * Check if a user should be allowed to remove this field + */ + canRemove(): boolean { + return this.fieldUpdate.changeType !== FieldChangeType.REMOVE; + } + + /** + * Check if a user should be allowed to cancel the update to this field + */ + canUndo(): boolean { + return this.fieldUpdate.changeType >= 0; + } + +} diff --git a/src/app/+item-page/edit-item-page/item-relationships/item-relationships.component.html b/src/app/+item-page/edit-item-page/item-relationships/item-relationships.component.html new file mode 100644 index 0000000000..4bd0b3df2c --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-relationships/item-relationships.component.html @@ -0,0 +1,43 @@ +
+
+ + + +
+
+ +
+
+
+ + + +
+
+
diff --git a/src/app/+item-page/edit-item-page/item-relationships/item-relationships.component.scss b/src/app/+item-page/edit-item-page/item-relationships/item-relationships.component.scss new file mode 100644 index 0000000000..898533a9f0 --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-relationships/item-relationships.component.scss @@ -0,0 +1,22 @@ +@import '../../../../styles/variables.scss'; + +.button-row { + .btn { + margin-right: 0.5 * $spacer; + + &:last-child { + margin-right: 0; + } + + @media screen and (min-width: map-get($grid-breakpoints, sm)) { + min-width: $edit-item-button-min-width; + } + } + + &.top .btn { + margin-top: $spacer/2; + margin-bottom: $spacer/2; + } + + +} diff --git a/src/app/+item-page/edit-item-page/item-relationships/item-relationships.component.spec.ts b/src/app/+item-page/edit-item-page/item-relationships/item-relationships.component.spec.ts new file mode 100644 index 0000000000..48bc28a1b9 --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-relationships/item-relationships.component.spec.ts @@ -0,0 +1,233 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { ItemRelationshipsComponent } from './item-relationships.component'; +import { ChangeDetectorRef, DebugElement, NO_ERRORS_SCHEMA } from '@angular/core'; +import { INotification, Notification } from '../../../shared/notifications/models/notification.model'; +import { NotificationType } from '../../../shared/notifications/models/notification-type'; +import { RouterStub } from '../../../shared/testing/router-stub'; +import { TestScheduler } from 'rxjs/testing'; +import { SharedModule } from '../../../shared/shared.module'; +import { TranslateModule } from '@ngx-translate/core'; +import { ItemDataService } from '../../../core/data/item-data.service'; +import { ObjectUpdatesService } from '../../../core/data/object-updates/object-updates.service'; +import { ActivatedRoute, Router } from '@angular/router'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { GLOBAL_CONFIG } from '../../../../config'; +import { RelationshipType } from '../../../core/shared/item-relationships/relationship-type.model'; +import { ResourceType } from '../../../core/shared/resource-type'; +import { Relationship } from '../../../core/shared/item-relationships/relationship.model'; +import { of as observableOf, combineLatest as observableCombineLatest } from 'rxjs'; +import { RemoteData } from '../../../core/data/remote-data'; +import { Item } from '../../../core/shared/item.model'; +import { PaginatedList } from '../../../core/data/paginated-list'; +import { PageInfo } from '../../../core/shared/page-info.model'; +import { FieldChangeType } from '../../../core/data/object-updates/object-updates.actions'; +import { RelationshipService } from '../../../core/data/relationship.service'; +import { ObjectCacheService } from '../../../core/cache/object-cache.service'; +import { getTestScheduler } from 'jasmine-marbles'; +import { RestResponse } from '../../../core/cache/response.models'; +import { RequestService } from '../../../core/data/request.service'; + +let comp: any; +let fixture: ComponentFixture; +let de: DebugElement; +let el: HTMLElement; +let objectUpdatesService; +let relationshipService; +let requestService; +let objectCache; +const infoNotification: INotification = new Notification('id', NotificationType.Info, 'info'); +const warningNotification: INotification = new Notification('id', NotificationType.Warning, 'warning'); +const successNotification: INotification = new Notification('id', NotificationType.Success, 'success'); +const notificationsService = jasmine.createSpyObj('notificationsService', + { + info: infoNotification, + warning: warningNotification, + success: successNotification + } +); +const router = new RouterStub(); +let routeStub; +let itemService; + +const url = 'http://test-url.com/test-url'; +router.url = url; + +let scheduler: TestScheduler; +let item; +let author1; +let author2; +let fieldUpdate1; +let fieldUpdate2; +let relationships; +let relationshipType; + +describe('ItemRelationshipsComponent', () => { + beforeEach(async(() => { + const date = new Date(); + + relationshipType = Object.assign(new RelationshipType(), { + id: '1', + uuid: '1', + leftwardType: 'isAuthorOfPublication', + rightwardType: 'isPublicationOfAuthor' + }); + + relationships = [ + Object.assign(new Relationship(), { + self: url + '/2', + id: '2', + uuid: '2', + relationshipType: observableOf(new RemoteData(false, false, true, undefined, relationshipType)) + }), + Object.assign(new Relationship(), { + self: url + '/3', + id: '3', + uuid: '3', + relationshipType: observableOf(new RemoteData(false, false, true, undefined, relationshipType)) + }) + ]; + + item = Object.assign(new Item(), { + self: 'fake-item-url/publication', + id: 'publication', + uuid: 'publication', + relationships: observableOf(new RemoteData(false, false, true, undefined, new PaginatedList(new PageInfo(), relationships))), + lastModified: date + }); + + author1 = Object.assign(new Item(), { + id: 'author1', + uuid: 'author1' + }); + author2 = Object.assign(new Item(), { + id: 'author2', + uuid: 'author2' + }); + + relationships[0].leftItem = observableOf(new RemoteData(false, false, true, undefined, author1)); + relationships[0].rightItem = observableOf(new RemoteData(false, false, true, undefined, item)); + relationships[1].leftItem = observableOf(new RemoteData(false, false, true, undefined, author2)); + relationships[1].rightItem = observableOf(new RemoteData(false, false, true, undefined, item)); + + fieldUpdate1 = { + field: author1, + changeType: undefined + }; + fieldUpdate2 = { + field: author2, + changeType: FieldChangeType.REMOVE + }; + + itemService = jasmine.createSpyObj('itemService', { + findById: observableOf(new RemoteData(false, false, true, undefined, item)) + }); + routeStub = { + parent: { + data: observableOf({ item: new RemoteData(false, false, true, null, item) }) + } + }; + + objectUpdatesService = jasmine.createSpyObj('objectUpdatesService', + { + getFieldUpdates: observableOf({ + [author1.uuid]: fieldUpdate1, + [author2.uuid]: fieldUpdate2 + }), + getFieldUpdatesExclusive: observableOf({ + [author1.uuid]: fieldUpdate1, + [author2.uuid]: fieldUpdate2 + }), + saveAddFieldUpdate: {}, + discardFieldUpdates: {}, + reinstateFieldUpdates: observableOf(true), + initialize: {}, + getUpdatedFields: observableOf([author1, author2]), + getLastModified: observableOf(date), + hasUpdates: observableOf(true), + isReinstatable: observableOf(false), // should always return something --> its in ngOnInit + isValidPage: observableOf(true) + } + ); + + relationshipService = jasmine.createSpyObj('relationshipService', + { + getItemRelationshipLabels: observableOf(['isAuthorOfPublication']), + getRelatedItems: observableOf([author1, author2]), + getRelatedItemsByLabel: observableOf([author1, author2]), + getItemRelationshipsArray: observableOf(relationships), + deleteRelationship: observableOf(new RestResponse(true, 200, 'OK')), + getItemResolvedRelatedItemsAndRelationships: observableCombineLatest(observableOf([author1, author2]), observableOf([item, item]), observableOf(relationships)) + } + ); + + requestService = jasmine.createSpyObj('requestService', + { + removeByHrefSubstring: {}, + hasByHrefObservable: observableOf(false) + } + ); + + objectCache = jasmine.createSpyObj('objectCache', { + remove: undefined + }); + + scheduler = getTestScheduler(); + TestBed.configureTestingModule({ + imports: [SharedModule, TranslateModule.forRoot()], + declarations: [ItemRelationshipsComponent], + providers: [ + { provide: ItemDataService, useValue: itemService }, + { provide: ObjectUpdatesService, useValue: objectUpdatesService }, + { provide: Router, useValue: router }, + { provide: ActivatedRoute, useValue: routeStub }, + { provide: NotificationsService, useValue: notificationsService }, + { provide: GLOBAL_CONFIG, useValue: { item: { edit: { undoTimeout: 10 } } } as any }, + { provide: RelationshipService, useValue: relationshipService }, + { provide: ObjectCacheService, useValue: objectCache }, + { provide: RequestService, useValue: requestService }, + ChangeDetectorRef + ], schemas: [ + NO_ERRORS_SCHEMA + ] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(ItemRelationshipsComponent); + comp = fixture.componentInstance; + de = fixture.debugElement; + el = de.nativeElement; + comp.url = url; + fixture.detectChanges(); + }); + + describe('discard', () => { + beforeEach(() => { + comp.discard(); + }); + + it('it should call discardFieldUpdates on the objectUpdatesService with the correct url and notification', () => { + expect(objectUpdatesService.discardFieldUpdates).toHaveBeenCalledWith(url, infoNotification); + }); + }); + + describe('reinstate', () => { + beforeEach(() => { + comp.reinstate(); + }); + + it('it should call reinstateFieldUpdates on the objectUpdatesService with the correct url', () => { + expect(objectUpdatesService.reinstateFieldUpdates).toHaveBeenCalledWith(url); + }); + }); + + describe('submit', () => { + beforeEach(() => { + comp.submit(); + }); + + it('it should delete the correct relationship', () => { + expect(relationshipService.deleteRelationship).toHaveBeenCalledWith(relationships[1].uuid); + }); + }); +}); diff --git a/src/app/+item-page/edit-item-page/item-relationships/item-relationships.component.ts b/src/app/+item-page/edit-item-page/item-relationships/item-relationships.component.ts new file mode 100644 index 0000000000..e8f34bc70e --- /dev/null +++ b/src/app/+item-page/edit-item-page/item-relationships/item-relationships.component.ts @@ -0,0 +1,172 @@ +import { ChangeDetectorRef, Component, Inject, OnDestroy } from '@angular/core'; +import { Item } from '../../../core/shared/item.model'; +import { FieldUpdate, FieldUpdates } from '../../../core/data/object-updates/object-updates.reducer'; +import { Observable } from 'rxjs/internal/Observable'; +import { filter, map, switchMap, take, tap } from 'rxjs/operators'; +import { combineLatest as observableCombineLatest, zip as observableZip } from 'rxjs'; +import { AbstractItemUpdateComponent } from '../abstract-item-update/abstract-item-update.component'; +import { ItemDataService } from '../../../core/data/item-data.service'; +import { ObjectUpdatesService } from '../../../core/data/object-updates/object-updates.service'; +import { ActivatedRoute, Router } from '@angular/router'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { TranslateService } from '@ngx-translate/core'; +import { GLOBAL_CONFIG, GlobalConfig } from '../../../../config'; +import { RelationshipService } from '../../../core/data/relationship.service'; +import { FieldChangeType } from '../../../core/data/object-updates/object-updates.actions'; +import { Relationship } from '../../../core/shared/item-relationships/relationship.model'; +import { ErrorResponse, RestResponse } from '../../../core/cache/response.models'; +import { isNotEmptyOperator } from '../../../shared/empty.util'; +import { RemoteData } from '../../../core/data/remote-data'; +import { ObjectCacheService } from '../../../core/cache/object-cache.service'; +import { getSucceededRemoteData } from '../../../core/shared/operators'; +import { RequestService } from '../../../core/data/request.service'; +import { Subscription } from 'rxjs/internal/Subscription'; +import { getRelationsByRelatedItemIds } from '../../simple/item-types/shared/item-relationships-utils'; + +@Component({ + selector: 'ds-item-relationships', + styleUrls: ['./item-relationships.component.scss'], + templateUrl: './item-relationships.component.html', +}) +/** + * Component for displaying an item's relationships edit page + */ +export class ItemRelationshipsComponent extends AbstractItemUpdateComponent implements OnDestroy { + + /** + * The labels of all different relations within this item + */ + relationLabels$: Observable; + + /** + * A subscription that checks when the item is deleted in cache and reloads the item by sending a new request + * This is used to update the item in cache after relationships are deleted + */ + itemUpdateSubscription: Subscription; + + constructor( + protected itemService: ItemDataService, + protected objectUpdatesService: ObjectUpdatesService, + protected router: Router, + protected notificationsService: NotificationsService, + protected translateService: TranslateService, + @Inject(GLOBAL_CONFIG) protected EnvConfig: GlobalConfig, + protected route: ActivatedRoute, + protected relationshipService: RelationshipService, + protected objectCache: ObjectCacheService, + protected requestService: RequestService, + protected cdRef: ChangeDetectorRef + ) { + super(itemService, objectUpdatesService, router, notificationsService, translateService, EnvConfig, route); + } + + /** + * Set up and initialize all fields + */ + ngOnInit(): void { + super.ngOnInit(); + this.relationLabels$ = this.relationshipService.getItemRelationshipLabels(this.item); + this.initializeItemUpdate(); + } + + /** + * Update the item (and view) when it's removed in the request cache + */ + public initializeItemUpdate(): void { + this.itemUpdateSubscription = this.requestService.hasByHrefObservable(this.item.self).pipe( + filter((exists: boolean) => !exists), + switchMap(() => this.itemService.findById(this.item.uuid)), + getSucceededRemoteData(), + ).subscribe((itemRD: RemoteData) => { + this.item = itemRD.payload; + this.cdRef.detectChanges(); + }); + } + + /** + * Initialize the values and updates of the current item's relationship fields + */ + public initializeUpdates(): void { + this.updates$ = this.relationshipService.getRelatedItems(this.item).pipe( + switchMap((items: Item[]) => this.objectUpdatesService.getFieldUpdates(this.url, items)) + ); + } + + /** + * Initialize the prefix for notification messages + */ + public initializeNotificationsPrefix(): void { + this.notificationsPrefix = 'item.edit.relationships.notifications.'; + } + + /** + * Resolve the currently selected related items back to relationships and send a delete request for each of the relationships found + * Make sure the lists are refreshed afterwards and notifications are sent for success and errors + */ + public submit(): void { + // Get all IDs of related items of which their relationship with the current item is about to be removed + const removedItemIds$ = this.relationshipService.getRelatedItems(this.item).pipe( + switchMap((items: Item[]) => this.objectUpdatesService.getFieldUpdatesExclusive(this.url, items) as Observable), + map((fieldUpdates: FieldUpdates) => Object.values(fieldUpdates).filter((fieldUpdate: FieldUpdate) => fieldUpdate.changeType === FieldChangeType.REMOVE)), + map((fieldUpdates: FieldUpdate[]) => fieldUpdates.map((fieldUpdate: FieldUpdate) => fieldUpdate.field.uuid) as string[]), + isNotEmptyOperator() + ); + // Get all the relationships that should be removed + const removedRelationships$ = removedItemIds$.pipe( + getRelationsByRelatedItemIds(this.item, this.relationshipService) + ); + // Request a delete for every relationship found in the observable created above + removedRelationships$.pipe( + take(1), + map((removedRelationships: Relationship[]) => removedRelationships.map((rel: Relationship) => rel.id)), + switchMap((removedIds: string[]) => observableZip(...removedIds.map((uuid: string) => this.relationshipService.deleteRelationship(uuid)))) + ).subscribe((responses: RestResponse[]) => { + this.displayNotifications(responses); + this.reset(); + }); + } + + /** + * Display notifications + * - Error notification for each failed response with their message + * - Success notification in case there's at least one successful response + * @param responses + */ + displayNotifications(responses: RestResponse[]) { + const failedResponses = responses.filter((response: RestResponse) => !response.isSuccessful); + const successfulResponses = responses.filter((response: RestResponse) => response.isSuccessful); + + failedResponses.forEach((response: ErrorResponse) => { + this.notificationsService.error(this.getNotificationTitle('failed'), response.errorMessage); + }); + if (successfulResponses.length > 0) { + this.notificationsService.success(this.getNotificationTitle('saved'), this.getNotificationContent('saved')); + } + } + + /** + * Re-initialize fields and subscriptions + */ + reset() { + this.initializeOriginalFields(); + this.initializeUpdates(); + this.initializeItemUpdate(); + } + + /** + * Sends all initial values of this item to the object updates service + */ + public initializeOriginalFields() { + this.relationshipService.getRelatedItems(this.item).pipe(take(1)).subscribe((items: Item[]) => { + this.objectUpdatesService.initialize(this.url, items, this.item.lastModified); + }); + } + + /** + * Unsubscribe from the item update when the component is destroyed + */ + ngOnDestroy(): void { + this.itemUpdateSubscription.unsubscribe(); + } + +} diff --git a/src/app/+item-page/edit-item-page/item-status/item-status.component.ts b/src/app/+item-page/edit-item-page/item-status/item-status.component.ts index c7e3a023d1..d293188aa6 100644 --- a/src/app/+item-page/edit-item-page/item-status/item-status.component.ts +++ b/src/app/+item-page/edit-item-page/item-status/item-status.component.ts @@ -79,6 +79,7 @@ export class ItemStatusComponent implements OnInit { this.operations.push(new ItemOperation('public', this.getCurrentUrl(item) + '/public')); } this.operations.push(new ItemOperation('delete', this.getCurrentUrl(item) + '/delete')); + this.operations.push(new ItemOperation('move', this.getCurrentUrl(item) + '/move')); }); } diff --git a/src/app/+item-page/item-page.module.ts b/src/app/+item-page/item-page.module.ts index 6743028b6c..f510ccf19b 100644 --- a/src/app/+item-page/item-page.module.ts +++ b/src/app/+item-page/item-page.module.ts @@ -62,7 +62,8 @@ import { MetadataFieldWrapperComponent } from './field-components/metadata-field GenericItemPageFieldComponent, RelatedEntitiesSearchComponent, RelatedItemsComponent, - MetadataRepresentationListComponent + MetadataRepresentationListComponent, + ItemPageTitleFieldComponent ], entryComponents: [ PublicationComponent diff --git a/src/app/+item-page/simple/item-types/shared/item-relationships-utils.ts b/src/app/+item-page/simple/item-types/shared/item-relationships-utils.ts index 65ad743245..9f120a87dd 100644 --- a/src/app/+item-page/simple/item-types/shared/item-relationships-utils.ts +++ b/src/app/+item-page/simple/item-types/shared/item-relationships-utils.ts @@ -7,10 +7,12 @@ import { hasNoValue, hasValue } from '../../../../shared/empty.util'; import { Observable } from 'rxjs/internal/Observable'; import { Relationship } from '../../../../core/shared/item-relationships/relationship.model'; import { RelationshipType } from '../../../../core/shared/item-relationships/relationship-type.model'; -import { distinctUntilChanged, flatMap, map, switchMap } from 'rxjs/operators'; +import { distinctUntilChanged, filter, flatMap, map, switchMap, tap } from 'rxjs/operators'; import { of as observableOf, zip as observableZip, combineLatest as observableCombineLatest } from 'rxjs'; import { ItemDataService } from '../../../../core/data/item-data.service'; import { Item } from '../../../../core/shared/item.model'; +import { RemoteData } from '../../../../core/data/remote-data'; +import { RelationshipService } from '../../../../core/data/relationship.service'; /** * Operator for comparing arrays using a mapping function @@ -58,10 +60,10 @@ export const filterRelationsByTypeLabel = (label: string, thisId?: string) => return relatedItems$.pipe( map((arr) => relsCurrentPage.filter((rel: Relationship, idx: number) => hasValue(relTypesCurrentPage[idx]) && ( - (hasNoValue(thisId) && (relTypesCurrentPage[idx].leftLabel === label || - relTypesCurrentPage[idx].rightLabel === label)) || - (thisId === arr[idx][0].id && relTypesCurrentPage[idx].leftLabel === label) || - (thisId === arr[idx][1].id && relTypesCurrentPage[idx].rightLabel === label) + (hasNoValue(thisId) && (relTypesCurrentPage[idx].leftwardType === label || + relTypesCurrentPage[idx].rightwardType === label)) || + (thisId === arr[idx][0].id && relTypesCurrentPage[idx].leftwardType === label) || + (thisId === arr[idx][1].id && relTypesCurrentPage[idx].rightwardType === label) ) )) ); @@ -147,3 +149,17 @@ export const relationsToRepresentations = (parentId: string, itemType: string, m ) ) ); + +/** + * Operator for fetching an item's relationships, but filtered by related item IDs (essentially performing a reverse lookup) + * Only relationships where leftItem or rightItem's ID is present in the list provided will be returned + * @param item + * @param relationshipService + */ +export const getRelationsByRelatedItemIds = (item: Item, relationshipService: RelationshipService) => + (source: Observable): Observable => + source.pipe( + flatMap((relatedItemIds: string[]) => relationshipService.getItemResolvedRelatedItemsAndRelationships(item).pipe( + map(([leftItems, rightItems, rels]) => rels.filter((rel: Relationship, index: number) => relatedItemIds.indexOf(leftItems[index].uuid) > -1 || relatedItemIds.indexOf(rightItems[index].uuid) > -1)) + )) + ); diff --git a/src/app/+login-page/login-page.component.html b/src/app/+login-page/login-page.component.html index 4ce29024e9..ebfa0a3aeb 100644 --- a/src/app/+login-page/login-page.component.html +++ b/src/app/+login-page/login-page.component.html @@ -3,6 +3,8 @@

{{"login.form.header" | translate}}

+
diff --git a/src/app/+my-dspace-page/my-dspace-configuration.service.ts b/src/app/+my-dspace-page/my-dspace-configuration.service.ts index 705ec897f8..39c7574407 100644 --- a/src/app/+my-dspace-page/my-dspace-configuration.service.ts +++ b/src/app/+my-dspace-page/my-dspace-configuration.service.ts @@ -8,7 +8,7 @@ import { MyDSpaceConfigurationValueType } from './my-dspace-configuration-value- import { RoleService } from '../core/roles/role.service'; import { SearchConfigurationOption } from '../+search-page/search-switch-configuration/search-configuration-option.model'; import { SearchConfigurationService } from '../+search-page/search-service/search-configuration.service'; -import { RouteService } from '../shared/services/route.service'; +import { RouteService } from '../core/services/route.service'; import { PaginationComponentOptions } from '../shared/pagination/pagination-component-options.model'; import { SortDirection, SortOptions } from '../core/cache/models/sort-options.model'; import { SearchFixedFilterService } from '../+search-page/search-filters/search-filter/search-fixed-filter.service'; diff --git a/src/app/+my-dspace-page/my-dspace-page.component.spec.ts b/src/app/+my-dspace-page/my-dspace-page.component.spec.ts index 34af21073f..27daa30a0f 100644 --- a/src/app/+my-dspace-page/my-dspace-page.component.spec.ts +++ b/src/app/+my-dspace-page/my-dspace-page.component.spec.ts @@ -17,7 +17,7 @@ import { HostWindowService } from '../shared/host-window.service'; import { PaginationComponentOptions } from '../shared/pagination/pagination-component-options.model'; import { RemoteData } from '../core/data/remote-data'; import { MyDSpacePageComponent, SEARCH_CONFIG_SERVICE } from './my-dspace-page.component'; -import { RouteService } from '../shared/services/route.service'; +import { RouteService } from '../core/services/route.service'; import { routeServiceStub } from '../shared/testing/route-service-stub'; import { SearchConfigurationServiceStub } from '../shared/testing/search-configuration-service-stub'; import { SearchService } from '../+search-page/search-service/search.service'; @@ -82,9 +82,6 @@ describe('MyDSpacePageComponent', () => { expand: () => this.isCollapsed = observableOf(false) }; const mockFixedFilterService: SearchFixedFilterService = { - getQueryByFilterName: (filter: string) => { - return observableOf(undefined) - } } as SearchFixedFilterService; beforeEach(async(() => { diff --git a/src/app/+search-page/configuration-search-page.component.ts b/src/app/+search-page/configuration-search-page.component.ts index 85619e8f04..b1a94fc086 100644 --- a/src/app/+search-page/configuration-search-page.component.ts +++ b/src/app/+search-page/configuration-search-page.component.ts @@ -4,12 +4,12 @@ import { SearchSidebarService } from './search-sidebar/search-sidebar.service'; import { SearchPageComponent } from './search-page.component'; import { ChangeDetectionStrategy, Component, Inject, Input, OnInit } from '@angular/core'; import { pushInOut } from '../shared/animations/push'; -import { RouteService } from '../shared/services/route.service'; import { SearchConfigurationService } from './search-service/search-configuration.service'; import { Observable } from 'rxjs'; import { PaginatedSearchOptions } from './paginated-search-options.model'; import { SEARCH_CONFIG_SERVICE } from '../+my-dspace-page/my-dspace-page.component'; import { map } from 'rxjs/operators'; +import { RouteService } from '../core/services/route.service'; /** * This component renders a search page using a configuration as input. diff --git a/src/app/+search-page/filtered-search-page.component.ts b/src/app/+search-page/filtered-search-page.component.ts index 66c619b823..0bcc9e14e3 100644 --- a/src/app/+search-page/filtered-search-page.component.ts +++ b/src/app/+search-page/filtered-search-page.component.ts @@ -4,12 +4,12 @@ import { SearchSidebarService } from './search-sidebar/search-sidebar.service'; import { SearchPageComponent } from './search-page.component'; import { ChangeDetectionStrategy, Component, Inject, Input, OnInit } from '@angular/core'; import { pushInOut } from '../shared/animations/push'; -import { RouteService } from '../shared/services/route.service'; import { SearchConfigurationService } from './search-service/search-configuration.service'; import { Observable } from 'rxjs'; import { PaginatedSearchOptions } from './paginated-search-options.model'; import { SEARCH_CONFIG_SERVICE } from '../+my-dspace-page/my-dspace-page.component'; import { map } from 'rxjs/operators'; +import { RouteService } from '../core/services/route.service'; /** * This component renders a simple item page. diff --git a/src/app/+search-page/search-filters/search-filter/search-authority-filter/search-authority-filter.component.html b/src/app/+search-page/search-filters/search-filter/search-authority-filter/search-authority-filter.component.html index 76cdc6c8f5..5e6bcfaf8b 100644 --- a/src/app/+search-page/search-filters/search-filter/search-authority-filter/search-authority-filter.component.html +++ b/src/app/+search-page/search-filters/search-filter/search-authority-filter/search-authority-filter.component.html @@ -15,13 +15,13 @@ | translate}} - + ngDefaultControl> diff --git a/src/app/+search-page/search-filters/search-filter/search-facet-filter-options/search-facet-option/search-facet-option.component.html b/src/app/+search-page/search-filters/search-filter/search-facet-filter-options/search-facet-option/search-facet-option.component.html index b853346fa5..9441081661 100644 --- a/src/app/+search-page/search-filters/search-filter/search-facet-filter-options/search-facet-option/search-facet-option.component.html +++ b/src/app/+search-page/search-filters/search-filter/search-facet-filter-options/search-facet-option/search-facet-option.component.html @@ -1,5 +1,5 @@ {{filterValue.value}} diff --git a/src/app/+search-page/search-filters/search-filter/search-facet-filter-options/search-facet-option/search-facet-option.component.ts b/src/app/+search-page/search-filters/search-filter/search-facet-filter-options/search-facet-option/search-facet-option.component.ts index 1fccee3736..eae9a8cc7f 100644 --- a/src/app/+search-page/search-filters/search-filter/search-facet-filter-options/search-facet-option/search-facet-option.component.ts +++ b/src/app/+search-page/search-filters/search-filter/search-facet-filter-options/search-facet-option/search-facet-option.component.ts @@ -50,6 +50,10 @@ export class SearchFacetOptionComponent implements OnInit, OnDestroy { */ addQueryParams; + /** + * Link to the search page + */ + searchLink: string; /** * Subscription to unsubscribe from on destroy */ @@ -66,6 +70,7 @@ export class SearchFacetOptionComponent implements OnInit, OnDestroy { * Initializes all observable instance variables and starts listening to them */ ngOnInit(): void { + this.searchLink = this.getSearchLink(); this.isVisible = this.isChecked().pipe(map((checked: boolean) => !checked)); this.sub = observableCombineLatest(this.selectedValues$, this.searchConfigService.searchOptions) .subscribe(([selectedValues, searchOptions]) => { @@ -83,7 +88,7 @@ export class SearchFacetOptionComponent implements OnInit, OnDestroy { /** * @returns {string} The base path to the search page, or the current page when inPlaceSearch is true */ - public getSearchLink(): string { + private getSearchLink(): string { if (this.inPlaceSearch) { return './'; } diff --git a/src/app/+search-page/search-filters/search-filter/search-facet-filter-options/search-facet-range-option/search-facet-range-option.component.html b/src/app/+search-page/search-filters/search-filter/search-facet-filter-options/search-facet-range-option/search-facet-range-option.component.html index 4efee3e7b5..798038503f 100644 --- a/src/app/+search-page/search-filters/search-filter/search-facet-filter-options/search-facet-range-option/search-facet-range-option.component.html +++ b/src/app/+search-page/search-filters/search-filter/search-facet-filter-options/search-facet-range-option/search-facet-range-option.component.html @@ -1,5 +1,5 @@ {{filterValue.label}} diff --git a/src/app/+search-page/search-filters/search-filter/search-facet-filter-options/search-facet-range-option/search-facet-range-option.component.ts b/src/app/+search-page/search-filters/search-filter/search-facet-filter-options/search-facet-range-option/search-facet-range-option.component.ts index 77f240a899..39b24b36fd 100644 --- a/src/app/+search-page/search-filters/search-filter/search-facet-filter-options/search-facet-range-option/search-facet-range-option.component.ts +++ b/src/app/+search-page/search-filters/search-filter/search-facet-filter-options/search-facet-range-option/search-facet-range-option.component.ts @@ -56,6 +56,11 @@ export class SearchFacetRangeOptionComponent implements OnInit, OnDestroy { */ sub: Subscription; + /** + * Link to the search page + */ + searchLink: string; + constructor(protected searchService: SearchService, protected filterService: SearchFilterService, protected searchConfigService: SearchConfigurationService, @@ -67,6 +72,7 @@ export class SearchFacetRangeOptionComponent implements OnInit, OnDestroy { * Initializes all observable instance variables and starts listening to them */ ngOnInit(): void { + this.searchLink = this.getSearchLink(); this.isVisible = this.isChecked().pipe(map((checked: boolean) => !checked)); this.sub = this.searchConfigService.searchOptions.subscribe(() => { this.updateChangeParams() @@ -83,7 +89,7 @@ export class SearchFacetRangeOptionComponent implements OnInit, OnDestroy { /** * @returns {string} The base path to the search page, or the current page when inPlaceSearch is true */ - public getSearchLink(): string { + private getSearchLink(): string { if (this.inPlaceSearch) { return './'; } diff --git a/src/app/+search-page/search-filters/search-filter/search-facet-filter-options/search-facet-selected-option/search-facet-selected-option.component.html b/src/app/+search-page/search-filters/search-filter/search-facet-filter-options/search-facet-selected-option/search-facet-selected-option.component.html index 5657bd224e..5198433207 100644 --- a/src/app/+search-page/search-filters/search-filter/search-facet-filter-options/search-facet-selected-option/search-facet-selected-option.component.html +++ b/src/app/+search-page/search-filters/search-filter/search-facet-filter-options/search-facet-selected-option/search-facet-selected-option.component.html @@ -1,5 +1,5 @@ {{selectedValue.label}} diff --git a/src/app/+search-page/search-filters/search-filter/search-facet-filter-options/search-facet-selected-option/search-facet-selected-option.component.ts b/src/app/+search-page/search-filters/search-filter/search-facet-filter-options/search-facet-selected-option/search-facet-selected-option.component.ts index 78dde92c2b..3f84b7c4e5 100644 --- a/src/app/+search-page/search-filters/search-filter/search-facet-filter-options/search-facet-selected-option/search-facet-selected-option.component.ts +++ b/src/app/+search-page/search-filters/search-filter/search-facet-filter-options/search-facet-selected-option/search-facet-selected-option.component.ts @@ -49,6 +49,11 @@ export class SearchFacetSelectedOptionComponent implements OnInit, OnDestroy { */ sub: Subscription; + /** + * Link to the search page + */ + searchLink: string; + constructor(protected searchService: SearchService, protected filterService: SearchFilterService, protected searchConfigService: SearchConfigurationService, @@ -64,12 +69,13 @@ export class SearchFacetSelectedOptionComponent implements OnInit, OnDestroy { .subscribe(([selectedValues, searchOptions]) => { this.updateRemoveParams(selectedValues) }); + this.searchLink = this.getSearchLink(); } /** * @returns {string} The base path to the search page, or the current page when inPlaceSearch is true */ - public getSearchLink(): string { + private getSearchLink(): string { if (this.inPlaceSearch) { return './'; } 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 ee980a0599..cb25aba44e 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 @@ -21,9 +21,9 @@ import { SearchService } from '../../../search-service/search.service'; import { FILTER_CONFIG, IN_PLACE_SEARCH, SearchFilterService } from '../search-filter.service'; import { SearchConfigurationService } from '../../../search-service/search-configuration.service'; import { getSucceededRemoteData } from '../../../../core/shared/operators'; -import { InputSuggestion } from '../../../../shared/input-suggestions/input-suggestions.model'; import { SearchOptions } from '../../../search-options.model'; import { SEARCH_CONFIG_SERVICE } from '../../../../+my-dspace-page/my-dspace-page.component'; +import { InputSuggestion } from '../../../../shared/input-suggestions/input-suggestions.model'; @Component({ selector: 'ds-search-facet-filter', @@ -80,6 +80,11 @@ export class SearchFacetFilterComponent implements OnInit, OnDestroy { */ searchOptions$: Observable; + /** + * The current URL + */ + currentUrl: string; + constructor(protected searchService: SearchService, protected filterService: SearchFilterService, protected rdbs: RemoteDataBuildService, @@ -93,6 +98,7 @@ export class SearchFacetFilterComponent implements OnInit, OnDestroy { * Initializes all observable instance variables and starts listening to them */ ngOnInit(): void { + this.currentUrl = this.router.url; this.filterValues$ = new BehaviorSubject(new RemoteData(true, false, undefined, undefined, undefined)); this.currentPage = this.getCurrentPage().pipe(distinctUntilChanged()); @@ -215,13 +221,6 @@ export class SearchFacetFilterComponent implements OnInit, OnDestroy { return this.filterService.getPage(this.filterConfig.name); } - /** - * @returns {string} the current URL - */ - getCurrentUrl() { - return this.router.url; - } - /** * Submits a new active custom value to the filter from the input field * @param data The string from the input field 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 e317a27698..aefa5c145f 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 @@ -28,11 +28,7 @@ describe('SearchFilterService', () => { pageSize: 2 }); - const mockFixedFilterService: SearchFixedFilterService = { - getQueryByFilterName: (filter: string) => { - return observableOf(undefined) - } - } as SearchFixedFilterService + const mockFixedFilterService: SearchFixedFilterService = {} as SearchFixedFilterService const value1 = 'random value'; // const value2 = 'another value'; const store: Store = jasmine.createSpyObj('store', { @@ -264,20 +260,6 @@ describe('SearchFilterService', () => { }); }); - describe('when the getCurrentFixedFilter method is called', () => { - const filter = 'filter'; - - beforeEach(() => { - spyOn(routeServiceStub, 'getRouteParameterValue').and.returnValue(observableOf(filter)); - spyOn(mockFixedFilterService, 'getQueryByFilterName').and.returnValue(observableOf(filter)); - service.getCurrentFixedFilter().subscribe(); - }); - - it('should call getQueryByFilterName on the fixed-filter service with the correct filter', () => { - expect(mockFixedFilterService.getQueryByFilterName).toHaveBeenCalledWith(filter); - }); - }); - describe('when the getCurrentView method is called', () => { beforeEach(() => { spyOn(routeServiceStub, 'getQueryParameterValue'); 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 4b12417084..a453dc29bf 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,5 +1,5 @@ import { combineLatest as observableCombineLatest, Observable } from 'rxjs'; -import { mergeMap, map, distinctUntilChanged } from 'rxjs/operators'; +import { distinctUntilChanged, map, mergeMap } from 'rxjs/operators'; import { Injectable, InjectionToken } from '@angular/core'; import { SearchFiltersState, SearchFilterState } from './search-filter.reducer'; import { createSelector, MemoizedSelector, select, Store } from '@ngrx/store'; @@ -14,16 +14,12 @@ import { } from './search-filter.actions'; import { hasValue, isNotEmpty, } from '../../../shared/empty.util'; import { SearchFilterConfig } from '../../search-service/search-filter-config.model'; -import { RouteService } from '../../../shared/services/route.service'; +import { RouteService } from '../../../core/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 { SearchFixedFilterService } from './search-fixed-filter.service'; import { Params } from '@angular/router'; -import * as postcss from 'postcss'; -import prefix = postcss.vendor.prefix; -// const spy = create(); + const filterStateSelector = (state: SearchFiltersState) => state.searchFilter; export const FILTER_CONFIG: InjectionToken = new InjectionToken('filterConfig'); @@ -117,15 +113,6 @@ export class SearchFilterService { return this.routeService.getQueryParamsWithPrefix('f.'); } - /** - * Fetch the current active fixed filter from the route parameters and return the query by filter name - * @returns {Observable} - */ - getCurrentFixedFilter(): Observable { - const filter: Observable = this.routeService.getRouteParameterValue('filter'); - return filter.pipe(mergeMap((f) => this.fixedFilterService.getQueryByFilterName(f))); - } - /** * Fetch the current view from the query parameters * @returns {Observable} diff --git a/src/app/+search-page/search-filters/search-filter/search-fixed-filter.service.spec.ts b/src/app/+search-page/search-filters/search-filter/search-fixed-filter.service.spec.ts index 3f6c2ef133..591e26c8cc 100644 --- a/src/app/+search-page/search-filters/search-filter/search-fixed-filter.service.spec.ts +++ b/src/app/+search-page/search-filters/search-filter/search-fixed-filter.service.spec.ts @@ -1,48 +1,27 @@ import { SearchFixedFilterService } from './search-fixed-filter.service'; -import { RouteService } from '../../../shared/services/route.service'; import { RequestService } from '../../../core/data/request.service'; -import { HALEndpointService } from '../../../core/shared/hal-endpoint.service'; import { of as observableOf } from 'rxjs'; import { RequestEntry } from '../../../core/data/request.reducer'; -import { FilteredDiscoveryQueryResponse, RestResponse } from '../../../core/cache/response.models'; +import { FilteredDiscoveryQueryResponse } from '../../../core/cache/response.models'; describe('SearchFixedFilterService', () => { let service: SearchFixedFilterService; const filterQuery = 'filter:query'; - const routeServiceStub = {} as RouteService; const requestServiceStub = Object.assign({ /* tslint:disable:no-empty */ - configure: () => {}, + configure: () => { + }, /* tslint:enable:no-empty */ generateRequestId: () => 'fake-id', getByHref: () => observableOf(Object.assign(new RequestEntry(), { response: new FilteredDiscoveryQueryResponse(filterQuery, 200, 'OK') })) }) as RequestService; - const halServiceStub = Object.assign(new HALEndpointService(requestServiceStub, undefined), { - getEndpoint: () => observableOf('fake-url') - }); beforeEach(() => { - service = new SearchFixedFilterService(routeServiceStub, requestServiceStub, halServiceStub); - }); - - describe('when getQueryByFilterName is called with a filterName', () => { - it('should return the filter query', () => { - service.getQueryByFilterName('filter').subscribe((query) => { - expect(query).toBe(filterQuery); - }); - }); - }); - - describe('when getQueryByFilterName is called without a filterName', () => { - it('should return undefined', () => { - service.getQueryByFilterName(undefined).subscribe((query) => { - expect(query).toBeUndefined(); - }); - }); + service = new SearchFixedFilterService(); }); describe('when getQueryByRelations is called', () => { diff --git a/src/app/+search-page/search-filters/search-filter/search-fixed-filter.service.ts b/src/app/+search-page/search-filters/search-filter/search-fixed-filter.service.ts index 0f17b508c9..e2ac7e1547 100644 --- a/src/app/+search-page/search-filters/search-filter/search-fixed-filter.service.ts +++ b/src/app/+search-page/search-filters/search-filter/search-fixed-filter.service.ts @@ -1,63 +1,10 @@ import { Injectable } from '@angular/core'; -import { flatMap, map, switchMap, tap } from 'rxjs/operators'; -import { Observable, of as observableOf } from 'rxjs'; -import { HALEndpointService } from '../../../core/shared/hal-endpoint.service'; -import { GetRequest, RestRequest } from '../../../core/data/request.models'; -import { RequestService } from '../../../core/data/request.service'; -import { ResponseParsingService } from '../../../core/data/parsing.service'; -import { GenericConstructor } from '../../../core/shared/generic-constructor'; -import { FilteredDiscoveryPageResponseParsingService } from '../../../core/data/filtered-discovery-page-response-parsing.service'; -import { hasValue } from '../../../shared/empty.util'; -import { configureRequest, getResponseFromEntry } from '../../../core/shared/operators'; -import { RouteService } from '../../../shared/services/route.service'; -import { FilteredDiscoveryQueryResponse } from '../../../core/cache/response.models'; /** * Service for performing actions on the filtered-discovery-pages REST endpoint */ @Injectable() export class SearchFixedFilterService { - private queryByFilterPath = 'filtered-discovery-pages'; - - constructor(private routeService: RouteService, - protected requestService: RequestService, - private halService: HALEndpointService) { - - } - - /** - * Get the filter query for a certain filter by name - * @param {string} filterName Name of the filter - * @returns {Observable} Filter query - */ - getQueryByFilterName(filterName: string): Observable { - if (hasValue(filterName)) { - const requestUuid = this.requestService.generateRequestId(); - const requestObs = this.halService.getEndpoint(this.queryByFilterPath).pipe( - map((url: string) => { - url += ('/' + filterName); - const request = new GetRequest(requestUuid, url); - return Object.assign(request, { - getResponseParser(): GenericConstructor { - return FilteredDiscoveryPageResponseParsingService; - } - }); - }), - configureRequest(this.requestService) - ); - - const requestEntryObs = requestObs.pipe( - switchMap((request: RestRequest) => this.requestService.getByHref(request.href)), - ); - const filterQuery = requestEntryObs.pipe( - getResponseFromEntry(), - map((response: FilteredDiscoveryQueryResponse) => - response.filterQuery - )); - return filterQuery; - } - return observableOf(undefined); - } /** * Get the query for looking up items by relation type diff --git a/src/app/+search-page/search-filters/search-filter/search-hierarchy-filter/search-hierarchy-filter.component.html b/src/app/+search-page/search-filters/search-filter/search-hierarchy-filter/search-hierarchy-filter.component.html index ac2a72f4b6..06b60b5ecd 100644 --- a/src/app/+search-page/search-filters/search-filter/search-hierarchy-filter/search-hierarchy-filter.component.html +++ b/src/app/+search-page/search-filters/search-filter/search-hierarchy-filter/search-hierarchy-filter.component.html @@ -15,14 +15,14 @@ | translate}} - + > diff --git a/src/app/+search-page/search-filters/search-filter/search-range-filter/search-range-filter.component.html b/src/app/+search-page/search-filters/search-filter/search-range-filter/search-range-filter.component.html index cad31e7f0f..8c4fe2b174 100644 --- a/src/app/+search-page/search-filters/search-filter/search-range-filter/search-range-filter.component.html +++ b/src/app/+search-page/search-filters/search-filter/search-range-filter/search-range-filter.component.html @@ -1,7 +1,7 @@
+ [action]="currentUrl">
- + ngDefaultControl>
diff --git a/src/app/+search-page/search-filters/search-filters.component.html b/src/app/+search-page/search-filters/search-filters.component.html index 05f4a693c2..53bdf06907 100644 --- a/src/app/+search-page/search-filters/search-filters.component.html +++ b/src/app/+search-page/search-filters/search-filters.component.html @@ -4,4 +4,4 @@ -{{"search.filters.reset" | translate}} +{{"search.filters.reset" | translate}} 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 dc883cd290..030702d93e 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 @@ -58,7 +58,7 @@ describe('SearchFiltersComponent', () => { describe('when the getSearchLink method is called', () => { beforeEach(() => { spyOn(searchService, 'getSearchLink'); - comp.getSearchLink(); + (comp as any).getSearchLink(); }); it('should call getSearchLink on the searchService', () => { 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 e970647747..9d0dfceb15 100644 --- a/src/app/+search-page/search-filters/search-filters.component.ts +++ b/src/app/+search-page/search-filters/search-filters.component.ts @@ -37,6 +37,11 @@ export class SearchFiltersComponent implements OnInit { */ @Input() inPlaceSearch; + /** + * Link to the search page + */ + searchLink: string; + /** * Initialize instance variables * @param {SearchService} searchService @@ -60,12 +65,13 @@ export class SearchFiltersComponent implements OnInit { Object.keys(filters).forEach((f) => filters[f] = null); return filters; })); + this.searchLink = this.getSearchLink(); } /** * @returns {string} The base path to the search page, or the current page when inPlaceSearch is true */ - public getSearchLink(): string { + private getSearchLink(): string { if (this.inPlaceSearch) { return './'; } diff --git a/src/app/+search-page/search-labels/search-label/search-label.component.html b/src/app/+search-page/search-labels/search-label/search-label.component.html new file mode 100644 index 0000000000..391efcb763 --- /dev/null +++ b/src/app/+search-page/search-labels/search-label/search-label.component.html @@ -0,0 +1,6 @@ + + {{('search.filters.applied.' + key) | translate}}: {{normalizeFilterValue(value)}} + × + \ No newline at end of file diff --git a/src/app/+search-page/search-labels/search-label/search-label.component.spec.ts b/src/app/+search-page/search-labels/search-label/search-label.component.spec.ts new file mode 100644 index 0000000000..a2603b7b8b --- /dev/null +++ b/src/app/+search-page/search-labels/search-label/search-label.component.spec.ts @@ -0,0 +1,87 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { TranslateModule } from '@ngx-translate/core'; +import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { Observable, of as observableOf } from 'rxjs'; +import { Params } from '@angular/router'; +import { SearchLabelComponent } from './search-label.component'; +import { ObjectKeysPipe } from '../../../shared/utils/object-keys-pipe'; +import { SearchService } from '../../search-service/search.service'; +import { SEARCH_CONFIG_SERVICE } from '../../../+my-dspace-page/my-dspace-page.component'; +import { SearchServiceStub } from '../../../shared/testing/search-service-stub'; +import { SearchConfigurationServiceStub } from '../../../shared/testing/search-configuration-service-stub'; + +describe('SearchLabelComponent', () => { + let comp: SearchLabelComponent; + let fixture: ComponentFixture; + + const searchLink = '/search'; + let searchService; + + const key1 = 'author'; + const key2 = 'subject'; + const value1 = 'Test, Author'; + const normValue1 = 'Test, Author'; + const value2 = 'TestSubject'; + const value3 = 'Test, Authority,authority'; + const normValue3 = 'Test, Authority'; + const filter1 = [key1, value1]; + const filter2 = [key2, value2]; + const mockFilters = [ + filter1, + filter2 + ]; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot(), NoopAnimationsModule, FormsModule], + declarations: [SearchLabelComponent, ObjectKeysPipe], + providers: [ + { provide: SearchService, useValue: new SearchServiceStub(searchLink) }, + { provide: SEARCH_CONFIG_SERVICE, useValue: new SearchConfigurationServiceStub() } + // { provide: SearchConfigurationService, useValue: {getCurrentFrontendFilters : () => observableOf({})} } + ], + schemas: [NO_ERRORS_SCHEMA] + }).overrideComponent(SearchLabelComponent, { + set: { changeDetection: ChangeDetectionStrategy.Default } + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(SearchLabelComponent); + comp = fixture.componentInstance; + searchService = (comp as any).searchService; + comp.key = key1; + comp.value = value1; + (comp as any).appliedFilters = observableOf(mockFilters); + fixture.detectChanges(); + }); + + describe('when getRemoveParams is called', () => { + let obs: Observable; + + beforeEach(() => { + obs = comp.getRemoveParams(); + }); + + it('should return all params but the provided filter', () => { + obs.subscribe((params) => { + // Should contain only filter2 and page: length == 2 + expect(Object.keys(params).length).toBe(2); + }); + }) + }); + + describe('when normalizeFilterValue is called', () => { + it('should return properly filter value', () => { + let result: string; + + result = comp.normalizeFilterValue(value1); + expect(result).toBe(normValue1); + + result = comp.normalizeFilterValue(value3); + expect(result).toBe(normValue3); + }) + }); +}); diff --git a/src/app/+search-page/search-labels/search-label/search-label.component.ts b/src/app/+search-page/search-labels/search-label/search-label.component.ts new file mode 100644 index 0000000000..2f44f91a35 --- /dev/null +++ b/src/app/+search-page/search-labels/search-label/search-label.component.ts @@ -0,0 +1,75 @@ +import { Component, Input, OnInit } from '@angular/core'; +import { Observable } from 'rxjs'; +import { Params } from '@angular/router'; +import { SearchService } from '../../search-service/search.service'; +import { map } from 'rxjs/operators'; +import { hasValue, isNotEmpty } from '../../../shared/empty.util'; + +@Component({ + selector: 'ds-search-label', + templateUrl: './search-label.component.html', +}) + +/** + * Component that represents the label containing the currently active filters + */ +export class SearchLabelComponent implements OnInit { + @Input() key: string; + @Input() value: string; + @Input() inPlaceSearch: boolean; + @Input() appliedFilters: Observable; + searchLink: string; + removeParameters: Observable; + + /** + * Initialize the instance variable + */ + constructor( + private searchService: SearchService) { + } + + ngOnInit(): void { + this.searchLink = this.getSearchLink(); + this.removeParameters = this.getRemoveParams(); + } + + /** + * Calculates the parameters that should change if a given value for the given filter would be removed from the active filters + * @returns {Observable} The changed filter parameters + */ + getRemoveParams(): Observable { + return this.appliedFilters.pipe( + map((filters) => { + const field: string = Object.keys(filters).find((f) => f === this.key); + const newValues = hasValue(filters[field]) ? filters[field].filter((v) => v !== this.value) : null; + return { + [field]: isNotEmpty(newValues) ? newValues : null, + page: 1 + }; + }) + ) + } + + /** + * @returns {string} The base path to the search page, or the current page when inPlaceSearch is true + */ + private getSearchLink(): string { + if (this.inPlaceSearch) { + return './'; + } + return this.searchService.getSearchLink(); + } + + /** + * TODO to review after https://github.com/DSpace/dspace-angular/issues/368 is resolved + * Strips authority operator from filter value + * e.g. 'test ,authority' => 'test' + * + * @param value + */ + normalizeFilterValue(value: string) { + // const pattern = /,[^,]*$/g; + const pattern = /,authority*$/g; + return value.replace(pattern, ''); + } +} diff --git a/src/app/+search-page/search-labels/search-labels.component.html b/src/app/+search-page/search-labels/search-labels.component.html index cac81e8717..6a668826da 100644 --- a/src/app/+search-page/search-labels/search-labels.component.html +++ b/src/app/+search-page/search-labels/search-labels.component.html @@ -1,13 +1,7 @@ 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 d28698764c..7984805206 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,11 +6,9 @@ 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, of as observableOf } from 'rxjs'; -import { Params } from '@angular/router'; +import { of as observableOf } from 'rxjs'; import { ObjectKeysPipe } from '../../shared/utils/object-keys-pipe'; import { SEARCH_CONFIG_SERVICE } from '../../+my-dspace-page/my-dspace-page.component'; -import { SearchConfigurationServiceStub } from '../../shared/testing/search-configuration-service-stub'; describe('SearchLabelsComponent', () => { let comp: SearchLabelsComponent; @@ -22,10 +20,7 @@ describe('SearchLabelsComponent', () => { const field1 = 'author'; const field2 = 'subject'; const value1 = 'Test, Author'; - const normValue1 = 'Test, Author'; const value2 = 'TestSubject'; - const value3 = 'Test, Authority,authority'; - const normValue3 = 'Test, Authority'; const filter1 = [field1, value1]; const filter2 = [field2, value2]; const mockFilters = [ @@ -39,8 +34,7 @@ describe('SearchLabelsComponent', () => { declarations: [SearchLabelsComponent, ObjectKeysPipe], providers: [ { provide: SearchService, useValue: new SearchServiceStub(searchLink) }, - { provide: SEARCH_CONFIG_SERVICE, useValue: new SearchConfigurationServiceStub() } - // { provide: SearchConfigurationService, useValue: {getCurrentFrontendFilters : () => observableOf({})} } + { provide: SEARCH_CONFIG_SERVICE, useValue: { getCurrentFrontendFilters: () => observableOf(mockFilters) } } ], schemas: [NO_ERRORS_SCHEMA] }).overrideComponent(SearchLabelsComponent, { @@ -56,30 +50,11 @@ describe('SearchLabelsComponent', () => { fixture.detectChanges(); }); - describe('when getRemoveParams is called', () => { - let obs: Observable; - - beforeEach(() => { - obs = comp.getRemoveParams(filter1[0], filter1[1]); - }); - + describe('when the component has been initialized', () => { it('should return all params but the provided filter', () => { - obs.subscribe((params) => { - // Should contain only filter2 and page: length == 2 - expect(Object.keys(params).length).toBe(2); + comp.appliedFilters.subscribe((filters) => { + expect(filters).toBe(mockFilters); }); }) }); - - describe('when normalizeFilterValue is called', () => { - it('should return properly filter value', () => { - let result: string; - - result = comp.normalizeFilterValue(value1); - expect(result).toBe(normValue1); - - result = comp.normalizeFilterValue(value3); - expect(result).toBe(normValue3); - }) - }); }); 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 104ed5b08b..5f95525bed 100644 --- a/src/app/+search-page/search-labels/search-labels.component.ts +++ b/src/app/+search-page/search-labels/search-labels.component.ts @@ -1,4 +1,4 @@ -import { Component, Inject, Input } from '@angular/core'; +import { Component, Inject, Input, OnInit } from '@angular/core'; import { SearchService } from '../search-service/search.service'; import { Observable } from 'rxjs'; import { Params } from '@angular/router'; @@ -31,50 +31,7 @@ export class SearchLabelsComponent { * Initialize the instance variable */ constructor( - private searchService: SearchService, @Inject(SEARCH_CONFIG_SERVICE) public searchConfigService: SearchConfigurationService) { this.appliedFilters = this.searchConfigService.getCurrentFrontendFilters(); } - - /** - * Calculates the parameters that should change if a given value for the given filter would be removed from the active filters - * @param {string} filterField The filter field parameter name from which the value should be removed - * @param {string} filterValue The value that is removed for this given filter field - * @returns {Observable} The changed filter parameters - */ - getRemoveParams(filterField: string, filterValue: string): Observable { - return this.appliedFilters.pipe( - map((filters) => { - const field: string = Object.keys(filters).find((f) => f === filterField); - const newValues = hasValue(filters[field]) ? filters[field].filter((v) => v !== filterValue) : null; - return { - [field]: isNotEmpty(newValues) ? newValues : null, - page: 1 - }; - }) - ) - } - - /** - * @returns {string} The base path to the search page, or the current page when inPlaceSearch is true - */ - public getSearchLink(): string { - if (this.inPlaceSearch) { - return './'; - } - return this.searchService.getSearchLink(); - } - - /** - * TODO to review after https://github.com/DSpace/dspace-angular/issues/368 is resolved - * Strips authority operator from filter value - * e.g. 'test ,authority' => 'test' - * - * @param value - */ - normalizeFilterValue(value: string) { - // const pattern = /,[^,]*$/g; - const pattern = /,authority*$/g; - return value.replace(pattern, ''); - } } diff --git a/src/app/+search-page/search-page-routing.module.ts b/src/app/+search-page/search-page-routing.module.ts index d1ab02945e..d2c3b3be39 100644 --- a/src/app/+search-page/search-page-routing.module.ts +++ b/src/app/+search-page/search-page-routing.module.ts @@ -13,4 +13,5 @@ import { ConfigurationSearchPageComponent } from './configuration-search-page.co ]) ] }) -export class SearchPageRoutingModule { } +export class SearchPageRoutingModule { +} diff --git a/src/app/+search-page/search-page.component.html b/src/app/+search-page/search-page.component.html index ea04a2b04e..85ad8286bf 100644 --- a/src/app/+search-page/search-page.component.html +++ b/src/app/+search-page/search-page.component.html @@ -7,7 +7,7 @@ @@ -15,12 +15,12 @@
+ [@pushInOut]="(isSidebarCollapsed$ | async) ? 'collapsed' : 'expanded'"> + [ngClass]="{'active': !(isSidebarCollapsed$ | async)}">
diff --git a/src/app/+search-page/search-page.component.spec.ts b/src/app/+search-page/search-page.component.spec.ts index fe4c301bd5..d072c80628 100644 --- a/src/app/+search-page/search-page.component.spec.ts +++ b/src/app/+search-page/search-page.component.spec.ts @@ -21,7 +21,7 @@ import { SearchFilterService } from './search-filters/search-filter/search-filte import { SearchConfigurationService } from './search-service/search-configuration.service'; import { RemoteData } from '../core/data/remote-data'; import { SEARCH_CONFIG_SERVICE } from '../+my-dspace-page/my-dspace-page.component'; -import { RouteService } from '../shared/services/route.service'; +import { RouteService } from '../core/services/route.service'; import { SearchConfigurationServiceStub } from '../shared/testing/search-configuration-service-stub'; import { PaginatedSearchOptions } from './paginated-search-options.model'; import { SearchFixedFilterService } from './search-filters/search-filter/search-fixed-filter.service'; @@ -89,11 +89,7 @@ const routeServiceStub = { return observableOf('') } }; -const mockFixedFilterService: SearchFixedFilterService = { - getQueryByFilterName: (filter: string) => { - return observableOf(undefined) - } -} as SearchFixedFilterService; +const mockFixedFilterService: SearchFixedFilterService = {} as SearchFixedFilterService; export function configureSearchComponentTestingModule(compType) { TestBed.configureTestingModule({ @@ -201,7 +197,7 @@ describe('SearchPageComponent', () => { beforeEach(() => { menu = fixture.debugElement.query(By.css('#search-sidebar-sm')).nativeElement; - comp.isSidebarCollapsed = () => observableOf(true); + (comp as any).isSidebarCollapsed$ = observableOf(true); fixture.detectChanges(); }); @@ -216,7 +212,7 @@ describe('SearchPageComponent', () => { beforeEach(() => { menu = fixture.debugElement.query(By.css('#search-sidebar-sm')).nativeElement; - comp.isSidebarCollapsed = () => observableOf(false); + (comp as any).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 03433d1da1..0558065142 100644 --- a/src/app/+search-page/search-page.component.ts +++ b/src/app/+search-page/search-page.component.ts @@ -13,7 +13,7 @@ import { SearchSidebarService } from './search-sidebar/search-sidebar.service'; import { hasValue, isNotEmpty } from '../shared/empty.util'; import { SearchConfigurationService } from './search-service/search-configuration.service'; import { getSucceededRemoteData } from '../core/shared/operators'; -import { RouteService } from '../shared/services/route.service'; +import { RouteService } from '../core/services/route.service'; import { SEARCH_CONFIG_SERVICE } from '../+my-dspace-page/my-dspace-page.component'; export const SEARCH_ROUTE = '/search'; @@ -91,6 +91,16 @@ export class SearchPageComponent implements OnInit { @Input() configuration$: Observable; + /** + * Link to the search page + */ + searchLink: string; + + /** + * Observable for whether or not the sidebar is currently collapsed + */ + isSidebarCollapsed$: Observable; + constructor(protected service: SearchService, protected sidebarService: SearchSidebarService, protected windowService: HostWindowService, @@ -107,9 +117,11 @@ export class SearchPageComponent implements OnInit { * If something changes, update the list of scopes for the dropdown */ ngOnInit(): void { + this.isSidebarCollapsed$ = this.isSidebarCollapsed(); + this.searchLink = this.getSearchLink(); this.searchOptions$ = this.getSearchOptions(); this.sub = this.searchOptions$.pipe( - switchMap((options) => this.service.search(options).pipe(getSucceededRemoteData(), startWith(observableOf(undefined))))) + switchMap((options) => this.service.search(options).pipe(getSucceededRemoteData(), startWith(undefined)))) .subscribe((results) => { this.resultsRD$.next(results); }); @@ -147,14 +159,14 @@ export class SearchPageComponent implements OnInit { * Check if the sidebar is collapsed * @returns {Observable} emits true if the sidebar is currently collapsed, false if it is expanded */ - public isSidebarCollapsed(): Observable { + private isSidebarCollapsed(): Observable { return this.sidebarService.isCollapsed; } /** * @returns {string} The base path to the search page, or the current page when inPlaceSearch is true */ - public getSearchLink(): string { + private getSearchLink(): string { if (this.inPlaceSearch) { return './'; } diff --git a/src/app/+search-page/search-page.module.ts b/src/app/+search-page/search-page.module.ts index d7d66d854c..6ca449460b 100644 --- a/src/app/+search-page/search-page.module.ts +++ b/src/app/+search-page/search-page.module.ts @@ -30,6 +30,7 @@ import { SearchFacetSelectedOptionComponent } from './search-filters/search-filt import { SearchFacetRangeOptionComponent } from './search-filters/search-filter/search-facet-filter-options/search-facet-range-option/search-facet-range-option.component'; import { SearchSwitchConfigurationComponent } from './search-switch-configuration/search-switch-configuration.component'; import { SearchAuthorityFilterComponent } from './search-filters/search-filter/search-authority-filter/search-authority-filter.component'; +import { SearchLabelComponent } from './search-labels/search-label/search-label.component'; import { ConfigurationSearchPageComponent } from './configuration-search-page.component'; import { ConfigurationSearchPageGuard } from './configuration-search-page.guard'; import { FilteredSearchPageComponent } from './filtered-search-page.component'; @@ -50,6 +51,7 @@ const components = [ SearchFilterComponent, SearchFacetFilterComponent, SearchLabelsComponent, + SearchLabelComponent, SearchFacetFilterComponent, SearchFacetFilterWrapperComponent, SearchRangeFilterComponent, @@ -79,7 +81,6 @@ const components = [ SearchFilterService, SearchFixedFilterService, ConfigurationSearchPageGuard, - SearchFilterService, SearchConfigurationService ], entryComponents: [ 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 fb95ab8d04..f1aedd9fe5 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 @@ -163,12 +163,4 @@ describe('SearchConfigurationService', () => { }); }); - describe('when getCurrentFixedFilter is called', () => { - beforeEach(() => { - service.getCurrentFixedFilter(); - }); - it('should call getRouteParameterValue on the routeService with parameter name \'filter\'', () => { - expect((service as any).routeService.getRouteParameterValue).toHaveBeenCalledWith('filter'); - }); - }); }); 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 06efc16be2..5a2343b058 100644 --- a/src/app/+search-page/search-service/search-configuration.service.ts +++ b/src/app/+search-page/search-service/search-configuration.service.ts @@ -14,7 +14,7 @@ import { SortDirection, SortOptions } from '../../core/cache/models/sort-options import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model'; import { SearchOptions } from '../search-options.model'; import { PaginatedSearchOptions } from '../paginated-search-options.model'; -import { RouteService } from '../../shared/services/route.service'; +import { RouteService } from '../../core/services/route.service'; import { hasNoValue, hasValue, isNotEmpty, isNotEmptyOperator } from '../../shared/empty.util'; import { RemoteData } from '../../core/data/remote-data'; import { getSucceededRemoteData } from '../../core/shared/operators'; @@ -205,15 +205,6 @@ export class SearchConfigurationService implements OnDestroy { })); } - /** - * @returns {Observable} Emits the current fixed filter as a string - */ - getCurrentFixedFilter(): Observable { - return this.routeService.getRouteParameterValue('filter').pipe( - switchMap((f) => this.fixedFilterService.getQueryByFilterName(f)) - ); - } - /** * @returns {Observable} Emits the current active filters with their values as they are displayed in the frontend URL */ @@ -233,7 +224,6 @@ export class SearchConfigurationService implements OnDestroy { this.getQueryPart(defaults.query), this.getDSOTypePart(), this.getFiltersPart(), - this.getFixedFilterPart() ).subscribe((update) => { const currentValue: SearchOptions = this.searchOptions.getValue(); const updatedValue: SearchOptions = Object.assign(currentValue, update); @@ -255,7 +245,6 @@ export class SearchConfigurationService implements OnDestroy { this.getQueryPart(defaults.query), this.getDSOTypePart(), this.getFiltersPart(), - this.getFixedFilterPart() ).subscribe((update) => { const currentValue: PaginatedSearchOptions = this.paginatedSearchOptions.getValue(); const updatedValue: PaginatedSearchOptions = Object.assign(currentValue, update); @@ -352,16 +341,4 @@ export class SearchConfigurationService implements OnDestroy { return { filters } })); } - - /** - * @returns {Observable} Emits the current fixed filter as a partial SearchOptions object - */ - private getFixedFilterPart(): Observable { - return this.getCurrentFixedFilter().pipe( - isNotEmptyOperator(), - map((fixedFilter) => { - return { fixedFilter } - }), - ); - } } 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 4ae9876159..798670a0e0 100644 --- a/src/app/+search-page/search-service/search.service.spec.ts +++ b/src/app/+search-page/search-service/search.service.spec.ts @@ -26,7 +26,7 @@ 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 { map } from 'rxjs/operators'; -import { RouteService } from '../../shared/services/route.service'; +import { RouteService } from '../../core/services/route.service'; import { routeServiceStub } from '../../shared/testing/route-service-stub'; import { createSuccessfulRemoteDataObject$ } from '../../shared/testing/utils'; diff --git a/src/app/+search-page/search-service/search.service.ts b/src/app/+search-page/search-service/search.service.ts index 86611cc87b..e2b24200df 100644 --- a/src/app/+search-page/search-service/search.service.ts +++ b/src/app/+search-page/search-service/search.service.ts @@ -1,7 +1,7 @@ -import { combineLatest as observableCombineLatest, Observable, of as observableOf } from 'rxjs'; +import { combineLatest as observableCombineLatest, Observable, of as observableOf, zip as observableZip } from 'rxjs'; import { Injectable, OnDestroy } from '@angular/core'; -import { NavigationExtras, Router } from '@angular/router'; -import { first, map, switchMap } from 'rxjs/operators'; +import { NavigationExtras, PRIMARY_OUTLET, Router, UrlSegmentGroup } from '@angular/router'; +import { first, map, switchMap, tap } from 'rxjs/operators'; import { RemoteDataBuildService } from '../../core/cache/builders/remote-data-build.service'; import { FacetConfigSuccessResponse, @@ -23,7 +23,7 @@ import { getSucceededRemoteData } from '../../core/shared/operators'; import { URLCombiner } from '../../core/url-combiner/url-combiner'; -import { hasValue, isEmpty, isNotEmpty, isNotUndefined } from '../../shared/empty.util'; +import { hasValue, hasValueOperator, isEmpty, isNotEmpty, isNotUndefined } from '../../shared/empty.util'; import { NormalizedSearchResult } from '../normalized-search-result.model'; import { SearchOptions } from '../search-options.model'; import { SearchResult } from '../search-result.model'; @@ -41,7 +41,7 @@ import { Community } from '../../core/shared/community.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 { RouteService } from '../../shared/services/route.service'; +import { RouteService } from '../../core/services/route.service'; /** * Service that performs all general actions that have to do with the search page @@ -103,11 +103,18 @@ export class SearchService implements OnDestroy { * @returns {Observable>>>} Emits a paginated list with all search results found */ search(searchOptions?: PaginatedSearchOptions): Observable>>> { - const requestObs = this.halService.getEndpoint(this.searchLinkPath).pipe( + const hrefObs = this.halService.getEndpoint(this.searchLinkPath).pipe( map((url: string) => { if (hasValue(searchOptions)) { - url = (searchOptions as PaginatedSearchOptions).toRestUrl(url); + return (searchOptions as PaginatedSearchOptions).toRestUrl(url); + } else { + return url; } + }) + ); + + const requestObs = hrefObs.pipe( + map((url: string) => { const request = new this.request(this.requestService.generateRequestId(), url); const getResponseParserFn: () => GenericConstructor = () => { @@ -136,10 +143,11 @@ export class SearchService implements OnDestroy { map((sqr: SearchQueryResponse) => { return sqr.objects .filter((nsr: NormalizedSearchResult) => isNotUndefined(nsr.indexableObject)) - .map((nsr: NormalizedSearchResult) => { - return this.rdb.buildSingle(nsr.indexableObject); - }) + .map((nsr: NormalizedSearchResult) => new GetRequest(this.requestService.generateRequestId(), nsr.indexableObject)) }), + // Send a request for each item to ensure fresh cache + tap((reqs: RestRequest[]) => reqs.forEach((req: RestRequest) => this.requestService.configure(req))), + map((reqs: RestRequest[]) => reqs.map((req: RestRequest) => this.rdb.buildSingle(req.href))), switchMap((input: Array>>) => this.rdb.aggregate(input)), ); @@ -168,11 +176,20 @@ export class SearchService implements OnDestroy { const payloadObs = observableCombineLatest(tDomainListObs, pageInfoObs).pipe( map(([tDomainList, pageInfo]) => { - return new PaginatedList(pageInfo, tDomainList); + return new PaginatedList(pageInfo, tDomainList.filter((obj) => hasValue(obj))); }) ); - return this.rdb.toRemoteDataObservable(requestEntryObs, payloadObs); + return observableCombineLatest(hrefObs, tDomainListObs, requestEntryObs).pipe( + switchMap(([href, tDomainList, requestEntry]) => { + if (tDomainList.indexOf(undefined) > -1 && requestEntry && requestEntry.completed) { + this.requestService.removeByHrefSubstring(href); + return this.search(searchOptions) + } else { + return this.rdb.toRemoteDataObservable(requestEntryObs, payloadObs); + } + }) + ); } /** diff --git a/src/app/app.component.spec.ts b/src/app/app.component.spec.ts index bd2d832c67..5b78e3462f 100644 --- a/src/app/app.component.spec.ts +++ b/src/app/app.component.spec.ts @@ -26,7 +26,7 @@ import { HostWindowResizeAction } from './shared/host-window.actions'; import { MetadataService } from './core/metadata/metadata.service'; import { GLOBAL_CONFIG, ENV_CONFIG } from '../config'; -import { NativeWindowRef, NativeWindowService } from './shared/services/window.service'; +import { NativeWindowRef, NativeWindowService } from './core/services/window.service'; import { MockTranslateLoader } from './shared/mocks/mock-translate-loader'; import { MockMetadataService } from './shared/mocks/mock-metadata-service'; @@ -41,9 +41,11 @@ import { MenuServiceStub } from './shared/testing/menu-service-stub'; import { HostWindowService } from './shared/host-window.service'; import { HostWindowServiceStub } from './shared/testing/host-window-service-stub'; import { ActivatedRoute, Router } from '@angular/router'; -import { RouteService } from './shared/services/route.service'; +import { RouteService } from './core/services/route.service'; import { MockActivatedRoute } from './shared/mocks/mock-active-router'; import { MockRouter } from './shared/mocks/mock-router'; +import { MockCookieService } from './shared/mocks/mock-cookie.service'; +import { CookieService } from './core/services/cookie.service'; let comp: AppComponent; let fixture: ComponentFixture; @@ -78,6 +80,7 @@ describe('App component', () => { { provide: MenuService, useValue: menuService }, { provide: CSSVariableService, useClass: CSSVariableServiceStub }, { provide: HostWindowService, useValue: new HostWindowServiceStub(800) }, + { provide: CookieService, useValue: new MockCookieService()}, AppComponent, RouteService ], diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 52c169e7bc..50baaf6e57 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -19,11 +19,11 @@ import { GLOBAL_CONFIG, GlobalConfig } from '../config'; import { MetadataService } from './core/metadata/metadata.service'; import { HostWindowResizeAction } from './shared/host-window.actions'; import { HostWindowState } from './shared/host-window.reducer'; -import { NativeWindowRef, NativeWindowService } from './shared/services/window.service'; +import { NativeWindowRef, NativeWindowService } from './core/services/window.service'; import { isAuthenticated } from './core/auth/selectors'; import { AuthService } from './core/auth/auth.service'; import { Angulartics2GoogleAnalytics } from 'angulartics2/ga'; -import { RouteService } from './shared/services/route.service'; +import { RouteService } from './core/services/route.service'; import variables from '../styles/_exposed_variables.scss'; import { CSSVariableService } from './shared/sass-helper/sass-helper.service'; import { MenuService } from './shared/menu/menu.service'; @@ -32,6 +32,10 @@ import { combineLatest as combineLatestObservable, Observable, of } from 'rxjs'; import { slideSidebarPadding } from './shared/animations/slide'; import { HostWindowService } from './shared/host-window.service'; import { Theme } from '../config/theme.inferface'; +import { isNotEmpty } from './shared/empty.util'; +import { CookieService } from './core/services/cookie.service'; + +export const LANG_COOKIE = 'language_cookie'; @Component({ selector: 'ds-app', @@ -61,6 +65,7 @@ export class AppComponent implements OnInit, AfterViewInit { private cssService: CSSVariableService, private menuService: MenuService, private windowService: HostWindowService, + private cookie: CookieService ) { // Load all the languages that are defined as active from the config file translate.addLangs(config.languages.filter((LangConfig) => LangConfig.active === true).map((a) => a.code)); @@ -68,11 +73,20 @@ export class AppComponent implements OnInit, AfterViewInit { // Load the default language from the config file translate.setDefaultLang(config.defaultLanguage); - // Attempt to get the browser language from the user - if (translate.getLangs().includes(translate.getBrowserLang())) { - translate.use(translate.getBrowserLang()); + // Attempt to get the language from a cookie + const lang = cookie.get(LANG_COOKIE); + if (isNotEmpty(lang)) { + // Cookie found + // Use the language from the cookie + translate.use(lang); } else { - translate.use(config.defaultLanguage); + // Cookie not found + // Attempt to get the browser language from the user + if (translate.getLangs().includes(translate.getBrowserLang())) { + translate.use(translate.getBrowserLang()); + } else { + translate.use(config.defaultLanguage); + } } metadata.listenForRouteChange(); diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 46da97e8f6..4967379b03 100755 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -39,6 +39,7 @@ import { ExpandableAdminSidebarSectionComponent } from './+admin/admin-sidebar/e import { NavbarModule } from './navbar/navbar.module'; import { JournalEntitiesModule } from './entity-groups/journal-entities/journal-entities.module'; import { ResearchEntitiesModule } from './entity-groups/research-entities/research-entities.module'; +import { ClientCookieService } from './core/services/client-cookie.service'; import { ShibbolethTargetPageComponent } from './+login-page/shibbolethTargetPage/shibboleth-target-page.component'; export function getConfig() { @@ -77,7 +78,6 @@ const ENTITY_IMPORTS = [ IMPORTS.push( StoreDevtoolsModule.instrument({ maxAge: 100, - // logOnly: false, logOnly: ENV_CONFIG.production, }) ); @@ -99,7 +99,8 @@ const PROVIDERS = [ { provide: RouterStateSerializer, useClass: DSpaceRouterStateSerializer - } + }, + ClientCookieService ]; const DECLARATIONS = [ diff --git a/src/app/core/auth/auth.service.spec.ts b/src/app/core/auth/auth.service.spec.ts index 5d55e15adc..5084dc8596 100644 --- a/src/app/core/auth/auth.service.spec.ts +++ b/src/app/core/auth/auth.service.spec.ts @@ -7,12 +7,12 @@ import { REQUEST } from '@nguniversal/express-engine/tokens'; import { of as observableOf } from 'rxjs'; import { authReducer, AuthState } from './auth.reducer'; -import { NativeWindowRef, NativeWindowService } from '../../shared/services/window.service'; +import { NativeWindowRef, NativeWindowService } from '../services/window.service'; import { AuthService } from './auth.service'; import { RouterStub } from '../../shared/testing/router-stub'; import { ActivatedRouteStub } from '../../shared/testing/active-router-stub'; -import { CookieService } from '../../shared/services/cookie.service'; +import { CookieService } from '../services/cookie.service'; import { AuthRequestServiceStub } from '../../shared/testing/auth-request-service-stub'; import { AuthRequestService } from './auth-request.service'; import { AuthStatus } from './models/auth-status.model'; @@ -20,16 +20,17 @@ import { AuthTokenInfo } from './models/auth-token-info.model'; 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 { ClientCookieService } from '../services/client-cookie.service'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { getMockRemoteDataBuildService } from '../../shared/mocks/mock-remote-data-build.service'; -import {GlobalConfig} from '../../../config/global-config.interface'; -import {GLOBAL_CONFIG} from '../../../config'; +import { routeServiceStub } from '../../shared/testing/route-service-stub'; +import { RouteService } from '../services/route.service'; describe('AuthService test', () => { let mockStore: Store; let authService: AuthService; + let routeServiceMock: RouteService; let authRequest; let window; let routerStub; @@ -76,6 +77,7 @@ describe('AuthService test', () => { { provide: NativeWindowService, useValue: window }, { provide: REQUEST, useValue: {} }, { provide: Router, useValue: routerStub }, + { provide: RouteService, useValue: routeServiceStub }, { provide: ActivatedRoute, useValue: routeStub }, { provide: Store, useValue: mockStore }, { provide: RemoteDataBuildService, useValue: rdbService }, @@ -140,19 +142,21 @@ describe('AuthService test', () => { { provide: AuthRequestService, useValue: authRequest }, { provide: REQUEST, useValue: {} }, { provide: Router, useValue: routerStub }, - CookieService, - AuthService, + { provide: RouteService, useValue: routeServiceStub }, + { provide: RemoteDataBuildService, useValue: rdbService }, + CookieService, + AuthService ] }).compileComponents(); })); - beforeEach(inject([CookieService, AuthRequestService, Store, Router], (cookieService: CookieService, authReqService: AuthRequestService, store: Store, router: Router) => { + beforeEach(inject([CookieService, AuthRequestService, Store, Router, RouteService], (cookieService: CookieService, authReqService: AuthRequestService, store: Store, router: Router, routeService: RouteService) => { store .subscribe((state) => { (state as any).core = Object.create({}); (state as any).core.auth = authenticatedState; }); - authService = new AuthService(undefined, {}, window, undefined, authReqService, router, cookieService, store, rdbService); + authService = new AuthService({}, window, undefined, authReqService, router, routeService, cookieService, store, rdbService); })); it('should return true when user is logged in', () => { @@ -190,6 +194,7 @@ describe('AuthService test', () => { { provide: AuthRequestService, useValue: authRequest }, { provide: REQUEST, useValue: {} }, { provide: Router, useValue: routerStub }, + { provide: RouteService, useValue: routeServiceStub }, { provide: RemoteDataBuildService, useValue: rdbService }, ClientCookieService, CookieService, @@ -198,7 +203,7 @@ describe('AuthService test', () => { }).compileComponents(); })); - beforeEach(inject([ClientCookieService, AuthRequestService, Store, Router], (cookieService: ClientCookieService, authReqService: AuthRequestService, store: Store, router: Router) => { + beforeEach(inject([ClientCookieService, AuthRequestService, Store, Router, RouteService], (cookieService: ClientCookieService, authReqService: AuthRequestService, store: Store, router: Router, routeService: RouteService) => { const expiredToken: AuthTokenInfo = new AuthTokenInfo('test_token'); expiredToken.expires = Date.now() - (1000 * 60 * 60); authenticatedState = { @@ -213,11 +218,14 @@ describe('AuthService test', () => { (state as any).core = Object.create({}); (state as any).core.auth = authenticatedState; }); - authService = new AuthService(undefined, {}, window, undefined, authReqService, router, cookieService, store, rdbService); + authService = new AuthService({}, window, undefined, authReqService, router, routeService, cookieService, store, rdbService); storage = (authService as any).storage; + routeServiceMock = TestBed.get(RouteService); + routerStub = TestBed.get(Router); spyOn(storage, 'get'); spyOn(storage, 'remove'); spyOn(storage, 'set'); + })); it('should throw false when token is not valid', () => { @@ -239,5 +247,32 @@ describe('AuthService test', () => { expect(storage.remove).toHaveBeenCalled(); }); + it ('should set redirect url to previous page', () => { + spyOn(routeServiceMock, 'getHistory').and.callThrough(); + authService.redirectAfterLoginSuccess(true); + expect(routeServiceMock.getHistory).toHaveBeenCalled(); + expect(routerStub.navigate).toHaveBeenCalledWith(['/collection/123']); + }); + + it ('should set redirect url to current page', () => { + spyOn(routeServiceMock, 'getHistory').and.callThrough(); + authService.redirectAfterLoginSuccess(false); + expect(routeServiceMock.getHistory).toHaveBeenCalled(); + expect(routerStub.navigate).toHaveBeenCalledWith(['/home']); + }); + + it ('should redirect to / and not to /login', () => { + spyOn(routeServiceMock, 'getHistory').and.returnValue(observableOf(['/login', '/login'])); + authService.redirectAfterLoginSuccess(true); + expect(routeServiceMock.getHistory).toHaveBeenCalled(); + expect(routerStub.navigate).toHaveBeenCalledWith(['/']); + }); + + it ('should redirect to / when no redirect url is found', () => { + spyOn(routeServiceMock, 'getHistory').and.returnValue(observableOf([''])); + authService.redirectAfterLoginSuccess(true); + expect(routeServiceMock.getHistory).toHaveBeenCalled(); + expect(routerStub.navigate).toHaveBeenCalledWith(['/']); + }); }); }); diff --git a/src/app/core/auth/auth.service.ts b/src/app/core/auth/auth.service.ts index e93c39e7ed..24b4d82ed4 100644 --- a/src/app/core/auth/auth.service.ts +++ b/src/app/core/auth/auth.service.ts @@ -1,27 +1,28 @@ -import {Inject, Injectable, Optional} from '@angular/core'; -import {PRIMARY_OUTLET, Router, UrlSegmentGroup, UrlTree} from '@angular/router'; -import {HttpHeaders} from '@angular/common/http'; -import {REQUEST, RESPONSE} from '@nguniversal/express-engine/tokens'; +import { Inject, Injectable, Optional } from '@angular/core'; +import { PRIMARY_OUTLET, Router, UrlSegmentGroup, UrlTree } from '@angular/router'; +import { HttpHeaders } from '@angular/common/http'; +import { REQUEST, RESPONSE } from '@nguniversal/express-engine/tokens'; -import {Observable, of as observableOf} from 'rxjs'; -import {distinctUntilChanged, filter, map, startWith, switchMap, take, withLatestFrom} from 'rxjs/operators'; -import {RouterReducerState} from '@ngrx/router-store'; -import {select, Store} from '@ngrx/store'; -import {CookieAttributes} from 'js-cookie'; +import { Observable, of as observableOf } from 'rxjs'; +import { distinctUntilChanged, filter, map, startWith, switchMap, take, withLatestFrom } from 'rxjs/operators'; +import { RouterReducerState } from '@ngrx/router-store'; +import { select, Store } from '@ngrx/store'; +import { CookieAttributes } from 'js-cookie'; -import {EPerson} from '../eperson/models/eperson.model'; -import {AuthRequestService} from './auth-request.service'; -import {HttpOptions} from '../dspace-rest-v2/dspace-rest-v2.service'; -import {AuthStatus} from './models/auth-status.model'; -import {AuthTokenInfo, TOKENITEM} from './models/auth-token-info.model'; -import {isEmpty, isNotEmpty, isNotNull, isNotUndefined} from '../../shared/empty.util'; -import {CookieService} from '../../shared/services/cookie.service'; -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 { EPerson } from '../eperson/models/eperson.model'; +import { AuthRequestService } from './auth-request.service'; +import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service'; +import { AuthStatus } from './models/auth-status.model'; +import { AuthTokenInfo, TOKENITEM } from './models/auth-token-info.model'; +import { isEmpty, isNotEmpty, isNotNull, isNotUndefined } from '../../shared/empty.util'; +import { CookieService } from '../services/cookie.service'; +import { getAuthenticationToken, getRedirectUrl, isAuthenticated, isTokenRefreshing } from './selectors'; +import { AppState, routerStateSelector } from '../../app.reducer'; +import { ResetAuthenticationMessagesAction, SetRedirectUrlAction } from './auth.actions'; +import { NativeWindowRef, NativeWindowService } from '../services/window.service'; +import { Base64EncodeUrl } from '../../shared/utils/encode-decode.util'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import {RouteService} from '../services/route.service'; import {GlobalConfig} from '../../../config/global-config.interface'; import {GLOBAL_CONFIG} from '../../../config'; import {AuthMethodModel} from './models/auth-method.model'; @@ -43,16 +44,15 @@ export class AuthService { */ protected _authenticated: boolean; - constructor( - @Inject(GLOBAL_CONFIG) public config: GlobalConfig, - @Inject(REQUEST) protected req: any, - @Inject(NativeWindowService) protected _window: NativeWindowRef, - protected authRequestService: AuthRequestService, - @Optional() @Inject(RESPONSE) private response: any, - protected router: Router, - protected storage: CookieService, - protected store: Store, - protected rdbService: RemoteDataBuildService + constructor(@Inject(REQUEST) protected req: any, + @Inject(NativeWindowService) protected _window: NativeWindowRef, + protected authRequestService: AuthRequestService, + @Optional() @Inject(RESPONSE) private response: any, + protected router: Router, + protected routeService: RouteService, + protected storage: CookieService, + protected store: Store, + protected rdbService: RemoteDataBuildService ) { this.store.pipe( select(isAuthenticated), @@ -115,19 +115,6 @@ export class AuthService { } - shibbolethAuthenticate(location: string) { - // implement a call to the backend here - console.log('location: ', location); - /*return this.authRequestService.postToEndpoint('login').pipe( - map((status: AuthStatus) => { - if (status.authenticated) { - return status; - } else { - throw(new Error('Shibboleth login failed')); - } - }))*/ - } - public startShibbAuth(): Observable { return this.authRequestService.postToEndpoint('login').pipe( @@ -260,7 +247,7 @@ 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'}); + const options: HttpOptions = Object.create({ headers, responseType: 'text' }); return this.authRequestService.getRequest('logout', options).pipe( map((status: AuthStatus) => { if (!status.authenticated) { @@ -336,7 +323,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); @@ -384,7 +371,7 @@ export class AuthService { /** * Redirect to the route navigated before the login */ - public redirectToPreviousUrl() { + public redirectAfterLoginSuccess(isStandalonePage: boolean) { this.getRedirectUrl().pipe( take(1)) .subscribe((redirectUrl) => { @@ -393,18 +380,39 @@ export class AuthService { this.clearRedirectUrl(); this.router.onSameUrlNavigation = 'reload'; 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; + this.navigateToRedirectUrl(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 = '/'; + // If redirectUrl is empty use history. + this.routeService.getHistory().pipe( + take(1) + ).subscribe((history) => { + let redirUrl; + if (isStandalonePage) { + // For standalone login pages, use the previous route. + redirUrl = history[history.length - 2] || ''; + } else { + redirUrl = history[history.length - 1] || ''; + } + this.navigateToRedirectUrl(redirUrl); + }); } - }) + }); } + protected navigateToRedirectUrl(url: string) { + // in case the user navigates directly to /login (via bookmark, etc), or the route history is not found. + if (isEmpty(url) || url.startsWith(LOGIN_ROUTE)) { + this.router.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 = '/'; + } else { + /* TODO Reenable hard redirect when REST API can handle x-forwarded-for, see https://github.com/DSpace/DSpace/pull/2207 */ + // this._window.nativeWindow.location.href = url; + this.router.navigate([url]); + } + } + /** * Refresh route navigated */ @@ -435,7 +443,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/server-auth.service.ts b/src/app/core/auth/server-auth.service.ts index c344683e38..cf4d4a658e 100644 --- a/src/app/core/auth/server-auth.service.ts +++ b/src/app/core/auth/server-auth.service.ts @@ -1,12 +1,12 @@ -import { map, switchMap, take } from 'rxjs/operators'; +import { filter, map, switchMap, take } from 'rxjs/operators'; import { Injectable } from '@angular/core'; 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'; -import { isNotEmpty } from '../../shared/empty.util'; -import { AuthService } from './auth.service'; +import { isEmpty, isNotEmpty } from '../../shared/empty.util'; +import { AuthService, LOGIN_ROUTE } from './auth.service'; import { AuthTokenInfo } from './models/auth-token-info.model'; import { CheckAuthenticationTokenAction } from './auth.actions'; import { EPerson } from '../eperson/models/eperson.model'; @@ -54,7 +54,7 @@ export class ServerAuthService extends AuthService { /** * Redirect to the route navigated before the login */ - public redirectToPreviousUrl() { + public redirectAfterLoginSuccess(isStandalonePage: boolean) { this.getRedirectUrl().pipe( take(1)) .subscribe((redirectUrl) => { @@ -67,10 +67,15 @@ export class ServerAuthService extends AuthService { const url = decodeURIComponent(redirectUrl); this.router.navigateByUrl(url); } else { - this.router.navigate(['/']); + // If redirectUrl is empty use history. For ssr the history array should contain the requested url. + this.routeService.getHistory().pipe( + filter((history) => history.length > 0), + take(1) + ).subscribe((history) => { + this.navigateToRedirectUrl(history[history.length - 1] || ''); + }); } }) - } } diff --git a/src/app/core/cache/models/items/normalized-relationship-type.model.ts b/src/app/core/cache/models/items/normalized-relationship-type.model.ts index 800b27cd7e..23c3333a9b 100644 --- a/src/app/core/cache/models/items/normalized-relationship-type.model.ts +++ b/src/app/core/cache/models/items/normalized-relationship-type.model.ts @@ -23,7 +23,7 @@ export class NormalizedRelationshipType extends NormalizedObject { + return this.store.pipe( + select(entryFromSelfLinkSelector(selfLink)), + map((entry: ObjectCacheEntry) => this.isValid(entry)) + ); + } + /** * Check whether an ObjectCacheEntry should still be cached * diff --git a/src/app/core/core.effects.ts b/src/app/core/core.effects.ts index 1aa5d2d0b1..0eabfc5dc8 100644 --- a/src/app/core/core.effects.ts +++ b/src/app/core/core.effects.ts @@ -5,7 +5,7 @@ import { AuthEffects } from './auth/auth.effects'; import { JsonPatchOperationsEffects } from './json-patch/json-patch-operations.effects'; import { ServerSyncBufferEffects } from './cache/server-sync-buffer.effects'; import { ObjectUpdatesEffects } from './data/object-updates/object-updates.effects'; -import { RouteEffects } from '../shared/services/route.effects'; +import { RouteEffects } from './services/route.effects'; export const coreEffects = [ RequestEffects, diff --git a/src/app/core/core.module.ts b/src/app/core/core.module.ts index 60d1bce3b8..f4d4dcb269 100644 --- a/src/app/core/core.module.ts +++ b/src/app/core/core.module.ts @@ -14,7 +14,7 @@ import { coreReducers } from './core.reducers'; import { isNotEmpty } from '../shared/empty.util'; -import { ApiService } from '../shared/services/api.service'; +import { ApiService } from './services/api.service'; import { BrowseEntriesResponseParsingService } from './data/browse-entries-response-parsing.service'; import { CollectionDataService } from './data/collection-data.service'; import { CommunityDataService } from './data/community-data.service'; @@ -34,12 +34,12 @@ import { PaginationComponentOptions } from '../shared/pagination/pagination-comp import { RemoteDataBuildService } from './cache/builders/remote-data-build.service'; import { RequestService } from './data/request.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'; +import { ServerResponseService } from './services/server-response.service'; +import { NativeWindowFactory, NativeWindowService } from './services/window.service'; import { BrowseService } from './browse/browse.service'; import { BrowseResponseParsingService } from './data/browse-response-parsing.service'; import { ConfigResponseParsingService } from './config/config-response-parsing.service'; -import { RouteService } from '../shared/services/route.service'; +import { RouteService } from './services/route.service'; import { SubmissionDefinitionsConfigService } from './config/submission-definitions-config.service'; import { SubmissionFormsConfigService } from './config/submission-forms-config.service'; import { SubmissionSectionsConfigService } from './config/submission-sections-config.service'; @@ -101,6 +101,7 @@ import { NormalizedSubmissionFormsModel } from './config/models/normalized-confi import { NormalizedSubmissionSectionModel } from './config/models/normalized-config-submission-section.model'; import { NormalizedAuthStatus } from './auth/models/normalized-auth-status.model'; import { NormalizedAuthorityValue } from './integration/models/normalized-authority-value.model'; +import { RelationshipService } from './data/relationship.service'; import { RoleService } from './roles/role.service'; import { MyDSpaceGuard } from '../+my-dspace-page/my-dspace.guard'; import { MyDSpaceResponseParsingService } from './data/mydspace-response-parsing.service'; @@ -200,6 +201,7 @@ const PROVIDERS = [ MenuService, ObjectUpdatesService, SearchService, + RelationshipService, MyDSpaceGuard, RoleService, TaskResponseParsingService, diff --git a/src/app/core/core.reducers.ts b/src/app/core/core.reducers.ts index 7aecb91a7a..4fcf36f9cc 100644 --- a/src/app/core/core.reducers.ts +++ b/src/app/core/core.reducers.ts @@ -13,7 +13,7 @@ import { objectUpdatesReducer, ObjectUpdatesState } from './data/object-updates/object-updates.reducer'; -import { routeReducer, RouteState } from '../shared/services/route.reducer'; +import { routeReducer, RouteState } from './services/route.reducer'; export interface CoreState { 'cache/object': ObjectCacheState, diff --git a/src/app/core/data/collection-data.service.ts b/src/app/core/data/collection-data.service.ts index 993954a360..04e483604c 100644 --- a/src/app/core/data/collection-data.service.ts +++ b/src/app/core/data/collection-data.service.ts @@ -19,6 +19,7 @@ import { Observable } from 'rxjs/internal/Observable'; import { FindAllOptions } from './request.models'; import { RemoteData } from './remote-data'; import { PaginatedList } from './paginated-list'; +import { SearchParam } from '../cache/models/search-param.model'; @Injectable() export class CollectionDataService extends ComColDataService { @@ -40,6 +41,36 @@ export class CollectionDataService extends ComColDataService { super(); } + /** + * Get all collections the user is authorized to submit to + * + * @param options The [[FindAllOptions]] object + * @return Observable>> + * collection list + */ + getAuthorizedCollection(options: FindAllOptions = {}): Observable>> { + const searchHref = 'findAuthorized'; + + return this.searchBy(searchHref, options).pipe( + filter((collections: RemoteData>) => !collections.isResponsePending)); + } + + /** + * Get all collections the user is authorized to submit to, by community + * + * @param communityId The community id + * @param options The [[FindAllOptions]] object + * @return Observable>> + * collection list + */ + getAuthorizedCollectionByCommunity(communityId: string, options: FindAllOptions = {}): Observable>> { + const searchHref = 'findAuthorizedByCommunity'; + options.searchParams = [new SearchParam('uuid', communityId)]; + + return this.searchBy(searchHref, options).pipe( + filter((collections: RemoteData>) => !collections.isResponsePending)); + } + /** * Find whether there is a collection whom user has authorization to submit to * diff --git a/src/app/core/data/item-data.service.ts b/src/app/core/data/item-data.service.ts index f6adbb23c2..07d8ed8405 100644 --- a/src/app/core/data/item-data.service.ts +++ b/src/app/core/data/item-data.service.ts @@ -1,8 +1,8 @@ -import { distinctUntilChanged, filter, map } from 'rxjs/operators'; +import { distinctUntilChanged, filter, find, map } from 'rxjs/operators'; import { Injectable } from '@angular/core'; import { Store } from '@ngrx/store'; import { Observable } from 'rxjs'; -import { isNotEmpty } from '../../shared/empty.util'; +import { hasValue, isNotEmpty } from '../../shared/empty.util'; import { BrowseService } from '../browse/browse.service'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { CoreState } from '../core.reducers'; @@ -12,14 +12,17 @@ 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, PatchRequest, RestRequest } from './request.models'; +import { FindAllOptions, PatchRequest, PutRequest, RestRequest } from './request.models'; import { ObjectCacheService } from '../cache/object-cache.service'; import { NotificationsService } from '../../shared/notifications/notifications.service'; import { DSOChangeAnalyzer } from './dso-change-analyzer.service'; -import { HttpClient } from '@angular/common/http'; +import { HttpClient, HttpHeaders } from '@angular/common/http'; import { NormalizedObjectBuildService } from '../cache/builders/normalized-object-build.service'; import { configureRequest, getRequestFromRequestHref } from '../shared/operators'; import { RequestEntry } from './request.reducer'; +import { RestResponse } from '../cache/response.models'; +import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service'; +import { Collection } from '../shared/collection.model'; @Injectable() export class ItemDataService extends DataService { @@ -118,4 +121,43 @@ export class ItemDataService extends DataService { map((requestEntry: RequestEntry) => requestEntry.response) ); } + + /** + * Get the endpoint to move the item + * @param itemId + */ + public getMoveItemEndpoint(itemId: string): Observable { + return this.halService.getEndpoint(this.linkPath).pipe( + map((endpoint: string) => this.getIDHref(endpoint, itemId)), + map((endpoint: string) => `${endpoint}/owningCollection`) + ); + } + + /** + * Move the item to a different owning collection + * @param itemId + * @param collection + */ + public moveToCollection(itemId: string, collection: Collection): Observable { + const options: HttpOptions = Object.create({}); + let headers = new HttpHeaders(); + headers = headers.append('Content-Type', 'text/uri-list'); + options.headers = headers; + + const requestId = this.requestService.generateRequestId(); + const hrefObs = this.getMoveItemEndpoint(itemId); + + hrefObs.pipe( + find((href: string) => hasValue(href)), + map((href: string) => { + const request = new PutRequest(requestId, href, collection.self, options); + this.requestService.configure(request); + }) + ).subscribe(); + + return this.requestService.getByUUID(requestId).pipe( + find((request: RequestEntry) => request.completed), + map((request: RequestEntry) => request.response) + ); + } } diff --git a/src/app/core/data/object-updates/object-updates.service.ts b/src/app/core/data/object-updates/object-updates.service.ts index 22d5fd3e77..08745f9223 100644 --- a/src/app/core/data/object-updates/object-updates.service.ts +++ b/src/app/core/data/object-updates/object-updates.service.ts @@ -105,6 +105,27 @@ export class ObjectUpdatesService { })) } + /** + * Method that combines the state's updates (excluding updates that aren't part of the initialFields) with + * the initial values (when there's no update) to create a FieldUpdates object + * @param url The URL of the page for which the FieldUpdates should be requested + * @param initialFields The initial values of the fields + */ + getFieldUpdatesExclusive(url: string, initialFields: Identifiable[]): Observable { + const objectUpdates = this.getObjectEntry(url); + return objectUpdates.pipe(map((objectEntry) => { + const fieldUpdates: FieldUpdates = {}; + for (const object of initialFields) { + let fieldUpdate = objectEntry.fieldUpdates[object.uuid]; + if (isEmpty(fieldUpdate)) { + fieldUpdate = { field: object, changeType: undefined }; + } + fieldUpdates[object.uuid] = fieldUpdate; + } + return fieldUpdates; + })) + } + /** * Method to check if a specific field is currently editable in the store * @param url The URL of the page on which the field resides diff --git a/src/app/core/data/relationship.service.spec.ts b/src/app/core/data/relationship.service.spec.ts new file mode 100644 index 0000000000..31513bb779 --- /dev/null +++ b/src/app/core/data/relationship.service.spec.ts @@ -0,0 +1,156 @@ +import { RelationshipService } from './relationship.service'; +import { RequestService } from './request.service'; +import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service-stub'; +import { getMockRemoteDataBuildService } from '../../shared/mocks/mock-remote-data-build.service'; +import { of as observableOf } from 'rxjs/internal/observable/of'; +import { RequestEntry } from './request.reducer'; +import { RelationshipType } from '../shared/item-relationships/relationship-type.model'; +import { Relationship } from '../shared/item-relationships/relationship.model'; +import { RemoteData } from './remote-data'; +import { getMockRequestService } from '../../shared/mocks/mock-request.service'; +import { Item } from '../shared/item.model'; +import { PaginatedList } from './paginated-list'; +import { PageInfo } from '../shared/page-info.model'; +import { DeleteRequest } from './request.models'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { Observable } from 'rxjs/internal/Observable'; + +describe('RelationshipService', () => { + let service: RelationshipService; + let requestService: RequestService; + + const restEndpointURL = 'https://rest.api/'; + const relationshipsEndpointURL = `${restEndpointURL}/relationships`; + const halService: any = new HALEndpointServiceStub(restEndpointURL); + const rdbService = getMockRemoteDataBuildService(); + const objectCache = Object.assign({ + /* tslint:disable:no-empty */ + remove: () => {} + /* tslint:enable:no-empty */ + }) as ObjectCacheService; + + const relationshipType = Object.assign(new RelationshipType(), { + id: '1', + uuid: '1', + leftwardType: 'isAuthorOfPublication', + rightwardType: 'isPublicationOfAuthor' + }); + + const relationship1 = Object.assign(new Relationship(), { + self: relationshipsEndpointURL + '/2', + id: '2', + uuid: '2', + relationshipType: observableOf(new RemoteData(false, false, true, undefined, relationshipType)) + }); + const relationship2 = Object.assign(new Relationship(), { + self: relationshipsEndpointURL + '/3', + id: '3', + uuid: '3', + relationshipType: observableOf(new RemoteData(false, false, true, undefined, relationshipType)) + }); + + const relationships = [ relationship1, relationship2 ]; + + const item = Object.assign(new Item(), { + self: 'fake-item-url/publication', + id: 'publication', + uuid: 'publication', + relationships: observableOf(new RemoteData(false, false, true, undefined, new PaginatedList(new PageInfo(), relationships))) + }); + + const relatedItem1 = Object.assign(new Item(), { + id: 'author1', + uuid: 'author1' + }); + const relatedItem2 = Object.assign(new Item(), { + id: 'author2', + uuid: 'author2' + }); + relationship1.leftItem = getRemotedataObservable(relatedItem1); + relationship1.rightItem = getRemotedataObservable(item); + relationship2.leftItem = getRemotedataObservable(relatedItem2); + relationship2.rightItem = getRemotedataObservable(item); + const relatedItems = [relatedItem1, relatedItem2]; + + const itemService = jasmine.createSpyObj('itemService', { + findById: (uuid) => new RemoteData(false, false, true, undefined, relatedItems.filter((relatedItem) => relatedItem.id === uuid)[0]) + }); + + function initTestService() { + return new RelationshipService( + requestService, + halService, + rdbService, + itemService, + objectCache + ); + } + + const getRequestEntry$ = (successful: boolean) => { + return observableOf({ + response: { isSuccessful: successful, payload: relationships } as any + } as RequestEntry) + }; + + beforeEach(() => { + requestService = getMockRequestService(getRequestEntry$(true)); + service = initTestService(); + }); + + describe('deleteRelationship', () => { + beforeEach(() => { + spyOn(service, 'findById').and.returnValue(getRemotedataObservable(relationship1)); + spyOn(objectCache, 'remove'); + service.deleteRelationship(relationships[0].uuid).subscribe(); + }); + + it('should send a DeleteRequest', () => { + const expected = new DeleteRequest(requestService.generateRequestId(), relationshipsEndpointURL + '/' + relationship1.uuid); + expect(requestService.configure).toHaveBeenCalledWith(expected, undefined); + }); + + it('should clear the related items their cache', () => { + expect(objectCache.remove).toHaveBeenCalledWith(relatedItem1.self); + expect(objectCache.remove).toHaveBeenCalledWith(item.self); + expect(requestService.removeByHrefSubstring).toHaveBeenCalledWith(relatedItem1.self); + expect(requestService.removeByHrefSubstring).toHaveBeenCalledWith(item.self); + }); + }); + + describe('getItemRelationshipsArray', () => { + it('should return the item\'s relationships in the form of an array', () => { + service.getItemRelationshipsArray(item).subscribe((result) => { + expect(result).toEqual(relationships); + }); + }); + }); + + describe('getItemRelationshipLabels', () => { + it('should return the correct labels', () => { + service.getItemRelationshipLabels(item).subscribe((result) => { + expect(result).toEqual([relationshipType.rightwardType]); + }); + }); + }); + + describe('getRelatedItems', () => { + it('should return the related items', () => { + service.getRelatedItems(item).subscribe((result) => { + expect(result).toEqual(relatedItems); + }); + }); + }); + + describe('getRelatedItemsByLabel', () => { + it('should return the related items by label', () => { + service.getRelatedItemsByLabel(item, relationshipType.rightwardType).subscribe((result) => { + expect(result).toEqual(relatedItems); + }); + }); + }) + +}); + +function getRemotedataObservable(obj: any): Observable> { + return observableOf(new RemoteData(false, false, true, undefined, obj)); +} diff --git a/src/app/core/data/relationship.service.ts b/src/app/core/data/relationship.service.ts new file mode 100644 index 0000000000..b07e4b714c --- /dev/null +++ b/src/app/core/data/relationship.service.ts @@ -0,0 +1,235 @@ +import { Injectable } from '@angular/core'; +import { RequestService } from './request.service'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { hasValue, hasValueOperator, isNotEmptyOperator } from '../../shared/empty.util'; +import { distinctUntilChanged, filter, flatMap, map, switchMap, take, tap } from 'rxjs/operators'; +import { + configureRequest, + filterSuccessfulResponses, + getRemoteDataPayload, getResponseFromEntry, + getSucceededRemoteData +} from '../shared/operators'; +import { DeleteRequest, RestRequest } from './request.models'; +import { Observable } from 'rxjs/internal/Observable'; +import { RestResponse } from '../cache/response.models'; +import { Item } from '../shared/item.model'; +import { Relationship } from '../shared/item-relationships/relationship.model'; +import { RelationshipType } from '../shared/item-relationships/relationship-type.model'; +import { RemoteData } from './remote-data'; +import { combineLatest as observableCombineLatest } from 'rxjs/internal/observable/combineLatest'; +import { zip as observableZip } from 'rxjs'; +import { PaginatedList } from './paginated-list'; +import { ItemDataService } from './item-data.service'; +import { + compareArraysUsingIds, filterRelationsByTypeLabel, + relationsToItems +} from '../../+item-page/simple/item-types/shared/item-relationships-utils'; +import { ObjectCacheService } from '../cache/object-cache.service'; + +/** + * The service handling all relationship requests + */ +@Injectable() +export class RelationshipService { + protected linkPath = 'relationships'; + + constructor(protected requestService: RequestService, + protected halService: HALEndpointService, + protected rdbService: RemoteDataBuildService, + protected itemService: ItemDataService, + protected objectCache: ObjectCacheService) { + } + + /** + * Get the endpoint for a relationship by ID + * @param uuid + */ + getRelationshipEndpoint(uuid: string) { + return this.halService.getEndpoint(this.linkPath).pipe( + map((href: string) => `${href}/${uuid}`) + ); + } + + /** + * Find a relationship by its UUID + * @param uuid + */ + findById(uuid: string): Observable> { + const href$ = this.getRelationshipEndpoint(uuid); + return this.rdbService.buildSingle(href$); + } + + /** + * Send a delete request for a relationship by ID + * @param uuid + */ + deleteRelationship(uuid: string): Observable { + return this.getRelationshipEndpoint(uuid).pipe( + isNotEmptyOperator(), + distinctUntilChanged(), + map((endpointURL: string) => new DeleteRequest(this.requestService.generateRequestId(), endpointURL)), + configureRequest(this.requestService), + switchMap((restRequest: RestRequest) => this.requestService.getByUUID(restRequest.uuid)), + getResponseFromEntry(), + tap(() => this.clearRelatedCache(uuid)) + ); + } + + /** + * Get a combined observable containing an array of all relationships in an item, as well as an array of the relationships their types + * This is used for easier access of a relationship's type because they exist as observables + * @param item + */ + getItemResolvedRelsAndTypes(item: Item): Observable<[Relationship[], RelationshipType[]]> { + return observableCombineLatest( + this.getItemRelationshipsArray(item), + this.getItemRelationshipTypesArray(item) + ); + } + + /** + * Get a combined observable containing an array of all the item's relationship's left- and right-side items, as well as an array of the relationships their types + * This is used for easier access of a relationship's type and left and right items because they exist as observables + * @param item + */ + getItemResolvedRelatedItemsAndTypes(item: Item): Observable<[Item[], Item[], RelationshipType[]]> { + return observableCombineLatest( + this.getItemLeftRelatedItemArray(item), + this.getItemRightRelatedItemArray(item), + this.getItemRelationshipTypesArray(item) + ); + } + + /** + * Get a combined observable containing an array of all the item's relationship's left- and right-side items, as well as an array of the relationships themselves + * This is used for easier access of the relationship and their left and right items because they exist as observables + * @param item + */ + getItemResolvedRelatedItemsAndRelationships(item: Item): Observable<[Item[], Item[], Relationship[]]> { + return observableCombineLatest( + this.getItemLeftRelatedItemArray(item), + this.getItemRightRelatedItemArray(item), + this.getItemRelationshipsArray(item) + ); + } + + /** + * Get an item their relationships in the form of an array + * @param item + */ + getItemRelationshipsArray(item: Item): Observable { + return item.relationships.pipe( + getSucceededRemoteData(), + getRemoteDataPayload(), + map((rels: PaginatedList) => rels.page), + hasValueOperator(), + distinctUntilChanged(compareArraysUsingIds()) + ); + } + + /** + * Get an item their relationship types in the form of an array + * @param item + */ + getItemRelationshipTypesArray(item: Item): Observable { + return this.getItemRelationshipsArray(item).pipe( + flatMap((rels: Relationship[]) => + observableZip(...rels.map((rel: Relationship) => rel.relationshipType)).pipe( + map(([...arr]: Array>) => arr.map((d: RemoteData) => d.payload).filter((type) => hasValue(type))), + filter((arr) => arr.length === rels.length) + ) + ), + distinctUntilChanged(compareArraysUsingIds()) + ); + } + + /** + * Get an item his relationship's left-side related items in the form of an array + * @param item + */ + getItemLeftRelatedItemArray(item: Item): Observable { + return this.getItemRelationshipsArray(item).pipe( + flatMap((rels: Relationship[]) => observableZip(...rels.map((rel: Relationship) => rel.leftItem)).pipe( + map(([...arr]: Array>) => arr.map((rd: RemoteData) => rd.payload).filter((i) => hasValue(i))), + filter((arr) => arr.length === rels.length) + )), + distinctUntilChanged(compareArraysUsingIds()) + ); + } + + /** + * Get an item his relationship's right-side related items in the form of an array + * @param item + */ + getItemRightRelatedItemArray(item: Item): Observable { + return this.getItemRelationshipsArray(item).pipe( + flatMap((rels: Relationship[]) => observableZip(...rels.map((rel: Relationship) => rel.rightItem)).pipe( + map(([...arr]: Array>) => arr.map((rd: RemoteData) => rd.payload).filter((i) => hasValue(i))), + filter((arr) => arr.length === rels.length) + )), + distinctUntilChanged(compareArraysUsingIds()) + ); + } + + /** + * Get an array of an item their unique relationship type's labels + * The array doesn't contain any duplicate labels + * @param item + */ + getItemRelationshipLabels(item: Item): Observable { + return this.getItemResolvedRelatedItemsAndTypes(item).pipe( + map(([leftItems, rightItems, relTypesCurrentPage]) => { + return relTypesCurrentPage.map((type, index) => { + if (leftItems[index].uuid === item.uuid) { + return type.leftwardType; + } else { + return type.rightwardType; + } + }); + }), + map((labels: string[]) => Array.from(new Set(labels))) + ) + } + + /** + * Resolve a given item's relationships into related items and return the items as an array + * @param item + */ + getRelatedItems(item: Item): Observable { + return this.getItemRelationshipsArray(item).pipe( + relationsToItems(item.uuid) + ); + } + + /** + * Resolve a given item's relationships into related items, filtered by a relationship label + * and return the items as an array + * @param item + * @param label + */ + getRelatedItemsByLabel(item: Item, label: string): Observable { + return this.getItemResolvedRelsAndTypes(item).pipe( + filterRelationsByTypeLabel(label), + relationsToItems(item.uuid) + ); + } + + /** + * Clear object and request caches of the items related to a relationship (left and right items) + * @param uuid + */ + clearRelatedCache(uuid: string) { + this.findById(uuid).pipe( + getSucceededRemoteData(), + flatMap((rd: RemoteData) => observableCombineLatest(rd.payload.leftItem.pipe(getSucceededRemoteData()), rd.payload.rightItem.pipe(getSucceededRemoteData()))), + take(1) + ).subscribe(([leftItem, rightItem]) => { + this.objectCache.remove(leftItem.payload.self); + this.objectCache.remove(rightItem.payload.self); + this.requestService.removeByHrefSubstring(leftItem.payload.self); + this.requestService.removeByHrefSubstring(rightItem.payload.self); + }); + } + +} diff --git a/src/app/shared/services/api.service.ts b/src/app/core/services/api.service.ts similarity index 100% rename from src/app/shared/services/api.service.ts rename to src/app/core/services/api.service.ts diff --git a/src/app/shared/services/client-cookie.service.ts b/src/app/core/services/client-cookie.service.ts similarity index 100% rename from src/app/shared/services/client-cookie.service.ts rename to src/app/core/services/client-cookie.service.ts diff --git a/src/app/shared/services/cookie.service.spec.ts b/src/app/core/services/cookie.service.spec.ts similarity index 100% rename from src/app/shared/services/cookie.service.spec.ts rename to src/app/core/services/cookie.service.spec.ts diff --git a/src/app/shared/services/cookie.service.ts b/src/app/core/services/cookie.service.ts similarity index 100% rename from src/app/shared/services/cookie.service.ts rename to src/app/core/services/cookie.service.ts diff --git a/src/app/shared/services/route.actions.ts b/src/app/core/services/route.actions.ts similarity index 100% rename from src/app/shared/services/route.actions.ts rename to src/app/core/services/route.actions.ts diff --git a/src/app/shared/services/route.effects.ts b/src/app/core/services/route.effects.ts similarity index 100% rename from src/app/shared/services/route.effects.ts rename to src/app/core/services/route.effects.ts diff --git a/src/app/shared/services/route.reducer.ts b/src/app/core/services/route.reducer.ts similarity index 100% rename from src/app/shared/services/route.reducer.ts rename to src/app/core/services/route.reducer.ts diff --git a/src/app/shared/services/route.service.spec.ts b/src/app/core/services/route.service.spec.ts similarity index 97% rename from src/app/shared/services/route.service.spec.ts rename to src/app/core/services/route.service.spec.ts index c6003521a7..ae31f28384 100644 --- a/src/app/shared/services/route.service.spec.ts +++ b/src/app/core/services/route.service.spec.ts @@ -6,9 +6,9 @@ import { Store } from '@ngrx/store'; import { getTestScheduler, hot } from 'jasmine-marbles'; import { RouteService } from './route.service'; -import { MockRouter } from '../mocks/mock-router'; +import { MockRouter } from '../../shared/mocks/mock-router'; import { TestScheduler } from 'rxjs/testing'; -import { AddUrlToHistoryAction } from '../history/history.actions'; +import { AddUrlToHistoryAction } from '../../shared/history/history.actions'; describe('RouteService', () => { let scheduler: TestScheduler; diff --git a/src/app/shared/services/route.service.ts b/src/app/core/services/route.service.ts similarity index 95% rename from src/app/shared/services/route.service.ts rename to src/app/core/services/route.service.ts index dc626484c1..65aa858945 100644 --- a/src/app/shared/services/route.service.ts +++ b/src/app/core/services/route.service.ts @@ -12,12 +12,12 @@ import { combineLatest, Observable } from 'rxjs'; import { createSelector, MemoizedSelector, select, Store } from '@ngrx/store'; import { isEqual } from 'lodash'; -import { AddUrlToHistoryAction } from '../history/history.actions'; -import { historySelector } from '../history/selectors'; +import { AddUrlToHistoryAction } from '../../shared/history/history.actions'; +import { historySelector } from '../../shared/history/selectors'; import { SetParametersAction, SetQueryParametersAction } from './route.actions'; -import { CoreState } from '../../core/core.reducers'; -import { hasValue } from '../empty.util'; -import { coreSelector } from '../../core/core.selectors'; +import { CoreState } from '../core.reducers'; +import { hasValue } from '../../shared/empty.util'; +import { coreSelector } from '../core.selectors'; /** * Selector to select all route parameters from the store diff --git a/src/app/shared/services/server-cookie.service.ts b/src/app/core/services/server-cookie.service.ts similarity index 100% rename from src/app/shared/services/server-cookie.service.ts rename to src/app/core/services/server-cookie.service.ts diff --git a/src/app/shared/services/server-response.service.ts b/src/app/core/services/server-response.service.ts similarity index 100% rename from src/app/shared/services/server-response.service.ts rename to src/app/core/services/server-response.service.ts diff --git a/src/app/shared/services/window.service.ts b/src/app/core/services/window.service.ts similarity index 100% rename from src/app/shared/services/window.service.ts rename to src/app/core/services/window.service.ts diff --git a/src/app/core/shared/item-relationships/relationship-type.model.ts b/src/app/core/shared/item-relationships/relationship-type.model.ts index 98454bc000..06ac94b041 100644 --- a/src/app/core/shared/item-relationships/relationship-type.model.ts +++ b/src/app/core/shared/item-relationships/relationship-type.model.ts @@ -33,7 +33,7 @@ export class RelationshipType implements CacheableObject { /** * The label that describes the Relation to the left of this RelationshipType */ - leftLabel: string; + leftwardType: string; /** * The maximum amount of Relationships allowed to the left of this RelationshipType @@ -48,7 +48,7 @@ export class RelationshipType implements CacheableObject { /** * The label that describes the Relation to the right of this RelationshipType */ - rightLabel: string; + rightwardType: string; /** * The maximum amount of Relationships allowed to the right of this RelationshipType diff --git a/src/app/core/shared/item.model.ts b/src/app/core/shared/item.model.ts index 515ea3cd69..a3e625c022 100644 --- a/src/app/core/shared/item.model.ts +++ b/src/app/core/shared/item.model.ts @@ -5,10 +5,11 @@ import { DSpaceObject } from './dspace-object.model'; import { Collection } from './collection.model'; import { RemoteData } from '../data/remote-data'; import { Bitstream } from './bitstream.model'; -import { hasValue, isNotEmpty, isNotUndefined } from '../../shared/empty.util'; +import { hasValue, isNotEmpty } from '../../shared/empty.util'; import { PaginatedList } from '../data/paginated-list'; import { Relationship } from './item-relationships/relationship.model'; import { ResourceType } from './resource-type'; +import { getSucceededRemoteData } from './operators'; export class Item extends DSpaceObject { static type = new ResourceType('item'); @@ -97,7 +98,7 @@ export class Item extends DSpaceObject { */ getBitstreamsByBundleName(bundleName: string): Observable { return this.bitstreams.pipe( - filter((rd: RemoteData>) => !rd.isResponsePending && isNotUndefined(rd.payload)), + getSucceededRemoteData(), map((rd: RemoteData>) => rd.payload.page), filter((bitstreams: Bitstream[]) => hasValue(bitstreams)), take(1), diff --git a/src/app/core/shared/operators.ts b/src/app/core/shared/operators.ts index ae46691e39..d46c688e68 100644 --- a/src/app/core/shared/operators.ts +++ b/src/app/core/shared/operators.ts @@ -91,7 +91,7 @@ export const toDSpaceObjectListRD = () => source.pipe( filter((rd: RemoteData>>) => rd.hasSucceeded), map((rd: RemoteData>>) => { - const dsoPage: T[] = rd.payload.page.map((searchResult: SearchResult) => searchResult.indexableObject); + const dsoPage: T[] = rd.payload.page.filter((result) => hasValue(result)).map((searchResult: SearchResult) => searchResult.indexableObject); const payload = Object.assign(rd.payload, { page: dsoPage }) as PaginatedList; return Object.assign(rd, { payload: payload }); }) diff --git a/src/app/core/submission/submission-response-parsing.service.ts b/src/app/core/submission/submission-response-parsing.service.ts index a0811c8f2d..de7d683d91 100644 --- a/src/app/core/submission/submission-response-parsing.service.ts +++ b/src/app/core/submission/submission-response-parsing.service.ts @@ -128,7 +128,10 @@ export class SubmissionResponseParsingService extends BaseResponseParsingService // Iterate over all workspaceitem's sections Object.keys(item.sections) .forEach((sectionId) => { - if (typeof item.sections[sectionId] === 'object' && isNotEmpty(item.sections[sectionId])) { + if (typeof item.sections[sectionId] === 'object' && (isNotEmpty(item.sections[sectionId]) && + // When Upload section is disabled, add to submission only if there are files + (!item.sections[sectionId].hasOwnProperty('files') || isNotEmpty((item.sections[sectionId] as any).files)))) { + const normalizedSectionData = Object.create({}); // Iterate over all sections property Object.keys(item.sections[sectionId]) diff --git a/src/app/entity-groups/journal-entities/item-grid-elements/journal-issue/journal-issue-grid-element.component.html b/src/app/entity-groups/journal-entities/item-grid-elements/journal-issue/journal-issue-grid-element.component.html new file mode 100644 index 0000000000..3aa79fc70a --- /dev/null +++ b/src/app/entity-groups/journal-entities/item-grid-elements/journal-issue/journal-issue-grid-element.component.html @@ -0,0 +1,30 @@ + +
+ +
+ + +
+
+
+ + +

+
+

+ + + +

+

+ + + +

+
+ View +
+
+
+
diff --git a/src/app/entity-groups/journal-entities/item-grid-elements/journal-issue/journal-issue-grid-element.component.scss b/src/app/entity-groups/journal-entities/item-grid-elements/journal-issue/journal-issue-grid-element.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/entity-groups/journal-entities/item-grid-elements/journal-issue/journal-issue-grid-element.component.spec.ts b/src/app/entity-groups/journal-entities/item-grid-elements/journal-issue/journal-issue-grid-element.component.spec.ts new file mode 100644 index 0000000000..d13feda406 --- /dev/null +++ b/src/app/entity-groups/journal-entities/item-grid-elements/journal-issue/journal-issue-grid-element.component.spec.ts @@ -0,0 +1,50 @@ +import { ItemSearchResult } from '../../../../shared/object-collection/shared/item-search-result.model'; +import { Item } from '../../../../core/shared/item.model'; +import { of as observableOf } from 'rxjs/internal/observable/of'; +import { getEntityGridElementTestComponent } from '../../../../shared/object-grid/item-grid-element/item-types/publication/publication-grid-element.component.spec'; +import { JournalIssueGridElementComponent } from './journal-issue-grid-element.component'; +import { createSuccessfulRemoteDataObject$ } from '../../../../shared/testing/utils'; +import { PaginatedList } from '../../../../core/data/paginated-list'; +import { PageInfo } from '../../../../core/shared/page-info.model'; + +const mockItemWithMetadata: ItemSearchResult = new ItemSearchResult(); +mockItemWithMetadata.hitHighlights = {}; +mockItemWithMetadata.indexableObject = Object.assign(new Item(), { + bitstreams: createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), [])), + metadata: { + 'dc.title': [ + { + language: 'en_US', + value: 'This is just another title' + } + ], + 'creativework.datePublished': [ + { + language: null, + value: '2015-06-26' + } + ], + 'journal.title': [ + { + language: 'en_US', + value: 'The journal title' + } + ] + } +}); + +const mockItemWithoutMetadata: ItemSearchResult = new ItemSearchResult(); +mockItemWithoutMetadata.hitHighlights = {}; +mockItemWithoutMetadata.indexableObject = Object.assign(new Item(), { + bitstreams: createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), [])), + metadata: { + 'dc.title': [ + { + language: 'en_US', + value: 'This is just another title' + } + ] + } +}); + +describe('JournalIssueGridElementComponent', getEntityGridElementTestComponent(JournalIssueGridElementComponent, mockItemWithMetadata, mockItemWithoutMetadata, ['date', 'journal-title'])); diff --git a/src/app/entity-groups/journal-entities/item-grid-elements/journal-issue/journal-issue-grid-element.component.ts b/src/app/entity-groups/journal-entities/item-grid-elements/journal-issue/journal-issue-grid-element.component.ts new file mode 100644 index 0000000000..06c27ebacf --- /dev/null +++ b/src/app/entity-groups/journal-entities/item-grid-elements/journal-issue/journal-issue-grid-element.component.ts @@ -0,0 +1,17 @@ +import { ItemViewMode, rendersItemType } from '../../../../shared/items/item-type-decorator'; +import { Component } from '@angular/core'; +import { focusShadow } from '../../../../shared/animations/focus'; +import { TypedItemSearchResultGridElementComponent } from '../../../../shared/object-grid/item-grid-element/item-types/typed-item-search-result-grid-element.component'; + +@rendersItemType('JournalIssue', ItemViewMode.Card) +@Component({ + selector: 'ds-journal-issue-grid-element', + styleUrls: ['./journal-issue-grid-element.component.scss'], + templateUrl: './journal-issue-grid-element.component.html', + animations: [focusShadow] +}) +/** + * The component for displaying a grid element for an item of the type Journal Issue + */ +export class JournalIssueGridElementComponent extends TypedItemSearchResultGridElementComponent { +} diff --git a/src/app/entity-groups/journal-entities/item-grid-elements/journal-volume/journal-volume-grid-element.component.html b/src/app/entity-groups/journal-entities/item-grid-elements/journal-volume/journal-volume-grid-element.component.html new file mode 100644 index 0000000000..b2b251f550 --- /dev/null +++ b/src/app/entity-groups/journal-entities/item-grid-elements/journal-volume/journal-volume-grid-element.component.html @@ -0,0 +1,30 @@ + +
+ +
+ + +
+
+
+ + +

+
+

+ + + +

+

+ + + +

+
+ View +
+
+
+
diff --git a/src/app/entity-groups/journal-entities/item-grid-elements/journal-volume/journal-volume-grid-element.component.scss b/src/app/entity-groups/journal-entities/item-grid-elements/journal-volume/journal-volume-grid-element.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/entity-groups/journal-entities/item-grid-elements/journal-volume/journal-volume-grid-element.component.spec.ts b/src/app/entity-groups/journal-entities/item-grid-elements/journal-volume/journal-volume-grid-element.component.spec.ts new file mode 100644 index 0000000000..8c854aeb77 --- /dev/null +++ b/src/app/entity-groups/journal-entities/item-grid-elements/journal-volume/journal-volume-grid-element.component.spec.ts @@ -0,0 +1,50 @@ +import { ItemSearchResult } from '../../../../shared/object-collection/shared/item-search-result.model'; +import { Item } from '../../../../core/shared/item.model'; +import { of as observableOf } from 'rxjs/internal/observable/of'; +import { getEntityGridElementTestComponent } from '../../../../shared/object-grid/item-grid-element/item-types/publication/publication-grid-element.component.spec'; +import { JournalVolumeGridElementComponent } from './journal-volume-grid-element.component'; +import { createSuccessfulRemoteDataObject$ } from '../../../../shared/testing/utils'; +import { PaginatedList } from '../../../../core/data/paginated-list'; +import { PageInfo } from '../../../../core/shared/page-info.model'; + +const mockItemWithMetadata: ItemSearchResult = new ItemSearchResult(); +mockItemWithMetadata.hitHighlights = {}; +mockItemWithMetadata.indexableObject = Object.assign(new Item(), { + bitstreams: createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), [])), + metadata: { + 'dc.title': [ + { + language: 'en_US', + value: 'This is just another title' + } + ], + 'creativework.datePublished': [ + { + language: null, + value: '2015-06-26' + } + ], + 'dc.description': [ + { + language: 'en_US', + value: 'A description for the journal volume' + } + ] + } +}); + +const mockItemWithoutMetadata: ItemSearchResult = new ItemSearchResult(); +mockItemWithoutMetadata.hitHighlights = {}; +mockItemWithoutMetadata.indexableObject = Object.assign(new Item(), { + bitstreams: createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), [])), + metadata: { + 'dc.title': [ + { + language: 'en_US', + value: 'This is just another title' + } + ] + } +}); + +describe('JournalVolumeGridElementComponent', getEntityGridElementTestComponent(JournalVolumeGridElementComponent, mockItemWithMetadata, mockItemWithoutMetadata, ['date', 'description'])); diff --git a/src/app/entity-groups/journal-entities/item-grid-elements/journal-volume/journal-volume-grid-element.component.ts b/src/app/entity-groups/journal-entities/item-grid-elements/journal-volume/journal-volume-grid-element.component.ts new file mode 100644 index 0000000000..e5183536ef --- /dev/null +++ b/src/app/entity-groups/journal-entities/item-grid-elements/journal-volume/journal-volume-grid-element.component.ts @@ -0,0 +1,17 @@ +import { ItemViewMode, rendersItemType } from '../../../../shared/items/item-type-decorator'; +import { Component } from '@angular/core'; +import { focusShadow } from '../../../../shared/animations/focus'; +import { TypedItemSearchResultGridElementComponent } from '../../../../shared/object-grid/item-grid-element/item-types/typed-item-search-result-grid-element.component'; + +@rendersItemType('JournalVolume', ItemViewMode.Card) +@Component({ + selector: 'ds-journal-volume-grid-element', + styleUrls: ['./journal-volume-grid-element.component.scss'], + templateUrl: './journal-volume-grid-element.component.html', + animations: [focusShadow] +}) +/** + * The component for displaying a grid element for an item of the type Journal Volume + */ +export class JournalVolumeGridElementComponent extends TypedItemSearchResultGridElementComponent { +} diff --git a/src/app/entity-groups/journal-entities/item-grid-elements/journal/journal-grid-element.component.html b/src/app/entity-groups/journal-entities/item-grid-elements/journal/journal-grid-element.component.html new file mode 100644 index 0000000000..af0739004c --- /dev/null +++ b/src/app/entity-groups/journal-entities/item-grid-elements/journal/journal-grid-element.component.html @@ -0,0 +1,35 @@ + +
+ +
+ + +
+
+
+ + +

+
+

+ + {{dso.firstMetadataValue('creativework.editor')}} + + , + {{dso.firstMetadataValue('creativework.publisher')}} + + +

+

+ + + +

+
+ View +
+
+
+
diff --git a/src/app/entity-groups/journal-entities/item-grid-elements/journal/journal-grid-element.component.scss b/src/app/entity-groups/journal-entities/item-grid-elements/journal/journal-grid-element.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/entity-groups/journal-entities/item-grid-elements/journal/journal-grid-element.component.spec.ts b/src/app/entity-groups/journal-entities/item-grid-elements/journal/journal-grid-element.component.spec.ts new file mode 100644 index 0000000000..0d0f77842a --- /dev/null +++ b/src/app/entity-groups/journal-entities/item-grid-elements/journal/journal-grid-element.component.spec.ts @@ -0,0 +1,56 @@ +import { ItemSearchResult } from '../../../../shared/object-collection/shared/item-search-result.model'; +import { Item } from '../../../../core/shared/item.model'; +import { of as observableOf } from 'rxjs/internal/observable/of'; +import { getEntityGridElementTestComponent } from '../../../../shared/object-grid/item-grid-element/item-types/publication/publication-grid-element.component.spec'; +import { JournalGridElementComponent } from './journal-grid-element.component'; +import { createSuccessfulRemoteDataObject$ } from '../../../../shared/testing/utils'; +import { PaginatedList } from '../../../../core/data/paginated-list'; +import { PageInfo } from '../../../../core/shared/page-info.model'; + +const mockItemWithMetadata: ItemSearchResult = new ItemSearchResult(); +mockItemWithMetadata.hitHighlights = {}; +mockItemWithMetadata.indexableObject = Object.assign(new Item(), { + bitstreams: createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), [])), + metadata: { + 'dc.title': [ + { + language: 'en_US', + value: 'This is just another title' + } + ], + 'creativework.editor': [ + { + language: 'en_US', + value: 'Smith, Donald' + } + ], + 'creativework.publisher': [ + { + language: 'en_US', + value: 'A company' + } + ], + 'dc.description': [ + { + language: 'en_US', + value: 'This is the description' + } + ] + } +}); + +const mockItemWithoutMetadata: ItemSearchResult = new ItemSearchResult(); +mockItemWithoutMetadata.hitHighlights = {}; +mockItemWithoutMetadata.indexableObject = Object.assign(new Item(), { + bitstreams: createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), [])), + metadata: { + 'dc.title': [ + { + language: 'en_US', + value: 'This is just another title' + } + ] + } +}); + +describe('JournalGridElementComponent', getEntityGridElementTestComponent(JournalGridElementComponent, mockItemWithMetadata, mockItemWithoutMetadata, ['editor', 'publisher', 'description'])); diff --git a/src/app/entity-groups/journal-entities/item-grid-elements/journal/journal-grid-element.component.ts b/src/app/entity-groups/journal-entities/item-grid-elements/journal/journal-grid-element.component.ts new file mode 100644 index 0000000000..7f23211538 --- /dev/null +++ b/src/app/entity-groups/journal-entities/item-grid-elements/journal/journal-grid-element.component.ts @@ -0,0 +1,17 @@ +import { ItemViewMode, rendersItemType } from '../../../../shared/items/item-type-decorator'; +import { Component } from '@angular/core'; +import { focusShadow } from '../../../../shared/animations/focus'; +import { TypedItemSearchResultGridElementComponent } from '../../../../shared/object-grid/item-grid-element/item-types/typed-item-search-result-grid-element.component'; + +@rendersItemType('Journal', ItemViewMode.Card) +@Component({ + selector: 'ds-journal-grid-element', + styleUrls: ['./journal-grid-element.component.scss'], + templateUrl: './journal-grid-element.component.html', + animations: [focusShadow] +}) +/** + * The component for displaying a grid element for an item of the type Journal + */ +export class JournalGridElementComponent extends TypedItemSearchResultGridElementComponent { +} diff --git a/src/app/entity-groups/journal-entities/journal-entities.module.ts b/src/app/entity-groups/journal-entities/journal-entities.module.ts index 50ec160650..4033645e1b 100644 --- a/src/app/entity-groups/journal-entities/journal-entities.module.ts +++ b/src/app/entity-groups/journal-entities/journal-entities.module.ts @@ -9,6 +9,9 @@ import { JournalListElementComponent } from './item-list-elements/journal/journa import { JournalIssueListElementComponent } from './item-list-elements/journal-issue/journal-issue-list-element.component'; import { JournalVolumeListElementComponent } from './item-list-elements/journal-volume/journal-volume-list-element.component'; import { TooltipModule } from 'ngx-bootstrap'; +import { JournalIssueGridElementComponent } from './item-grid-elements/journal-issue/journal-issue-grid-element.component'; +import { JournalVolumeGridElementComponent } from './item-grid-elements/journal-volume/journal-volume-grid-element.component'; +import { JournalGridElementComponent } from './item-grid-elements/journal/journal-grid-element.component'; const ENTRY_COMPONENTS = [ JournalComponent, @@ -16,7 +19,10 @@ const ENTRY_COMPONENTS = [ JournalVolumeComponent, JournalListElementComponent, JournalIssueListElementComponent, - JournalVolumeListElementComponent + JournalVolumeListElementComponent, + JournalIssueGridElementComponent, + JournalVolumeGridElementComponent, + JournalGridElementComponent ]; @NgModule({ diff --git a/src/app/entity-groups/research-entities/item-grid-elements/orgunit/orgunit-grid-element.component.html b/src/app/entity-groups/research-entities/item-grid-elements/orgunit/orgunit-grid-element.component.html new file mode 100644 index 0000000000..a4765c4e8f --- /dev/null +++ b/src/app/entity-groups/research-entities/item-grid-elements/orgunit/orgunit-grid-element.component.html @@ -0,0 +1,35 @@ + +
+ +
+ + +
+
+
+ + +

+
+

+ + + +

+

+ + {{dso.firstMetadataValue('organization.address.addressCountry')}} + + , + {{dso.firstMetadataValue('organization.address.addressLocality')}} + + +

+
+ View +
+
+
+
diff --git a/src/app/entity-groups/research-entities/item-grid-elements/orgunit/orgunit-grid-element.component.scss b/src/app/entity-groups/research-entities/item-grid-elements/orgunit/orgunit-grid-element.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/entity-groups/research-entities/item-grid-elements/orgunit/orgunit-grid-element.component.spec.ts b/src/app/entity-groups/research-entities/item-grid-elements/orgunit/orgunit-grid-element.component.spec.ts new file mode 100644 index 0000000000..15c7b75bf5 --- /dev/null +++ b/src/app/entity-groups/research-entities/item-grid-elements/orgunit/orgunit-grid-element.component.spec.ts @@ -0,0 +1,56 @@ +import { ItemSearchResult } from '../../../../shared/object-collection/shared/item-search-result.model'; +import { Item } from '../../../../core/shared/item.model'; +import { of as observableOf } from 'rxjs/internal/observable/of'; +import { getEntityGridElementTestComponent } from '../../../../shared/object-grid/item-grid-element/item-types/publication/publication-grid-element.component.spec'; +import { OrgunitGridElementComponent } from './orgunit-grid-element.component'; +import { createSuccessfulRemoteDataObject$ } from '../../../../shared/testing/utils'; +import { PaginatedList } from '../../../../core/data/paginated-list'; +import { PageInfo } from '../../../../core/shared/page-info.model'; + +const mockItemWithMetadata: ItemSearchResult = new ItemSearchResult(); +mockItemWithMetadata.hitHighlights = {}; +mockItemWithMetadata.indexableObject = Object.assign(new Item(), { + bitstreams: createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), [])), + metadata: { + 'dc.title': [ + { + language: 'en_US', + value: 'This is just another title' + } + ], + 'organization.foundingDate': [ + { + language: null, + value: '2015-06-26' + } + ], + 'organization.address.addressCountry': [ + { + language: 'en_US', + value: 'Belgium' + } + ], + 'organization.address.addressLocality': [ + { + language: 'en_US', + value: 'Brussels' + } + ] + } +}); + +const mockItemWithoutMetadata: ItemSearchResult = new ItemSearchResult(); +mockItemWithoutMetadata.hitHighlights = {}; +mockItemWithoutMetadata.indexableObject = Object.assign(new Item(), { + bitstreams: createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), [])), + metadata: { + 'dc.title': [ + { + language: 'en_US', + value: 'This is just another title' + } + ] + } +}); + +describe('OrgunitGridElementComponent', getEntityGridElementTestComponent(OrgunitGridElementComponent, mockItemWithMetadata, mockItemWithoutMetadata, ['date', 'country', 'city'])); diff --git a/src/app/entity-groups/research-entities/item-grid-elements/orgunit/orgunit-grid-element.component.ts b/src/app/entity-groups/research-entities/item-grid-elements/orgunit/orgunit-grid-element.component.ts new file mode 100644 index 0000000000..0effc22027 --- /dev/null +++ b/src/app/entity-groups/research-entities/item-grid-elements/orgunit/orgunit-grid-element.component.ts @@ -0,0 +1,17 @@ +import { ItemViewMode, rendersItemType } from '../../../../shared/items/item-type-decorator'; +import { Component } from '@angular/core'; +import { focusShadow } from '../../../../shared/animations/focus'; +import { TypedItemSearchResultGridElementComponent } from '../../../../shared/object-grid/item-grid-element/item-types/typed-item-search-result-grid-element.component'; + +@rendersItemType('OrgUnit', ItemViewMode.Card) +@Component({ + selector: 'ds-orgunit-grid-element', + styleUrls: ['./orgunit-grid-element.component.scss'], + templateUrl: './orgunit-grid-element.component.html', + animations: [focusShadow] +}) +/** + * The component for displaying a grid element for an item of the type Organisation Unit + */ +export class OrgunitGridElementComponent extends TypedItemSearchResultGridElementComponent { +} diff --git a/src/app/entity-groups/research-entities/item-grid-elements/person/person-grid-element.component.html b/src/app/entity-groups/research-entities/item-grid-elements/person/person-grid-element.component.html new file mode 100644 index 0000000000..331c2bd520 --- /dev/null +++ b/src/app/entity-groups/research-entities/item-grid-elements/person/person-grid-element.component.html @@ -0,0 +1,30 @@ + +
+ +
+ + +
+
+
+ + +

+
+ +

+ + + +

+
+ View +
+
+
+
diff --git a/src/app/entity-groups/research-entities/item-grid-elements/person/person-grid-element.component.scss b/src/app/entity-groups/research-entities/item-grid-elements/person/person-grid-element.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/entity-groups/research-entities/item-grid-elements/person/person-grid-element.component.spec.ts b/src/app/entity-groups/research-entities/item-grid-elements/person/person-grid-element.component.spec.ts new file mode 100644 index 0000000000..25268261e1 --- /dev/null +++ b/src/app/entity-groups/research-entities/item-grid-elements/person/person-grid-element.component.spec.ts @@ -0,0 +1,50 @@ +import { ItemSearchResult } from '../../../../shared/object-collection/shared/item-search-result.model'; +import { Item } from '../../../../core/shared/item.model'; +import { of as observableOf } from 'rxjs/internal/observable/of'; +import { getEntityGridElementTestComponent } from '../../../../shared/object-grid/item-grid-element/item-types/publication/publication-grid-element.component.spec'; +import { PersonGridElementComponent } from './person-grid-element.component'; +import { createSuccessfulRemoteDataObject$ } from '../../../../shared/testing/utils'; +import { PaginatedList } from '../../../../core/data/paginated-list'; +import { PageInfo } from '../../../../core/shared/page-info.model'; + +const mockItemWithMetadata: ItemSearchResult = new ItemSearchResult(); +mockItemWithMetadata.hitHighlights = {}; +mockItemWithMetadata.indexableObject = Object.assign(new Item(), { + bitstreams: createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), [])), + metadata: { + 'dc.title': [ + { + language: 'en_US', + value: 'This is just another title' + } + ], + 'person.email': [ + { + language: 'en_US', + value: 'Smith-Donald@gmail.com' + } + ], + 'person.jobTitle': [ + { + language: 'en_US', + value: 'Web Developer' + } + ] + } +}); + +const mockItemWithoutMetadata: ItemSearchResult = new ItemSearchResult(); +mockItemWithoutMetadata.hitHighlights = {}; +mockItemWithoutMetadata.indexableObject = Object.assign(new Item(), { + bitstreams: createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), [])), + metadata: { + 'dc.title': [ + { + language: 'en_US', + value: 'This is just another title' + } + ] + } +}); + +describe('PersonGridElementComponent', getEntityGridElementTestComponent(PersonGridElementComponent, mockItemWithMetadata, mockItemWithoutMetadata, ['email', 'jobtitle'])); diff --git a/src/app/entity-groups/research-entities/item-grid-elements/person/person-grid-element.component.ts b/src/app/entity-groups/research-entities/item-grid-elements/person/person-grid-element.component.ts new file mode 100644 index 0000000000..bf7b8aa119 --- /dev/null +++ b/src/app/entity-groups/research-entities/item-grid-elements/person/person-grid-element.component.ts @@ -0,0 +1,17 @@ +import { ItemViewMode, rendersItemType } from '../../../../shared/items/item-type-decorator'; +import { Component } from '@angular/core'; +import { TypedItemSearchResultGridElementComponent } from '../../../../shared/object-grid/item-grid-element/item-types/typed-item-search-result-grid-element.component'; +import { focusShadow } from '../../../../shared/animations/focus'; + +@rendersItemType('Person', ItemViewMode.Card) +@Component({ + selector: 'ds-person-grid-element', + styleUrls: ['./person-grid-element.component.scss'], + templateUrl: './person-grid-element.component.html', + animations: [focusShadow] +}) +/** + * The component for displaying a grid element for an item of the type Person + */ +export class PersonGridElementComponent extends TypedItemSearchResultGridElementComponent { +} diff --git a/src/app/entity-groups/research-entities/item-grid-elements/project/project-grid-element.component.html b/src/app/entity-groups/research-entities/item-grid-elements/project/project-grid-element.component.html new file mode 100644 index 0000000000..889276b29b --- /dev/null +++ b/src/app/entity-groups/research-entities/item-grid-elements/project/project-grid-element.component.html @@ -0,0 +1,25 @@ + +
+ +
+ + +
+
+
+ + +

+
+

+ + + +

+
+ View +
+
+
+
diff --git a/src/app/entity-groups/research-entities/item-grid-elements/project/project-grid-element.component.scss b/src/app/entity-groups/research-entities/item-grid-elements/project/project-grid-element.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/entity-groups/research-entities/item-grid-elements/project/project-grid-element.component.spec.ts b/src/app/entity-groups/research-entities/item-grid-elements/project/project-grid-element.component.spec.ts new file mode 100644 index 0000000000..969912976c --- /dev/null +++ b/src/app/entity-groups/research-entities/item-grid-elements/project/project-grid-element.component.spec.ts @@ -0,0 +1,44 @@ +import { ItemSearchResult } from '../../../../shared/object-collection/shared/item-search-result.model'; +import { Item } from '../../../../core/shared/item.model'; +import { of as observableOf } from 'rxjs/internal/observable/of'; +import { getEntityGridElementTestComponent } from '../../../../shared/object-grid/item-grid-element/item-types/publication/publication-grid-element.component.spec'; +import { ProjectGridElementComponent } from './project-grid-element.component'; +import { createSuccessfulRemoteDataObject$ } from '../../../../shared/testing/utils'; +import { PaginatedList } from '../../../../core/data/paginated-list'; +import { PageInfo } from '../../../../core/shared/page-info.model'; + +const mockItemWithMetadata: ItemSearchResult = new ItemSearchResult(); +mockItemWithMetadata.hitHighlights = {}; +mockItemWithMetadata.indexableObject = Object.assign(new Item(), { + bitstreams: createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), [])), + metadata: { + 'dc.title': [ + { + language: 'en_US', + value: 'This is just another title' + } + ], + 'dc.description': [ + { + language: 'en_US', + value: 'The project description' + } + ] + } +}); + +const mockItemWithoutMetadata: ItemSearchResult = new ItemSearchResult(); +mockItemWithoutMetadata.hitHighlights = {}; +mockItemWithoutMetadata.indexableObject = Object.assign(new Item(), { + bitstreams: createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), [])), + metadata: { + 'dc.title': [ + { + language: 'en_US', + value: 'This is just another title' + } + ] + } +}); + +describe('ProjectGridElementComponent', getEntityGridElementTestComponent(ProjectGridElementComponent, mockItemWithMetadata, mockItemWithoutMetadata, ['description'])); diff --git a/src/app/entity-groups/research-entities/item-grid-elements/project/project-grid-element.component.ts b/src/app/entity-groups/research-entities/item-grid-elements/project/project-grid-element.component.ts new file mode 100644 index 0000000000..15d525fcf2 --- /dev/null +++ b/src/app/entity-groups/research-entities/item-grid-elements/project/project-grid-element.component.ts @@ -0,0 +1,17 @@ +import { ItemViewMode, rendersItemType } from '../../../../shared/items/item-type-decorator'; +import { Component } from '@angular/core'; +import { focusShadow } from '../../../../shared/animations/focus'; +import { TypedItemSearchResultGridElementComponent } from '../../../../shared/object-grid/item-grid-element/item-types/typed-item-search-result-grid-element.component'; + +@rendersItemType('Project', ItemViewMode.Card) +@Component({ + selector: 'ds-project-grid-element', + styleUrls: ['./project-grid-element.component.scss'], + templateUrl: './project-grid-element.component.html', + animations: [focusShadow] +}) +/** + * The component for displaying a grid element for an item of the type Project + */ +export class ProjectGridElementComponent extends TypedItemSearchResultGridElementComponent { +} diff --git a/src/app/entity-groups/research-entities/research-entities.module.ts b/src/app/entity-groups/research-entities/research-entities.module.ts index ba28f174df..099fa2a6a3 100644 --- a/src/app/entity-groups/research-entities/research-entities.module.ts +++ b/src/app/entity-groups/research-entities/research-entities.module.ts @@ -11,6 +11,9 @@ import { PersonMetadataListElementComponent } from './item-list-elements/person/ import { PersonListElementComponent } from './item-list-elements/person/person-list-element.component'; import { ProjectListElementComponent } from './item-list-elements/project/project-list-element.component'; import { TooltipModule } from 'ngx-bootstrap'; +import { PersonGridElementComponent } from './item-grid-elements/person/person-grid-element.component'; +import { OrgunitGridElementComponent } from './item-grid-elements/orgunit/orgunit-grid-element.component'; +import { ProjectGridElementComponent } from './item-grid-elements/project/project-grid-element.component'; const ENTRY_COMPONENTS = [ OrgunitComponent, @@ -20,7 +23,10 @@ const ENTRY_COMPONENTS = [ OrgUnitMetadataListElementComponent, PersonListElementComponent, PersonMetadataListElementComponent, - ProjectListElementComponent + ProjectListElementComponent, + PersonGridElementComponent, + OrgunitGridElementComponent, + ProjectGridElementComponent ]; @NgModule({ diff --git a/src/app/header/header.component.scss b/src/app/header/header.component.scss index 4d25bd0d43..70c66f119d 100644 --- a/src/app/header/header.component.scss +++ b/src/app/header/header.component.scss @@ -8,3 +8,14 @@ background-image: none !important; line-height: 1.5; } + +.navbar ::ng-deep { + a { + color: $header-icon-color; + + &:hover, &focus { + color: darken($header-icon-color, 15%); + } + } +} + diff --git a/src/app/pagenotfound/pagenotfound.component.ts b/src/app/pagenotfound/pagenotfound.component.ts index 6e173b4139..b11de58269 100644 --- a/src/app/pagenotfound/pagenotfound.component.ts +++ b/src/app/pagenotfound/pagenotfound.component.ts @@ -1,4 +1,4 @@ -import { ServerResponseService } from '../shared/services/server-response.service'; +import { ServerResponseService } from '../core/services/server-response.service'; import { Component, ChangeDetectionStrategy, OnInit } from '@angular/core'; import { AuthService } from '../core/auth/auth.service'; diff --git a/src/app/shared/auth-nav-menu/auth-nav-menu.component.html b/src/app/shared/auth-nav-menu/auth-nav-menu.component.html index 8456264631..7a64eb5e9f 100644 --- a/src/app/shared/auth-nav-menu/auth-nav-menu.component.html +++ b/src/app/shared/auth-nav-menu/auth-nav-menu.component.html @@ -3,6 +3,8 @@
{{ 'nav.login' | translate }}
+ diff --git a/src/app/shared/chips/models/chips-item.model.ts b/src/app/shared/chips/models/chips-item.model.ts index 540f94166f..913232fa71 100644 --- a/src/app/shared/chips/models/chips-item.model.ts +++ b/src/app/shared/chips/models/chips-item.model.ts @@ -2,6 +2,7 @@ import { isObject, uniqueId } from 'lodash'; import { hasValue, isNotEmpty } from '../../empty.util'; import { FormFieldMetadataValueObject } from '../../form/builder/models/form-field-metadata-value.model'; import { ConfidenceType } from '../../../core/integration/models/confidence-type'; +import { PLACEHOLDER_PARENT_METADATA } from '../../form/builder/ds-dynamic-form-ui/models/relation-group/dynamic-relation-group.model'; export interface ChipsItemIcon { metadata: string; @@ -62,7 +63,7 @@ export class ChipsItem { if (this._item.hasOwnProperty(icon.metadata) && (((typeof this._item[icon.metadata] === 'string') && hasValue(this._item[icon.metadata])) || (this._item[icon.metadata] as FormFieldMetadataValueObject).hasValue()) - && !(this._item[icon.metadata] as FormFieldMetadataValueObject).hasPlaceholder()) { + && !this.hasPlaceholder(this._item[icon.metadata])) { if ((icon.visibleWhenAuthorityEmpty || (this._item[icon.metadata] as FormFieldMetadataValueObject).confidence !== ConfidenceType.CF_UNSET) && isNotEmpty(icon.style)) { @@ -109,4 +110,9 @@ export class ChipsItem { this.display = value; } + + private hasPlaceholder(value: any) { + return (typeof value === 'string') ? (value === PLACEHOLDER_PARENT_METADATA) : + (value as FormFieldMetadataValueObject).hasPlaceholder() + } } diff --git a/src/app/shared/comcol-forms/create-comcol-page/create-comcol-page.component.spec.ts b/src/app/shared/comcol-forms/create-comcol-page/create-comcol-page.component.spec.ts index 08f15ad052..6ad2e5b5e1 100644 --- a/src/app/shared/comcol-forms/create-comcol-page/create-comcol-page.component.spec.ts +++ b/src/app/shared/comcol-forms/create-comcol-page/create-comcol-page.component.spec.ts @@ -1,6 +1,6 @@ import { async, ComponentFixture, TestBed } from '@angular/core/testing'; import { CommunityDataService } from '../../../core/data/community-data.service'; -import { RouteService } from '../../services/route.service'; +import { RouteService } from '../../../core/services/route.service'; import { Router } from '@angular/router'; import { TranslateModule } from '@ngx-translate/core'; import { of as observableOf } from 'rxjs'; diff --git a/src/app/shared/comcol-forms/create-comcol-page/create-comcol-page.component.ts b/src/app/shared/comcol-forms/create-comcol-page/create-comcol-page.component.ts index c9fcfecb97..e07f2a5a0a 100644 --- a/src/app/shared/comcol-forms/create-comcol-page/create-comcol-page.component.ts +++ b/src/app/shared/comcol-forms/create-comcol-page/create-comcol-page.component.ts @@ -2,7 +2,7 @@ import { Component, OnInit } from '@angular/core'; import { Community } from '../../../core/shared/community.model'; import { CommunityDataService } from '../../../core/data/community-data.service'; import { Observable } from 'rxjs'; -import { RouteService } from '../../services/route.service'; +import { RouteService } from '../../../core/services/route.service'; import { Router } from '@angular/router'; import { RemoteData } from '../../../core/data/remote-data'; import { isNotEmpty, isNotUndefined } from '../../empty.util'; diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.html b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.html index 217f9e79cf..52a924604f 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.html +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.html @@ -14,7 +14,8 @@ - +
{{ message | translate:model.validators }} diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-concat.model.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-concat.model.ts index fc618023f9..66bdf97dad 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-concat.model.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/ds-dynamic-concat.model.ts @@ -1,4 +1,7 @@ import { DynamicFormControlLayout, DynamicFormGroupModel, DynamicFormGroupModelConfig, serializable } from '@ng-dynamic-forms/core'; + +import { Subject } from 'rxjs'; + import { isNotEmpty } from '../../../../empty.util'; import { DsDynamicInputModel } from './ds-dynamic-input.model'; import { FormFieldMetadataValueObject } from '../../models/form-field-metadata-value.model'; @@ -16,12 +19,16 @@ export class DynamicConcatModel extends DynamicFormGroupModel { @serializable() separator: string; @serializable() hasLanguages = false; isCustomGroup = true; + valueUpdates: Subject; constructor(config: DynamicConcatModelConfig, layout?: DynamicFormControlLayout) { super(config, layout); this.separator = config.separator + ' '; + + this.valueUpdates = new Subject(); + this.valueUpdates.subscribe((value: string) => this.value = value); } get value() { 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 860c481820..4e4a944319 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 @@ -28,6 +28,7 @@ export class DsDynamicInputModel extends DynamicInputModel { constructor(config: DsDynamicInputModelConfig, layout?: DynamicFormControlLayout) { super(config, layout); + this.hint = config.hint; this.readOnly = config.readOnly; this.value = config.value; this.language = config.language; @@ -57,11 +58,7 @@ export class DsDynamicInputModel extends DynamicInputModel { } get hasLanguages(): boolean { - if (this.languageCodes && this.languageCodes.length > 1) { - return true; - } else { - return false; - } + return this.languageCodes && this.languageCodes.length > 1; } get language(): string { 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 6bd5a604a0..5d2cbc58b7 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,5 +1,5 @@ -import { DynamicFormControlLayout, DynamicFormGroupModel, DynamicInputModelConfig, serializable } from '@ng-dynamic-forms/core'; -import { DsDynamicInputModel, DsDynamicInputModelConfig } from './ds-dynamic-input.model'; +import { DynamicFormControlLayout, DynamicFormGroupModel, serializable } from '@ng-dynamic-forms/core'; +import { DsDynamicInputModel } from './ds-dynamic-input.model'; 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'; @@ -12,6 +12,7 @@ export interface DsDynamicQualdropModelConfig extends DynamicFormGroupModelConfi languageCodes?: LanguageCode[]; language?: string; readOnly: boolean; + hint?: string; } export class DynamicQualdropModel extends DynamicFormGroupModel { @@ -20,6 +21,7 @@ export class DynamicQualdropModel extends DynamicFormGroupModel { @serializable() languageUpdates: Subject; @serializable() hasLanguages = false; @serializable() readOnly: boolean; + @serializable() hint: string; isCustomGroup = true; constructor(config: DsDynamicQualdropModelConfig, layout?: DynamicFormControlLayout) { @@ -33,6 +35,8 @@ export class DynamicQualdropModel extends DynamicFormGroupModel { this.languageUpdates.subscribe((lang: string) => { this.language = lang; }); + + this.hint = config.hint; } get value() { diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/lookup/dynamic-lookup.component.html b/src/app/shared/form/builder/ds-dynamic-form-ui/models/lookup/dynamic-lookup.component.html index cb2d1fe217..3cfb5980c6 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/lookup/dynamic-lookup.component.html +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/lookup/dynamic-lookup.component.html @@ -20,11 +20,10 @@ [disabled]="isInputDisabled()" [placeholder]="model.placeholder | translate" [readonly]="model.readOnly" - (change)="$event.preventDefault()" + (change)="onChange($event)" (blur)="onBlurEvent($event); $event.stopPropagation(); sdRef.close();" (focus)="onFocusEvent($event); $event.stopPropagation(); sdRef.close();" - (click)="$event.stopPropagation(); $event.stopPropagation(); sdRef.close();" - (input)="onInput($event)"> + (click)="$event.stopPropagation(); $event.stopPropagation(); sdRef.close();">
@@ -40,11 +39,10 @@ [disabled]="firstInputValue.length === 0 || isInputDisabled()" [placeholder]="model.secondPlaceholder | translate" [readonly]="model.readOnly" - (change)="$event.preventDefault()" + (change)="onChange($event)" (blur)="onBlurEvent($event); $event.stopPropagation(); sdRef.close();" (focus)="onFocusEvent($event); $event.stopPropagation(); sdRef.close();" - (click)="$event.stopPropagation(); sdRef.close();" - (input)="onInput($event)"> + (click)="$event.stopPropagation(); sdRef.close();">
+
+
+
+ + diff --git a/src/app/shared/input-suggestions/dso-input-suggestions/dso-input-suggestions.component.spec.ts b/src/app/shared/input-suggestions/dso-input-suggestions/dso-input-suggestions.component.spec.ts new file mode 100644 index 0000000000..4229060e86 --- /dev/null +++ b/src/app/shared/input-suggestions/dso-input-suggestions/dso-input-suggestions.component.spec.ts @@ -0,0 +1,71 @@ +import { ChangeDetectionStrategy, DebugElement, NO_ERRORS_SCHEMA } from '@angular/core'; + +import { TranslateModule } from '@ngx-translate/core'; +import { By } from '@angular/platform-browser'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { FormsModule } from '@angular/forms'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { RouterTestingModule } from '@angular/router/testing'; +import { DsoInputSuggestionsComponent } from './dso-input-suggestions.component'; +import { DSpaceObject } from '../../../core/shared/dspace-object.model'; + +describe('DsoInputSuggestionsComponent', () => { + + let comp: DsoInputSuggestionsComponent; + let fixture: ComponentFixture; + let de: DebugElement; + let el: HTMLElement; + + const dso1 = { + uuid: 'test-uuid-1', + name: 'test-name-1' + } as DSpaceObject; + + const dso2 = { + uuid: 'test-uuid-2', + name: 'test-name-2' + } as DSpaceObject; + + const dso3 = { + uuid: 'test-uuid-3', + name: 'test-name-3' + } as DSpaceObject; + + const suggestions = [dso1, dso2, dso3]; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([]), NoopAnimationsModule, FormsModule], + declarations: [DsoInputSuggestionsComponent], + providers: [], + schemas: [NO_ERRORS_SCHEMA] + }).overrideComponent(DsoInputSuggestionsComponent, { + set: {changeDetection: ChangeDetectionStrategy.Default} + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(DsoInputSuggestionsComponent); + + comp = fixture.componentInstance; // LoadingComponent test instance + comp.suggestions = suggestions; + // query for the message