diff --git a/config/environment.default.js b/config/environment.default.js index 22a70f3513..a6ef738f41 100644 --- a/config/environment.default.js +++ b/config/environment.default.js @@ -8,13 +8,13 @@ module.exports = { nameSpace: '/' }, // The REST API server settings. - rest: { + rest: { ssl: true, host: 'dspace7.4science.it', port: 443, // NOTE: Space is capitalized because 'namespace' is a reserved string in TypeScript nameSpace: '/dspace-spring-rest/api' - }, + }, // Caching settings cache: { // NOTE: how long should objects be cached for by default diff --git a/package.json b/package.json index dca2420c62..cc4de4de64 100644 --- a/package.json +++ b/package.json @@ -89,11 +89,11 @@ "angular2-text-mask": "9.0.0", "angulartics2": "^6.2.0", "body-parser": "1.18.2", - "bootstrap": "4.1.1", + "bootstrap": "4.1.3", "cerialize": "0.1.18", "compression": "1.7.1", "cookie-parser": "1.4.3", - "core-js": "2.5.3", + "core-js": "^2.5.7", "express": "4.16.2", "express-session": "1.15.6", "font-awesome": "4.7.0", diff --git a/resources/i18n/cs.json b/resources/i18n/cs.json new file mode 100644 index 0000000000..1fdd02401b --- /dev/null +++ b/resources/i18n/cs.json @@ -0,0 +1,277 @@ +{ + "footer": { + "copyright": "copyright © 2002-{{ year }}", + "link.dspace": "software DSpace", + "link.duraspace": "DuraSpace" + }, + "collection": { + "page": { + "news": "Novinky", + "license": "Licence", + "browse": { + "recent": { + "head": "Poslední příspěvky" + } + } + } + }, + "community": { + "page": { + "news": "Novinky", + "license": "Licence" + }, + "sub-collection-list": { + "head": "Kolekce v této komunitě" + } + }, + "item": { + "page": { + "author": "Autor", + "abstract": "Abstract", + "date": "Datum", + "uri": "URI", + "files": "Soubory", + "collections": "Kolekce", + "filesection": { + "download": "Stáhnout", + "name": "Název:", + "format": "Formát:", + "size": "Velikost:", + "description": "Popis:" + }, + "link": { + "simple": "Minimální záznam", + "full": "Úplný záznam" + } + } + }, + "nav": { + "home": "Domů", + "login": "Přihlásit se", + "logout": "Odhlásit se" + }, + "pagination": { + "results-per-page": "Výsledků na stránku", + "sort-direction": "Seřazení", + "showing": { + "label": "Zobrazují se záznamy ", + "detail": "{{ range }} z {{ total }}" + } + }, + "sorting": { + "score": { + "DESC": "Relevance" + }, + "dc.title": { + "ASC": "Název vzestupně", + "DESC": "Název sestupně" + } + }, + "title": "DSpace", + "404": { + "help": "Nepodařilo se najít stránku, kterou hledáte. Je možné, že stránka byla přesunuta nebo smazána. Pomocí tlačítka níže můžete přejít na domovskou stránku. ", + "page-not-found": "stránka nenalezena", + "link": { + "home-page": "Přejít na domovskou stránku" + } + }, + "home": { + "title": "DSpace Angular :: Domů", + "description": "", + "top-level-communities": { + "head": "Komunity v DSpace", + "help": "Vybráním komunity můžete prohlížet její kolekce." + } + }, + "search": { + "title": "DSpace Angular :: Hledat", + "description": "", + "form": { + "search": "Hledat", + "search_dspace": "Hledat v DSpace" + }, + "results": { + "head": "Výsledky hledání", + "no-results": "Nebyli nalezeny žádné výsledky" + }, + "sidebar": { + "close": "Zpět na výsledky", + "open": "Vyhledávací nástroje", + "results": "výsledky", + "filters": { + "title": "Filtry" + }, + "settings": { + "title": "Nastavení", + "sort-by": "Řadit dle", + "rpp": "Výsledků na stránku" + } + }, + "view-switch": { + "show-list": "Zobrazit seznam", + "show-grid": "Zobrazit mřížku" + }, + "filters": { + "head": "Filtry", + "reset": "Obnovit filtry", + "applied": { + "f.author": "Autor", + "f.dateIssued.min": "Od data", + "f.dateIssued.max": "Do data", + "f.subject": "Předmět", + "f.has_content_in_original_bundle": "Má soubory" + }, + "filter": { + "show-more": "Zobrazit více", + "show-less": "Sbalit", + "author": { + "placeholder": "Jméno autora", + "head": "Autor" + }, + "scope": { + "placeholder": "Filtr rozsahu", + "head": "Rozsah" + }, + "subject": { + "placeholder": "Předmět", + "head": "Předmět" + }, + "dateIssued": { + "max": { + "placeholder": "Datum od" + }, + "min": { + "placeholder": "Datum do" + }, + "head": "Datum" + }, + "has_content_in_original_bundle": { + "head": "Má soubory" + } + } + } + }, + "browse": { + "title": "Prohlížíte {{ collection }} dle {{ field }} {{ value }}" + }, + "admin": { + "registries": { + "metadata": { + "title": "DSpace Angular :: Registr metadat", + "head": "Registr metadat", + "description": "Registr metadat je seznam všech metadatových polí dostupných v repozitáři. Tyto pole mohou být rozdělena do více schémat. DSpace však vyžaduje použití schématu kvalifikový Dublin Core.", + "schemas": { + "table": { + "id": "ID", + "namespace": "Jmenný prostor", + "name": "Název" + }, + "no-items": "Žádná schémata metadat." + } + }, + "schema": { + "title": "DSpace Angular :: Registr schémat metadat", + "head": "Metadata Schema", + "description": "Toto je schéma metadat pro „{{namespace}}“.", + "fields": { + "head": "Pole schématu metadat", + "table": { + "field": "Pole", + "scopenote": "Poznámka o rozsahu" + }, + "no-items": "Žádná metadatová pole." + } + }, + "bitstream-formats": { + "title": "DSpace Angular :: Registr formátů souborů", + "head": "Registr formátů souborů", + "description": "Tento seznam formátů souborů poskytuje informace o známých formátech a o úrovni jejich podpory.", + "formats": { + "table": { + "name": "Název", + "mimetype": "Typ MIME", + "supportLevel": { + "head": "Úroveň podpory", + "0": "Neznámá", + "1": "Známá", + "2": "Podpora" + }, + "internal": "interní" + }, + "no-items": "Žádné formáty souborů." + } + } + } + }, + "loading": { + "default": "Načítá se...", + "top-level-communities": "Načítají se komunity nejvyšší úrovně...", + "community": "Načítá se komunita...", + "collection": "Načítá se kolekce...", + "sub-collections": "Načítají se subkolekce...", + "recent-submissions": "Načítají se poslední příspěvky...", + "item": "Načítá se záznam...", + "objects": "Načítá se...", + "search-results": "Načítají se výsledky hledání...", + "browse-by": "Načítají se záznamy..." + }, + "error": { + "default": "Chyba", + "top-level-communities": "Chyba během stahování komunit nejvyšší úrovně", + "community": "Chyba během stahování komunity", + "collection": "Chyba během stahování kolekce", + "sub-collections": "Chyba během stahování subkolekcí", + "recent-submissions": "Chyba během stahování posledních příspěvků", + "item": "Chyba během stahování záznamu", + "objects": "Chyba během stahování objektů", + "search-results": "Chyba během stahování výsledků hledání", + "browse-by": "Chyba během stahování záznamů", + "validation": { + "pattern": "Tento vstup je omezen dle vzoru: {{ pattern }}.", + "license": { + "notgranted": "Pro dokončení zaslání Musíte udělit licenci. Pokud v tuto chvíli tuto licenci nemůžete udělit, můžete svou práci uložit a později se k svému příspěveku vrátit nebo jej smazat." + } + } + }, + "form": { + "submit": "Odeslat", + "cancel": "Zrušit", + "search": "Hledat", + "remove": "Smazat", + "first-name": "Křestní jméno", + "last-name": "Příjmení", + "loading": "Načítá se...", + "no-results": "Nebyli nalezeny žádné výsledky", + "no-value": "Nebyla zadána hodnota", + "group-collapse": "Sbalit", + "group-expand": "Rozbalit", + "group-collapse-help": "Kliknutím sem sbalíte", + "group-expand-help": "Kliknutím sem rozbalíte a přidáte další prvky" + }, + "login": { + "title": "Přihlásit se", + "form": { + "header": "Prosím, přihlaste se do DSpace", + "email": "E-mailová adresa", + "forgot-password": "Zapomněli jste své heslo?", + "new-user": "Nový uživatel? Zaregistrujte se kliknutím sem.", + "password": "Heslo", + "submit": "Přihlásit se" + } + }, + "logout": { + "title": "Odhlásit se", + "form": { + "header": "Odhlásit se z DSpace", + "submit": "Odhlásit se" + } + }, + "auth": { + "messages": { + "expired": "Vaše relace vypršela. Prosím, znova se přihlaste." + }, + "errors": { + "invalid-user": "Neplatná e-mailová adresa nebo heslo." + } + } +} diff --git a/resources/i18n/de.json b/resources/i18n/de.json new file mode 100644 index 0000000000..d6b02ff533 --- /dev/null +++ b/resources/i18n/de.json @@ -0,0 +1,277 @@ +{ + "footer": { + "copyright": "Copyright © 2002-{{ year }}", + "link.dspace": "DSpace Software", + "link.duraspace": "DuraSpace" + }, + "collection": { + "page": { + "news": "Neuigkeiten", + "license": "Lizenz", + "browse": { + "recent": { + "head": "Aktuellste Veröffentlichungen" + } + } + } + }, + "community": { + "page": { + "news": "Neuigkeiten", + "license": "Lizenz" + }, + "sub-collection-list": { + "head": "Sammlungen in diesem Bereich" + } + }, + "item": { + "page": { + "author": "Autor", + "abstract": "Kurzfassung", + "date": "Datum", + "uri": "URI", + "files": "Dateien", + "collections": "Sammlungen", + "filesection": { + "download": "Herunterladen", + "name": "Name:", + "format": "Format:", + "size": "Größe:", + "description": "Beschreibung:" + }, + "link": { + "simple": "Kurzanzeige", + "full": "Vollanzeige" + } + } + }, + "nav": { + "home": "Zur Startseite", + "login": "Anmelden", + "logout": "Abmelden" + }, + "pagination": { + "results-per-page": "Ergebnisse pro Seite", + "sort-direction": "Sortiermöglichkeiten", + "showing": { + "label": "Anzeige der Treffer ", + "detail": "{{ range }} bis {{ total }}" + } + }, + "sorting": { + "score": { + "DESC": "Relevanz" + }, + "dc.title": { + "ASC": "Titel aufsteigend", + "DESC": "Titel absteigend" + } + }, + "title": "DSpace", + "404": { + "help": "Die Seite, die Sie aufrufen wollten, konnte nicht gefunden werden. Sie könnte verschoben oder gelöscht worden sein. Mit dem Link unten kommen Sie zurück zur Startseite. ", + "page-not-found": "Seite nicht gefunden", + "link": { + "home-page": "Zurück zur Startseite" + } + }, + "home": { + "title": "DSpace Angular :: Startseite", + "description": "", + "top-level-communities": { + "head": "Bereiche in DSpace", + "help": "Wählen Sie einen Bereich, um seine Sammlungen einzusehen." + } + }, + "search": { + "title": "DSpace Angular :: Suche", + "description": "", + "form": { + "search": "Suche", + "search_dspace": "DSpace durchsuchen" + }, + "results": { + "head": "Suchergebnisse", + "no-results": "Zu dieser Suche gibt es keine Treffer." + }, + "sidebar": { + "close": "Zurück zu den Ergebnissen", + "open": "Suchwerkzeuge", + "results": "Ergebnisse", + "filters": { + "title": "Filter" + }, + "settings": { + "title": "Einstellungen", + "sort-by": "Sortiere nach", + "rpp": "Treffer pro Seite" + } + }, + "view-switch": { + "show-list": "Zeige als Liste", + "show-grid": "Zeige als Raster" + }, + "filters": { + "head": "Filter", + "reset": "Filter zurücksetzen", + "applied": { + "f.author": "Autor", + "f.dateIssued.min": "Anfangsdatum", + "f.dateIssued.max": "Enddatum", + "f.subject": "Thema", + "f.has_content_in_original_bundle": "Besitzt Dateien" + }, + "filter": { + "show-more": "Zeige mehr", + "show-less": "Zeige weniger", + "author": { + "placeholder": "Autor", + "head": "Autor" + }, + "scope": { + "placeholder": "Bereichsfilter", + "head": "Bereich" + }, + "subject": { + "placeholder": "Schlagwort", + "head": "Schlagwort" + }, + "dateIssued": { + "max": { + "placeholder": "Frühestes Datum" + }, + "min": { + "placeholder": "Ältestes Datum" + }, + "head": "Datum" + }, + "has_content_in_original_bundle": { + "head": "Besitzt Dateien" + } + } + } + }, + "browse": { + "title": "Anzeige {{ collection }} nach {{ field }} {{ value }}" + }, + "admin": { + "registries": { + "metadata": { + "title": "DSpace Angular :: Metadatenreferenzliste", + "head": "Metadatenreferenzliste", + "description": "Die Metadatenreferenzliste beinhaltet alle Metadatenfelder, die zur Verfügung stehen. Die Felder können in unterschiedlichen Schemata enthalten sein. Nichtsdestotrotz benötigt DSpace mindestens qualifiziertes Dublin Core.", + "schemas": { + "table": { + "id": "ID", + "namespace": "Namensraum", + "name": "Name" + }, + "no-items": "Es gbit keine Metadatenschemata." + } + }, + "schema": { + "title": "DSpace Angular :: Referenzliste der Metadatenschemata", + "head": "Metadatenschemata", + "description": "Dies ist das Metadatenschema für \"{{namespace}}\".", + "fields": { + "head": "Felder in diesem Schema", + "table": { + "field": "Feld", + "scopenote": "Gültigkeitsbereich" + }, + "no-items": "Es gibt keine Felder in diesem Schema." + } + }, + "bitstream-formats": { + "title": "DSpace Angular :: Referenzliste der Dateiformate", + "head": "Referenzliste der Dateiformate", + "description": "Diese Liste enhtält die in diesem Repositorium zulässigen Dateiformate und den jeweiligen Unterstützungsgrad.", + "formats": { + "table": { + "name": "Name", + "mimetype": "MIME Type", + "supportLevel": { + "head": "Unterstützungsgrad", + "0": "Unbekannt", + "1": "Bekannt", + "2": "Unterstützt" + }, + "internal": "intern" + }, + "no-items": "Es gibt keine Formate in dieser Referenzliste." + } + } + } + }, + "loading": { + "default": "Am Laden ...", + "top-level-communities": "Die Hauptbereiche werden geladen ...", + "community": "Der Bereich wird geladen ...", + "collection": "Die Sammlung wird geladen ...", + "sub-collections": "Die untergeordneten Sammlungen werden geladen ...", + "recent-submissions": "Die aktuellsten Veröffentlichungen werden geladen ...", + "item": "Die Ressource wird geladen ...", + "objects": "Am Laden ...", + "search-results": "Die Suchergebnisse werden geladen ...", + "browse-by": "Die Ressourcen werden geladen ..." + }, + "error": { + "default": "Fehler", + "top-level-communities": "Fehler beim Laden der Hauptbereiche.", + "community": "Fehler beim Laden des Bereiches.", + "collection": "Fehler beim Laden der Sammlung.", + "sub-collections": "Fehler beim Laden der untergeordneten Sammlungen.", + "recent-submissions": "Fehler beim Laden der aktuellsten Veröffentlichungen.", + "item": "Fehler beim Laden der Ressource.", + "objects": "Fehler beim Laden der Objekte.", + "search-results": "Fehler beim Laden der Suchergebnisse.", + "browse-by": "Fehler beim Laden der Ressourcen", + "validation": { + "pattern": "Die Eingabe kann nur folgendes Muster haben: {{ pattern }}.", + "license": { + "notgranted": "Sie müssen der Lizenz zustimmen, um die Ressource einzureichen. Wenn dies zur Zeit nicht geht, können Sie die Einreichung speichern und später wiederaufnehmen oder löschen." + } + } + }, + "form": { + "submit": "Los", + "cancel": "Abbrechen", + "search": "Suchen", + "remove": "Löschen", + "first-name": "Vorname", + "last-name": "Nachname", + "loading": "Am Laden ...", + "no-results": "Keine Ergebnisse gefunden", + "no-value": "Kein Wert eingegeben", + "group-collapse": "Weniger", + "group-expand": "Mehr", + "group-collapse-help": "Hier klicken, um die Anzeige zu reduzieren", + "group-expand-help": "Hier klicken, um mehr Elemente anzuzeigen" + }, + "login": { + "title": "Einloggen", + "form": { + "header": "Bitte Loggen Sie sich ein.", + "email": "E-Mail-Adresse", + "forgot-password": "Haben Sie Ihr Passwort vergessen?", + "new-user": "Sind Sie neu hier? Klicken Sie hier, um sich zu registrieren.", + "password": "Passwort", + "submit": "Einloggen" + } + }, + "logout": { + "title": "Ausloggen", + "form": { + "header": "Ausloggen aus DSpace", + "submit": "Ausloggen" + } + }, + "auth": { + "messages": { + "expired": "Ihre Sitzung ist abgelaufen, bitte melden Sie sich erneut an." + }, + "errors": { + "invalid-user": "Ungültige E-Mail-Adresse oder Passwort." + } + } +} diff --git a/resources/i18n/en.json b/resources/i18n/en.json index 2566f4e7ab..b6a23068d7 100644 --- a/resources/i18n/en.json +++ b/resources/i18n/en.json @@ -151,6 +151,9 @@ } } }, + "browse": { + "title": "Browsing {{ collection }} by {{ field }} {{ value }}" + }, "admin": { "registries": { "metadata": { @@ -202,18 +205,19 @@ }, "loading": { "default": "Loading...", - "top-level-communities": "Loading top level communities...", + "top-level-communities": "Loading top-level communities...", "community": "Loading community...", "collection": "Loading collection...", "sub-collections": "Loading sub-collections...", "recent-submissions": "Loading recent submissions...", "item": "Loading item...", "objects": "Loading...", - "search-results": "Loading search results..." + "search-results": "Loading search results...", + "browse-by": "Loading items..." }, "error": { "default": "Error", - "top-level-communities": "Error fetching top level communities", + "top-level-communities": "Error fetching top-level communities", "community": "Error fetching community", "collection": "Error fetching collection", "sub-collections": "Error fetching sub-collections", @@ -221,6 +225,7 @@ "item": "Error fetching item", "objects": "Error fetching objects", "search-results": "Error fetching search results", + "browse-by": "Error fetching items", "validation": { "pattern": "This input is restricted by the current pattern: {{ pattern }}.", "license": { @@ -241,7 +246,7 @@ "group-collapse": "Collapse", "group-expand": "Expand", "group-collapse-help": "Click here to collapse", - "group-expand-help": "Click here to expand and add more element" + "group-expand-help": "Click here to expand and add more elements" }, "login": { "title": "Login", @@ -266,7 +271,7 @@ "expired": "Your session has expired. Please log in again." }, "errors": { - "invalid-user": "Invalid email or password." + "invalid-user": "Invalid email address or password." } } } diff --git a/resources/i18n/nl.json b/resources/i18n/nl.json new file mode 100644 index 0000000000..6c3b1fe401 --- /dev/null +++ b/resources/i18n/nl.json @@ -0,0 +1,277 @@ +{ + "footer": { + "copyright": "copyright © 2002-{{ year }}", + "link.dspace": "DSpace software", + "link.duraspace": "DuraSpace" + }, + "collection": { + "page": { + "news": "Nieuws", + "license": "Licentie", + "browse": { + "recent": { + "head": "Recent toegevoegd" + } + } + } + }, + "community": { + "page": { + "news": "Nieuws", + "license": "Licentie" + }, + "sub-collection-list": { + "head": "Collecties in deze Community" + } + }, + "item": { + "page": { + "author": "Auteur", + "abstract": "Abstract", + "date": "Datum", + "uri": "URI", + "files": "Bestanden", + "collections": "Collecties", + "filesection": { + "download": "Download", + "name": "Naam:", + "format": "Formaat:", + "size": "Grootte:", + "description": "Beschrijving:" + }, + "link": { + "simple": "Eenvoudige item weergave", + "full": "Volledige item weergave" + } + } + }, + "nav": { + "home": "Home", + "login": "Log In", + "logout": "Log Uit" + }, + "pagination": { + "results-per-page": "Resultaten per pagina", + "sort-direction": "Sorteer mogelijkheden", + "showing": { + "label": "Getoonde items ", + "detail": "{{ range }} tot {{ total }}" + } + }, + "sorting": { + "score": { + "DESC": "Relevantie" + }, + "dc.title": { + "ASC": "Oplopend op titel", + "DESC": "Aflopend op titel" + } + }, + "title": "DSpace", + "404": { + "help": "De pagina die u zoekt kan niet gevonden worden. De pagina werd mogelijk verplaatst of verwijderd. U kan onderstaande knop gebruiken om terug naar de homepagina te gaan. ", + "page-not-found": "Pagina niet gevonden", + "link": { + "home-page": "Terug naar de homepagina" + } + }, + "home": { + "title": "DSpace Angular :: Home", + "description": "", + "top-level-communities": { + "head": "Communities in DSpace", + "help": "Selecteer een community om diens collecties te verkennen." + } + }, + "search": { + "title": "DSpace Angular :: Zoek", + "description": "", + "form": { + "search": "Zoek", + "search_dspace": "Zoek in DSpace" + }, + "results": { + "head": "Zoekresultaten", + "no-results": "Er waren geen resultaten voor deze zoekopdracht" + }, + "sidebar": { + "close": "Terug naar de resultaten", + "open": "Zoek Tools", + "results": "resultaten", + "filters": { + "title": "Filters" + }, + "settings": { + "title": "Instellingen", + "sort-by": "Sorteer volgens", + "rpp": "Resultaten per pagina" + } + }, + "view-switch": { + "show-list": "Toon als lijst", + "show-grid": "Toon in raster" + }, + "filters": { + "head": "Filters", + "reset": "Filters verwijderen", + "applied": { + "f.author": "Auteur", + "f.dateIssued.min": "Start datum", + "f.dateIssued.max": "Eind datum", + "f.subject": "Sleutelwoord", + "f.has_content_in_original_bundle": "Heeft bestanden" + }, + "filter": { + "show-more": "Toon meer", + "show-less": "Inklappen", + "author": { + "placeholder": "Auteursnaam", + "head": "Auteur" + }, + "scope": { + "placeholder": "Bereik filter", + "head": "Bereik" + }, + "subject": { + "placeholder": "Onderwerp", + "head": "Onderwerp" + }, + "dateIssued": { + "max": { + "placeholder": "Vroegste Datum" + }, + "min": { + "placeholder": "Laatste Datum" + }, + "head": "Datum" + }, + "has_content_in_original_bundle": { + "head": "Heeft bestanden" + } + } + } + }, + "browse": { + "title": "Verken {{ collection }} volgens {{ field }} {{ value }}" + }, + "admin": { + "registries": { + "metadata": { + "title": "DSpace Angular :: Metadata Register", + "head": "Metadata Register", + "description": "Het metadata register omvat de lijst van alle metadata velden die beschikbaar zijn in het systeem. Deze velden kunnen verspreid zijn over verschillende metadata schema's. Het qualified Dublin Core schema (dc) is een verplicht schema en kan niet worden verwijderd.", + "schemas": { + "table": { + "id": "ID", + "namespace": "Naamruimte", + "name": "Naam" + }, + "no-items": "Er kunnen geen metadata schema's getoond worden." + } + }, + "schema": { + "title": "DSpace Angular :: Metadata Schema Register", + "head": "Metadata Schema", + "description": "Dit is het metadata schema voor \"{{namespace}}\".", + "fields": { + "head": "Schema metadata velden", + "table": { + "field": "Veld", + "scopenote": "Opmerking over bereik" + }, + "no-items": "Er kunnen geen metadata velden getoond worden." + } + }, + "bitstream-formats": { + "title": "DSpace Angular :: Bitstream Formaat Register", + "head": "Bitstream Formaat Register", + "description": "Deze lijst van Bitstream formaten biedt informatie over de formaten die in deze repository zijn toegelaten en op welke manier ze ondersteund worden. De term Bitstream wordt in DSpace gebruikt om een bestand aan te duiden dat samen met metadata onderdeel uitmaakt van een item. De naam bitstream duidt op het feit dat het bestand achterliggend wordt opgeslaan zonder bestandsextensie.", + "formats": { + "table": { + "name": "Naam", + "mimetype": "MIME Type", + "supportLevel": { + "head": "Ondersteuning", + "0": "Onbekend", + "1": "Gekend", + "2": "Ondersteund" + }, + "internal": "intern" + }, + "no-items": "Er kunnen geen bitstream formaten getoond worden." + } + } + } + }, + "loading": { + "default": "Laden...", + "top-level-communities": "Inladen van de Communities op het hoogste niveau...", + "community": "Community wordt ingeladen...", + "collection": "Collectie wordt ingeladen...", + "sub-collections": "De sub-collecties worden ingeladen...", + "recent-submissions": "Recent toegevoegde items worden ingeladen...", + "item": "Item wordt ingeladen...", + "objects": "Laden...", + "search-results": "Zoekresultaten worden ingeladen...", + "browse-by": "Items worden ingeladen..." + }, + "error": { + "default": "Fout", + "top-level-communities": "Fout bij het inladen van communities op het hoogste niveau", + "community": "Fout bij het ophalen van een community", + "collection": "Fout bij het ophalen van een collectie", + "sub-collections": "Fout bij het ophalen van sub-collecties", + "recent-submissions": "Fout bij het ophalen van recent toegevoegde items", + "item": "Fout bij het ophalen van items", + "objects": "Fout bij het ophalen van objecten", + "search-results": "Fout bij het ophalen van zoekresultaten", + "browse-by": "Fout bij het ophalen van items", + "validation": { + "pattern": "Deze invoer is niet toegelaten volgens dit patroon: {{ pattern }}.", + "license": { + "notgranted": "U moet de invoerlicentie goedkeuren om de invoer af te werken. Indien u deze licentie momenteel niet kan of mag goedkeuren, kan u uw werk opslaan en de invoer later afwerken. U kan dit nieuwe item ook verwijderen indien u niet voldoet aan de vereisten van de invoer licentie." + } + } + }, + "form": { + "submit": "Verstuur", + "cancel": "Annuleer", + "search": "Zoek", + "remove": "Verwijder", + "first-name": "Voornaam", + "last-name": "Achternaam", + "loading": "Inladen...", + "no-results": "Geen resultaten gevonden", + "no-value": "Geen waarde ingevoerd", + "group-collapse": "Inklappen", + "group-expand": "Uitklappen", + "group-collapse-help": "Klik hier op in te klappen", + "group-expand-help": "Klik hier om uit te klappen en om meer onderdelen toe te voegen" + }, + "login": { + "title": "Aanmelden", + "form": { + "header": "Gelieve in te loggen in DSpace", + "email": "Email adres", + "forgot-password": "Bent u uw wachtwoord vergeten?", + "new-user": "Nieuwe gebruiker? Gelieve u hier te registreren", + "password": "Wachtwoord", + "submit": "Aanmelden" + } + }, + "logout": { + "title": "Afmelden", + "form": { + "header": "Afmelden in DSpace", + "submit": "Afmelden" + } + }, + "auth": { + "messages": { + "expired": "Uw sessie is vervallen. Gelieve opnieuw aan te melden." + }, + "errors": { + "invalid-user": "Ongeldig email adres of wachtwoord." + } + } +} diff --git a/src/app/+browse-by/+browse-by-author-page/browse-by-author-page.component.html b/src/app/+browse-by/+browse-by-author-page/browse-by-author-page.component.html new file mode 100644 index 0000000000..438c318994 --- /dev/null +++ b/src/app/+browse-by/+browse-by-author-page/browse-by-author-page.component.html @@ -0,0 +1,11 @@ +
+
+ + +
+
diff --git a/src/app/+browse-by/+browse-by-author-page/browse-by-author-page.component.scss b/src/app/+browse-by/+browse-by-author-page/browse-by-author-page.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/+browse-by/+browse-by-author-page/browse-by-author-page.component.ts b/src/app/+browse-by/+browse-by-author-page/browse-by-author-page.component.ts new file mode 100644 index 0000000000..1553889741 --- /dev/null +++ b/src/app/+browse-by/+browse-by-author-page/browse-by-author-page.component.ts @@ -0,0 +1,108 @@ +import { Component, OnInit } from '@angular/core'; +import { RemoteData } from '../../core/data/remote-data'; +import { PaginatedList } from '../../core/data/paginated-list'; +import { ItemDataService } from '../../core/data/item-data.service'; +import { Observable } from 'rxjs/Observable'; +import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model'; +import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model'; +import { Subscription } from 'rxjs/Subscription'; +import { ActivatedRoute } from '@angular/router'; +import { hasValue, isNotEmpty } from '../../shared/empty.util'; +import { Metadatum } from '../../core/shared/metadatum.model'; +import { BrowseService } from '../../core/browse/browse.service'; +import { BrowseEntry } from '../../core/shared/browse-entry.model'; +import { Item } from '../../core/shared/item.model'; + +@Component({ + selector: 'ds-browse-by-author-page', + styleUrls: ['./browse-by-author-page.component.scss'], + templateUrl: './browse-by-author-page.component.html' +}) +/** + * Component for browsing (items) by author (dc.contributor.author) + */ +export class BrowseByAuthorPageComponent implements OnInit { + + authors$: Observable>>; + items$: Observable>>; + paginationConfig: PaginationComponentOptions = Object.assign(new PaginationComponentOptions(), { + id: 'browse-by-author-pagination', + currentPage: 1, + pageSize: 20 + }); + sortConfig: SortOptions = new SortOptions('dc.contributor.author', SortDirection.ASC); + subs: Subscription[] = []; + currentUrl: string; + value = ''; + + public constructor(private itemDataService: ItemDataService, private route: ActivatedRoute, private browseService: BrowseService) { + } + + ngOnInit(): void { + this.currentUrl = this.route.snapshot.pathFromRoot + .map((snapshot) => (snapshot.routeConfig) ? snapshot.routeConfig.path : '') + .join('/'); + this.updatePage({ + pagination: this.paginationConfig, + sort: this.sortConfig + }); + this.subs.push( + Observable.combineLatest( + this.route.params, + this.route.queryParams, + (params, queryParams, ) => { + return Object.assign({}, params, queryParams); + }) + .subscribe((params) => { + const page = +params.page || this.paginationConfig.currentPage; + const pageSize = +params.pageSize || this.paginationConfig.pageSize; + const sortDirection = params.sortDirection || this.sortConfig.direction; + const sortField = params.sortField || this.sortConfig.field; + this.value = +params.value || params.value || ''; + const pagination = Object.assign({}, + this.paginationConfig, + { currentPage: page, pageSize: pageSize } + ); + const sort = Object.assign({}, + this.sortConfig, + { direction: sortDirection, field: sortField } + ); + const searchOptions = { + pagination: pagination, + sort: sort + }; + if (isNotEmpty(this.value)) { + this.updatePageWithItems(searchOptions, this.value); + } else { + this.updatePage(searchOptions); + } + })); + } + + /** + * Updates the current page with searchOptions + * @param searchOptions Options to narrow down your search: + * { pagination: PaginationComponentOptions, + * sort: SortOptions } + */ + updatePage(searchOptions) { + this.authors$ = this.browseService.getBrowseEntriesFor('author', searchOptions); + this.items$ = undefined; + } + + /** + * Updates the current page with searchOptions and display items linked to author + * @param searchOptions Options to narrow down your search: + * { pagination: PaginationComponentOptions, + * sort: SortOptions } + * @param author The author's name for displaying items + */ + updatePageWithItems(searchOptions, author: string) { + this.items$ = this.browseService.getBrowseItemsFor('author', author, searchOptions); + } + + ngOnDestroy(): void { + this.subs.filter((sub) => hasValue(sub)).forEach((sub) => sub.unsubscribe()); + } + +} diff --git a/src/app/+browse-by/+browse-by-title-page/browse-by-title-page.component.html b/src/app/+browse-by/+browse-by-title-page/browse-by-title-page.component.html new file mode 100644 index 0000000000..d37727be36 --- /dev/null +++ b/src/app/+browse-by/+browse-by-title-page/browse-by-title-page.component.html @@ -0,0 +1,11 @@ +
+
+ + +
+
diff --git a/src/app/+browse-by/+browse-by-title-page/browse-by-title-page.component.scss b/src/app/+browse-by/+browse-by-title-page/browse-by-title-page.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/+browse-by/+browse-by-title-page/browse-by-title-page.component.ts b/src/app/+browse-by/+browse-by-title-page/browse-by-title-page.component.ts new file mode 100644 index 0000000000..1759264e2a --- /dev/null +++ b/src/app/+browse-by/+browse-by-title-page/browse-by-title-page.component.ts @@ -0,0 +1,92 @@ +import { Component, OnInit } from '@angular/core'; +import { RemoteData } from '../../core/data/remote-data'; +import { DSpaceObject } from '../../core/shared/dspace-object.model'; +import { PaginatedList } from '../../core/data/paginated-list'; +import { ItemDataService } from '../../core/data/item-data.service'; +import { Observable } from 'rxjs/Observable'; +import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model'; +import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model'; +import { Item } from '../../core/shared/item.model'; +import { Subscription } from 'rxjs/Subscription'; +import { ActivatedRoute, PRIMARY_OUTLET, UrlSegmentGroup } from '@angular/router'; +import { hasValue } from '../../shared/empty.util'; +import { Collection } from '../../core/shared/collection.model'; + +@Component({ + selector: 'ds-browse-by-title-page', + styleUrls: ['./browse-by-title-page.component.scss'], + templateUrl: './browse-by-title-page.component.html' +}) +/** + * Component for browsing items by title (dc.title) + */ +export class BrowseByTitlePageComponent implements OnInit { + + items$: Observable>>; + paginationConfig: PaginationComponentOptions = Object.assign(new PaginationComponentOptions(), { + id: 'browse-by-title-pagination', + currentPage: 1, + pageSize: 20 + }); + sortConfig: SortOptions = new SortOptions('dc.title', SortDirection.ASC); + subs: Subscription[] = []; + currentUrl: string; + + public constructor(private itemDataService: ItemDataService, private route: ActivatedRoute) { + + } + + ngOnInit(): void { + this.currentUrl = this.route.snapshot.pathFromRoot + .map((snapshot) => (snapshot.routeConfig) ? snapshot.routeConfig.path : '') + .join('/'); + this.updatePage({ + pagination: this.paginationConfig, + sort: this.sortConfig + }); + this.subs.push( + Observable.combineLatest( + this.route.params, + this.route.queryParams, + (params, queryParams, ) => { + return Object.assign({}, params, queryParams); + }) + .subscribe((params) => { + const page = +params.page || this.paginationConfig.currentPage; + const pageSize = +params.pageSize || this.paginationConfig.pageSize; + const sortDirection = params.sortDirection || this.sortConfig.direction; + const sortField = params.sortField || this.sortConfig.field; + const pagination = Object.assign({}, + this.paginationConfig, + { currentPage: page, pageSize: pageSize } + ); + const sort = Object.assign({}, + this.sortConfig, + { direction: sortDirection, field: sortField } + ); + this.updatePage({ + pagination: pagination, + sort: sort + }); + })); + } + + /** + * Updates the current page with searchOptions + * @param searchOptions Options to narrow down your search: + * { pagination: PaginationComponentOptions, + * sort: SortOptions } + */ + updatePage(searchOptions) { + this.items$ = this.itemDataService.findAll({ + currentPage: searchOptions.pagination.currentPage, + elementsPerPage: searchOptions.pagination.pageSize, + sort: searchOptions.sort + }); + } + + ngOnDestroy(): void { + this.subs.filter((sub) => hasValue(sub)).forEach((sub) => sub.unsubscribe()); + } + +} diff --git a/src/app/+browse-by/browse-by-routing.module.ts b/src/app/+browse-by/browse-by-routing.module.ts new file mode 100644 index 0000000000..630a7c0db5 --- /dev/null +++ b/src/app/+browse-by/browse-by-routing.module.ts @@ -0,0 +1,16 @@ +import { RouterModule } from '@angular/router'; +import { NgModule } from '@angular/core'; +import { BrowseByTitlePageComponent } from './+browse-by-title-page/browse-by-title-page.component'; +import { BrowseByAuthorPageComponent } from './+browse-by-author-page/browse-by-author-page.component'; + +@NgModule({ + imports: [ + RouterModule.forChild([ + { path: 'title', component: BrowseByTitlePageComponent }, + { path: 'author', component: BrowseByAuthorPageComponent } + ]) + ] +}) +export class BrowseByRoutingModule { + +} diff --git a/src/app/+browse-by/browse-by.module.ts b/src/app/+browse-by/browse-by.module.ts new file mode 100644 index 0000000000..51843a13d8 --- /dev/null +++ b/src/app/+browse-by/browse-by.module.ts @@ -0,0 +1,27 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { BrowseByTitlePageComponent } from './+browse-by-title-page/browse-by-title-page.component'; +import { ItemDataService } from '../core/data/item-data.service'; +import { SharedModule } from '../shared/shared.module'; +import { BrowseByRoutingModule } from './browse-by-routing.module'; +import { BrowseByAuthorPageComponent } from './+browse-by-author-page/browse-by-author-page.component'; +import { BrowseService } from '../core/browse/browse.service'; + +@NgModule({ + imports: [ + BrowseByRoutingModule, + CommonModule, + SharedModule + ], + declarations: [ + BrowseByTitlePageComponent, + BrowseByAuthorPageComponent + ], + providers: [ + ItemDataService, + BrowseService + ] +}) +export class BrowseByModule { + +} diff --git a/src/app/+collection-page/collection-page.component.html b/src/app/+collection-page/collection-page.component.html index 7b56d2307c..a233163070 100644 --- a/src/app/+collection-page/collection-page.component.html +++ b/src/app/+collection-page/collection-page.component.html @@ -35,7 +35,7 @@ - +
diff --git a/src/app/+collection-page/collection-page.resolver.ts b/src/app/+collection-page/collection-page.resolver.ts index 82e614caa1..d4835e2e14 100644 --- a/src/app/+collection-page/collection-page.resolver.ts +++ b/src/app/+collection-page/collection-page.resolver.ts @@ -6,13 +6,21 @@ import { CollectionDataService } from '../core/data/collection-data.service'; import { RemoteData } from '../core/data/remote-data'; import { getSucceededRemoteData } from '../core/shared/operators'; +/** + * This class represents a resolver that requests a specific collection before the route is activated + */ @Injectable() export class CollectionPageResolver implements Resolve> { constructor(private collectionService: CollectionDataService) { } + /** + * Method for resolving a collection based on the parameters in the current route + * @param {ActivatedRouteSnapshot} route The current ActivatedRouteSnapshot + * @param {RouterStateSnapshot} state The current RouterStateSnapshot + * @returns Observable<> Emits the found collection based on the parameters in the current route + */ resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable> { - return this.collectionService.findById(route.params.id).pipe( getSucceededRemoteData() ); diff --git a/src/app/+community-page/community-page.resolver.ts b/src/app/+community-page/community-page.resolver.ts index e18de17804..a32fe78bc5 100644 --- a/src/app/+community-page/community-page.resolver.ts +++ b/src/app/+community-page/community-page.resolver.ts @@ -6,13 +6,21 @@ import { getSucceededRemoteData } from '../core/shared/operators'; import { Community } from '../core/shared/community.model'; import { CommunityDataService } from '../core/data/community-data.service'; +/** + * This class represents a resolver that requests a specific community before the route is activated + */ @Injectable() export class CommunityPageResolver implements Resolve> { constructor(private communityService: CommunityDataService) { } + /** + * Method for resolving a community based on the parameters in the current route + * @param {ActivatedRouteSnapshot} route The current ActivatedRouteSnapshot + * @param {RouterStateSnapshot} state The current RouterStateSnapshot + * @returns Observable<> Emits the found community based on the parameters in the current route + */ resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable> { - return this.communityService.findById(route.params.id).pipe( getSucceededRemoteData() ); diff --git a/src/app/+item-page/item-page.resolver.ts b/src/app/+item-page/item-page.resolver.ts index d3e0145488..c0ee6a84ee 100644 --- a/src/app/+item-page/item-page.resolver.ts +++ b/src/app/+item-page/item-page.resolver.ts @@ -6,11 +6,20 @@ import { getSucceededRemoteData } from '../core/shared/operators'; import { ItemDataService } from '../core/data/item-data.service'; import { Item } from '../core/shared/item.model'; +/** + * This class represents a resolver that requests a specific item before the route is activated + */ @Injectable() export class ItemPageResolver implements Resolve> { constructor(private itemService: ItemDataService) { } + /** + * Method for resolving an item based on the parameters in the current route + * @param {ActivatedRouteSnapshot} route The current ActivatedRouteSnapshot + * @param {RouterStateSnapshot} state The current RouterStateSnapshot + * @returns Observable<> Emits the found item based on the parameters in the current route + */ resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable> { return this.itemService.findById(route.params.id).pipe( getSucceededRemoteData() diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts index 4bc8c43152..7de83651ff 100644 --- a/src/app/app-routing.module.ts +++ b/src/app/app-routing.module.ts @@ -12,6 +12,7 @@ import { PageNotFoundComponent } from './pagenotfound/pagenotfound.component'; { path: 'collections', loadChildren: './+collection-page/collection-page.module#CollectionPageModule' }, { path: 'items', loadChildren: './+item-page/item-page.module#ItemPageModule' }, { path: 'search', loadChildren: './+search-page/search-page.module#SearchPageModule' }, + { path: 'browse', loadChildren: './+browse-by/browse-by.module#BrowseByModule' }, { path: 'admin', loadChildren: './+admin/admin.module#AdminModule' }, { path: 'login', loadChildren: './+login-page/login-page.module#LoginPageModule' }, { path: 'logout', loadChildren: './+logout-page/logout-page.module#LogoutPageModule' }, diff --git a/src/app/core/auth/auth-object-factory.ts b/src/app/core/auth/auth-object-factory.ts index c3e70eaaac..b6df1fac34 100644 --- a/src/app/core/auth/auth-object-factory.ts +++ b/src/app/core/auth/auth-object-factory.ts @@ -1,14 +1,15 @@ import { AuthType } from './auth-type'; import { GenericConstructor } from '../shared/generic-constructor'; import { NormalizedAuthStatus } from './models/normalized-auth-status.model'; -import { NormalizedDSpaceObject } from '../cache/models/normalized-dspace-object.model'; -import { NormalizedEpersonModel } from '../eperson/models/NormalizedEperson.model'; +import { NormalizedEPerson } from '../eperson/models/normalized-eperson.model'; +import { NormalizedObject } from '../cache/models/normalized-object.model'; +import { EPerson } from '../eperson/models/eperson.model'; export class AuthObjectFactory { - public static getConstructor(type): GenericConstructor { + public static getConstructor(type): GenericConstructor { switch (type) { - case AuthType.Eperson: { - return NormalizedEpersonModel + case AuthType.EPerson: { + return NormalizedEPerson } case AuthType.Status: { diff --git a/src/app/core/auth/auth-response-parsing.service.spec.ts b/src/app/core/auth/auth-response-parsing.service.spec.ts index f7d899a9bc..f6dd87e99a 100644 --- a/src/app/core/auth/auth-response-parsing.service.spec.ts +++ b/src/app/core/auth/auth-response-parsing.service.spec.ts @@ -8,12 +8,13 @@ import { CoreState } from '../core.reducers'; import { AuthStatus } from './models/auth-status.model'; import { AuthResponseParsingService } from './auth-response-parsing.service'; import { AuthGetRequest, AuthPostRequest } from '../data/request.models'; +import { getMockStore } from '../../shared/mocks/mock-store'; -describe('ConfigResponseParsingService', () => { +describe('AuthResponseParsingService', () => { let service: AuthResponseParsingService; - const EnvConfig = {} as GlobalConfig; - const store = {} as Store; + const EnvConfig = {cache: {msToLive: 1000}} as GlobalConfig; + const store = getMockStore() as Store; const objectCacheService = new ObjectCacheService(store); beforeEach(() => { @@ -86,13 +87,19 @@ describe('ConfigResponseParsingService', () => { type: 'eperson', uuid: '4dc70ab5-cd73-492f-b007-3179d2d9296b', _links: { - self: 'https://hasselt-dspace.dev01.4science.it/dspace-spring-rest/api/eperson/epersons/4dc70ab5-cd73-492f-b007-3179d2d9296b' + self: { + href: 'https://hasselt-dspace.dev01.4science.it/dspace-spring-rest/api/eperson/epersons/4dc70ab5-cd73-492f-b007-3179d2d9296b' + } } } }, _links: { - eperson: 'https://hasselt-dspace.dev01.4science.it/dspace-spring-rest/api/eperson/epersons/4dc70ab5-cd73-492f-b007-3179d2d9296b', - self: 'https://hasselt-dspace.dev01.4science.it/dspace-spring-rest/api/authn/status' + eperson: { + href: 'https://hasselt-dspace.dev01.4science.it/dspace-spring-rest/api/eperson/epersons/4dc70ab5-cd73-492f-b007-3179d2d9296b' + }, + self: { + href: 'https://hasselt-dspace.dev01.4science.it/dspace-spring-rest/api/authn/status' + } } }, statusCode: '200' diff --git a/src/app/core/auth/auth-response-parsing.service.ts b/src/app/core/auth/auth-response-parsing.service.ts index 80c1b2eeca..8efa36f9e2 100644 --- a/src/app/core/auth/auth-response-parsing.service.ts +++ b/src/app/core/auth/auth-response-parsing.service.ts @@ -12,22 +12,23 @@ import { ResponseParsingService } from '../data/parsing.service'; import { RestRequest } from '../data/request.models'; import { AuthType } from './auth-type'; import { AuthStatus } from './models/auth-status.model'; +import { NormalizedAuthStatus } from './models/normalized-auth-status.model'; @Injectable() export class AuthResponseParsingService extends BaseResponseParsingService implements ResponseParsingService { protected objectFactory = AuthObjectFactory; - protected toCache = false; + protected toCache = true; constructor(@Inject(GLOBAL_CONFIG) protected EnvConfig: GlobalConfig, - protected objectCache: ObjectCacheService,) { + protected objectCache: ObjectCacheService) { super(); } parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse { if (isNotEmpty(data.payload) && isNotEmpty(data.payload._links) && (data.statusCode === '200' || data.statusCode === 'OK')) { - const response = this.process(data.payload, request.href); - return new AuthStatusResponse(response[Object.keys(response)[0]][0], data.statusCode); + const response = this.process(data.payload, request.href); + return new AuthStatusResponse(response, data.statusCode); } else { return new AuthStatusResponse(data.payload as AuthStatus, data.statusCode); } diff --git a/src/app/core/auth/auth-type.ts b/src/app/core/auth/auth-type.ts index b8879ae445..9a248da91f 100644 --- a/src/app/core/auth/auth-type.ts +++ b/src/app/core/auth/auth-type.ts @@ -1,4 +1,4 @@ export enum AuthType { - Eperson = 'eperson', + EPerson = 'eperson', Status = 'status' } diff --git a/src/app/core/auth/auth.actions.ts b/src/app/core/auth/auth.actions.ts index 207e8fae70..d0969d38d4 100644 --- a/src/app/core/auth/auth.actions.ts +++ b/src/app/core/auth/auth.actions.ts @@ -5,7 +5,7 @@ import { Action } from '@ngrx/store'; import { type } from '../../shared/ngrx/type'; // import models -import { Eperson } from '../eperson/models/eperson.model'; +import { EPerson } from '../eperson/models/eperson.model'; import { AuthTokenInfo } from './models/auth-token-info.model'; export const AuthActionTypes = { @@ -76,10 +76,10 @@ export class AuthenticatedSuccessAction implements Action { payload: { authenticated: boolean; authToken: AuthTokenInfo; - user: Eperson + user: EPerson }; - constructor(authenticated: boolean, authToken: AuthTokenInfo, user: Eperson) { + constructor(authenticated: boolean, authToken: AuthTokenInfo, user: EPerson) { this.payload = { authenticated, authToken, user }; } } @@ -250,9 +250,9 @@ export class RefreshTokenErrorAction implements Action { */ export class RegistrationAction implements Action { public type: string = AuthActionTypes.REGISTRATION; - payload: Eperson; + payload: EPerson; - constructor(user: Eperson) { + constructor(user: EPerson) { this.payload = user; } } @@ -278,9 +278,9 @@ export class RegistrationErrorAction implements Action { */ export class RegistrationSuccessAction implements Action { public type: string = AuthActionTypes.REGISTRATION_SUCCESS; - payload: Eperson; + payload: EPerson; - constructor(user: Eperson) { + constructor(user: EPerson) { this.payload = user; } } diff --git a/src/app/core/auth/auth.effects.spec.ts b/src/app/core/auth/auth.effects.spec.ts index a707e13ee0..2820f2d210 100644 --- a/src/app/core/auth/auth.effects.spec.ts +++ b/src/app/core/auth/auth.effects.spec.ts @@ -25,12 +25,11 @@ import { AuthServiceStub } from '../../shared/testing/auth-service-stub'; import { AuthService } from './auth.service'; import { TruncatablesState } from '../../shared/truncatable/truncatable.reducer'; -import { EpersonMock } from '../../shared/testing/eperson-mock'; +import { EPersonMock } from '../../shared/testing/eperson-mock'; describe('AuthEffects', () => { let authEffects: AuthEffects; let actions: Observable; - const authServiceStub = new AuthServiceStub(); const store: Store = jasmine.createSpyObj('store', { /* tslint:disable:no-empty */ @@ -105,7 +104,7 @@ describe('AuthEffects', () => { it('should return a AUTHENTICATED_SUCCESS action in response to a AUTHENTICATED action', () => { actions = hot('--a-', {a: {type: AuthActionTypes.AUTHENTICATED, payload: token}}); - const expected = cold('--b-', {b: new AuthenticatedSuccessAction(true, token, EpersonMock)}); + const expected = cold('--b-', {b: new AuthenticatedSuccessAction(true, token, EPersonMock)}); expect(authEffects.authenticated$).toBeObservable(expected); }); diff --git a/src/app/core/auth/auth.effects.ts b/src/app/core/auth/auth.effects.ts index 785fee84eb..c57fa3f70e 100644 --- a/src/app/core/auth/auth.effects.ts +++ b/src/app/core/auth/auth.effects.ts @@ -28,7 +28,7 @@ import { RegistrationErrorAction, RegistrationSuccessAction } from './auth.actions'; -import { Eperson } from '../eperson/models/eperson.model'; +import { EPerson } from '../eperson/models/eperson.model'; import { AuthStatus } from './models/auth-status.model'; import { AuthTokenInfo } from './models/auth-token-info.model'; import { AppState } from '../../app.reducer'; @@ -66,7 +66,7 @@ export class AuthEffects { ofType(AuthActionTypes.AUTHENTICATED), switchMap((action: AuthenticatedAction) => { return this.authService.authenticatedUser(action.payload).pipe( - map((user: Eperson) => new AuthenticatedSuccessAction((user !== null), action.payload, user)), + map((user: EPerson) => new AuthenticatedSuccessAction((user !== null), action.payload, user)), catchError((error) => observableOf(new AuthenticatedErrorAction(error))),); }) ); @@ -94,7 +94,7 @@ export class AuthEffects { debounceTime(500), // to remove when functionality is implemented switchMap((action: RegistrationAction) => { return this.authService.create(action.payload).pipe( - map((user: Eperson) => new RegistrationSuccessAction(user)), + map((user: EPerson) => new RegistrationSuccessAction(user)), catchError((error) => observableOf(new RegistrationErrorAction(error))) ); }) diff --git a/src/app/core/auth/auth.interceptor.ts b/src/app/core/auth/auth.interceptor.ts index dc751ce19f..07043d6950 100644 --- a/src/app/core/auth/auth.interceptor.ts +++ b/src/app/core/auth/auth.interceptor.ts @@ -42,7 +42,7 @@ export class AuthInterceptor implements HttpInterceptor { } private isSuccess(response: HttpResponseBase): boolean { - return response.status === 200; + return (response.status === 200 || response.status === 204); } private isAuthRequest(http: HttpRequest | HttpResponseBase): boolean { diff --git a/src/app/core/auth/auth.reducer.spec.ts b/src/app/core/auth/auth.reducer.spec.ts index f148f3ac8d..ca2ba00036 100644 --- a/src/app/core/auth/auth.reducer.spec.ts +++ b/src/app/core/auth/auth.reducer.spec.ts @@ -21,7 +21,7 @@ import { SetRedirectUrlAction } from './auth.actions'; import { AuthTokenInfo } from './models/auth-token-info.model'; -import { EpersonMock } from '../../shared/testing/eperson-mock'; +import { EPersonMock } from '../../shared/testing/eperson-mock'; describe('authReducer', () => { @@ -107,7 +107,7 @@ describe('authReducer', () => { loading: true, info: undefined }; - const action = new AuthenticatedSuccessAction(true, mockTokenInfo, EpersonMock); + const action = new AuthenticatedSuccessAction(true, mockTokenInfo, EPersonMock); const newState = authReducer(initialState, action); state = { authenticated: true, @@ -116,7 +116,7 @@ describe('authReducer', () => { error: undefined, loading: false, info: undefined, - user: EpersonMock + user: EPersonMock }; expect(newState).toEqual(state); }); @@ -182,7 +182,7 @@ describe('authReducer', () => { error: undefined, loading: false, info: undefined, - user: EpersonMock + user: EPersonMock }; const action = new LogOutAction(); @@ -199,7 +199,7 @@ describe('authReducer', () => { error: undefined, loading: false, info: undefined, - user: EpersonMock + user: EPersonMock }; const action = new LogOutSuccessAction(); @@ -225,7 +225,7 @@ describe('authReducer', () => { error: undefined, loading: false, info: undefined, - user: EpersonMock + user: EPersonMock }; const action = new LogOutErrorAction(mockError); @@ -237,7 +237,7 @@ describe('authReducer', () => { error: 'Test error message', loading: false, info: undefined, - user: EpersonMock + user: EPersonMock }; expect(newState).toEqual(state); }); @@ -250,7 +250,7 @@ describe('authReducer', () => { error: undefined, loading: false, info: undefined, - user: EpersonMock + user: EPersonMock }; const newTokenInfo = new AuthTokenInfo('Refreshed token'); const action = new RefreshTokenAction(newTokenInfo); @@ -262,7 +262,7 @@ describe('authReducer', () => { error: undefined, loading: false, info: undefined, - user: EpersonMock, + user: EPersonMock, refreshing: true }; expect(newState).toEqual(state); @@ -276,7 +276,7 @@ describe('authReducer', () => { error: undefined, loading: false, info: undefined, - user: EpersonMock, + user: EPersonMock, refreshing: true }; const newTokenInfo = new AuthTokenInfo('Refreshed token'); @@ -289,7 +289,7 @@ describe('authReducer', () => { error: undefined, loading: false, info: undefined, - user: EpersonMock, + user: EPersonMock, refreshing: false }; expect(newState).toEqual(state); @@ -303,7 +303,7 @@ describe('authReducer', () => { error: undefined, loading: false, info: undefined, - user: EpersonMock, + user: EPersonMock, refreshing: true }; const action = new RefreshTokenErrorAction(); @@ -329,7 +329,7 @@ describe('authReducer', () => { error: undefined, loading: false, info: undefined, - user: EpersonMock + user: EPersonMock }; state = { diff --git a/src/app/core/auth/auth.reducer.ts b/src/app/core/auth/auth.reducer.ts index 0c5e36ce91..98827d842e 100644 --- a/src/app/core/auth/auth.reducer.ts +++ b/src/app/core/auth/auth.reducer.ts @@ -12,7 +12,7 @@ import { SetRedirectUrlAction } from './auth.actions'; // import models -import { Eperson } from '../eperson/models/eperson.model'; +import { EPerson } from '../eperson/models/eperson.model'; import { AuthTokenInfo } from './models/auth-token-info.model'; /** @@ -46,7 +46,7 @@ export interface AuthState { refreshing?: boolean; // the authenticated user - user?: Eperson; + user?: EPerson; } /** diff --git a/src/app/core/auth/auth.service.spec.ts b/src/app/core/auth/auth.service.spec.ts index 47110fd203..bfc21359fb 100644 --- a/src/app/core/auth/auth.service.spec.ts +++ b/src/app/core/auth/auth.service.spec.ts @@ -17,10 +17,12 @@ import { AuthRequestServiceStub } from '../../shared/testing/auth-request-servic import { AuthRequestService } from './auth-request.service'; import { AuthStatus } from './models/auth-status.model'; import { AuthTokenInfo } from './models/auth-token-info.model'; -import { Eperson } from '../eperson/models/eperson.model'; -import { EpersonMock } from '../../shared/testing/eperson-mock'; +import { EPerson } from '../eperson/models/eperson.model'; +import { EPersonMock } from '../../shared/testing/eperson-mock'; import { AppState } from '../../app.reducer'; import { ClientCookieService } from '../../shared/services/client-cookie.service'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { getMockRemoteDataBuildService } from '../../shared/mocks/mock-remote-data-build.service'; describe('AuthService test', () => { @@ -41,9 +43,9 @@ describe('AuthService test', () => { loaded: true, loading: false, authToken: token, - user: EpersonMock + user: EPersonMock }; - + const rdbService = getMockRemoteDataBuildService(); describe('', () => { beforeEach(() => { @@ -60,6 +62,7 @@ describe('AuthService test', () => { {provide: Router, useValue: routerStub}, {provide: ActivatedRoute, useValue: routeStub}, {provide: Store, useValue: mockStore}, + {provide: RemoteDataBuildService, useValue: rdbService}, CookieService, AuthService ], @@ -78,7 +81,7 @@ describe('AuthService test', () => { }); it('should return the authenticated user object when user token is valid', () => { - authService.authenticatedUser(new AuthTokenInfo('test_token')).subscribe((user: Eperson) => { + authService.authenticatedUser(new AuthTokenInfo('test_token')).subscribe((user: EPerson) => { expect(user).toBeDefined(); }); }); @@ -120,6 +123,7 @@ describe('AuthService test', () => { {provide: AuthRequestService, useValue: authRequest}, {provide: REQUEST, useValue: {}}, {provide: Router, useValue: routerStub}, + {provide: RemoteDataBuildService, useValue: rdbService}, CookieService ] }).compileComponents(); @@ -131,7 +135,7 @@ describe('AuthService test', () => { (state as any).core = Object.create({}); (state as any).core.auth = authenticatedState; }); - authService = new AuthService({}, window, authReqService, router, cookieService, store); + authService = new AuthService({}, window, authReqService, router, cookieService, store, rdbService); })); it('should return true when user is logged in', () => { @@ -183,14 +187,14 @@ describe('AuthService test', () => { loaded: true, loading: false, authToken: expiredToken, - user: EpersonMock + user: EPersonMock }; store .subscribe((state) => { (state as any).core = Object.create({}); (state as any).core.auth = authenticatedState; }); - authService = new AuthService({}, window, authReqService, router, cookieService, store); + authService = new AuthService({}, window, authReqService, router, cookieService, store, rdbService); storage = (authService as any).storage; spyOn(storage, 'get'); spyOn(storage, 'remove'); diff --git a/src/app/core/auth/auth.service.ts b/src/app/core/auth/auth.service.ts index 5ed2483bce..deb2875bf5 100644 --- a/src/app/core/auth/auth.service.ts +++ b/src/app/core/auth/auth.service.ts @@ -16,8 +16,10 @@ import { REQUEST } from '@nguniversal/express-engine/tokens'; import { RouterReducerState } from '@ngrx/router-store'; import { select, Store } from '@ngrx/store'; import { CookieAttributes } from 'js-cookie'; +import { Observable } from 'rxjs/Observable'; +import { map, switchMap, withLatestFrom } from 'rxjs/operators'; -import { Eperson } from '../eperson/models/eperson.model'; +import { EPerson } from '../eperson/models/eperson.model'; import { AuthRequestService } from './auth-request.service'; import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service'; @@ -35,6 +37,8 @@ import { AppState, routerStateSelector } from '../../app.reducer'; import { ResetAuthenticationMessagesAction, SetRedirectUrlAction } from './auth.actions'; import { NativeWindowRef, NativeWindowService } from '../../shared/services/window.service'; import { Base64EncodeUrl } from '../../shared/utils/encode-decode.util'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { NormalizedEPerson } from '../eperson/models/normalized-eperson.model'; export const LOGIN_ROUTE = '/login'; export const LOGOUT_ROUTE = '/logout'; @@ -58,7 +62,9 @@ export class AuthService { protected authRequestService: AuthRequestService, protected router: Router, protected storage: CookieService, - protected store: Store) { + protected store: Store, + protected rdbService: RemoteDataBuildService + ) { this.store.pipe( select(isAuthenticated), startWith(false) @@ -132,7 +138,7 @@ export class AuthService { * Returns the authenticated user * @returns {User} */ - public authenticatedUser(token: AuthTokenInfo): Observable { + public authenticatedUser(token: AuthTokenInfo): Observable { // Determine if the user has an existing auth session on the server const options: HttpOptions = Object.create({}); let headers = new HttpHeaders(); @@ -140,13 +146,18 @@ export class AuthService { headers = headers.append('Authorization', `Bearer ${token.accessToken}`); options.headers = headers; return this.authRequestService.getRequest('status', options).pipe( - map((status: AuthStatus) => { + switchMap((status: AuthStatus) => { + if (status.authenticated) { - return status.eperson[0]; + // TODO this should be cleaned up, AuthStatus could be parsed by the RemoteDataService as a whole... + // Review when https://jira.duraspace.org/browse/DS-4006 is fixed + // See https://github.com/DSpace/dspace-angular/issues/292 + const person$ = this.rdbService.buildSingle(status.eperson.toString()); + return person$.pipe(map((eperson) => eperson.payload)); } else { throw(new Error('Not authenticated')); } - })); + })) } /** @@ -206,7 +217,7 @@ export class AuthService { * Create a new user * @returns {User} */ - public create(user: Eperson): Observable { + public create(user: EPerson): Observable { // Normally you would do an HTTP request to POST the user // details and then return the new user object // but, let's just return the new user for this example. @@ -357,8 +368,12 @@ export class AuthService { this.router.navigated = false; const url = decodeURIComponent(redirectUrl); this.router.navigateByUrl(url); + /* TODO Reenable hard redirect when REST API can handle x-forwarded-for, see https://github.com/DSpace/DSpace/pull/2207 */ + // this._window.nativeWindow.location.href = url; } else { this.router.navigate(['/']); + /* TODO Reenable hard redirect when REST API can handle x-forwarded-for, see https://github.com/DSpace/DSpace/pull/2207 */ + // this._window.nativeWindow.location.href = '/'; } }) diff --git a/src/app/core/auth/models/auth-status.model.ts b/src/app/core/auth/models/auth-status.model.ts index 22c9d14718..b8ccf9ed6d 100644 --- a/src/app/core/auth/models/auth-status.model.ts +++ b/src/app/core/auth/models/auth-status.model.ts @@ -1,7 +1,8 @@ import { AuthError } from './auth-error.model'; import { AuthTokenInfo } from './auth-token-info.model'; -import { DSpaceObject } from '../../shared/dspace-object.model'; -import { Eperson } from '../../eperson/models/eperson.model'; +import { EPerson } from '../../eperson/models/eperson.model'; +import { RemoteData } from '../../data/remote-data'; +import { Observable } from 'rxjs/Observable'; export class AuthStatus { @@ -13,7 +14,7 @@ export class AuthStatus { error?: AuthError; - eperson: Eperson[]; + eperson: Observable>; token?: AuthTokenInfo; diff --git a/src/app/core/auth/models/normalized-auth-status.model.ts b/src/app/core/auth/models/normalized-auth-status.model.ts index 19952f7c70..b8dd2aa23e 100644 --- a/src/app/core/auth/models/normalized-auth-status.model.ts +++ b/src/app/core/auth/models/normalized-auth-status.model.ts @@ -1,12 +1,18 @@ import { AuthStatus } from './auth-status.model'; import { autoserialize, autoserializeAs, inheritSerialization } from 'cerialize'; -import { mapsTo } from '../../cache/builders/build-decorators'; -import { NormalizedDSpaceObject } from '../../cache/models/normalized-dspace-object.model'; -import { Eperson } from '../../eperson/models/eperson.model'; +import { mapsTo, relationship } from '../../cache/builders/build-decorators'; +import { ResourceType } from '../../shared/resource-type'; +import { NormalizedObject } from '../../cache/models/normalized-object.model'; +import { IDToUUIDSerializer } from '../../cache/id-to-uuid-serializer'; @mapsTo(AuthStatus) -@inheritSerialization(NormalizedDSpaceObject) -export class NormalizedAuthStatus extends NormalizedDSpaceObject { +@inheritSerialization(NormalizedObject) +export class NormalizedAuthStatus extends NormalizedObject { + @autoserialize + id: string; + + @autoserializeAs(new IDToUUIDSerializer('auth-status'), 'id') + uuid: string; /** * True if REST API is up and running, should never return false @@ -20,7 +26,7 @@ export class NormalizedAuthStatus extends NormalizedDSpaceObject { @autoserialize authenticated: boolean; - @autoserializeAs(Eperson) - eperson: Eperson[]; - + @relationship(ResourceType.EPerson, false) + @autoserialize + eperson: string; } diff --git a/src/app/core/auth/server-auth.service.ts b/src/app/core/auth/server-auth.service.ts index 7111eed255..089cbd0ba2 100644 --- a/src/app/core/auth/server-auth.service.ts +++ b/src/app/core/auth/server-auth.service.ts @@ -1,5 +1,4 @@ - -import {first, map} from 'rxjs/operators'; +import { first, map, switchMap } from 'rxjs/operators'; import { Injectable } from '@angular/core'; import { Observable } from 'rxjs'; @@ -10,7 +9,8 @@ import { isNotEmpty } from '../../shared/empty.util'; import { AuthService } from './auth.service'; import { AuthTokenInfo } from './models/auth-token-info.model'; import { CheckAuthenticationTokenAction } from './auth.actions'; -import { Eperson } from '../eperson/models/eperson.model'; +import { EPerson } from '../eperson/models/eperson.model'; +import { NormalizedEPerson } from '../eperson/models/normalized-eperson.model'; /** * The auth service. @@ -22,7 +22,7 @@ export class ServerAuthService extends AuthService { * Returns the authenticated user * @returns {User} */ - public authenticatedUser(token: AuthTokenInfo): Observable { + public authenticatedUser(token: AuthTokenInfo): Observable { // Determine if the user has an existing auth session on the server const options: HttpOptions = Object.create({}); let headers = new HttpHeaders(); @@ -35,13 +35,18 @@ export class ServerAuthService extends AuthService { options.headers = headers; return this.authRequestService.getRequest('status', options).pipe( - map((status: AuthStatus) => { + switchMap((status: AuthStatus) => { + if (status.authenticated) { - return status.eperson[0]; + + // TODO this should be cleaned up, AuthStatus could be parsed by the RemoteDataService as a whole... + const person$ = this.rdbService.buildSingle(status.eperson.toString()); + // person$.subscribe(() => console.log('test')); + return person$.pipe(map((eperson) => eperson.payload)); } else { throw(new Error('Not authenticated')); } - })); + })) } /** diff --git a/src/app/core/browse/browse.service.spec.ts b/src/app/core/browse/browse.service.spec.ts index 85fd4b3804..d43a26ed4b 100644 --- a/src/app/core/browse/browse.service.spec.ts +++ b/src/app/core/browse/browse.service.spec.ts @@ -6,7 +6,7 @@ import { getMockResponseCacheService } from '../../shared/mocks/mock-response-ca import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service-stub'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { ResponseCacheService } from '../cache/response-cache.service'; -import { BrowseEndpointRequest, BrowseEntriesRequest } from '../data/request.models'; +import { BrowseEndpointRequest, BrowseEntriesRequest, BrowseItemsRequest } from '../data/request.models'; import { RequestService } from '../data/request.service'; import { BrowseDefinition } from '../shared/browse-definition.model'; import { BrowseService } from './browse.service'; @@ -143,7 +143,9 @@ describe('BrowseService', () => { }); - describe('getBrowseEntriesFor', () => { + describe('getBrowseEntriesFor and getBrowseItemsFor', () => { + const mockAuthorName = 'Donald Smith'; + beforeEach(() => { responseCache = initMockResponseCacheService(true); requestService = getMockRequestService(); @@ -156,7 +158,7 @@ describe('BrowseService', () => { spyOn(rdbService, 'toRemoteDataObservable').and.callThrough(); }); - describe('when called with a valid browse definition id', () => { + describe('when getBrowseEntriesFor is called with a valid browse definition id', () => { it('should configure a new BrowseEntriesRequest', () => { const expected = new BrowseEntriesRequest(requestService.generateRequestId(), browseDefinitions[1]._links.entries); @@ -175,7 +177,26 @@ describe('BrowseService', () => { }); - describe('when called with an invalid browse definition id', () => { + describe('when getBrowseItemsFor is called with a valid browse definition id', () => { + it('should configure a new BrowseItemsRequest', () => { + const expected = new BrowseItemsRequest(requestService.generateRequestId(), browseDefinitions[1]._links.items + '?filterValue=' + mockAuthorName); + + scheduler.schedule(() => service.getBrowseItemsFor(browseDefinitions[1].id, mockAuthorName).subscribe()); + scheduler.flush(); + + expect(requestService.configure).toHaveBeenCalledWith(expected); + }); + + it('should call RemoteDataBuildService to create the RemoteData Observable', () => { + service.getBrowseItemsFor(browseDefinitions[1].id, mockAuthorName); + + expect(rdbService.toRemoteDataObservable).toHaveBeenCalled(); + + }); + + }); + + describe('when getBrowseEntriesFor is called with an invalid browse definition id', () => { it('should throw an Error', () => { const definitionID = 'invalidID'; @@ -184,6 +205,16 @@ describe('BrowseService', () => { expect(service.getBrowseEntriesFor(definitionID)).toBeObservable(expected); }); }); + + describe('when getBrowseItemsFor is called with an invalid browse definition id', () => { + it('should throw an Error', () => { + + const definitionID = 'invalidID'; + const expected = cold('--#-', undefined, new Error(`No metadata browse definition could be found for id '${definitionID}'`)) + + expect(service.getBrowseItemsFor(definitionID, mockAuthorName)).toBeObservable(expected); + }); + }); }); describe('getBrowseURLFor', () => { diff --git a/src/app/core/browse/browse.service.ts b/src/app/core/browse/browse.service.ts index 72e2be481d..ddce277e7e 100644 --- a/src/app/core/browse/browse.service.ts +++ b/src/app/core/browse/browse.service.ts @@ -16,19 +16,27 @@ import { ResponseCacheEntry } from '../cache/response-cache.reducer'; import { ResponseCacheService } from '../cache/response-cache.service'; import { PaginatedList } from '../data/paginated-list'; import { RemoteData } from '../data/remote-data'; -import { BrowseEndpointRequest, BrowseEntriesRequest, RestRequest } from '../data/request.models'; +import { + BrowseEndpointRequest, + BrowseEntriesRequest, + BrowseItemsRequest, + GetRequest, + RestRequest +} from '../data/request.models'; import { RequestService } from '../data/request.service'; import { BrowseDefinition } from '../shared/browse-definition.model'; import { BrowseEntry } from '../shared/browse-entry.model'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { configureRequest, - filterSuccessfulResponses, + filterSuccessfulResponses, getBrowseDefinitionLinks, getRemoteDataPayload, getRequestFromSelflink, getResponseFromSelflink } from '../shared/operators'; import { URLCombiner } from '../url-combiner/url-combiner'; +import { Item } from '../shared/item.model'; +import { DSpaceObject } from '../shared/dspace-object.model'; @Injectable() export class BrowseService { @@ -71,6 +79,8 @@ export class BrowseService { map((entry: ResponseCacheEntry) => entry.response), map((response: GenericSuccessResponse) => response.payload), ensureArrayHasValue(), + map((definitions: BrowseDefinition[]) => definitions + .map((definition: BrowseDefinition) => Object.assign(new BrowseDefinition(), definition))), distinctUntilChanged() ); @@ -82,17 +92,7 @@ export class BrowseService { sort?: SortOptions; } = {}): Observable>> { const request$ = this.getBrowseDefinitions().pipe( - getRemoteDataPayload(), - map((browseDefinitions: BrowseDefinition[]) => browseDefinitions - .find((def: BrowseDefinition) => def.id === definitionID && def.metadataBrowse === true) - ), - map((def: BrowseDefinition) => { - if (isNotEmpty(def)) { - return def._links; - } else { - throw new Error(`No metadata browse definition could be found for id '${definitionID}'`); - } - }), + getBrowseDefinitionLinks(definitionID), hasValueOperator(), map((_links: any) => _links.entries), hasValueOperator(), @@ -124,6 +124,66 @@ export class BrowseService { filterSuccessfulResponses(), map((entry: ResponseCacheEntry) => entry.response), map((response: GenericSuccessResponse) => new PaginatedList(response.pageInfo, response.payload)), + map((list: PaginatedList) => Object.assign(list, { + page: list.page ? list.page.map((entry: BrowseEntry) => Object.assign(new BrowseEntry(), entry)) : list.page + })), + distinctUntilChanged() + ); + + return this.rdb.toRemoteDataObservable(requestEntry$, responseCache$, payload$); + } + + /** + * Get all items linked to a certain metadata value + * @param {string} definitionID definition ID to define the metadata-field (e.g. author) + * @param {string} filterValue metadata value to filter by (e.g. author's name) + * @param options Options to narrow down your search: + * { pagination: PaginationComponentOptions, + * sort: SortOptions } + * @returns {Observable>>} + */ + getBrowseItemsFor(definitionID: string, filterValue: string, options: { + pagination?: PaginationComponentOptions; + sort?: SortOptions; + } = {}): Observable>> { + const request$ = this.getBrowseDefinitions().pipe( + getBrowseDefinitionLinks(definitionID), + hasValueOperator(), + map((_links: any) => _links.items), + hasValueOperator(), + map((href: string) => { + const args = []; + if (isNotEmpty(options.sort)) { + args.push(`sort=${options.sort.field},${options.sort.direction}`); + } + if (isNotEmpty(options.pagination)) { + args.push(`page=${options.pagination.currentPage - 1}`); + args.push(`size=${options.pagination.pageSize}`); + } + if (isNotEmpty(filterValue)) { + args.push(`filterValue=${filterValue}`); + } + if (isNotEmpty(args)) { + href = new URLCombiner(href, `?${args.join('&')}`).toString(); + } + return href; + }), + map((endpointURL: string) => new BrowseItemsRequest(this.requestService.generateRequestId(), endpointURL)), + configureRequest(this.requestService) + ); + + const href$ = request$.pipe(map((request: RestRequest) => request.href)); + + const requestEntry$ = href$.pipe(getRequestFromSelflink(this.requestService)); + const responseCache$ = href$.pipe(getResponseFromSelflink(this.responseCache)); + + const payload$ = responseCache$.pipe( + filterSuccessfulResponses(), + map((entry: ResponseCacheEntry) => entry.response), + map((response: GenericSuccessResponse) => new PaginatedList(response.pageInfo, response.payload)), + map((list: PaginatedList) => Object.assign(list, { + page: list.page ? list.page.map((item: DSpaceObject) => Object.assign(new Item(), item)) : list.page + })), distinctUntilChanged() ); diff --git a/src/app/core/cache/models/normalized-object-factory.ts b/src/app/core/cache/models/normalized-object-factory.ts index df67a1f2ce..5c5ebf50aa 100644 --- a/src/app/core/cache/models/normalized-object-factory.ts +++ b/src/app/core/cache/models/normalized-object-factory.ts @@ -8,6 +8,8 @@ import { ResourceType } from '../../shared/resource-type'; import { NormalizedObject } from './normalized-object.model'; import { NormalizedBitstreamFormat } from './normalized-bitstream-format.model'; import { NormalizedResourcePolicy } from './normalized-resource-policy.model'; +import { NormalizedEPerson } from '../../eperson/models/normalized-eperson.model'; +import { NormalizedGroup } from '../../eperson/models/normalized-group.model'; export class NormalizedObjectFactory { public static getConstructor(type: ResourceType): GenericConstructor { @@ -33,6 +35,12 @@ export class NormalizedObjectFactory { case ResourceType.ResourcePolicy: { return NormalizedResourcePolicy } + case ResourceType.EPerson: { + return NormalizedEPerson + } + case ResourceType.Group: { + return NormalizedGroup + } default: { return undefined; } diff --git a/src/app/core/core.module.ts b/src/app/core/core.module.ts index dabdfba0ab..73e97c7933 100644 --- a/src/app/core/core.module.ts +++ b/src/app/core/core.module.ts @@ -62,6 +62,7 @@ import { RegistryMetadatafieldsResponseParsingService } from './data/registry-me import { RegistryBitstreamformatsResponseParsingService } from './data/registry-bitstreamformats-response-parsing.service'; import { NotificationsService } from '../shared/notifications/notifications.service'; import { UploaderService } from '../shared/uploader/uploader.service'; +import { BrowseItemsResponseParsingService } from './data/browse-items-response-parsing-service'; import { DSpaceObjectDataService } from './data/dspace-object-data.service'; const IMPORTS = [ @@ -115,6 +116,7 @@ const PROVIDERS = [ ServerResponseService, BrowseResponseParsingService, BrowseEntriesResponseParsingService, + BrowseItemsResponseParsingService, BrowseService, ConfigResponseParsingService, RouteService, diff --git a/src/app/core/data/base-response-parsing.service.ts b/src/app/core/data/base-response-parsing.service.ts index 050b3c2da5..fdf5b4eb97 100644 --- a/src/app/core/data/base-response-parsing.service.ts +++ b/src/app/core/data/base-response-parsing.service.ts @@ -7,6 +7,8 @@ import { GlobalConfig } from '../../../config/global-config.interface'; import { GenericConstructor } from '../shared/generic-constructor'; import { PaginatedList } from './paginated-list'; import { NormalizedObject } from '../cache/models/normalized-object.model'; +import { ResourceType } from '../shared/resource-type'; +import { RESTURLCombiner } from '../url-combiner/rest-url-combiner'; function isObjectLevel(halObj: any) { return isNotEmpty(halObj._links) && hasValue(halObj._links.self); @@ -34,6 +36,7 @@ export abstract class BaseResponseParsingService { } else if (Array.isArray(data)) { return this.processArray(data, requestHref); } else if (isObjectLevel(data)) { + data = this.fixBadEPersonRestResponse(data); const object = this.deserialize(data); if (isNotEmpty(data._embedded)) { Object @@ -53,6 +56,7 @@ export abstract class BaseResponseParsingService { } }); } + this.cache(object, requestHref); return object; } @@ -145,4 +149,23 @@ export abstract class BaseResponseParsingService { } return obj[keys[0]]; } + + // TODO Remove when https://jira.duraspace.org/browse/DS-4006 is fixed + // See https://github.com/DSpace/dspace-angular/issues/292 + private fixBadEPersonRestResponse(obj: any): any { + if (obj.type === ResourceType.EPerson) { + const groups = obj.groups; + const normGroups = []; + if (isNotEmpty(groups)) { + groups.forEach((group) => { + const parts = ['eperson', 'groups', group.uuid]; + const href = new RESTURLCombiner(this.EnvConfig, ...parts).toString(); + normGroups.push(href); + } + ) + } + return Object.assign({}, obj, { groups: normGroups }); + } + return obj; + } } diff --git a/src/app/core/data/browse-items-response-parsing-service.spec.ts b/src/app/core/data/browse-items-response-parsing-service.spec.ts new file mode 100644 index 0000000000..6a141c01c4 --- /dev/null +++ b/src/app/core/data/browse-items-response-parsing-service.spec.ts @@ -0,0 +1,168 @@ +import { getMockObjectCacheService } from '../../shared/mocks/mock-object-cache.service'; +import { ErrorResponse, GenericSuccessResponse } from '../cache/response-cache.models'; +import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; +import { BrowseEntriesResponseParsingService } from './browse-entries-response-parsing.service'; +import { BrowseEntriesRequest, BrowseItemsRequest } from './request.models'; +import { BrowseItemsResponseParsingService } from './browse-items-response-parsing-service'; + +describe('BrowseItemsResponseParsingService', () => { + let service: BrowseItemsResponseParsingService; + + beforeEach(() => { + service = new BrowseItemsResponseParsingService(undefined, getMockObjectCacheService()); + }); + + describe('parse', () => { + const request = new BrowseItemsRequest('client/f5b4ccb8-fbb0-4548-b558-f234d9fdfad6', 'https://rest.api/discover/browses/author/items'); + + const validResponse = { + payload: { + _embedded: { + items: [ + { + id: 'd7b6bc6f-ff6c-444a-a0d3-0cd9b68043e7', + uuid: 'd7b6bc6f-ff6c-444a-a0d3-0cd9b68043e7', + name: 'Development of Local Supply Chain : A Critical Link for Concentrated Solar Power in India', + handle: '10986/17472', + metadata: [ + { + key: 'dc.creator', + value: 'World Bank', + language: null + } + ], + inArchive: true, + discoverable: true, + withdrawn: false, + lastModified: '2018-05-25T09:32:58.005+0000', + type: 'item', + _links: { + bitstreams: { + href: 'https://dspace7-internal.atmire.com/rest/api/core/items/d7b6bc6f-ff6c-444a-a0d3-0cd9b68043e7/bitstreams' + }, + owningCollection: { + href: 'https://dspace7-internal.atmire.com/rest/api/core/items/d7b6bc6f-ff6c-444a-a0d3-0cd9b68043e7/owningCollection' + }, + templateItemOf: { + href: 'https://dspace7-internal.atmire.com/rest/api/core/items/d7b6bc6f-ff6c-444a-a0d3-0cd9b68043e7/templateItemOf' + }, + self: { + href: 'https://dspace7-internal.atmire.com/rest/api/core/items/d7b6bc6f-ff6c-444a-a0d3-0cd9b68043e7' + } + } + }, + { + id: '27c6f976-257c-4ad0-a0ef-c5e34ffe4d5b', + uuid: '27c6f976-257c-4ad0-a0ef-c5e34ffe4d5b', + name: 'Development of Local Supply Chain : The Missing Link for Concentrated Solar Power Projects in India', + handle: '10986/17475', + metadata: [ + { + key: 'dc.creator', + value: 'World Bank', + language: null + } + ], + inArchive: true, + discoverable: true, + withdrawn: false, + lastModified: '2018-05-25T09:33:42.526+0000', + type: 'item', + _links: { + bitstreams: { + href: 'https://dspace7-internal.atmire.com/rest/api/core/items/27c6f976-257c-4ad0-a0ef-c5e34ffe4d5b/bitstreams' + }, + owningCollection: { + href: 'https://dspace7-internal.atmire.com/rest/api/core/items/27c6f976-257c-4ad0-a0ef-c5e34ffe4d5b/owningCollection' + }, + templateItemOf: { + href: 'https://dspace7-internal.atmire.com/rest/api/core/items/27c6f976-257c-4ad0-a0ef-c5e34ffe4d5b/templateItemOf' + }, + self: { + href: 'https://dspace7-internal.atmire.com/rest/api/core/items/27c6f976-257c-4ad0-a0ef-c5e34ffe4d5b' + } + } + } + ] + }, + _links: { + first: { + href: 'https://dspace7-internal.atmire.com/rest/api/discover/browses/author/items?page=0&size=2' + }, + self: { + href: 'https://dspace7-internal.atmire.com/rest/api/discover/browses/author/items' + }, + next: { + href: 'https://dspace7-internal.atmire.com/rest/api/discover/browses/author/items?page=1&size=2' + }, + last: { + href: 'https://dspace7-internal.atmire.com/rest/api/discover/browses/author/items?page=7&size=2' + } + }, + page: { + size: 2, + totalElements: 16, + totalPages: 8, + number: 0 + } + }, + statusCode: '200' + } as DSpaceRESTV2Response; + + const invalidResponseNotAList = { + payload: { + id: 'd7b6bc6f-ff6c-444a-a0d3-0cd9b68043e7', + uuid: 'd7b6bc6f-ff6c-444a-a0d3-0cd9b68043e7', + name: 'Development of Local Supply Chain : A Critical Link for Concentrated Solar Power in India', + handle: '10986/17472', + metadata: [ + { + key: 'dc.creator', + value: 'World Bank', + language: null + } + ], + inArchive: true, + discoverable: true, + withdrawn: false, + lastModified: '2018-05-25T09:32:58.005+0000', + type: 'item', + _links: { + bitstreams: { + href: 'https://dspace7-internal.atmire.com/rest/api/core/items/d7b6bc6f-ff6c-444a-a0d3-0cd9b68043e7/bitstreams' + }, + owningCollection: { + href: 'https://dspace7-internal.atmire.com/rest/api/core/items/d7b6bc6f-ff6c-444a-a0d3-0cd9b68043e7/owningCollection' + }, + templateItemOf: { + href: 'https://dspace7-internal.atmire.com/rest/api/core/items/d7b6bc6f-ff6c-444a-a0d3-0cd9b68043e7/templateItemOf' + }, + self: { + href: 'https://dspace7-internal.atmire.com/rest/api/core/items/d7b6bc6f-ff6c-444a-a0d3-0cd9b68043e7' + } + } + }, + statusCode: '200' + } as DSpaceRESTV2Response; + + const invalidResponseStatusCode = { + payload: {}, statusCode: '500' + } as DSpaceRESTV2Response; + + it('should return a GenericSuccessResponse if data contains a valid browse items response', () => { + const response = service.parse(request, validResponse); + expect(response.constructor).toBe(GenericSuccessResponse); + }); + + it('should return an ErrorResponse if data contains an invalid browse entries response', () => { + const response = service.parse(request, invalidResponseNotAList); + expect(response.constructor).toBe(ErrorResponse); + }); + + it('should return an ErrorResponse if data contains a statuscode other than 200', () => { + const response = service.parse(request, invalidResponseStatusCode); + expect(response.constructor).toBe(ErrorResponse); + }); + + }); +}); diff --git a/src/app/core/data/browse-items-response-parsing-service.ts b/src/app/core/data/browse-items-response-parsing-service.ts new file mode 100644 index 0000000000..e513ad0898 --- /dev/null +++ b/src/app/core/data/browse-items-response-parsing-service.ts @@ -0,0 +1,58 @@ +import { Inject, Injectable } from '@angular/core'; +import { GLOBAL_CONFIG } from '../../../config'; +import { GlobalConfig } from '../../../config/global-config.interface'; +import { isNotEmpty } from '../../shared/empty.util'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { + ErrorResponse, + GenericSuccessResponse, + RestResponse +} from '../cache/response-cache.models'; +import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; +import { DSpaceRESTv2Serializer } from '../dspace-rest-v2/dspace-rest-v2.serializer'; +import { BaseResponseParsingService } from './base-response-parsing.service'; +import { ResponseParsingService } from './parsing.service'; +import { RestRequest } from './request.models'; +import { Item } from '../shared/item.model'; +import { DSpaceObject } from '../shared/dspace-object.model'; + +/** + * A ResponseParsingService used to parse DSpaceRESTV2Response coming from the REST API to Browse Items (DSpaceObject[]) + */ +@Injectable() +export class BrowseItemsResponseParsingService extends BaseResponseParsingService implements ResponseParsingService { + + protected objectFactory = { + getConstructor: () => DSpaceObject + }; + protected toCache = false; + + constructor( + @Inject(GLOBAL_CONFIG) protected EnvConfig: GlobalConfig, + protected objectCache: ObjectCacheService, + ) { super(); + } + + /** + * Parses data from the browse endpoint to a list of DSpaceObjects + * @param {RestRequest} request + * @param {DSpaceRESTV2Response} data + * @returns {RestResponse} + */ + parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse { + if (isNotEmpty(data.payload) && isNotEmpty(data.payload._embedded) + && Array.isArray(data.payload._embedded[Object.keys(data.payload._embedded)[0]])) { + const serializer = new DSpaceRESTv2Serializer(DSpaceObject); + const items = serializer.deserializeArray(data.payload._embedded[Object.keys(data.payload._embedded)[0]]); + return new GenericSuccessResponse(items, data.statusCode, this.processPageInfo(data.payload)); + } else { + return new ErrorResponse( + Object.assign( + new Error('Unexpected response from browse endpoint'), + { statusText: data.statusCode } + ) + ); + } + } + +} diff --git a/src/app/core/data/comcol-data.service.spec.ts b/src/app/core/data/comcol-data.service.spec.ts index eb2e1bd1cf..dca7caedd4 100644 --- a/src/app/core/data/comcol-data.service.spec.ts +++ b/src/app/core/data/comcol-data.service.spec.ts @@ -9,7 +9,7 @@ import { ResponseCacheService } from '../cache/response-cache.service'; import { CoreState } from '../core.reducers'; import { ComColDataService } from './comcol-data.service'; import { CommunityDataService } from './community-data.service'; -import { FindByIDRequest } from './request.models'; +import { FindAllOptions, FindByIDRequest } from './request.models'; import { RequestService } from './request.service'; import { NormalizedObject } from '../cache/models/normalized-object.model'; import { HALEndpointService } from '../shared/hal-endpoint.service'; @@ -52,6 +52,10 @@ describe('ComColDataService', () => { const EnvConfig = {} as GlobalConfig; const scopeID = 'd9d30c0c-69b7-4369-8397-ca67c888974d'; + const options = Object.assign(new FindAllOptions(), { + scopeID: scopeID + }); + const communitiesEndpoint = 'https://rest.api/core/communities'; const communityEndpoint = `${communitiesEndpoint}/${scopeID}`; const scopedEndpoint = `${communityEndpoint}/${LINK_NAME}`; @@ -98,7 +102,7 @@ describe('ComColDataService', () => { ); } - describe('getScopedEndpoint', () => { + describe('getBrowseEndpoint', () => { beforeEach(() => { scheduler = getTestScheduler(); }); @@ -112,7 +116,7 @@ describe('ComColDataService', () => { const expected = new FindByIDRequest(requestService.generateRequestId(), communityEndpoint, scopeID); - scheduler.schedule(() => service.getScopedEndpoint(scopeID).subscribe()); + scheduler.schedule(() => service.getBrowseEndpoint(options).subscribe()); scheduler.flush(); expect(requestService.configure).toHaveBeenCalledWith(expected); @@ -128,13 +132,13 @@ describe('ComColDataService', () => { }); it('should fetch the scope Community from the cache', () => { - scheduler.schedule(() => service.getScopedEndpoint(scopeID).subscribe()); + scheduler.schedule(() => service.getBrowseEndpoint(options).subscribe()); scheduler.flush(); expect(objectCache.getByUUID).toHaveBeenCalledWith(scopeID); }); it('should return the endpoint to fetch resources within the given scope', () => { - const result = service.getScopedEndpoint(scopeID); + const result = service.getBrowseEndpoint(options); const expected = cold('--e-', { e: scopedEndpoint }); expect(result).toBeObservable(expected); @@ -151,7 +155,7 @@ describe('ComColDataService', () => { }); it('should throw an error', () => { - const result = service.getScopedEndpoint(scopeID); + const result = service.getBrowseEndpoint(options); const expected = cold('--#-', undefined, new Error(`The Community with scope ${scopeID} couldn't be retrieved`)); expect(result).toBeObservable(expected); diff --git a/src/app/core/data/comcol-data.service.ts b/src/app/core/data/comcol-data.service.ts index c59ecbb3e3..12be32a4f4 100644 --- a/src/app/core/data/comcol-data.service.ts +++ b/src/app/core/data/comcol-data.service.ts @@ -7,7 +7,7 @@ import { ResponseCacheEntry } from '../cache/response-cache.reducer'; import { CommunityDataService } from './community-data.service'; import { DataService } from './data.service'; -import { FindByIDRequest } from './request.models'; +import { FindAllOptions, FindByIDRequest } from './request.models'; import { NormalizedObject } from '../cache/models/normalized-object.model'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { DSOSuccessResponse } from '../cache/response-cache.models'; @@ -27,17 +27,18 @@ export abstract class ComColDataService } * an Observable containing the scoped URL */ - public getScopedEndpoint(scopeID: string): Observable { - if (isEmpty(scopeID)) { + public getBrowseEndpoint(options: FindAllOptions = {}): Observable { + if (isEmpty(options.scopeID)) { return this.halService.getEndpoint(this.linkPath); } else { - const scopeCommunityHrefObs = this.cds.getEndpoint().pipe( - mergeMap((endpoint: string) => this.cds.getFindByIDHref(endpoint, scopeID)), - first((href: string) => isNotEmpty(href)), - tap((href: string) => { - const request = new FindByIDRequest(this.requestService.generateRequestId(), href, scopeID); + const scopeCommunityHrefObs = this.cds.getEndpoint() + .flatMap((endpoint: string) => this.cds.getFindByIDHref(endpoint, options.scopeID)) + .filter((href: string) => isNotEmpty(href)) + .take(1) + .do((href: string) => { + const request = new FindByIDRequest(this.requestService.generateRequestId(), href, options.scopeID); this.requestService.configure(request); - }),); + }); // return scopeCommunityHrefObs.pipe( // mergeMap((href: string) => this.responseCache.get(href)), @@ -61,16 +62,15 @@ export abstract class ComColDataService entry.response)); const errorResponses = responses.pipe( filter((response) => !response.isSuccessful), - mergeMap(() => observableThrowError(new Error(`The Community with scope ${scopeID} couldn't be retrieved`))) + mergeMap(() => observableThrowError(new Error(`The Community with scope ${options.scopeID} couldn't be retrieved`))) ); const successResponses = responses.pipe( filter((response) => response.isSuccessful), - mergeMap(() => this.objectCache.getByUUID(scopeID)), + mergeMap(() => this.objectCache.getByUUID(options.scopeID)), map((nc: NormalizedCommunity) => nc._links[this.linkPath]), filter((href) => isNotEmpty(href)) ); - return observableMerge(errorResponses, successResponses).pipe(distinctUntilChanged()); } } diff --git a/src/app/core/data/data.service.spec.ts b/src/app/core/data/data.service.spec.ts index add84818de..1adf108687 100644 --- a/src/app/core/data/data.service.spec.ts +++ b/src/app/core/data/data.service.spec.ts @@ -10,7 +10,7 @@ import { Observable } from 'rxjs'; import { FindAllOptions } from './request.models'; import { SortOptions, SortDirection } from '../cache/models/sort-options.model'; -const LINK_NAME = 'test' +const endpoint = 'https://rest.api/core'; // tslint:disable:max-classes-per-file class NormalizedTestObject extends NormalizedObject { @@ -28,10 +28,9 @@ class TestService extends DataService { super(); } - public getScopedEndpoint(scope: string): Observable { - throw new Error('getScopedEndpoint is abstract in DataService'); + public getBrowseEndpoint(options: FindAllOptions): Observable { + return Observable.of(endpoint); } - } describe('DataService', () => { @@ -42,7 +41,6 @@ describe('DataService', () => { const halService = {} as HALEndpointService; const rdbService = {} as RemoteDataBuildService; const store = {} as Store; - const endpoint = 'https://rest.api/core'; function initTestService(): TestService { return new TestService( @@ -50,7 +48,7 @@ describe('DataService', () => { requestService, rdbService, store, - LINK_NAME, + endpoint, halService ); } @@ -62,25 +60,17 @@ describe('DataService', () => { it('should return an observable with the endpoint', () => { options = {}; - (service as any).getFindAllHref(endpoint).subscribe((value) => { + (service as any).getFindAllHref(options).subscribe((value) => { expect(value).toBe(endpoint); } ); }); - // getScopedEndpoint is not implemented in abstract DataService - it('should throw error if scopeID provided in options', () => { - options = { scopeID: 'somevalue' }; - - expect(() => { (service as any).getFindAllHref(endpoint, options) }) - .toThrowError('getScopedEndpoint is abstract in DataService'); - }); - it('should include page in href if currentPage provided in options', () => { options = { currentPage: 2 }; const expected = `${endpoint}?page=${options.currentPage - 1}`; - (service as any).getFindAllHref(endpoint, options).subscribe((value) => { + (service as any).getFindAllHref(options).subscribe((value) => { expect(value).toBe(expected); }); }); @@ -89,7 +79,7 @@ describe('DataService', () => { options = { elementsPerPage: 5 }; const expected = `${endpoint}?size=${options.elementsPerPage}`; - (service as any).getFindAllHref(endpoint, options).subscribe((value) => { + (service as any).getFindAllHref(options).subscribe((value) => { expect(value).toBe(expected); }); }); @@ -99,7 +89,7 @@ describe('DataService', () => { options = { sort: sortOptions}; const expected = `${endpoint}?sort=${sortOptions.field},${sortOptions.direction}`; - (service as any).getFindAllHref(endpoint, options).subscribe((value) => { + (service as any).getFindAllHref(options).subscribe((value) => { expect(value).toBe(expected); }); }); @@ -108,7 +98,7 @@ describe('DataService', () => { options = { startsWith: 'ab' }; const expected = `${endpoint}?startsWith=${options.startsWith}`; - (service as any).getFindAllHref(endpoint, options).subscribe((value) => { + (service as any).getFindAllHref(options).subscribe((value) => { expect(value).toBe(expected); }); }); @@ -124,7 +114,7 @@ describe('DataService', () => { const expected = `${endpoint}?page=${options.currentPage - 1}&size=${options.elementsPerPage}` + `&sort=${sortOptions.field},${sortOptions.direction}&startsWith=${options.startsWith}`; - (service as any).getFindAllHref(endpoint, options).subscribe((value) => { + (service as any).getFindAllHref(options).subscribe((value) => { expect(value).toBe(expected); }); }) diff --git a/src/app/core/data/data.service.ts b/src/app/core/data/data.service.ts index 1d0703bb47..a42990f780 100644 --- a/src/app/core/data/data.service.ts +++ b/src/app/core/data/data.service.ts @@ -1,7 +1,5 @@ - +import { filter, take, first } from 'rxjs/operators'; import {of as observableOf, Observable } from 'rxjs'; - -import {mergeMap, first, take, distinctUntilChanged, map, filter} from 'rxjs/operators'; import { Store } from '@ngrx/store'; import { hasValue, isNotEmpty } from '../../shared/empty.util'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; @@ -14,6 +12,8 @@ import { RemoteData } from './remote-data'; import { FindAllOptions, FindAllRequest, FindByIDRequest, GetRequest } from './request.models'; import { RequestService } from './request.service'; import { NormalizedObject } from '../cache/models/normalized-object.model'; +import { promise } from 'selenium-webdriver'; +import map = promise.map; export abstract class DataService { protected abstract responseCache: ResponseCacheService; @@ -23,17 +23,13 @@ export abstract class DataService protected abstract linkPath: string; protected abstract halService: HALEndpointService; - public abstract getScopedEndpoint(scope: string): Observable + public abstract getBrowseEndpoint(options: FindAllOptions): Observable - protected getFindAllHref(endpoint, options: FindAllOptions = {}): Observable { + protected getFindAllHref(options: FindAllOptions = {}): Observable { let result: Observable; const args = []; - if (hasValue(options.scopeID)) { - result = this.getScopedEndpoint(options.scopeID).pipe(distinctUntilChanged()); - } else { - result = observableOf(endpoint); - } + result = this.getBrowseEndpoint(options).distinctUntilChanged(); if (hasValue(options.currentPage) && typeof options.currentPage === 'number') { /* TODO: this is a temporary fix for the pagination start index (0 or 1) discrepancy between the rest and the frontend respectively */ @@ -60,12 +56,11 @@ export abstract class DataService } findAll(options: FindAllOptions = {}): Observable>> { - const hrefObs = this.halService.getEndpoint(this.linkPath).pipe(filter((href: string) => isNotEmpty(href)), - mergeMap((endpoint: string) => this.getFindAllHref(endpoint, options)),); + const hrefObs = this.getFindAllHref(options); hrefObs.pipe( filter((href: string) => hasValue(href)), - take(1),) + take(1)) .subscribe((href: string) => { const request = new FindAllRequest(this.requestService.generateRequestId(), href, options); this.requestService.configure(request); diff --git a/src/app/core/data/dspace-object-data.service.ts b/src/app/core/data/dspace-object-data.service.ts index 09eea8a95f..c44f4ce1d1 100644 --- a/src/app/core/data/dspace-object-data.service.ts +++ b/src/app/core/data/dspace-object-data.service.ts @@ -10,6 +10,7 @@ import { HALEndpointService } from '../shared/hal-endpoint.service'; import { DataService } from './data.service'; import { RemoteData } from './remote-data'; import { RequestService } from './request.service'; +import { FindAllOptions } from './request.models'; /* tslint:disable:max-classes-per-file */ class DataServiceImpl extends DataService { @@ -24,8 +25,8 @@ class DataServiceImpl extends DataService super(); } - getScopedEndpoint(scope: string): Observable { - return undefined; + getBrowseEndpoint(options: FindAllOptions): Observable { + return this.halService.getEndpoint(this.linkPath); } getFindByIDHref(endpoint, resourceID): string { diff --git a/src/app/core/data/item-data.service.spec.ts b/src/app/core/data/item-data.service.spec.ts index 0a233d086c..8e2db15921 100644 --- a/src/app/core/data/item-data.service.spec.ts +++ b/src/app/core/data/item-data.service.spec.ts @@ -8,6 +8,7 @@ import { CoreState } from '../core.reducers'; import { ItemDataService } from './item-data.service'; import { RequestService } from './request.service'; import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { FindAllOptions } from './request.models'; describe('ItemDataService', () => { let scheduler: TestScheduler; @@ -20,6 +21,14 @@ describe('ItemDataService', () => { const halEndpointService = {} as HALEndpointService; const scopeID = '4af28e99-6a9c-4036-a199-e1b587046d39'; + const options = Object.assign(new FindAllOptions(), { + scopeID: scopeID, + sort: { + field: '', + direction: undefined + } + }); + const browsesEndpoint = 'https://rest.api/discover/browses'; const itemBrowseEndpoint = `${browsesEndpoint}/author/items`; const scopedEndpoint = `${itemBrowseEndpoint}?scope=${scopeID}`; @@ -46,16 +55,16 @@ describe('ItemDataService', () => { ); } - describe('getScopedEndpoint', () => { + describe('getBrowseEndpoint', () => { beforeEach(() => { scheduler = getTestScheduler(); }); - it('should return the endpoint to fetch Items within the given scope', () => { + it('should return the endpoint to fetch Items within the given scope and starting with the given string', () => { bs = initMockBrowseService(true); service = initTestService(); - const result = service.getScopedEndpoint(scopeID); + const result = service.getBrowseEndpoint(options); const expected = cold('--b-', { b: scopedEndpoint }); expect(result).toBeObservable(expected); @@ -67,7 +76,7 @@ describe('ItemDataService', () => { service = initTestService(); }); it('should throw an error', () => { - const result = service.getScopedEndpoint(scopeID); + const result = service.getBrowseEndpoint(options); const expected = cold('--#-', undefined, browseError); expect(result).toBeObservable(expected); diff --git a/src/app/core/data/item-data.service.ts b/src/app/core/data/item-data.service.ts index dead4e5f48..9a6a83952d 100644 --- a/src/app/core/data/item-data.service.ts +++ b/src/app/core/data/item-data.service.ts @@ -1,11 +1,7 @@ - -import {distinctUntilChanged, map, filter} from 'rxjs/operators'; -import { Inject, Injectable } from '@angular/core'; - +import { Injectable } from '@angular/core'; import { Store } from '@ngrx/store'; import { Observable } from 'rxjs'; -import { GLOBAL_CONFIG, GlobalConfig } from '../../../config'; -import { isEmpty, isNotEmpty } from '../../shared/empty.util'; +import { isNotEmpty } from '../../shared/empty.util'; import { BrowseService } from '../browse/browse.service'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { NormalizedItem } from '../cache/models/normalized-item.model'; @@ -17,6 +13,7 @@ import { URLCombiner } from '../url-combiner/url-combiner'; import { DataService } from './data.service'; import { RequestService } from './request.service'; import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { FindAllOptions } from './request.models'; @Injectable() export class ItemDataService extends DataService { @@ -32,15 +29,21 @@ export class ItemDataService extends DataService { super(); } - public getScopedEndpoint(scopeID: string): Observable { - if (isEmpty(scopeID)) { - return this.halService.getEndpoint(this.linkPath); - } else { - return this.bs.getBrowseURLFor('dc.date.issued', this.linkPath).pipe( - filter((href: string) => isNotEmpty(href)), - map((href: string) => new URLCombiner(href, `?scope=${scopeID}`).toString()), - distinctUntilChanged(),); + /** + * Get the endpoint for browsing items + * (When options.sort.field is empty, the default field to browse by will be 'dc.date.issued') + * @param {FindAllOptions} options + * @returns {Observable} + */ + public getBrowseEndpoint(options: FindAllOptions = {}): Observable { + let field = 'dc.date.issued'; + if (options.sort && options.sort.field) { + field = options.sort.field; } + return this.bs.getBrowseURLFor(field, this.linkPath) + .filter((href: string) => isNotEmpty(href)) + .map((href: string) => new URLCombiner(href, `?scope=${options.scopeID}`).toString()) + .distinctUntilChanged(); } } diff --git a/src/app/core/data/request.models.ts b/src/app/core/data/request.models.ts index ce45d5b41c..b87f9cefc8 100644 --- a/src/app/core/data/request.models.ts +++ b/src/app/core/data/request.models.ts @@ -12,6 +12,7 @@ import { AuthResponseParsingService } from '../auth/auth-response-parsing.servic import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service'; import { HttpHeaders } from '@angular/common/http'; import { IntegrationResponseParsingService } from '../integration/integration-response-parsing.service'; +import { BrowseItemsResponseParsingService } from './browse-items-response-parsing-service'; /* tslint:disable:max-classes-per-file */ @@ -184,6 +185,12 @@ export class BrowseEntriesRequest extends GetRequest { } } +export class BrowseItemsRequest extends GetRequest { + getResponseParser(): GenericConstructor { + return BrowseItemsResponseParsingService; + } +} + export class ConfigRequest extends GetRequest { constructor(uuid: string, href: string) { super(uuid, href); diff --git a/src/app/core/eperson/models/eperson.model.ts b/src/app/core/eperson/models/eperson.model.ts index 373fb42792..45d26761b0 100644 --- a/src/app/core/eperson/models/eperson.model.ts +++ b/src/app/core/eperson/models/eperson.model.ts @@ -1,7 +1,7 @@ import { DSpaceObject } from '../../shared/dspace-object.model'; import { Group } from './group.model'; -export class Eperson extends DSpaceObject { +export class EPerson extends DSpaceObject { public handle: string; diff --git a/src/app/core/eperson/models/NormalizedEperson.model.ts b/src/app/core/eperson/models/normalized-eperson.model.ts similarity index 84% rename from src/app/core/eperson/models/NormalizedEperson.model.ts rename to src/app/core/eperson/models/normalized-eperson.model.ts index 0c0b2490d6..9d0fa428e9 100644 --- a/src/app/core/eperson/models/NormalizedEperson.model.ts +++ b/src/app/core/eperson/models/normalized-eperson.model.ts @@ -2,13 +2,13 @@ import { autoserialize, inheritSerialization } from 'cerialize'; import { CacheableObject } from '../../cache/object-cache.reducer'; import { ListableObject } from '../../../shared/object-collection/shared/listable-object.model'; import { NormalizedDSpaceObject } from '../../cache/models/normalized-dspace-object.model'; -import { Eperson } from './eperson.model'; +import { EPerson } from './eperson.model'; import { mapsTo, relationship } from '../../cache/builders/build-decorators'; import { ResourceType } from '../../shared/resource-type'; -@mapsTo(Eperson) +@mapsTo(EPerson) @inheritSerialization(NormalizedDSpaceObject) -export class NormalizedEpersonModel extends NormalizedDSpaceObject implements CacheableObject, ListableObject { +export class NormalizedEPerson extends NormalizedDSpaceObject implements CacheableObject, ListableObject { @autoserialize public handle: string; diff --git a/src/app/core/eperson/models/NormalizedGroup.model.ts b/src/app/core/eperson/models/normalized-group.model.ts similarity index 79% rename from src/app/core/eperson/models/NormalizedGroup.model.ts rename to src/app/core/eperson/models/normalized-group.model.ts index 24f7da8eab..be5995d9c5 100644 --- a/src/app/core/eperson/models/NormalizedGroup.model.ts +++ b/src/app/core/eperson/models/normalized-group.model.ts @@ -2,13 +2,12 @@ import { autoserialize, inheritSerialization } from 'cerialize'; import { CacheableObject } from '../../cache/object-cache.reducer'; import { ListableObject } from '../../../shared/object-collection/shared/listable-object.model'; import { NormalizedDSpaceObject } from '../../cache/models/normalized-dspace-object.model'; -import { Eperson } from './eperson.model'; import { mapsTo } from '../../cache/builders/build-decorators'; import { Group } from './group.model'; @mapsTo(Group) @inheritSerialization(NormalizedDSpaceObject) -export class NormalizedGroupModel extends NormalizedDSpaceObject implements CacheableObject, ListableObject { +export class NormalizedGroup extends NormalizedDSpaceObject implements CacheableObject, ListableObject { @autoserialize public handle: string; diff --git a/src/app/core/shared/browse-entry.model.ts b/src/app/core/shared/browse-entry.model.ts index fede195a39..932c6946d1 100644 --- a/src/app/core/shared/browse-entry.model.ts +++ b/src/app/core/shared/browse-entry.model.ts @@ -1,6 +1,7 @@ import { autoserialize, autoserializeAs } from 'cerialize'; +import { ListableObject } from '../../shared/object-collection/shared/listable-object.model'; -export class BrowseEntry { +export class BrowseEntry implements ListableObject { @autoserialize type: string; diff --git a/src/app/core/shared/dspace-object.model.ts b/src/app/core/shared/dspace-object.model.ts index 061e525fe7..3a40d142aa 100644 --- a/src/app/core/shared/dspace-object.model.ts +++ b/src/app/core/shared/dspace-object.model.ts @@ -5,6 +5,7 @@ import { RemoteData } from '../data/remote-data'; import { ResourceType } from './resource-type'; import { ListableObject } from '../../shared/object-collection/shared/listable-object.model'; import { Observable } from 'rxjs'; +import { autoserialize } from 'cerialize'; /** * An abstract model class for a DSpaceObject. @@ -16,11 +17,13 @@ export class DSpaceObject implements CacheableObject, ListableObject { /** * The human-readable identifier of this DSpaceObject */ + @autoserialize id: string; /** * The universally unique identifier of this DSpaceObject */ + @autoserialize uuid: string; /** @@ -31,11 +34,13 @@ export class DSpaceObject implements CacheableObject, ListableObject { /** * The name for this DSpaceObject */ + @autoserialize name: string; /** * An array containing all metadata of this DSpaceObject */ + @autoserialize metadata: Metadatum[]; /** diff --git a/src/app/core/shared/operators.ts b/src/app/core/shared/operators.ts index a3e934341e..476119399b 100644 --- a/src/app/core/shared/operators.ts +++ b/src/app/core/shared/operators.ts @@ -1,6 +1,6 @@ import { Observable } from 'rxjs'; import { filter, first, flatMap, map, tap } from 'rxjs/operators'; -import { hasValueOperator } from '../../shared/empty.util'; +import { hasValueOperator, isNotEmpty } from '../../shared/empty.util'; import { DSOSuccessResponse } from '../cache/response-cache.models'; import { ResponseCacheEntry } from '../cache/response-cache.reducer'; import { ResponseCacheService } from '../cache/response-cache.service'; @@ -8,6 +8,7 @@ import { RemoteData } from '../data/remote-data'; import { RestRequest } from '../data/request.models'; import { RequestEntry } from '../data/request.reducer'; import { RequestService } from '../data/request.service'; +import { BrowseDefinition } from './browse-definition.model'; import { DSpaceObject } from './dspace-object.model'; import { PaginatedList } from '../data/paginated-list'; import { SearchResult } from '../../+search-page/search-result.model'; @@ -62,3 +63,24 @@ export const toDSpaceObjectListRD = () => return Object.assign(rd, {payload: payload}); }) ); + +/** + * Get the browse links from a definition by ID given an array of all definitions + * @param {string} definitionID + * @returns {(source: Observable>) => Observable} + */ +export const getBrowseDefinitionLinks = (definitionID: string) => + (source: Observable>): Observable => + source.pipe( + getRemoteDataPayload(), + map((browseDefinitions: BrowseDefinition[]) => browseDefinitions + .find((def: BrowseDefinition) => def.id === definitionID && def.metadataBrowse === true) + ), + map((def: BrowseDefinition) => { + if (isNotEmpty(def)) { + return def._links; + } else { + throw new Error(`No metadata browse definition could be found for id '${definitionID}'`); + } + }) + ); diff --git a/src/app/core/shared/resource-type.ts b/src/app/core/shared/resource-type.ts index 71053f628b..e67f3339de 100644 --- a/src/app/core/shared/resource-type.ts +++ b/src/app/core/shared/resource-type.ts @@ -6,7 +6,7 @@ export enum ResourceType { Item = 'item', Collection = 'collection', Community = 'community', - Eperson = 'eperson', + EPerson = 'eperson', Group = 'group', ResourcePolicy = 'resourcePolicy' } diff --git a/src/app/header/header.component.spec.ts b/src/app/header/header.component.spec.ts index 7636748614..fd02621471 100644 --- a/src/app/header/header.component.spec.ts +++ b/src/app/header/header.component.spec.ts @@ -20,6 +20,8 @@ import { RouterStub } from '../shared/testing/router-stub'; import { Router } from '@angular/router'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import * as ngrx from '@ngrx/store'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; + let comp: HeaderComponent; let fixture: ComponentFixture; let store: Store; @@ -35,11 +37,12 @@ describe('HeaderComponent', () => { NgbCollapseModule.forRoot(), NoopAnimationsModule, ReactiveFormsModule], - declarations: [HeaderComponent, AuthNavMenuComponent, LoadingComponent, LogInComponent, LogOutComponent], + declarations: [HeaderComponent], providers: [ { provide: HostWindowService, useValue: new HostWindowServiceStub(800) }, { provide: Router, useClass: RouterStub }, - ] + ], + schemas: [NO_ERRORS_SCHEMA] }) .compileComponents(); // compile template and css })); diff --git a/src/app/shared/auth-nav-menu/auth-nav-menu.component.spec.ts b/src/app/shared/auth-nav-menu/auth-nav-menu.component.spec.ts index 8b9f7c8775..e1a82f4a33 100644 --- a/src/app/shared/auth-nav-menu/auth-nav-menu.component.spec.ts +++ b/src/app/shared/auth-nav-menu/auth-nav-menu.component.spec.ts @@ -5,7 +5,7 @@ import { By } from '@angular/platform-browser'; import { Store, StoreModule } from '@ngrx/store'; import { authReducer, AuthState } from '../../core/auth/auth.reducer'; -import { EpersonMock } from '../testing/eperson-mock'; +import { EPersonMock } from '../testing/eperson-mock'; import { TranslateModule } from '@ngx-translate/core'; import { AppState } from '../../app.reducer'; import { AuthNavMenuComponent } from './auth-nav-menu.component'; @@ -13,6 +13,7 @@ import { HostWindowServiceStub } from '../testing/host-window-service-stub'; import { HostWindowService } from '../host-window.service'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { AuthTokenInfo } from '../../core/auth/models/auth-token-info.model'; +import { AuthService } from '../../core/auth/auth.service'; describe('AuthNavMenuComponent', () => { @@ -31,7 +32,7 @@ describe('AuthNavMenuComponent', () => { loaded: true, loading: false, authToken: new AuthTokenInfo('test_token'), - user: EpersonMock + user: EPersonMock }; let routerState = { url: '/home' @@ -53,6 +54,7 @@ describe('AuthNavMenuComponent', () => { ], providers: [ {provide: HostWindowService, useValue: window}, + {provide: AuthService, useValue: {setRedirectUrl: () => { /*empty*/ }}} ], schemas: [ CUSTOM_ELEMENTS_SCHEMA @@ -222,6 +224,7 @@ describe('AuthNavMenuComponent', () => { ], providers: [ {provide: HostWindowService, useValue: window}, + {provide: AuthService, useValue: {setRedirectUrl: () => { /*empty*/ }}} ], schemas: [ CUSTOM_ELEMENTS_SCHEMA diff --git a/src/app/shared/auth-nav-menu/auth-nav-menu.component.ts b/src/app/shared/auth-nav-menu/auth-nav-menu.component.ts index d2924fc2e0..6f0f0370bb 100644 --- a/src/app/shared/auth-nav-menu/auth-nav-menu.component.ts +++ b/src/app/shared/auth-nav-menu/auth-nav-menu.component.ts @@ -14,8 +14,9 @@ import { isAuthenticated, isAuthenticationLoading } from '../../core/auth/selectors'; -import { Eperson } from '../../core/eperson/models/eperson.model'; -import { LOGIN_ROUTE, LOGOUT_ROUTE } from '../../core/auth/auth.service'; +import { EPerson } from '../../core/eperson/models/eperson.model'; +import { AuthService, LOGIN_ROUTE, LOGOUT_ROUTE } from '../../core/auth/auth.service'; +import { Subscription } from 'rxjs/Subscription'; @Component({ selector: 'ds-auth-nav-menu', @@ -40,10 +41,14 @@ export class AuthNavMenuComponent implements OnInit { public showAuth = observableOf(false); - public user: Observable; + public user: Observable; + + public sub: Subscription; constructor(private store: Store, - private windowService: HostWindowService) { + private windowService: HostWindowService, + private authService: AuthService + ) { this.isXsOrSm$ = this.windowService.isXsOrSm(); } @@ -56,12 +61,15 @@ export class AuthNavMenuComponent implements OnInit { this.user = this.store.pipe(select(getAuthenticatedUser)); - this.showAuth = this.store.pipe( - select(routerStateSelector), - filter((router: RouterReducerState) => isNotUndefined(router) && isNotUndefined(router.state)), - map((router: RouterReducerState) => { - return !router.state.url.startsWith(LOGIN_ROUTE) && !router.state.url.startsWith(LOGOUT_ROUTE); - }) - ); + this.showAuth = this.store.select(routerStateSelector) + .filter((router: RouterReducerState) => isNotUndefined(router) && isNotUndefined(router.state)) + .map((router: RouterReducerState) => { + const url = router.state.url; + const show = !router.state.url.startsWith(LOGIN_ROUTE) && !router.state.url.startsWith(LOGOUT_ROUTE); + if (show) { + this.authService.setRedirectUrl(url); + } + return show; + }); } } diff --git a/src/app/shared/browse-by/browse-by.component.html b/src/app/shared/browse-by/browse-by.component.html new file mode 100644 index 0000000000..f30c5b905c --- /dev/null +++ b/src/app/shared/browse-by/browse-by.component.html @@ -0,0 +1,12 @@ + +

{{title}}

+
+ + +
+ + +
diff --git a/src/app/shared/browse-by/browse-by.component.scss b/src/app/shared/browse-by/browse-by.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/shared/browse-by/browse-by.component.spec.ts b/src/app/shared/browse-by/browse-by.component.spec.ts new file mode 100644 index 0000000000..883d61a221 --- /dev/null +++ b/src/app/shared/browse-by/browse-by.component.spec.ts @@ -0,0 +1,44 @@ +import { BrowseByComponent } from './browse-by.component'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { By } from '@angular/platform-browser'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { Observable } from 'rxjs/Observable'; +import { SharedModule } from '../shared.module'; + +describe('BrowseByComponent', () => { + let comp: BrowseByComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot(), SharedModule], + declarations: [], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(BrowseByComponent); + comp = fixture.componentInstance; + }); + + it('should display a loading message when objects is empty',() => { + (comp as any).objects = undefined; + fixture.detectChanges(); + expect(fixture.debugElement.query(By.css('ds-loading'))).toBeDefined(); + }); + + it('should display results when objects is not empty', () => { + (comp as any).objects = Observable.of({ + payload: { + page: { + length: 1 + } + } + }); + fixture.detectChanges(); + expect(fixture.debugElement.query(By.css('ds-viewable-collection'))).toBeDefined(); + }); + +}); diff --git a/src/app/shared/browse-by/browse-by.component.ts b/src/app/shared/browse-by/browse-by.component.ts new file mode 100644 index 0000000000..062b41a440 --- /dev/null +++ b/src/app/shared/browse-by/browse-by.component.ts @@ -0,0 +1,30 @@ +import { Component, Input } from '@angular/core'; +import { RemoteData } from '../../core/data/remote-data'; +import { PaginatedList } from '../../core/data/paginated-list'; +import { PaginationComponentOptions } from '../pagination/pagination-component-options.model'; +import { SortOptions } from '../../core/cache/models/sort-options.model'; +import { fadeIn, fadeInOut } from '../animations/fade'; +import { Observable } from 'rxjs/Observable'; +import { Item } from '../../core/shared/item.model'; +import { ListableObject } from '../object-collection/shared/listable-object.model'; + +@Component({ + selector: 'ds-browse-by', + styleUrls: ['./browse-by.component.scss'], + templateUrl: './browse-by.component.html', + animations: [ + fadeIn, + fadeInOut + ] +}) +/** + * Component to display a browse-by page for any ListableObject + */ +export class BrowseByComponent { + @Input() title: string; + @Input() objects$: Observable>>; + @Input() paginationConfig: PaginationComponentOptions; + @Input() sortConfig: SortOptions; + @Input() currentUrl: string; + query: string; +} diff --git a/src/app/shared/log-in/log-in.component.spec.ts b/src/app/shared/log-in/log-in.component.spec.ts index dc4a0be1c6..dd2aea35d5 100644 --- a/src/app/shared/log-in/log-in.component.spec.ts +++ b/src/app/shared/log-in/log-in.component.spec.ts @@ -7,8 +7,8 @@ import { Store, StoreModule } from '@ngrx/store'; import { LogInComponent } from './log-in.component'; import { authReducer } from '../../core/auth/auth.reducer'; -import { EpersonMock } from '../testing/eperson-mock'; -import { Eperson } from '../../core/eperson/models/eperson.model'; +import { EPersonMock } from '../testing/eperson-mock'; +import { EPerson } from '../../core/eperson/models/eperson.model'; import { TranslateModule } from '@ngx-translate/core'; import { AuthService } from '../../core/auth/auth.service'; import { AuthServiceStub } from '../testing/auth-service-stub'; @@ -19,7 +19,7 @@ describe('LogInComponent', () => { let component: LogInComponent; let fixture: ComponentFixture; let page: Page; - let user: Eperson; + let user: EPerson; const authState = { authenticated: false, @@ -28,7 +28,7 @@ describe('LogInComponent', () => { }; beforeEach(() => { - user = EpersonMock; + user = EPersonMock; }); beforeEach(async(() => { diff --git a/src/app/shared/log-out/log-out.component.spec.ts b/src/app/shared/log-out/log-out.component.spec.ts index ad609f0aea..94780ead5a 100644 --- a/src/app/shared/log-out/log-out.component.spec.ts +++ b/src/app/shared/log-out/log-out.component.spec.ts @@ -5,8 +5,8 @@ import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { Store, StoreModule } from '@ngrx/store'; import { authReducer } from '../../core/auth/auth.reducer'; -import { EpersonMock } from '../testing/eperson-mock'; -import { Eperson } from '../../core/eperson/models/eperson.model'; +import { EPersonMock } from '../testing/eperson-mock'; +import { EPerson } from '../../core/eperson/models/eperson.model'; import { TranslateModule } from '@ngx-translate/core'; import { Router } from '@angular/router'; import { AppState } from '../../app.reducer'; @@ -18,7 +18,7 @@ describe('LogOutComponent', () => { let component: LogOutComponent; let fixture: ComponentFixture; let page: Page; - let user: Eperson; + let user: EPerson; const authState = { authenticated: false, @@ -28,7 +28,7 @@ describe('LogOutComponent', () => { const routerStub = new RouterStub(); beforeEach(() => { - user = EpersonMock; + user = EPersonMock; }); beforeEach(async(() => { diff --git a/src/app/shared/mocks/mock-remote-data-build.service.ts b/src/app/shared/mocks/mock-remote-data-build.service.ts index cb01f65972..7308051c85 100644 --- a/src/app/shared/mocks/mock-remote-data-build.service.ts +++ b/src/app/shared/mocks/mock-remote-data-build.service.ts @@ -5,6 +5,7 @@ import { ResponseCacheEntry } from '../../core/cache/response-cache.reducer'; import { RemoteData } from '../../core/data/remote-data'; import { RequestEntry } from '../../core/data/request.reducer'; import { hasValue } from '../empty.util'; +import { NormalizedObject } from '../../core/cache/models/normalized-object.model'; export function getMockRemoteDataBuildService(toRemoteDataObservable$?: Observable>): RemoteDataBuildService { return { @@ -17,7 +18,8 @@ export function getMockRemoteDataBuildService(toRemoteDataObservable$?: Observab payload } as RemoteData))) } - } + }, + buildSingle: (href$: string | Observable) => Observable.of(new RemoteData(false, false, true, undefined, {})) } as RemoteDataBuildService; } diff --git a/src/app/shared/object-collection/object-collection.component.html b/src/app/shared/object-collection/object-collection.component.html index c9b1cb92f5..b1d07db876 100644 --- a/src/app/shared/object-collection/object-collection.component.html +++ b/src/app/shared/object-collection/object-collection.component.html @@ -8,7 +8,7 @@ diff --git a/src/app/shared/object-grid/item-grid-element/item-grid-element.component.html b/src/app/shared/object-grid/item-grid-element/item-grid-element.component.html index 5100b984e0..728dba7549 100644 --- a/src/app/shared/object-grid/item-grid-element/item-grid-element.component.html +++ b/src/app/shared/object-grid/item-grid-element/item-grid-element.component.html @@ -1,22 +1,28 @@ -
+ +
+ + + + +
+

{{object.findMetadata('dc.title')}}

- - - - -
-

{{object.findMetadata('dc.title')}}

-

- {{authorMd.value}} - ; - - {{object.findMetadata("dc.date.issued")}} -

+ +

+ {{authorMd.value}} + ; + + {{object.findMetadata("dc.date.issued")}} +

+
-

{{object.findMetadata("dc.description.abstract") | dsTruncate:[200] }}

+ +

{{object.findMetadata("dc.description.abstract") }}

+
-
- View +
+ View +
-
+ diff --git a/src/app/shared/object-list/browse-entry-list-element/browse-entry-list-element.component.html b/src/app/shared/object-list/browse-entry-list-element/browse-entry-list-element.component.html new file mode 100644 index 0000000000..198e79b453 --- /dev/null +++ b/src/app/shared/object-list/browse-entry-list-element/browse-entry-list-element.component.html @@ -0,0 +1,7 @@ +
+ + {{object.value}} + +   + {{object.count}} +
diff --git a/src/app/shared/object-list/browse-entry-list-element/browse-entry-list-element.component.scss b/src/app/shared/object-list/browse-entry-list-element/browse-entry-list-element.component.scss new file mode 100644 index 0000000000..45a533cd01 --- /dev/null +++ b/src/app/shared/object-list/browse-entry-list-element/browse-entry-list-element.component.scss @@ -0,0 +1 @@ +@import '../../../../styles/variables'; diff --git a/src/app/shared/object-list/browse-entry-list-element/browse-entry-list-element.component.spec.ts b/src/app/shared/object-list/browse-entry-list-element/browse-entry-list-element.component.spec.ts new file mode 100644 index 0000000000..de53f2e095 --- /dev/null +++ b/src/app/shared/object-list/browse-entry-list-element/browse-entry-list-element.component.spec.ts @@ -0,0 +1,47 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; +import { By } from '@angular/platform-browser'; +import { TruncatePipe } from '../../utils/truncate.pipe'; +import { Metadatum } from '../../../core/shared/metadatum.model'; +import { BrowseEntryListElementComponent } from './browse-entry-list-element.component'; +import { BrowseEntry } from '../../../core/shared/browse-entry.model'; + +let browseEntryListElementComponent: BrowseEntryListElementComponent; +let fixture: ComponentFixture; + +const mockValue: BrowseEntry = Object.assign(new BrowseEntry(), { + type: 'browseEntry', + value: 'De Langhe Kristof' +}); + +describe('MetadataListElementComponent', () => { + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ BrowseEntryListElementComponent , TruncatePipe], + providers: [ + { provide: 'objectElementProvider', useValue: {mockValue}} + ], + + schemas: [ NO_ERRORS_SCHEMA ] + }).overrideComponent(BrowseEntryListElementComponent, { + set: { changeDetection: ChangeDetectionStrategy.Default } + }).compileComponents(); + })); + + beforeEach(async(() => { + fixture = TestBed.createComponent(BrowseEntryListElementComponent); + browseEntryListElementComponent = fixture.componentInstance; + })); + + describe('When the metadatum is loaded', () => { + beforeEach(() => { + browseEntryListElementComponent.object = mockValue; + fixture.detectChanges(); + }); + + it('should show the value as a link', () => { + const browseEntryLink = fixture.debugElement.query(By.css('a.lead')); + expect(browseEntryLink.nativeElement.textContent.trim()).toBe(mockValue.value); + }); + }); +}); diff --git a/src/app/shared/object-list/browse-entry-list-element/browse-entry-list-element.component.ts b/src/app/shared/object-list/browse-entry-list-element/browse-entry-list-element.component.ts new file mode 100644 index 0000000000..44f6a8d051 --- /dev/null +++ b/src/app/shared/object-list/browse-entry-list-element/browse-entry-list-element.component.ts @@ -0,0 +1,18 @@ +import { Component, Input, Inject } from '@angular/core'; + +import { AbstractListableElementComponent } from '../../object-collection/shared/object-collection-element/abstract-listable-element.component'; +import { renderElementsFor } from '../../object-collection/shared/dso-element-decorator'; +import { BrowseEntry } from '../../../core/shared/browse-entry.model'; +import { ViewMode } from '../../../core/shared/view-mode.model'; + +@Component({ + selector: 'ds-browse-entry-list-element', + styleUrls: ['./browse-entry-list-element.component.scss'], + templateUrl: './browse-entry-list-element.component.html' +}) + +/** + * This component is automatically used to create a list view for BrowseEntry objects when used in ObjectCollectionComponent + */ +@renderElementsFor(BrowseEntry, ViewMode.List) +export class BrowseEntryListElementComponent extends AbstractListableElementComponent {} diff --git a/src/app/shared/object-list/item-list-element/item-list-element.component.html b/src/app/shared/object-list/item-list-element/item-list-element.component.html index 1e4a944ffa..711ce19037 100644 --- a/src/app/shared/object-list/item-list-element/item-list-element.component.html +++ b/src/app/shared/object-list/item-list-element/item-list-element.component.html @@ -1,18 +1,24 @@ - - {{object.findMetadata("dc.title")}} - -
- - - {{authorMd.value}} - ; + + + {{object.findMetadata("dc.title")}} + +
+ + + + {{authorMd.value}} + ; + - - ({{object.findMetadata("dc.publisher")}}, {{object.findMetadata("dc.date.issued")}}) - -
- {{object.findMetadata("dc.description.abstract") | dsTruncate:[200] }} -
-
+ ({{object.findMetadata("dc.publisher")}}, {{object.findMetadata("dc.date.issued")}}) +
+ + +
+ {{object.findMetadata("dc.description.abstract")}} +
+
+
+ diff --git a/src/app/shared/shared.module.ts b/src/app/shared/shared.module.ts index 5cc90d4e07..305039e70b 100644 --- a/src/app/shared/shared.module.ts +++ b/src/app/shared/shared.module.ts @@ -76,6 +76,9 @@ import { NumberPickerComponent } from './number-picker/number-picker.component'; import { DsDatePickerComponent } from './form/builder/ds-dynamic-form-ui/models/date-picker/date-picker.component'; import { DsDynamicLookupComponent } from './form/builder/ds-dynamic-form-ui/models/lookup/dynamic-lookup.component'; import { MockAdminGuard } from './mocks/mock-admin-guard.service'; +import { BrowseByModule } from '../+browse-by/browse-by.module'; +import { BrowseByComponent } from './browse-by/browse-by.component'; +import { BrowseEntryListElementComponent } from './object-list/browse-entry-list-element/browse-entry-list-element.component'; import { DebounceDirective } from './utils/debounce.directive'; import { ClickOutsideDirective } from './utils/click-outside.directive'; import { EmphasizePipe } from './utils/emphasize.pipe'; @@ -155,6 +158,7 @@ const COMPONENTS = [ ViewModeSwitchComponent, TruncatableComponent, TruncatablePartComponent, + BrowseByComponent, InputSuggestionsComponent ]; @@ -168,6 +172,7 @@ const ENTRY_COMPONENTS = [ CollectionGridElementComponent, CommunityGridElementComponent, SearchResultGridElementComponent, + BrowseEntryListElementComponent ]; const PROVIDERS = [ diff --git a/src/app/shared/testing/auth-request-service-stub.ts b/src/app/shared/testing/auth-request-service-stub.ts index 7ade392aa0..a7ff7dcd53 100644 --- a/src/app/shared/testing/auth-request-service-stub.ts +++ b/src/app/shared/testing/auth-request-service-stub.ts @@ -2,12 +2,13 @@ import {of as observableOf, Observable } from 'rxjs'; import { HttpOptions } from '../../core/dspace-rest-v2/dspace-rest-v2.service'; import { AuthStatus } from '../../core/auth/models/auth-status.model'; import { AuthTokenInfo } from '../../core/auth/models/auth-token-info.model'; -import { Eperson } from '../../core/eperson/models/eperson.model'; +import { EPerson } from '../../core/eperson/models/eperson.model'; import { isNotEmpty } from '../empty.util'; -import { EpersonMock } from './eperson-mock'; +import { EPersonMock } from './eperson-mock'; +import { RemoteData } from '../../core/data/remote-data'; export class AuthRequestServiceStub { - protected mockUser: Eperson = EpersonMock; + protected mockUser: EPerson = EPersonMock; protected mockTokenInfo = new AuthTokenInfo('test_token'); public postToEndpoint(method: string, body: any, options?: HttpOptions): Observable { @@ -26,7 +27,7 @@ export class AuthRequestServiceStub { if (this.validateToken(token)) { authStatusStub.authenticated = true; authStatusStub.token = this.mockTokenInfo; - authStatusStub.eperson = [this.mockUser]; + authStatusStub.eperson = Observable.of(new RemoteData(false, false, true, undefined, this.mockUser)); } else { authStatusStub.authenticated = false; } @@ -45,7 +46,7 @@ export class AuthRequestServiceStub { if (this.validateToken(token)) { authStatusStub.authenticated = true; authStatusStub.token = this.mockTokenInfo; - authStatusStub.eperson = [this.mockUser]; + authStatusStub.eperson = Observable.of(new RemoteData(false, false, true, undefined, this.mockUser)); } else { authStatusStub.authenticated = false; } diff --git a/src/app/shared/testing/auth-service-stub.ts b/src/app/shared/testing/auth-service-stub.ts index ea0993d8dd..0af3e5d80b 100644 --- a/src/app/shared/testing/auth-service-stub.ts +++ b/src/app/shared/testing/auth-service-stub.ts @@ -2,8 +2,9 @@ import {of as observableOf, Observable } from 'rxjs'; import { AuthStatus } from '../../core/auth/models/auth-status.model'; import { AuthTokenInfo } from '../../core/auth/models/auth-token-info.model'; -import { EpersonMock } from './eperson-mock'; -import { Eperson } from '../../core/eperson/models/eperson.model'; +import { EPersonMock } from './eperson-mock'; +import { EPerson } from '../../core/eperson/models/eperson.model'; +import { RemoteData } from '../../core/data/remote-data'; export class AuthServiceStub { @@ -20,7 +21,7 @@ export class AuthServiceStub { authStatus.okay = true; authStatus.authenticated = true; authStatus.token = this.token; - authStatus.eperson = [EpersonMock]; + authStatus.eperson = Observable.of(new RemoteData(false, false, true, undefined, EPersonMock)); return observableOf(authStatus); } else { console.log('error'); @@ -28,9 +29,9 @@ export class AuthServiceStub { } } - public authenticatedUser(token: AuthTokenInfo): Observable { + public authenticatedUser(token: AuthTokenInfo): Observable { if (token.accessToken === 'token_test') { - return observableOf(EpersonMock); + return Observable.of(EPersonMock); } else { throw(new Error('Message Error test')); } diff --git a/src/app/shared/testing/eperson-mock.ts b/src/app/shared/testing/eperson-mock.ts index 9cf938fcf2..f163a490b9 100644 --- a/src/app/shared/testing/eperson-mock.ts +++ b/src/app/shared/testing/eperson-mock.ts @@ -1,6 +1,6 @@ -import { Eperson } from '../../core/eperson/models/eperson.model'; +import { EPerson } from '../../core/eperson/models/eperson.model'; -export const EpersonMock: Eperson = Object.assign(new Eperson(),{ +export const EPersonMock: EPerson = Object.assign(new EPerson(),{ handle: null, groups: [], netid: 'test@test.com', diff --git a/yarn.lock b/yarn.lock index 6129ef4e7e..e509a2878f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1216,9 +1216,9 @@ boom@5.x.x: dependencies: hoek "4.x.x" -bootstrap@4.1.1: - version "4.1.1" - resolved "https://registry.yarnpkg.com/bootstrap/-/bootstrap-4.1.1.tgz#3aec85000fa619085da8d2e4983dfd67cf2114cb" +bootstrap@4.1.3: + version "4.1.3" + resolved "https://registry.yarnpkg.com/bootstrap/-/bootstrap-4.1.3.tgz#0eb371af2c8448e8c210411d0cb824a6409a12be" boxen@^1.2.1: version "1.3.0" @@ -1965,14 +1965,14 @@ copy-webpack-plugin@^4.4.1: p-limit "^1.0.0" serialize-javascript "^1.4.0" -core-js@2.5.3: - version "2.5.3" - resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.5.3.tgz#8acc38345824f16d8365b7c9b4259168e8ed603e" - core-js@^2.2.0, core-js@^2.4.0: version "2.5.7" resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.5.7.tgz#f972608ff0cead68b841a16a932d0b183791814e" +core-js@^2.5.7: + version "2.5.7" + resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.5.7.tgz#f972608ff0cead68b841a16a932d0b183791814e" + core-js@~2.3.0: version "2.3.0" resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.3.0.tgz#fab83fbb0b2d8dc85fa636c4b9d34c75420c6d65"