mirror of
https://github.com/DSpace/dspace-angular.git
synced 2025-10-07 18:14:17 +00:00
Merge remote-tracking branch 'remotes/origin/master' into submission
# Conflicts: # src/app/+search-page/search-service/search.service.spec.ts # src/app/app.component.spec.ts # src/app/app.component.ts # src/app/core/auth/auth-response-parsing.service.ts # src/app/core/cache/models/normalized-object-factory.ts # src/app/core/cache/models/normalized-resource-policy.model.ts # src/app/core/core.module.ts # src/app/core/data/base-response-parsing.service.ts # src/app/core/data/config-response-parsing.service.spec.ts # src/app/core/data/config-response-parsing.service.ts # src/app/core/data/dso-response-parsing.service.ts # src/app/core/data/request.models.ts # src/app/core/data/search-response-parsing.service.ts # src/app/core/integration/integration-response-parsing.service.spec.ts # src/app/core/integration/integration-response-parsing.service.ts # src/app/core/shared/item.model.ts # src/app/core/shared/resource-policy.model.ts # src/app/core/shared/resource-type.ts # src/app/shared/shared.module.ts # src/app/thumbnail/thumbnail.component.html
This commit is contained in:
21
.dockerignore
Normal file
21
.dockerignore
Normal file
@@ -0,0 +1,21 @@
|
||||
.git
|
||||
node-modules
|
||||
__build__
|
||||
__server_build__
|
||||
typings
|
||||
tsd_typings
|
||||
npm-debug.log
|
||||
dist
|
||||
coverage
|
||||
.idea
|
||||
*.iml
|
||||
*.ngfactory.ts
|
||||
*.css.shim.ts
|
||||
*.scss.shim.ts
|
||||
.DS_Store
|
||||
webpack.records.json
|
||||
npm-debug.log.*
|
||||
morgan.log
|
||||
yarn-error.log
|
||||
*.css
|
||||
package-lock.json
|
10
Dockerfile
Normal file
10
Dockerfile
Normal file
@@ -0,0 +1,10 @@
|
||||
# This image will be published as dspace/dspace-angular
|
||||
# See https://dspace-labs.github.io/DSpace-Docker-Images/ for usage details
|
||||
|
||||
FROM node:8-alpine
|
||||
WORKDIR /app
|
||||
ADD . /app/
|
||||
EXPOSE 3000
|
||||
|
||||
RUN yarn install
|
||||
CMD yarn run watch
|
@@ -91,14 +91,15 @@
|
||||
"@nicky-lenaers/ngx-scroll-to": "^0.6.0",
|
||||
"angular-idle-preload": "2.0.4",
|
||||
"angular-sortablejs": "^2.5.0",
|
||||
"angular2-moment": "^1.9.0",
|
||||
"angular2-text-mask": "8.0.4",
|
||||
"angulartics2": "^5.2.0",
|
||||
"body-parser": "1.18.2",
|
||||
"bootstrap": "^4.0.0",
|
||||
"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",
|
||||
"file-saver": "^1.3.8",
|
||||
@@ -110,10 +111,13 @@
|
||||
"jsonschema": "1.2.2",
|
||||
"jwt-decode": "^2.2.0",
|
||||
"methods": "1.1.2",
|
||||
"moment": "^2.22.1",
|
||||
"morgan": "1.9.0",
|
||||
"ng2-file-upload": "1.2.1",
|
||||
"ng2-nouislider": "^1.7.11",
|
||||
"ngx-infinite-scroll": "0.8.2",
|
||||
"ngx-pagination": "3.0.3",
|
||||
"nouislider": "^11.0.0",
|
||||
"pem": "1.12.3",
|
||||
"reflect-metadata": "0.1.12",
|
||||
"rxjs": "5.5.6",
|
||||
|
277
resources/i18n/cs.json
Normal file
277
resources/i18n/cs.json
Normal file
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
277
resources/i18n/de.json
Normal file
277
resources/i18n/de.json
Normal file
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
@@ -59,8 +59,13 @@
|
||||
}
|
||||
},
|
||||
"sorting": {
|
||||
"ASC": "Ascending",
|
||||
"DESC": "Descending"
|
||||
"score": {
|
||||
"DESC": "Relevance"
|
||||
},
|
||||
"dc.title": {
|
||||
"ASC": "Title Ascending",
|
||||
"DESC": "Title Descending"
|
||||
}
|
||||
},
|
||||
"title": "DSpace",
|
||||
"404": {
|
||||
@@ -93,13 +98,13 @@
|
||||
"close": "Back to results",
|
||||
"open": "Search Tools",
|
||||
"results": "results",
|
||||
"filters":{
|
||||
"title":"Filters"
|
||||
"filters": {
|
||||
"title": "Filters"
|
||||
},
|
||||
"settings":{
|
||||
"title":"Settings",
|
||||
"sort-by":"Sort By",
|
||||
"rpp":"Results per page"
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
"sort-by": "Sort By",
|
||||
"rpp": "Results per page"
|
||||
}
|
||||
},
|
||||
"view-switch": {
|
||||
@@ -109,6 +114,13 @@
|
||||
"filters": {
|
||||
"head": "Filters",
|
||||
"reset": "Reset filters",
|
||||
"applied": {
|
||||
"f.author": "Author",
|
||||
"f.dateIssued.min": "Start date",
|
||||
"f.dateIssued.max": "End date",
|
||||
"f.subject": "Subject",
|
||||
"f.has_content_in_original_bundle": "Has files"
|
||||
},
|
||||
"filter": {
|
||||
"show-more": "Show more",
|
||||
"show-less": "Collapse",
|
||||
@@ -125,16 +137,23 @@
|
||||
"head": "Subject"
|
||||
},
|
||||
"dateIssued": {
|
||||
"placeholder": "Date",
|
||||
"max": {
|
||||
"placeholder": "Minimum Date"
|
||||
},
|
||||
"min": {
|
||||
"placeholder": "Maximum Date"
|
||||
},
|
||||
"head": "Date"
|
||||
},
|
||||
"has_content_in_original_bundle": {
|
||||
"placeholder": "Has files",
|
||||
"head": "Has files"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"browse": {
|
||||
"title": "Browsing {{ collection }} by {{ field }} {{ value }}"
|
||||
},
|
||||
"admin": {
|
||||
"registries": {
|
||||
"metadata": {
|
||||
@@ -186,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",
|
||||
@@ -205,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": {
|
||||
@@ -230,7 +251,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",
|
||||
@@ -255,7 +276,7 @@
|
||||
"expired": "Your session has expired. Please log in again."
|
||||
},
|
||||
"errors": {
|
||||
"invalid-user": "Invalid email or password."
|
||||
"invalid-user": "Invalid email address or password."
|
||||
}
|
||||
},
|
||||
"submission": {
|
||||
|
277
resources/i18n/nl.json
Normal file
277
resources/i18n/nl.json
Normal file
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
@@ -0,0 +1,11 @@
|
||||
<div class="container">
|
||||
<div class="browse-by-author w-100 row">
|
||||
<ds-browse-by class="col-xs-12 w-100"
|
||||
title="{{'browse.title' | translate:{collection: '', field: 'Author', value: (value)? '"' + value + '"': ''} }}"
|
||||
[objects$]="(items$ !== undefined)? items$ : authors$"
|
||||
[currentUrl]="currentUrl"
|
||||
[paginationConfig]="paginationConfig"
|
||||
[sortConfig]="sortConfig">
|
||||
</ds-browse-by>
|
||||
</div>
|
||||
</div>
|
@@ -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<RemoteData<PaginatedList<BrowseEntry>>>;
|
||||
items$: Observable<RemoteData<PaginatedList<Item>>>;
|
||||
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());
|
||||
}
|
||||
|
||||
}
|
@@ -0,0 +1,11 @@
|
||||
<div class="container">
|
||||
<div class="browse-by-title w-100 row">
|
||||
<ds-browse-by class="col-xs-12 w-100"
|
||||
title="{{'browse.title' | translate:{collection: '', field: 'Title', value: ''} }}"
|
||||
[objects$]="items$"
|
||||
[currentUrl]="currentUrl"
|
||||
[paginationConfig]="paginationConfig"
|
||||
[sortConfig]="sortConfig">
|
||||
</ds-browse-by>
|
||||
</div>
|
||||
</div>
|
@@ -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<RemoteData<PaginatedList<Item>>>;
|
||||
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());
|
||||
}
|
||||
|
||||
}
|
16
src/app/+browse-by/browse-by-routing.module.ts
Normal file
16
src/app/+browse-by/browse-by-routing.module.ts
Normal file
@@ -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 {
|
||||
|
||||
}
|
27
src/app/+browse-by/browse-by.module.ts
Normal file
27
src/app/+browse-by/browse-by.module.ts
Normal file
@@ -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 {
|
||||
|
||||
}
|
@@ -2,12 +2,23 @@ import { NgModule } from '@angular/core';
|
||||
import { RouterModule } from '@angular/router';
|
||||
|
||||
import { CollectionPageComponent } from './collection-page.component';
|
||||
import { CollectionPageResolver } from './collection-page.resolver';
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
RouterModule.forChild([
|
||||
{ path: ':id', component: CollectionPageComponent, pathMatch: 'full' }
|
||||
{
|
||||
path: ':id',
|
||||
component: CollectionPageComponent,
|
||||
pathMatch: 'full',
|
||||
resolve: {
|
||||
collection: CollectionPageResolver
|
||||
}
|
||||
}
|
||||
])
|
||||
],
|
||||
providers: [
|
||||
CollectionPageResolver,
|
||||
]
|
||||
})
|
||||
export class CollectionPageRoutingModule {
|
||||
|
@@ -1,6 +1,6 @@
|
||||
<div class="container">
|
||||
<div class="collection-page"
|
||||
*ngVar="(collectionRDObs | async) as collectionRD">
|
||||
*ngVar="(collectionRD$ | async) as collectionRD">
|
||||
<div *ngIf="collectionRD?.hasSucceeded" @fadeInOut>
|
||||
<div *ngIf="collectionRD?.payload as collection">
|
||||
<!-- Collection Name -->
|
||||
@@ -8,8 +8,8 @@
|
||||
[name]="collection.name">
|
||||
</ds-comcol-page-header>
|
||||
<!-- Collection logo -->
|
||||
<ds-comcol-page-logo *ngIf="logoRDObs"
|
||||
[logo]="(logoRDObs | async)?.payload"
|
||||
<ds-comcol-page-logo *ngIf="logoRD$"
|
||||
[logo]="(logoRD$ | async)?.payload"
|
||||
[alternateText]="'Collection Logo'">
|
||||
</ds-comcol-page-logo>
|
||||
<!-- Introductionary text -->
|
||||
@@ -38,14 +38,14 @@
|
||||
<ds-error *ngIf="collectionRD?.hasFailed" message="{{'error.collection' | translate}}"></ds-error>
|
||||
<ds-loading *ngIf="collectionRD?.isLoading" message="{{'loading.collection' | translate}}"></ds-loading>
|
||||
<br>
|
||||
<ng-container *ngVar="(itemRDObs | async) as itemRD">
|
||||
<ng-container *ngVar="(itemRD$ | async) as itemRD">
|
||||
<div *ngIf="itemRD?.hasSucceeded" @fadeIn>
|
||||
<h2>{{'collection.page.browse.recent.head' | translate}}</h2>
|
||||
<ds-viewable-collection
|
||||
[config]="paginationConfig"
|
||||
[sortConfig]="sortConfig"
|
||||
[objects]="itemRD"
|
||||
[hideGear]="false"
|
||||
[hideGear]="true"
|
||||
(paginationChange)="onPaginationChange($event)">
|
||||
</ds-viewable-collection>
|
||||
</div>
|
||||
|
@@ -5,7 +5,6 @@ import { Observable } from 'rxjs/Observable';
|
||||
import { Subscription } from 'rxjs/Subscription';
|
||||
import { SortDirection, SortOptions } from '../core/cache/models/sort-options.model';
|
||||
import { CollectionDataService } from '../core/data/collection-data.service';
|
||||
import { ItemDataService } from '../core/data/item-data.service';
|
||||
import { PaginatedList } from '../core/data/paginated-list';
|
||||
import { RemoteData } from '../core/data/remote-data';
|
||||
|
||||
@@ -18,6 +17,11 @@ import { Item } from '../core/shared/item.model';
|
||||
import { fadeIn, fadeInOut } from '../shared/animations/fade';
|
||||
import { hasValue, isNotEmpty } from '../shared/empty.util';
|
||||
import { PaginationComponentOptions } from '../shared/pagination/pagination-component-options.model';
|
||||
import { filter, flatMap, map } from 'rxjs/operators';
|
||||
import { SearchService } from '../+search-page/search-service/search.service';
|
||||
import { PaginatedSearchOptions } from '../+search-page/paginated-search-options.model';
|
||||
import { toDSpaceObjectListRD } from '../core/shared/operators';
|
||||
import { DSpaceObjectType } from '../core/shared/dspace-object-type.model';
|
||||
|
||||
@Component({
|
||||
selector: 'ds-collection-page',
|
||||
@@ -30,9 +34,9 @@ import { PaginationComponentOptions } from '../shared/pagination/pagination-comp
|
||||
]
|
||||
})
|
||||
export class CollectionPageComponent implements OnInit, OnDestroy {
|
||||
collectionRDObs: Observable<RemoteData<Collection>>;
|
||||
itemRDObs: Observable<RemoteData<PaginatedList<Item>>>;
|
||||
logoRDObs: Observable<RemoteData<Bitstream>>;
|
||||
collectionRD$: Observable<RemoteData<Collection>>;
|
||||
itemRD$: Observable<RemoteData<PaginatedList<Item>>>;
|
||||
logoRD$: Observable<RemoteData<Bitstream>>;
|
||||
paginationConfig: PaginationComponentOptions;
|
||||
sortConfig: SortOptions;
|
||||
private subs: Subscription[] = [];
|
||||
@@ -40,7 +44,7 @@ export class CollectionPageComponent implements OnInit, OnDestroy {
|
||||
|
||||
constructor(
|
||||
private collectionDataService: CollectionDataService,
|
||||
private itemDataService: ItemDataService,
|
||||
private searchService: SearchService,
|
||||
private metadata: MetadataService,
|
||||
private route: ActivatedRoute
|
||||
) {
|
||||
@@ -48,52 +52,41 @@ export class CollectionPageComponent implements OnInit, OnDestroy {
|
||||
this.paginationConfig.id = 'collection-page-pagination';
|
||||
this.paginationConfig.pageSize = 5;
|
||||
this.paginationConfig.currentPage = 1;
|
||||
this.sortConfig = new SortOptions('dc.title', SortDirection.ASC);
|
||||
this.sortConfig = new SortOptions('dc.date.accessioned', SortDirection.DESC);
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.collectionRD$ = this.route.data.map((data) => data.collection);
|
||||
this.logoRD$ = this.collectionRD$.pipe(
|
||||
map((rd: RemoteData<Collection>) => rd.payload),
|
||||
filter((collection: Collection) => hasValue(collection)),
|
||||
flatMap((collection: Collection) => collection.logo)
|
||||
);
|
||||
this.subs.push(
|
||||
Observable.combineLatest(
|
||||
this.route.params,
|
||||
this.route.queryParams,
|
||||
(params, queryParams, ) => {
|
||||
return Object.assign({}, params, queryParams);
|
||||
})
|
||||
.subscribe((params) => {
|
||||
this.collectionId = params.id;
|
||||
this.collectionRDObs = this.collectionDataService.findById(this.collectionId);
|
||||
this.metadata.processRemoteData(this.collectionRDObs);
|
||||
this.subs.push(this.collectionRDObs
|
||||
.map((rd: RemoteData<Collection>) => rd.payload)
|
||||
.filter((collection: Collection) => hasValue(collection))
|
||||
.subscribe((collection: Collection) => this.logoRDObs = collection.logo));
|
||||
|
||||
const page = +params.page || this.paginationConfig.currentPage;
|
||||
const pageSize = +params.pageSize || this.paginationConfig.pageSize;
|
||||
const sortDirection = +params.page || this.sortConfig.direction;
|
||||
const pagination = Object.assign({},
|
||||
this.paginationConfig,
|
||||
{ currentPage: page, pageSize: pageSize }
|
||||
);
|
||||
const sort = Object.assign({},
|
||||
this.sortConfig,
|
||||
{ direction: sortDirection, field: params.sortField }
|
||||
);
|
||||
this.updatePage({
|
||||
pagination: pagination,
|
||||
sort: sort
|
||||
});
|
||||
}));
|
||||
this.route.queryParams.subscribe((params) => {
|
||||
this.metadata.processRemoteData(this.collectionRD$);
|
||||
const page = +params.page || this.paginationConfig.currentPage;
|
||||
const pageSize = +params.pageSize || this.paginationConfig.pageSize;
|
||||
const pagination = Object.assign({},
|
||||
this.paginationConfig,
|
||||
{ currentPage: page, pageSize: pageSize }
|
||||
);
|
||||
this.updatePage({
|
||||
pagination: pagination,
|
||||
sort: this.sortConfig
|
||||
});
|
||||
}));
|
||||
|
||||
}
|
||||
|
||||
updatePage(searchOptions) {
|
||||
this.itemRDObs = this.itemDataService.findAll({
|
||||
scopeID: this.collectionId,
|
||||
currentPage: searchOptions.pagination.currentPage,
|
||||
elementsPerPage: searchOptions.pagination.pageSize,
|
||||
sort: searchOptions.sort
|
||||
});
|
||||
this.itemRD$ = this.searchService.search(
|
||||
new PaginatedSearchOptions({
|
||||
scope: this.collectionId,
|
||||
pagination: searchOptions.pagination,
|
||||
sort: searchOptions.sort,
|
||||
dsoType: DSpaceObjectType.ITEM
|
||||
})).pipe(toDSpaceObjectListRD()) as Observable<RemoteData<PaginatedList<Item>>>;
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
|
@@ -5,11 +5,13 @@ import { SharedModule } from '../shared/shared.module';
|
||||
|
||||
import { CollectionPageComponent } from './collection-page.component';
|
||||
import { CollectionPageRoutingModule } from './collection-page-routing.module';
|
||||
import { SearchPageModule } from '../+search-page/search-page.module';
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
CommonModule,
|
||||
SharedModule,
|
||||
SearchPageModule,
|
||||
CollectionPageRoutingModule
|
||||
],
|
||||
declarations: [
|
||||
|
28
src/app/+collection-page/collection-page.resolver.ts
Normal file
28
src/app/+collection-page/collection-page.resolver.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { ActivatedRouteSnapshot, Resolve, RouterStateSnapshot } from '@angular/router';
|
||||
import { Collection } from '../core/shared/collection.model';
|
||||
import { Observable } from 'rxjs/Observable';
|
||||
import { 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<RemoteData<Collection>> {
|
||||
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<<RemoteData<Collection>> Emits the found collection based on the parameters in the current route
|
||||
*/
|
||||
resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<RemoteData<Collection>> {
|
||||
return this.collectionService.findById(route.params.id).pipe(
|
||||
getSucceededRemoteData()
|
||||
);
|
||||
}
|
||||
}
|
@@ -2,12 +2,23 @@ import { NgModule } from '@angular/core';
|
||||
import { RouterModule } from '@angular/router';
|
||||
|
||||
import { CommunityPageComponent } from './community-page.component';
|
||||
import { CommunityPageResolver } from './community-page.resolver';
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
RouterModule.forChild([
|
||||
{ path: ':id', component: CommunityPageComponent, pathMatch: 'full' }
|
||||
{
|
||||
path: ':id',
|
||||
component: CommunityPageComponent,
|
||||
pathMatch: 'full',
|
||||
resolve: {
|
||||
community: CommunityPageResolver
|
||||
}
|
||||
}
|
||||
])
|
||||
],
|
||||
providers: [
|
||||
CommunityPageResolver,
|
||||
]
|
||||
})
|
||||
export class CommunityPageRoutingModule {
|
||||
|
@@ -1,11 +1,11 @@
|
||||
<div class="container" *ngVar="(communityRDObs | async) as communityRD">
|
||||
<div class="container" *ngVar="(communityRD$ | async) as communityRD">
|
||||
<div class="community-page" *ngIf="communityRD?.hasSucceeded" @fadeInOut>
|
||||
<div *ngIf="communityRD?.payload; let communityPayload">
|
||||
<!-- Community name -->
|
||||
<ds-comcol-page-header [name]="communityPayload.name"></ds-comcol-page-header>
|
||||
<!-- Community logo -->
|
||||
<ds-comcol-page-logo *ngIf="logoRDObs"
|
||||
[logo]="(logoRDObs | async)?.payload"
|
||||
<ds-comcol-page-logo *ngIf="logoRD$"
|
||||
[logo]="(logoRD$ | async)?.payload"
|
||||
[alternateText]="'Community Logo'">
|
||||
</ds-comcol-page-logo>
|
||||
<!-- Introductory text -->
|
||||
|
@@ -22,8 +22,8 @@ import { Observable } from 'rxjs/Observable';
|
||||
animations: [fadeInOut]
|
||||
})
|
||||
export class CommunityPageComponent implements OnInit, OnDestroy {
|
||||
communityRDObs: Observable<RemoteData<Community>>;
|
||||
logoRDObs: Observable<RemoteData<Bitstream>>;
|
||||
communityRD$: Observable<RemoteData<Community>>;
|
||||
logoRD$: Observable<RemoteData<Bitstream>>;
|
||||
private subs: Subscription[] = [];
|
||||
|
||||
constructor(
|
||||
@@ -35,14 +35,11 @@ export class CommunityPageComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.route.params.subscribe((params: Params) => {
|
||||
this.communityRDObs = this.communityDataService.findById(params.id);
|
||||
this.metadata.processRemoteData(this.communityRDObs);
|
||||
this.subs.push(this.communityRDObs
|
||||
.map((rd: RemoteData<Community>) => rd.payload)
|
||||
.filter((community: Community) => hasValue(community))
|
||||
.subscribe((community: Community) => this.logoRDObs = community.logo));
|
||||
});
|
||||
this.communityRD$ = this.route.data.map((data) => data.community);
|
||||
this.logoRD$ = this.communityRD$
|
||||
.map((rd: RemoteData<Community>) => rd.payload)
|
||||
.filter((community: Community) => hasValue(community))
|
||||
.flatMap((community: Community) => community.logo);
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
|
28
src/app/+community-page/community-page.resolver.ts
Normal file
28
src/app/+community-page/community-page.resolver.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { ActivatedRouteSnapshot, Resolve, RouterStateSnapshot } from '@angular/router';
|
||||
import { Observable } from 'rxjs/Observable';
|
||||
import { RemoteData } from '../core/data/remote-data';
|
||||
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<RemoteData<Community>> {
|
||||
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<<RemoteData<Community>> Emits the found community based on the parameters in the current route
|
||||
*/
|
||||
resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<RemoteData<Community>> {
|
||||
return this.communityService.findById(route.params.id).pipe(
|
||||
getSucceededRemoteData()
|
||||
);
|
||||
}
|
||||
}
|
@@ -2,7 +2,7 @@
|
||||
<div *ngIf="subCollectionsRD?.hasSucceeded" @fadeIn>
|
||||
<h2>{{'community.sub-collection-list.head' | translate}}</h2>
|
||||
<ul>
|
||||
<li *ngFor="let collection of subCollectionsRD?.payload">
|
||||
<li *ngFor="let collection of subCollectionsRD?.payload.page">
|
||||
<p>
|
||||
<span class="lead"><a [routerLink]="['/collections', collection.id]">{{collection.name}}</a></span><br>
|
||||
<span class="text-muted">{{collection.shortDescription}}</span>
|
||||
|
@@ -6,6 +6,7 @@ import { Collection } from '../../core/shared/collection.model';
|
||||
import { Community } from '../../core/shared/community.model';
|
||||
|
||||
import { fadeIn } from '../../shared/animations/fade';
|
||||
import { PaginatedList } from '../../core/data/paginated-list';
|
||||
|
||||
@Component({
|
||||
selector: 'ds-community-page-sub-collection-list',
|
||||
@@ -15,7 +16,7 @@ import { fadeIn } from '../../shared/animations/fade';
|
||||
})
|
||||
export class CommunityPageSubCollectionListComponent implements OnInit {
|
||||
@Input() community: Community;
|
||||
subCollectionsRDObs: Observable<RemoteData<Collection[]>>;
|
||||
subCollectionsRDObs: Observable<RemoteData<PaginatedList<Collection>>>;
|
||||
|
||||
ngOnInit(): void {
|
||||
this.subCollectionsRDObs = this.community.collections;
|
||||
|
@@ -38,7 +38,7 @@ export class TopLevelCommunityListComponent {
|
||||
}
|
||||
|
||||
updatePage(data) {
|
||||
this.communitiesRDObs = this.cds.findAll({
|
||||
this.communitiesRDObs = this.cds.findTop({
|
||||
currentPage: data.page,
|
||||
elementsPerPage: data.pageSize,
|
||||
sort: { field: data.sortField, direction: data.sortDirection }
|
||||
|
@@ -1,4 +1,4 @@
|
||||
<ds-metadata-field-wrapper [label]="label | translate">
|
||||
<ds-metadata-field-wrapper *ngIf="hasSucceeded() | async" [label]="label | translate">
|
||||
<div class="collections">
|
||||
<a *ngFor="let collection of (collections | async); let last=last;" [routerLink]="['/collections', collection.id]">
|
||||
<span>{{collection?.name}}</span><span *ngIf="!last" [innerHTML]="separator"></span>
|
||||
|
@@ -0,0 +1,74 @@
|
||||
import { CollectionsComponent } from './collections.component';
|
||||
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core';
|
||||
import { By } from '@angular/platform-browser';
|
||||
import { Collection } from '../../../core/shared/collection.model';
|
||||
import { RemoteDataBuildService } from '../../../core/cache/builders/remote-data-build.service';
|
||||
import { getMockRemoteDataBuildService } from '../../../shared/mocks/mock-remote-data-build.service';
|
||||
import { Item } from '../../../core/shared/item.model';
|
||||
import { Observable } from 'rxjs/Observable';
|
||||
import { RemoteData } from '../../../core/data/remote-data';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
|
||||
let collectionsComponent: CollectionsComponent;
|
||||
let fixture: ComponentFixture<CollectionsComponent>;
|
||||
|
||||
const mockCollection1: Collection = Object.assign(new Collection(), {
|
||||
metadata: [
|
||||
{
|
||||
key: 'dc.description.abstract',
|
||||
language: 'en_US',
|
||||
value: 'Short description'
|
||||
}]
|
||||
});
|
||||
|
||||
const succeededMockItem: Item = Object.assign(new Item(), {owningCollection: Observable.of(new RemoteData(false, false, true, null, mockCollection1))});
|
||||
const failedMockItem: Item = Object.assign(new Item(), {owningCollection: Observable.of(new RemoteData(false, false, false, null, mockCollection1))});
|
||||
|
||||
describe('CollectionsComponent', () => {
|
||||
beforeEach(async(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [TranslateModule.forRoot()],
|
||||
declarations: [ CollectionsComponent ],
|
||||
providers: [
|
||||
{ provide: RemoteDataBuildService, useValue: getMockRemoteDataBuildService()}
|
||||
],
|
||||
|
||||
schemas: [ NO_ERRORS_SCHEMA ]
|
||||
}).overrideComponent(CollectionsComponent, {
|
||||
set: { changeDetection: ChangeDetectionStrategy.Default }
|
||||
}).compileComponents();
|
||||
}));
|
||||
|
||||
beforeEach(async(() => {
|
||||
fixture = TestBed.createComponent(CollectionsComponent);
|
||||
collectionsComponent = fixture.componentInstance;
|
||||
collectionsComponent.label = 'test.test';
|
||||
collectionsComponent.separator = '<br/>';
|
||||
|
||||
}));
|
||||
|
||||
describe('When the requested item request has succeeded', () => {
|
||||
beforeEach(() => {
|
||||
collectionsComponent.item = succeededMockItem;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should show the collection', () => {
|
||||
const collectionField = fixture.debugElement.query(By.css('ds-metadata-field-wrapper div.collections'));
|
||||
expect(collectionField).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('When the requested item request has failed', () => {
|
||||
beforeEach(() => {
|
||||
collectionsComponent.item = failedMockItem;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should not show the collection', () => {
|
||||
const collectionField = fixture.debugElement.query(By.css('ds-metadata-field-wrapper div.collections'));
|
||||
expect(collectionField).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
@@ -38,4 +38,8 @@ export class CollectionsComponent implements OnInit {
|
||||
this.collections = this.item.owner.map((rd: RemoteData<Collection>) => [rd.payload]);
|
||||
}
|
||||
|
||||
hasSucceeded() {
|
||||
return this.item.owner.map((rd: RemoteData<Collection>) => rd.hasSucceeded);
|
||||
}
|
||||
|
||||
}
|
||||
|
@@ -1,6 +1,8 @@
|
||||
<div class="simple-view-element">
|
||||
<span *ngIf="content.children.length != 0">
|
||||
<h5 class="simple-view-element-header" *ngIf="label">{{ label }}</h5>
|
||||
<div class="simple-view-element-body">
|
||||
<ng-content></ng-content>
|
||||
</div>
|
||||
</span>
|
||||
<div #content class="simple-view-element-body">
|
||||
<ng-content></ng-content>
|
||||
</div>
|
||||
</div>
|
||||
|
@@ -0,0 +1,54 @@
|
||||
import { ComponentFixture, TestBed, async } from '@angular/core/testing';
|
||||
import { By } from '@angular/platform-browser';
|
||||
import { Component, DebugElement } from '@angular/core';
|
||||
|
||||
import { MetadataFieldWrapperComponent } from './metadata-field-wrapper.component';
|
||||
|
||||
@Component({
|
||||
selector: 'ds-component-with-content',
|
||||
template: '<ds-metadata-field-wrapper [label]="\'test label\'">\n' +
|
||||
' <div class="my content">\n' +
|
||||
' </div>\n' +
|
||||
'</ds-metadata-field-wrapper>'
|
||||
})
|
||||
class ContentComponent {}
|
||||
|
||||
describe('MetadataFieldWrapperComponent', () => {
|
||||
let component: MetadataFieldWrapperComponent;
|
||||
let fixture: ComponentFixture<MetadataFieldWrapperComponent>;
|
||||
|
||||
beforeEach(async(() => {
|
||||
TestBed.configureTestingModule({
|
||||
declarations: [MetadataFieldWrapperComponent, ContentComponent]
|
||||
}).compileComponents();
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(MetadataFieldWrapperComponent);
|
||||
component = fixture.componentInstance;
|
||||
});
|
||||
|
||||
const wrapperSelector = '.simple-view-element';
|
||||
const labelSelector = '.simple-view-element-header';
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeDefined();
|
||||
});
|
||||
|
||||
it('should not show a label when there is no content', () => {
|
||||
component.label = 'test label';
|
||||
fixture.detectChanges();
|
||||
const debugLabel = fixture.debugElement.query(By.css(labelSelector));
|
||||
expect(debugLabel).toBeNull();
|
||||
});
|
||||
|
||||
it('should show a label when there is content', () => {
|
||||
const parentFixture = TestBed.createComponent(ContentComponent);
|
||||
parentFixture.detectChanges();
|
||||
const parentComponent = parentFixture.componentInstance;
|
||||
const parentNative = parentFixture.nativeElement;
|
||||
const nativeLabel = parentNative.querySelector(labelSelector);
|
||||
expect(nativeLabel.textContent).toContain('test label');
|
||||
});
|
||||
|
||||
});
|
@@ -1,4 +1,5 @@
|
||||
import { Component, Input } from '@angular/core';
|
||||
import { Metadatum } from '../../../core/shared/metadatum.model';
|
||||
|
||||
/**
|
||||
* This component renders the configured 'values' into the ds-metadata-field-wrapper component.
|
||||
@@ -11,7 +12,7 @@ import { Component, Input } from '@angular/core';
|
||||
})
|
||||
export class MetadataValuesComponent {
|
||||
|
||||
@Input() values: any;
|
||||
@Input() values: Metadatum[];
|
||||
|
||||
@Input() separator: string;
|
||||
|
||||
|
@@ -1,15 +1,15 @@
|
||||
<div class="container" *ngVar="(itemRDObs | async) as itemRD">
|
||||
<div class="container" *ngVar="(itemRD$ | async) as itemRD">
|
||||
<div class="item-page" *ngIf="itemRD?.hasSucceeded" @fadeInOut>
|
||||
<div *ngIf="itemRD?.payload as item">
|
||||
<ds-item-page-title-field [item]="item"></ds-item-page-title-field>
|
||||
<div class="simple-view-link">
|
||||
<a class="btn btn-outline-primary col-4" [routerLink]="['/items/' + item.id]">
|
||||
<div class="simple-view-link my-3">
|
||||
<a class="btn btn-outline-primary" [routerLink]="['/items/' + item.id]">
|
||||
{{"item.page.link.simple" | translate}}
|
||||
</a>
|
||||
</div>
|
||||
<table class="table table-responsive table-striped">
|
||||
<tbody>
|
||||
<tr *ngFor="let metadatum of (metadataObs | async)">
|
||||
<tr *ngFor="let metadatum of (metadata$ | async)">
|
||||
<td>{{metadatum.key}}</td>
|
||||
<td>{{metadatum.value}}</td>
|
||||
<td>{{metadatum.language}}</td>
|
||||
|
@@ -1,7 +1,10 @@
|
||||
@import '../../../styles/variables.scss';
|
||||
|
||||
:host {
|
||||
div.simple-view-link {
|
||||
text-align: center;
|
||||
margin: 20px;
|
||||
a {
|
||||
min-width: 25%;
|
||||
}
|
||||
}
|
||||
}
|
@@ -30,9 +30,9 @@ import { hasValue } from '../../shared/empty.util';
|
||||
})
|
||||
export class FullItemPageComponent extends ItemPageComponent implements OnInit {
|
||||
|
||||
itemRDObs: Observable<RemoteData<Item>>;
|
||||
itemRD$: Observable<RemoteData<Item>>;
|
||||
|
||||
metadataObs: Observable<Metadatum[]>;
|
||||
metadata$: Observable<Metadatum[]>;
|
||||
|
||||
constructor(route: ActivatedRoute, items: ItemDataService, metadataService: MetadataService) {
|
||||
super(route, items, metadataService);
|
||||
@@ -41,14 +41,9 @@ export class FullItemPageComponent extends ItemPageComponent implements OnInit {
|
||||
/*** AoT inheritance fix, will hopefully be resolved in the near future **/
|
||||
ngOnInit(): void {
|
||||
super.ngOnInit();
|
||||
}
|
||||
|
||||
initialize(params) {
|
||||
super.initialize(params);
|
||||
this.metadataObs = this.itemRDObs
|
||||
this.metadata$ = this.itemRD$
|
||||
.map((rd: RemoteData<Item>) => rd.payload)
|
||||
.filter((item: Item) => hasValue(item))
|
||||
.map((item: Item) => item.metadata);
|
||||
}
|
||||
|
||||
}
|
||||
|
@@ -3,13 +3,30 @@ import { RouterModule } from '@angular/router';
|
||||
|
||||
import { ItemPageComponent } from './simple/item-page.component';
|
||||
import { FullItemPageComponent } from './full/full-item-page.component';
|
||||
import { ItemPageResolver } from './item-page.resolver';
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
RouterModule.forChild([
|
||||
{ path: ':id', component: ItemPageComponent, pathMatch: 'full' },
|
||||
{ path: ':id/full', component: FullItemPageComponent }
|
||||
{
|
||||
path: ':id',
|
||||
component: ItemPageComponent,
|
||||
pathMatch: 'full',
|
||||
resolve: {
|
||||
item: ItemPageResolver
|
||||
}
|
||||
},
|
||||
{
|
||||
path: ':id/full',
|
||||
component: FullItemPageComponent,
|
||||
resolve: {
|
||||
item: ItemPageResolver
|
||||
}
|
||||
}
|
||||
])
|
||||
],
|
||||
providers: [
|
||||
ItemPageResolver,
|
||||
]
|
||||
})
|
||||
export class ItemPageRoutingModule {
|
||||
|
28
src/app/+item-page/item-page.resolver.ts
Normal file
28
src/app/+item-page/item-page.resolver.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { ActivatedRouteSnapshot, Resolve, RouterStateSnapshot } from '@angular/router';
|
||||
import { Observable } from 'rxjs/Observable';
|
||||
import { RemoteData } from '../core/data/remote-data';
|
||||
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<RemoteData<Item>> {
|
||||
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<<RemoteData<Item>> Emits the found item based on the parameters in the current route
|
||||
*/
|
||||
resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<RemoteData<Item>> {
|
||||
return this.itemService.findById(route.params.id).pipe(
|
||||
getSucceededRemoteData()
|
||||
);
|
||||
}
|
||||
}
|
@@ -1,11 +1,11 @@
|
||||
<div class="container" *ngVar="(itemRDObs | async) as itemRD">
|
||||
<div class="container" *ngVar="(itemRD$ | async) as itemRD">
|
||||
<div class="item-page" *ngIf="itemRD?.hasSucceeded" @fadeInOut>
|
||||
<div *ngIf="itemRD?.payload as item">
|
||||
<ds-item-page-title-field [item]="item"></ds-item-page-title-field>
|
||||
<div class="row">
|
||||
<div class="col-xs-12 col-md-4">
|
||||
<ds-metadata-field-wrapper>
|
||||
<ds-thumbnail [thumbnail]="thumbnailObs | async"></ds-thumbnail>
|
||||
<ds-thumbnail [thumbnail]="thumbnail$ | async"></ds-thumbnail>
|
||||
</ds-metadata-field-wrapper>
|
||||
<ds-item-page-file-section [item]="item"></ds-item-page-file-section>
|
||||
<ds-item-page-date-field [item]="item"></ds-item-page-date-field>
|
||||
|
@@ -31,9 +31,9 @@ export class ItemPageComponent implements OnInit {
|
||||
|
||||
private sub: any;
|
||||
|
||||
itemRDObs: Observable<RemoteData<Item>>;
|
||||
itemRD$: Observable<RemoteData<Item>>;
|
||||
|
||||
thumbnailObs: Observable<Bitstream>;
|
||||
thumbnail$: Observable<Bitstream>;
|
||||
|
||||
constructor(
|
||||
private route: ActivatedRoute,
|
||||
@@ -44,19 +44,11 @@ export class ItemPageComponent implements OnInit {
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.sub = this.route.params.subscribe((params) => {
|
||||
this.initialize(params);
|
||||
});
|
||||
}
|
||||
|
||||
initialize(params) {
|
||||
this.id = +params.id;
|
||||
this.itemRDObs = this.items.findById(params.id);
|
||||
this.metadataService.processRemoteData(this.itemRDObs);
|
||||
this.thumbnailObs = this.itemRDObs
|
||||
this.itemRD$ = this.route.data.map((data) => data.item);
|
||||
this.metadataService.processRemoteData(this.itemRD$);
|
||||
this.thumbnail$ = this.itemRD$
|
||||
.map((rd: RemoteData<Item>) => rd.payload)
|
||||
.filter((item: Item) => hasValue(item))
|
||||
.flatMap((item: Item) => item.getThumbnail());
|
||||
}
|
||||
|
||||
}
|
||||
|
@@ -2,11 +2,19 @@ import { autoserialize } from 'cerialize';
|
||||
import { Metadatum } from '../core/shared/metadatum.model';
|
||||
import { ListableObject } from '../shared/object-collection/shared/listable-object.model';
|
||||
|
||||
/**
|
||||
* Represents a normalized version of a search result object of a certain DSpaceObject
|
||||
*/
|
||||
export class NormalizedSearchResult implements ListableObject {
|
||||
|
||||
/**
|
||||
* The UUID of the DSpaceObject that was found
|
||||
*/
|
||||
@autoserialize
|
||||
dspaceObject: string;
|
||||
|
||||
/**
|
||||
* The metadata that was used to find this item, hithighlighted
|
||||
*/
|
||||
@autoserialize
|
||||
hitHighlights: Metadatum[];
|
||||
|
||||
|
38
src/app/+search-page/paginated-search-options.model.spec.ts
Normal file
38
src/app/+search-page/paginated-search-options.model.spec.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import 'rxjs/add/observable/of';
|
||||
import { SortDirection, SortOptions } from '../core/cache/models/sort-options.model';
|
||||
import { PaginationComponentOptions } from '../shared/pagination/pagination-component-options.model';
|
||||
import { PaginatedSearchOptions } from './paginated-search-options.model';
|
||||
import { DSpaceObjectType } from '../core/shared/dspace-object-type.model';
|
||||
import { SearchFilter } from './search-filter.model';
|
||||
|
||||
describe('PaginatedSearchOptions', () => {
|
||||
let options: PaginatedSearchOptions;
|
||||
const sortOptions = new SortOptions('test.field', SortDirection.DESC);
|
||||
const pageOptions = Object.assign(new PaginationComponentOptions(), { pageSize: 40, page: 1 });
|
||||
const filters = [new SearchFilter('f.test', ['value']), new SearchFilter('f.example', ['another value', 'second value'])];
|
||||
const query = 'search query';
|
||||
const scope = '0fde1ecb-82cc-425a-b600-ac3576d76b47';
|
||||
const baseUrl = 'www.rest.com';
|
||||
beforeEach(() => {
|
||||
options = new PaginatedSearchOptions({sort: sortOptions, pagination: pageOptions, filters: filters, query: query, scope: scope, dsoType: DSpaceObjectType.ITEM});
|
||||
});
|
||||
|
||||
describe('when toRestUrl is called', () => {
|
||||
|
||||
it('should generate a string with all parameters that are present', () => {
|
||||
const outcome = options.toRestUrl(baseUrl);
|
||||
expect(outcome).toEqual('www.rest.com?' +
|
||||
'sort=test.field,DESC&' +
|
||||
'page=0&' +
|
||||
'size=40&' +
|
||||
'query=search query&' +
|
||||
'scope=0fde1ecb-82cc-425a-b600-ac3576d76b47&' +
|
||||
'dsoType=ITEM&' +
|
||||
'f.test=value,query&' +
|
||||
'f.example=another value,query&' +
|
||||
'f.example=second value,query'
|
||||
);
|
||||
});
|
||||
|
||||
});
|
||||
});
|
@@ -1,12 +1,29 @@
|
||||
import { SortOptions } from '../core/cache/models/sort-options.model';
|
||||
import { PaginationComponentOptions } from '../shared/pagination/pagination-component-options.model';
|
||||
import { isNotEmpty } from '../shared/empty.util';
|
||||
import { URLCombiner } from '../core/url-combiner/url-combiner';
|
||||
import { SearchOptions } from './search-options.model';
|
||||
import { SearchFilter } from './search-filter.model';
|
||||
import { DSpaceObjectType } from '../core/shared/dspace-object-type.model';
|
||||
|
||||
/**
|
||||
* This model class represents all parameters needed to request information about a certain page of a search request, in a certain order
|
||||
*/
|
||||
export class PaginatedSearchOptions extends SearchOptions {
|
||||
pagination?: PaginationComponentOptions;
|
||||
sort?: SortOptions;
|
||||
|
||||
constructor(options: {scope?: string, query?: string, dsoType?: DSpaceObjectType, filters?: SearchFilter[], pagination?: PaginationComponentOptions, sort?: SortOptions}) {
|
||||
super(options);
|
||||
this.pagination = options.pagination;
|
||||
this.sort = options.sort;
|
||||
}
|
||||
|
||||
/**
|
||||
* Method to generate the URL that can be used to request a certain page with specific sort options
|
||||
* @param {string} url The URL to the REST endpoint
|
||||
* @param {string[]} args A list of query arguments that should be included in the URL
|
||||
* @returns {string} URL with all paginated search options and passed arguments as query parameters
|
||||
*/
|
||||
toRestUrl(url: string, args: string[] = []): string {
|
||||
if (isNotEmpty(this.sort)) {
|
||||
args.push(`sort=${this.sort.field},${this.sort.direction}`);
|
||||
|
20
src/app/+search-page/search-filter.model.ts
Normal file
20
src/app/+search-page/search-filter.model.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Represents a search filter
|
||||
*/
|
||||
import { hasValue } from '../shared/empty.util';
|
||||
|
||||
export class SearchFilter {
|
||||
key: string;
|
||||
values: string[];
|
||||
operator: string;
|
||||
|
||||
constructor(key: string, values: string[], operator?: string) {
|
||||
this.key = key;
|
||||
this.values = values;
|
||||
if (hasValue(operator)) {
|
||||
this.operator = operator;
|
||||
} else {
|
||||
this.operator = 'query';
|
||||
}
|
||||
}
|
||||
}
|
@@ -0,0 +1,34 @@
|
||||
<div>
|
||||
<div class="filters py-2">
|
||||
<a *ngFor="let value of (selectedValues | async)" class="d-flex flex-row"
|
||||
[routerLink]="[getSearchLink()]"
|
||||
[queryParams]="getRemoveParams(value) | async" queryParamsHandling="merge">
|
||||
<input type="checkbox" [checked]="true" class="my-1 align-self-stretch"/>
|
||||
<span class="filter-value pl-1">{{value}}</span>
|
||||
</a>
|
||||
<ng-container *ngFor="let page of (filterValues$ | async)?.payload">
|
||||
<div [@facetLoad]="animationState">
|
||||
<ng-container *ngFor="let value of page.page; let i=index">
|
||||
<a *ngIf="!(selectedValues | async).includes(value.value)" class="d-flex flex-row"
|
||||
[routerLink]="[getSearchLink()]"
|
||||
[queryParams]="getAddParams(value.value) | async" queryParamsHandling="merge">
|
||||
<input type="checkbox" [checked]="false" class="my-1 align-self-stretch"/>
|
||||
<span class="filter-value px-1">{{value.value}}</span>
|
||||
<span class="float-right filter-value-count ml-auto">
|
||||
<span class="badge badge-secondary badge-pill">{{value.count}}</span>
|
||||
</span>
|
||||
</a>
|
||||
</ng-container>
|
||||
</div>
|
||||
</ng-container>
|
||||
<div class="clearfix toggle-more-filters">
|
||||
<a class="float-left" *ngIf="!(isLastPage$ | async)"
|
||||
(click)="showMore()">{{"search.filters.filter.show-more"
|
||||
| translate}}</a>
|
||||
<a class="float-right" *ngIf="(currentPage | async) > 1"
|
||||
(click)="showFirstPageOnly()">{{"search.filters.filter.show-less"
|
||||
| translate}}</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
@@ -0,0 +1,25 @@
|
||||
@import '../../../../../styles/variables.scss';
|
||||
@import '../../../../../styles/mixins.scss';
|
||||
|
||||
.filters {
|
||||
a {
|
||||
color: $body-color;
|
||||
&:hover, &focus {
|
||||
text-decoration: none;
|
||||
}
|
||||
span.badge {
|
||||
vertical-align: text-top;
|
||||
}
|
||||
}
|
||||
.toggle-more-filters a {
|
||||
color: $link-color;
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
::ng-deep em {
|
||||
font-weight: bold;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
|
@@ -0,0 +1,21 @@
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { FilterType } from '../../../search-service/filter-type.model';
|
||||
import { renderFacetFor } from '../search-filter-type-decorator';
|
||||
import {
|
||||
facetLoad,
|
||||
SearchFacetFilterComponent
|
||||
} from '../search-facet-filter/search-facet-filter.component';
|
||||
|
||||
@Component({
|
||||
selector: 'ds-search-boolean-filter',
|
||||
styleUrls: ['./search-boolean-filter.component.scss'],
|
||||
templateUrl: './search-boolean-filter.component.html',
|
||||
animations: [facetLoad]
|
||||
})
|
||||
|
||||
/**
|
||||
* Component that represents a boolean facet for a specific filter configuration
|
||||
*/
|
||||
@renderFacetFor(FilterType.boolean)
|
||||
export class SearchBooleanFilterComponent extends SearchFacetFilterComponent implements OnInit {
|
||||
}
|
@@ -0,0 +1 @@
|
||||
<ng-container *ngComponentOutlet="getSearchFilter(); injector: objectInjector;"></ng-container>
|
@@ -0,0 +1,48 @@
|
||||
import { Component, Injector, Input, OnInit } from '@angular/core';
|
||||
import { renderFilterType } from '../search-filter-type-decorator';
|
||||
import { FilterType } from '../../../search-service/filter-type.model';
|
||||
import { SearchFilterConfig } from '../../../search-service/search-filter-config.model';
|
||||
import { FILTER_CONFIG } from '../search-filter.service';
|
||||
|
||||
@Component({
|
||||
selector: 'ds-search-facet-filter-wrapper',
|
||||
templateUrl: './search-facet-filter-wrapper.component.html'
|
||||
})
|
||||
|
||||
/**
|
||||
* Wrapper component that renders a specific facet filter based on the filter config's type
|
||||
*/
|
||||
export class SearchFacetFilterWrapperComponent implements OnInit {
|
||||
/**
|
||||
* Configuration for the filter of this wrapper component
|
||||
*/
|
||||
@Input() filterConfig: SearchFilterConfig;
|
||||
|
||||
/**
|
||||
* Injector to inject a child component with the @Input parameters
|
||||
*/
|
||||
objectInjector: Injector;
|
||||
|
||||
constructor(private injector: Injector) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize and add the filter config to the injector
|
||||
*/
|
||||
ngOnInit(): void {
|
||||
this.objectInjector = Injector.create({
|
||||
providers: [
|
||||
{ provide: FILTER_CONFIG, useFactory: () => (this.filterConfig), deps: [] }
|
||||
],
|
||||
parent: this.injector
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the correct component based on the filter config's type
|
||||
*/
|
||||
getSearchFilter() {
|
||||
const type: FilterType = this.filterConfig.type;
|
||||
return renderFilterType(type);
|
||||
}
|
||||
}
|
@@ -1,38 +0,0 @@
|
||||
<div>
|
||||
<div class="filters">
|
||||
<a *ngFor="let value of selectedValues" class="d-block"
|
||||
[routerLink]="[getSearchLink()]"
|
||||
[queryParams]="getRemoveParams(value)" queryParamsHandling="merge">
|
||||
<input type="checkbox" [checked]="true"/>
|
||||
<span class="filter-value">{{value}}</span>
|
||||
</a>
|
||||
<ng-container *ngFor="let page of (filterValues$ | async)">
|
||||
<ng-container *ngFor="let value of (page | async)?.payload.page; let i=index">
|
||||
<a *ngIf="!selectedValues.includes(value.value)" class="d-block clearfix"
|
||||
[routerLink]="[getSearchLink()]"
|
||||
[queryParams]="getAddParams(value.value)" queryParamsHandling="merge" >
|
||||
<input type="checkbox" [checked]="false"/>
|
||||
<span class="filter-value">{{value.value}}</span>
|
||||
<span class="float-right filter-value-count">
|
||||
<span class="badge badge-secondary badge-pill">{{value.count}}</span>
|
||||
</span>
|
||||
</a>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
<div class="clearfix toggle-more-filters">
|
||||
<a class="float-left" *ngIf="!(isLastPage$ | async)"
|
||||
(click)="showMore()">{{"search.filters.filter.show-more"
|
||||
| translate}}</a>
|
||||
<a class="float-right" *ngIf="(currentPage | async) > 1"
|
||||
(click)="showFirstPageOnly()">{{"search.filters.filter.show-less"
|
||||
| translate}}</a>
|
||||
</div>
|
||||
</div>
|
||||
<form #form="ngForm" (ngSubmit)="onSubmit(form.value)" class="add-filter"
|
||||
[action]="getCurrentUrl()">
|
||||
<input type="text" [(ngModel)]="filter" [name]="filterConfig.paramName" class="form-control"
|
||||
aria-label="New filter input"
|
||||
[placeholder]="'search.filters.filter.' + filterConfig.name + '.placeholder'| translate" [ngModelOptions]="{standalone: true}"/>
|
||||
<input type="submit" class="d-none"/>
|
||||
</form>
|
||||
</div>
|
@@ -1,10 +1,8 @@
|
||||
import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core';
|
||||
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { RouterTestingModule } from '@angular/router/testing';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
|
||||
import { SearchFacetFilterComponent } from './search-facet-filter.component';
|
||||
import { SearchFilterService } from '../search-filter.service';
|
||||
import { FILTER_CONFIG, SearchFilterService } from '../search-filter.service';
|
||||
import { SearchFilterConfig } from '../../../search-service/search-filter-config.model';
|
||||
import { FilterType } from '../../../search-service/filter-type.model';
|
||||
import { FacetValue } from '../../../search-service/facet-value.model';
|
||||
@@ -14,11 +12,12 @@ import { SearchService } from '../../../search-service/search.service';
|
||||
import { SearchServiceStub } from '../../../../shared/testing/search-service-stub';
|
||||
import { RemoteData } from '../../../../core/data/remote-data';
|
||||
import { PaginatedList } from '../../../../core/data/paginated-list';
|
||||
import { SearchOptions } from '../../../search-options.model';
|
||||
import { RouterStub } from '../../../../shared/testing/router-stub';
|
||||
import { Router } from '@angular/router';
|
||||
import { BehaviorSubject } from 'rxjs/BehaviorSubject';
|
||||
import { PageInfo } from '../../../../core/shared/page-info.model';
|
||||
import { SearchFacetFilterComponent } from './search-facet-filter.component';
|
||||
import { RemoteDataBuildService } from '../../../../core/cache/builders/remote-data-build.service';
|
||||
import { SearchConfigurationService } from '../../../search-service/search-configuration.service';
|
||||
|
||||
describe('SearchFacetFilterComponent', () => {
|
||||
let comp: SearchFacetFilterComponent;
|
||||
@@ -65,18 +64,21 @@ describe('SearchFacetFilterComponent', () => {
|
||||
providers: [
|
||||
{ provide: SearchService, useValue: new SearchServiceStub(searchLink) },
|
||||
{ provide: Router, useValue: new RouterStub() },
|
||||
{ provide: FILTER_CONFIG, useValue: new SearchFilterConfig() },
|
||||
{ provide: RemoteDataBuildService, useValue: {aggregate: () => Observable.of({})} },
|
||||
{ provide: SearchConfigurationService, useValue: {searchOptions: Observable.of({})} },
|
||||
{
|
||||
provide: SearchFilterService, useValue: {
|
||||
isFilterActiveWithValue: (paramName: string, filterValue: string) => true,
|
||||
getPage: (paramName: string) => page,
|
||||
/* tslint:disable:no-empty */
|
||||
incrementPage: (filterName: string) => {
|
||||
},
|
||||
resetPage: (filterName: string) => {
|
||||
},
|
||||
getSearchOptions: () => Observable.of({}),
|
||||
/* tslint:enable:no-empty */
|
||||
}
|
||||
getSelectedValuesForFilter: () => Observable.of(selectedValues),
|
||||
isFilterActiveWithValue: (paramName: string, filterValue: string) => true,
|
||||
getPage: (paramName: string) => page,
|
||||
/* tslint:disable:no-empty */
|
||||
incrementPage: (filterName: string) => {
|
||||
},
|
||||
resetPage: (filterName: string) => {
|
||||
}
|
||||
/* tslint:enable:no-empty */
|
||||
}
|
||||
}
|
||||
],
|
||||
schemas: [NO_ERRORS_SCHEMA]
|
||||
@@ -89,9 +91,6 @@ describe('SearchFacetFilterComponent', () => {
|
||||
fixture = TestBed.createComponent(SearchFacetFilterComponent);
|
||||
comp = fixture.componentInstance; // SearchPageComponent test instance
|
||||
comp.filterConfig = mockFilterConfig;
|
||||
comp.filterValues = [mockValues];
|
||||
comp.filterValues$ = new BehaviorSubject(comp.filterValues);
|
||||
comp.selectedValues = selectedValues;
|
||||
filterService = (comp as any).filterService;
|
||||
searchService = (comp as any).searchService;
|
||||
spyOn(searchService, 'getFacetValuesFor').and.returnValue(mockValues);
|
||||
@@ -124,14 +123,14 @@ describe('SearchFacetFilterComponent', () => {
|
||||
describe('when the getAddParams method is called wih a value', () => {
|
||||
it('should return the selectedValue list with the new parameter value', () => {
|
||||
const result = comp.getAddParams(value3);
|
||||
expect(result[mockFilterConfig.paramName]).toEqual([value1, value2, value3]);
|
||||
result.subscribe((r) => expect(r[mockFilterConfig.paramName]).toEqual([value1, value2, value3]));
|
||||
});
|
||||
});
|
||||
|
||||
describe('when the getRemoveParams method is called wih a value', () => {
|
||||
it('should return the selectedValue list with the parameter value left out', () => {
|
||||
const result = comp.getRemoveParams(value1);
|
||||
expect(result[mockFilterConfig.paramName]).toEqual([value2]);
|
||||
result.subscribe((r) => expect(r[mockFilterConfig.paramName]).toEqual([value2]));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -169,7 +168,7 @@ describe('SearchFacetFilterComponent', () => {
|
||||
});
|
||||
|
||||
describe('when the getCurrentUrl method is called', () => {
|
||||
const url = 'test.url/test'
|
||||
const url = 'test.url/test';
|
||||
beforeEach(() => {
|
||||
router.navigateByUrl(url);
|
||||
});
|
||||
@@ -182,7 +181,7 @@ describe('SearchFacetFilterComponent', () => {
|
||||
describe('when the onSubmit method is called with data', () => {
|
||||
const searchUrl = '/search/path';
|
||||
const testValue = 'test';
|
||||
const data = { [mockFilterConfig.paramName]: testValue };
|
||||
const data = testValue;
|
||||
beforeEach(() => {
|
||||
spyOn(comp, 'getSearchLink').and.returnValue(searchUrl);
|
||||
comp.onSubmit(data);
|
||||
@@ -197,46 +196,26 @@ describe('SearchFacetFilterComponent', () => {
|
||||
});
|
||||
|
||||
describe('when updateFilterValueList is called', () => {
|
||||
const cPage = 10;
|
||||
const searchOptions = new SearchOptions();
|
||||
beforeEach(() => {
|
||||
// spyOn(searchService, 'getFacetValuesFor'); Already spied upon
|
||||
comp.currentPage = Observable.of(cPage);
|
||||
comp.updateFilterValueList(searchOptions);
|
||||
spyOn(comp, 'showFirstPageOnly');
|
||||
comp.updateFilterValueList()
|
||||
});
|
||||
|
||||
it('should call getFacetValuesFor on the searchService with the correct parameters', () => {
|
||||
expect(searchService.getFacetValuesFor).toHaveBeenCalledWith(mockFilterConfig, cPage, searchOptions);
|
||||
it('should call showFirstPageOnly and empty the filter', () => {
|
||||
expect(comp.animationState).toEqual('loading');
|
||||
expect((comp as any).collapseNextUpdate).toBeTruthy();
|
||||
expect(comp.filter).toEqual('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('when updateFilterValueList is called and pageChange is set to true', () => {
|
||||
const searchOptions = new SearchOptions();
|
||||
describe('when findSuggestions is called with query \'test\'', () => {
|
||||
const query = 'test';
|
||||
beforeEach(() => {
|
||||
comp.pageChange = true;
|
||||
spyOn(comp, 'showFirstPageOnly');
|
||||
comp.updateFilterValueList(searchOptions);
|
||||
comp.findSuggestions(query);
|
||||
});
|
||||
|
||||
it('should not call showFirstPageOnly on the component', () => {
|
||||
expect(comp.showFirstPageOnly).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should set pageChange to false', () => {
|
||||
expect(comp.pageChange).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when updateFilterValueList is called and pageChange is set to false', () => {
|
||||
const searchOptions = new SearchOptions();
|
||||
beforeEach(() => {
|
||||
comp.pageChange = false;
|
||||
spyOn(comp, 'showFirstPageOnly');
|
||||
comp.updateFilterValueList(searchOptions);
|
||||
});
|
||||
|
||||
it('should call showFirstPageOnly on the component', () => {
|
||||
expect(comp.showFirstPageOnly).toHaveBeenCalled();
|
||||
it('should call getFacetValuesFor on the component\'s SearchService with the right query', () => {
|
||||
expect((comp as any).searchService.getFacetValuesFor).toHaveBeenCalledWith(comp.filterConfig, 1, {}, query);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@@ -1,125 +1,289 @@
|
||||
import { Component, Input, OnDestroy, OnInit } from '@angular/core';
|
||||
import { animate, state, style, transition, trigger } from '@angular/animations';
|
||||
import { Component, Inject, OnDestroy, OnInit } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { BehaviorSubject } from 'rxjs/BehaviorSubject';
|
||||
import { Observable } from 'rxjs/Observable';
|
||||
import { Subject } from 'rxjs/Subject';
|
||||
import { Subscription } from 'rxjs/Subscription';
|
||||
import { RemoteDataBuildService } from '../../../../core/cache/builders/remote-data-build.service';
|
||||
import { PaginatedList } from '../../../../core/data/paginated-list';
|
||||
import { RemoteData } from '../../../../core/data/remote-data';
|
||||
import { hasNoValue, hasValue, isNotEmpty } from '../../../../shared/empty.util';
|
||||
import { EmphasizePipe } from '../../../../shared/utils/emphasize.pipe';
|
||||
import { SearchOptions } from '../../../search-options.model';
|
||||
import { FacetValue } from '../../../search-service/facet-value.model';
|
||||
import { SearchFilterConfig } from '../../../search-service/search-filter-config.model';
|
||||
import { Router } from '@angular/router';
|
||||
import { Observable } from 'rxjs/Observable';
|
||||
import { SearchFilterService } from '../search-filter.service';
|
||||
import { hasNoValue, hasValue, isNotEmpty } from '../../../../shared/empty.util';
|
||||
import { RemoteData } from '../../../../core/data/remote-data';
|
||||
import { PaginatedList } from '../../../../core/data/paginated-list';
|
||||
import { SearchService } from '../../../search-service/search.service';
|
||||
import { SearchOptions } from '../../../search-options.model';
|
||||
import { BehaviorSubject } from 'rxjs/BehaviorSubject';
|
||||
import { Subscription } from 'rxjs/Subscription';
|
||||
|
||||
/**
|
||||
* This component renders a simple item page.
|
||||
* The route parameter 'id' is used to request the item it represents.
|
||||
* All fields of the item that should be displayed, are defined in its template.
|
||||
*/
|
||||
import { FILTER_CONFIG, SearchFilterService } from '../search-filter.service';
|
||||
import { SearchConfigurationService } from '../../../search-service/search-configuration.service';
|
||||
import { getSucceededRemoteData } from '../../../../core/shared/operators';
|
||||
import { map } from 'rxjs/operators';
|
||||
|
||||
@Component({
|
||||
selector: 'ds-search-facet-filter',
|
||||
styleUrls: ['./search-facet-filter.component.scss'],
|
||||
templateUrl: './search-facet-filter.component.html'
|
||||
template: ``,
|
||||
})
|
||||
|
||||
/**
|
||||
* Super class for all different representations of facets
|
||||
*/
|
||||
export class SearchFacetFilterComponent implements OnInit, OnDestroy {
|
||||
@Input() filterConfig: SearchFilterConfig;
|
||||
@Input() selectedValues: string[];
|
||||
filterValues: Array<Observable<RemoteData<PaginatedList<FacetValue>>>> = [];
|
||||
filterValues$: BehaviorSubject<any> = new BehaviorSubject(this.filterValues);
|
||||
/**
|
||||
* Emits an array of pages with values found for this facet
|
||||
*/
|
||||
filterValues$: Subject<RemoteData<Array<PaginatedList<FacetValue>>>>;
|
||||
|
||||
/**
|
||||
* Emits the current last shown page of this facet's values
|
||||
*/
|
||||
currentPage: Observable<number>;
|
||||
|
||||
/**
|
||||
* Emits true if the current page is also the last page available
|
||||
*/
|
||||
isLastPage$: BehaviorSubject<boolean> = new BehaviorSubject(false);
|
||||
|
||||
/**
|
||||
* The value of the input field that is used to query for possible values for this filter
|
||||
*/
|
||||
filter: string;
|
||||
pageChange = false;
|
||||
sub: Subscription;
|
||||
|
||||
constructor(private searchService: SearchService, private filterService: SearchFilterService, private router: Router) {
|
||||
/**
|
||||
* List of subscriptions to unsubscribe from
|
||||
*/
|
||||
private subs: Subscription[] = [];
|
||||
|
||||
/**
|
||||
* Emits the result values for this filter found by the current filter query
|
||||
*/
|
||||
filterSearchResults: Observable<any[]> = Observable.of([]);
|
||||
|
||||
/**
|
||||
* Emits the active values for this filter
|
||||
*/
|
||||
selectedValues: Observable<string[]>;
|
||||
private collapseNextUpdate = true;
|
||||
|
||||
/**
|
||||
* State of the requested facets used to time the animation
|
||||
*/
|
||||
animationState = 'loading';
|
||||
|
||||
constructor(protected searchService: SearchService,
|
||||
protected filterService: SearchFilterService,
|
||||
protected searchConfigService: SearchConfigurationService,
|
||||
protected rdbs: RemoteDataBuildService,
|
||||
protected router: Router,
|
||||
@Inject(FILTER_CONFIG) public filterConfig: SearchFilterConfig) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes all observable instance variables and starts listening to them
|
||||
*/
|
||||
ngOnInit(): void {
|
||||
this.currentPage = this.getCurrentPage();
|
||||
this.currentPage.distinctUntilChanged().subscribe((page) => this.pageChange = true);
|
||||
this.filterService.getSearchOptions().distinctUntilChanged().subscribe((options) => this.updateFilterValueList(options));
|
||||
}
|
||||
|
||||
updateFilterValueList(options: SearchOptions) {
|
||||
if (!this.pageChange) {
|
||||
this.showFirstPageOnly();
|
||||
}
|
||||
this.pageChange = false;
|
||||
|
||||
this.unsubscribe();
|
||||
this.sub = this.currentPage.distinctUntilChanged().map((page) => {
|
||||
return this.searchService.getFacetValuesFor(this.filterConfig, page, options);
|
||||
}).subscribe((newValues$) => {
|
||||
this.filterValues = [...this.filterValues, newValues$];
|
||||
this.filterValues$.next(this.filterValues);
|
||||
newValues$.first().subscribe((rd) => this.isLastPage$.next(hasNoValue(rd.payload.next)));
|
||||
this.filterValues$ = new BehaviorSubject(new RemoteData(true, false, undefined, undefined, undefined));
|
||||
this.currentPage = this.getCurrentPage().distinctUntilChanged();
|
||||
this.selectedValues = this.filterService.getSelectedValuesForFilter(this.filterConfig);
|
||||
const searchOptions = this.searchConfigService.searchOptions;
|
||||
this.subs.push(this.searchConfigService.searchOptions.subscribe(() => this.updateFilterValueList()));
|
||||
const facetValues = Observable.combineLatest(searchOptions, this.currentPage, (options, page) => {
|
||||
return { options, page }
|
||||
}).switchMap(({ options, page }) => {
|
||||
return this.searchService.getFacetValuesFor(this.filterConfig, page, options)
|
||||
.pipe(
|
||||
getSucceededRemoteData(),
|
||||
map((results) => {
|
||||
return {
|
||||
values: Observable.of(results),
|
||||
page: page
|
||||
};
|
||||
}
|
||||
)
|
||||
)
|
||||
});
|
||||
let filterValues = [];
|
||||
this.subs.push(facetValues.subscribe((facetOutcome) => {
|
||||
const newValues$ = facetOutcome.values;
|
||||
|
||||
if (this.collapseNextUpdate) {
|
||||
this.showFirstPageOnly();
|
||||
facetOutcome.page = 1;
|
||||
this.collapseNextUpdate = false;
|
||||
}
|
||||
if (facetOutcome.page === 1) {
|
||||
filterValues = [];
|
||||
}
|
||||
|
||||
filterValues = [...filterValues, newValues$];
|
||||
|
||||
this.subs.push(this.rdbs.aggregate(filterValues).subscribe((rd: RemoteData<Array<PaginatedList<FacetValue>>>) => {
|
||||
this.animationState = 'ready';
|
||||
this.filterValues$.next(rd);
|
||||
}));
|
||||
this.subs.push(newValues$.first().subscribe((rd) => {
|
||||
this.isLastPage$.next(hasNoValue(rd.payload.next))
|
||||
}));
|
||||
}));
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare for refreshing the values of this filter
|
||||
*/
|
||||
updateFilterValueList() {
|
||||
this.animationState = 'loading';
|
||||
this.collapseNextUpdate = true;
|
||||
this.filter = '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a value for this filter is currently active
|
||||
*/
|
||||
isChecked(value: FacetValue): Observable<boolean> {
|
||||
return this.filterService.isFilterActiveWithValue(this.filterConfig.paramName, value.value);
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {string} The base path to the search page
|
||||
*/
|
||||
getSearchLink() {
|
||||
return this.searchService.getSearchLink();
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the next page as well
|
||||
*/
|
||||
showMore() {
|
||||
this.filterService.incrementPage(this.filterConfig.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Make sure only the first page is shown
|
||||
*/
|
||||
showFirstPageOnly() {
|
||||
this.filterValues = [];
|
||||
this.filterService.resetPage(this.filterConfig.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Observable<number>} The current page of this filter
|
||||
*/
|
||||
getCurrentPage(): Observable<number> {
|
||||
return this.filterService.getPage(this.filterConfig.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {string} the current URL
|
||||
*/
|
||||
getCurrentUrl() {
|
||||
return this.router.url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Submits a new active custom value to the filter from the input field
|
||||
* @param data The string from the input field
|
||||
*/
|
||||
onSubmit(data: any) {
|
||||
if (isNotEmpty(data)) {
|
||||
this.router.navigate([this.getSearchLink()], {
|
||||
queryParams:
|
||||
{ [this.filterConfig.paramName]: [...this.selectedValues, data[this.filterConfig.paramName]] },
|
||||
queryParamsHandling: 'merge'
|
||||
});
|
||||
this.filter = '';
|
||||
}
|
||||
this.selectedValues.first().subscribe((selectedValues) => {
|
||||
if (isNotEmpty(data)) {
|
||||
this.router.navigate([this.getSearchLink()], {
|
||||
queryParams:
|
||||
{ [this.filterConfig.paramName]: [...selectedValues, data] },
|
||||
queryParamsHandling: 'merge'
|
||||
});
|
||||
this.filter = '';
|
||||
}
|
||||
this.filterSearchResults = Observable.of([]);
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
onClick(data: any) {
|
||||
this.filter = data;
|
||||
}
|
||||
|
||||
/**
|
||||
* For usage of the hasValue function in the template
|
||||
*/
|
||||
hasValue(o: any): boolean {
|
||||
return hasValue(o);
|
||||
}
|
||||
getRemoveParams(value: string) {
|
||||
return {
|
||||
[this.filterConfig.paramName]: this.selectedValues.filter((v) => v !== value),
|
||||
page: 1
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculates the parameters that should change if a given value for this filter would be removed from the active filters
|
||||
* @param {string} value The value that is removed for this filter
|
||||
* @returns {Observable<any>} The changed filter parameters
|
||||
*/
|
||||
getRemoveParams(value: string): Observable<any> {
|
||||
return this.selectedValues.map((selectedValues) => {
|
||||
return {
|
||||
[this.filterConfig.paramName]: selectedValues.filter((v) => v !== value),
|
||||
page: 1
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
getAddParams(value: string) {
|
||||
return {
|
||||
[this.filterConfig.paramName]: [...this.selectedValues, value],
|
||||
page: 1
|
||||
};
|
||||
/**
|
||||
* Calculates the parameters that should change if a given value for this filter would be added to the active filters
|
||||
* @param {string} value The value that is added for this filter
|
||||
* @returns {Observable<any>} The changed filter parameters
|
||||
*/
|
||||
getAddParams(value: string): Observable<any> {
|
||||
return this.selectedValues.map((selectedValues) => {
|
||||
return {
|
||||
[this.filterConfig.paramName]: [...selectedValues, value],
|
||||
page: 1
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsubscribe from all subscriptions
|
||||
*/
|
||||
ngOnDestroy(): void {
|
||||
this.unsubscribe();
|
||||
this.subs
|
||||
.filter((sub) => hasValue(sub))
|
||||
.forEach((sub) => sub.unsubscribe());
|
||||
}
|
||||
|
||||
unsubscribe(): void {
|
||||
if (hasValue(this.sub)) {
|
||||
this.sub.unsubscribe();
|
||||
/**
|
||||
* Updates the found facet value suggestions for a given query
|
||||
* Transforms the found values into display values
|
||||
* @param data The query for which is being searched
|
||||
*/
|
||||
findSuggestions(data): void {
|
||||
if (isNotEmpty(data)) {
|
||||
this.searchConfigService.searchOptions.first().subscribe(
|
||||
(options) => {
|
||||
this.filterSearchResults = this.searchService.getFacetValuesFor(this.filterConfig, 1, options, data.toLowerCase())
|
||||
.pipe(
|
||||
getSucceededRemoteData(),
|
||||
map(
|
||||
(rd: RemoteData<PaginatedList<FacetValue>>) => {
|
||||
return rd.payload.page.map((facet) => {
|
||||
return { displayValue: this.getDisplayValue(facet, data), value: facet.value }
|
||||
})
|
||||
}
|
||||
))
|
||||
}
|
||||
)
|
||||
} else {
|
||||
this.filterSearchResults = Observable.of([]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms the facet value string, so if the query matches part of the value, it's emphasized in the value
|
||||
* @param {FacetValue} facet The value of the facet as returned by the server
|
||||
* @param {string} query The query that was used to search facet values
|
||||
* @returns {string} The facet value with the query part emphasized
|
||||
*/
|
||||
getDisplayValue(facet: FacetValue, query: string): string {
|
||||
return new EmphasizePipe().transform(facet.value, query) + ' (' + facet.count + ')';
|
||||
}
|
||||
}
|
||||
|
||||
export const facetLoad = trigger('facetLoad', [
|
||||
state('ready', style({ opacity: 1 })),
|
||||
state('loading', style({ opacity: 0 })),
|
||||
transition('loading <=> ready', animate(100)),
|
||||
]);
|
||||
|
@@ -0,0 +1,30 @@
|
||||
|
||||
import { FilterType } from '../../search-service/filter-type.model';
|
||||
|
||||
/**
|
||||
* Contains the mapping between a facet component and a FilterType
|
||||
*/
|
||||
const filterTypeMap = new Map();
|
||||
|
||||
/**
|
||||
* Sets the mapping for a facet component in relation to a filter type
|
||||
* @param {FilterType} type The type for which the matching component is mapped
|
||||
* @returns Decorator function that performs the actual mapping on initialization of the facet component
|
||||
*/
|
||||
export function renderFacetFor(type: FilterType) {
|
||||
return function decorator(objectElement: any) {
|
||||
if (!objectElement) {
|
||||
return;
|
||||
}
|
||||
filterTypeMap.set(type, objectElement);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests the matching facet component based on a given filter type
|
||||
* @param {FilterType} type The filter type for which the facet component is requested
|
||||
* @returns The facet component's constructor that matches the given filter type
|
||||
*/
|
||||
export function renderFilterType(type: FilterType) {
|
||||
return filterTypeMap.get(type);
|
||||
}
|
@@ -22,41 +22,78 @@ export const SearchFilterActionTypes = {
|
||||
};
|
||||
|
||||
export class SearchFilterAction implements Action {
|
||||
/**
|
||||
* Name of the filter the action is performed on, used to identify the filter
|
||||
*/
|
||||
filterName: string;
|
||||
|
||||
/**
|
||||
* Type of action that will be performed
|
||||
*/
|
||||
type;
|
||||
|
||||
/**
|
||||
* Initialize with the filter's name
|
||||
* @param {string} name of the filter
|
||||
*/
|
||||
constructor(name: string) {
|
||||
this.filterName = name;
|
||||
}
|
||||
}
|
||||
|
||||
/* tslint:disable:max-classes-per-file */
|
||||
/**
|
||||
* Used to collapse a filter
|
||||
*/
|
||||
export class SearchFilterCollapseAction extends SearchFilterAction {
|
||||
type = SearchFilterActionTypes.COLLAPSE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Used to expand a filter
|
||||
*/
|
||||
export class SearchFilterExpandAction extends SearchFilterAction {
|
||||
type = SearchFilterActionTypes.EXPAND;
|
||||
}
|
||||
|
||||
/**
|
||||
* Used to collapse a filter when it's expanded and expand it when it's collapsed
|
||||
*/
|
||||
export class SearchFilterToggleAction extends SearchFilterAction {
|
||||
type = SearchFilterActionTypes.TOGGLE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Used to set the initial state of a filter to collapsed
|
||||
*/
|
||||
export class SearchFilterInitialCollapseAction extends SearchFilterAction {
|
||||
type = SearchFilterActionTypes.INITIAL_COLLAPSE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Used to set the initial state of a filter to expanded
|
||||
*/
|
||||
export class SearchFilterInitialExpandAction extends SearchFilterAction {
|
||||
type = SearchFilterActionTypes.INITIAL_EXPAND;
|
||||
}
|
||||
|
||||
/**
|
||||
* Used to set the state of a filter to the previous page
|
||||
*/
|
||||
export class SearchFilterDecrementPageAction extends SearchFilterAction {
|
||||
type = SearchFilterActionTypes.DECREMENT_PAGE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Used to set the state of a filter to the next page
|
||||
*/
|
||||
export class SearchFilterIncrementPageAction extends SearchFilterAction {
|
||||
type = SearchFilterActionTypes.INCREMENT_PAGE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Used to set the state of a filter to the first page
|
||||
*/
|
||||
export class SearchFilterResetPageAction extends SearchFilterAction {
|
||||
type = SearchFilterActionTypes.RESET_PAGE;
|
||||
}
|
||||
|
@@ -1,7 +1,7 @@
|
||||
<div>
|
||||
<div (click)="toggle()" class="filter-name"><h5 class="d-inline-block mb-0">{{'search.filters.filter.' + filter.name + '.head'| translate}}</h5> <span class="filter-toggle fa float-right"
|
||||
[ngClass]="(isCollapsed() | async) ? 'fa-plus' : 'fa-minus'"></span></div>
|
||||
<div [@slide]="(isCollapsed() | async) ? 'collapsed' : 'expanded'" class="search-filter-wrapper">
|
||||
<ds-search-facet-filter [filterConfig]="filter" [selectedValues]="getSelectedValues() | async"></ds-search-facet-filter>
|
||||
<div [@slide]="(isCollapsed() | async) ? 'collapsed' : 'expanded'" (@slide.start)="startSlide($event)" (@slide.done)="finishSlide($event)" class="search-filter-wrapper" [ngClass]="{'closed' : collapsed}">
|
||||
<ds-search-facet-filter-wrapper [filterConfig]="filter"></ds-search-facet-filter-wrapper>
|
||||
</div>
|
||||
</div>
|
@@ -3,7 +3,7 @@
|
||||
|
||||
:host {
|
||||
border: 1px solid map-get($theme-colors, light);
|
||||
.search-filter-wrapper {
|
||||
.search-filter-wrapper.closed {
|
||||
overflow: hidden;
|
||||
}
|
||||
.filter-toggle {
|
||||
|
@@ -1,18 +1,9 @@
|
||||
import { Component, Input, OnInit } from '@angular/core';
|
||||
import { SearchFilterConfig } from '../../search-service/search-filter-config.model';
|
||||
import { SearchService } from '../../search-service/search.service';
|
||||
import { RemoteData } from '../../../core/data/remote-data';
|
||||
import { FacetValue } from '../../search-service/facet-value.model';
|
||||
import { SearchFilterService } from './search-filter.service';
|
||||
import { Observable } from 'rxjs/Observable';
|
||||
import { slide } from '../../../shared/animations/slide';
|
||||
import { PaginatedList } from '../../../core/data/paginated-list';
|
||||
|
||||
/**
|
||||
* This component renders a simple item page.
|
||||
* The route parameter 'id' is used to request the item it represents.
|
||||
* All fields of the item that should be displayed, are defined in its template.
|
||||
*/
|
||||
import { isNotEmpty } from '../../../shared/empty.util';
|
||||
|
||||
@Component({
|
||||
selector: 'ds-search-filter',
|
||||
@@ -21,15 +12,31 @@ import { PaginatedList } from '../../../core/data/paginated-list';
|
||||
animations: [slide],
|
||||
})
|
||||
|
||||
/**
|
||||
* Represents a part of the filter section for a single type of filter
|
||||
*/
|
||||
export class SearchFilterComponent implements OnInit {
|
||||
/**
|
||||
* The filter config for this component
|
||||
*/
|
||||
@Input() filter: SearchFilterConfig;
|
||||
|
||||
/**
|
||||
* True when the filter is 100% collapsed in the UI
|
||||
*/
|
||||
collapsed;
|
||||
|
||||
constructor(private filterService: SearchFilterService) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests the current set values for this filter
|
||||
* If the filter config is open by default OR the filter has at least one value, the filter should be initially expanded
|
||||
* Else, the filter should initially be collapsed
|
||||
*/
|
||||
ngOnInit() {
|
||||
this.filterService.isFilterActive(this.filter.paramName).first().subscribe((isActive) => {
|
||||
if (this.filter.isOpenByDefault || isActive) {
|
||||
this.getSelectedValues().first().subscribe((isActive) => {
|
||||
if (this.filter.isOpenByDefault || isNotEmpty(isActive)) {
|
||||
this.initialExpand();
|
||||
} else {
|
||||
this.initialCollapse();
|
||||
@@ -37,23 +44,61 @@ export class SearchFilterComponent implements OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Changes the state for this filter to collapsed when it's expanded and to expanded it when it's collapsed
|
||||
*/
|
||||
toggle() {
|
||||
this.filterService.toggle(this.filter.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the filter is currently collapsed
|
||||
* @returns {Observable<boolean>} Emits true when the current state of the filter is collapsed, false when it's expanded
|
||||
*/
|
||||
isCollapsed(): Observable<boolean> {
|
||||
return this.filterService.isCollapsed(this.filter.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Changes the initial state to collapsed
|
||||
*/
|
||||
initialCollapse() {
|
||||
this.filterService.initialCollapse(this.filter.name);
|
||||
this.collapsed = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Changes the initial state to expanded
|
||||
*/
|
||||
initialExpand() {
|
||||
this.filterService.initialExpand(this.filter.name);
|
||||
this.collapsed = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Observable<string[]>} Emits a list of all values that are currently active for this filter
|
||||
*/
|
||||
getSelectedValues(): Observable<string[]> {
|
||||
return this.filterService.getSelectedValuesForFilter(this.filter);
|
||||
}
|
||||
|
||||
/**
|
||||
* Method to change this.collapsed to false when the slide animation ends and is sliding open
|
||||
* @param event The animation event
|
||||
*/
|
||||
finishSlide(event: any): void {
|
||||
if (event.fromState === 'collapsed') {
|
||||
this.collapsed = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Method to change this.collapsed to true when the slide animation starts and is sliding closed
|
||||
* @param event The animation event
|
||||
*/
|
||||
startSlide(event: any): void {
|
||||
if (event.toState === 'collapsed') {
|
||||
this.collapsed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -1,17 +1,29 @@
|
||||
import { SearchFilterAction, SearchFilterActionTypes } from './search-filter.actions';
|
||||
import { isEmpty } from '../../../shared/empty.util';
|
||||
|
||||
/**
|
||||
* Interface that represents the state for a single filters
|
||||
*/
|
||||
export interface SearchFilterState {
|
||||
filterCollapsed: boolean,
|
||||
page: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface that represents the state for all available filters
|
||||
*/
|
||||
export interface SearchFiltersState {
|
||||
[name: string]: SearchFilterState
|
||||
}
|
||||
|
||||
const initialState: SearchFiltersState = Object.create(null);
|
||||
|
||||
/**
|
||||
* Performs a search filter action on the current state
|
||||
* @param {SearchFiltersState} state The state before the action is performed
|
||||
* @param {SearchFilterAction} action The action that should be performed
|
||||
* @returns {SearchFiltersState} The state after the action is performed
|
||||
*/
|
||||
export function filterReducer(state = initialState, action: SearchFilterAction): SearchFiltersState {
|
||||
|
||||
switch (action.type) {
|
||||
|
@@ -10,6 +10,7 @@ import {
|
||||
import { SearchFiltersState } from './search-filter.reducer';
|
||||
import { SearchFilterConfig } from '../../search-service/search-filter-config.model';
|
||||
import { FilterType } from '../../search-service/filter-type.model';
|
||||
import { ActivatedRouteStub } from '../../../shared/testing/active-router-stub';
|
||||
|
||||
describe('SearchFilterService', () => {
|
||||
let service: SearchFilterService;
|
||||
@@ -41,10 +42,14 @@ describe('SearchFilterService', () => {
|
||||
addQueryParameterValue: (param: string, value: string) => {
|
||||
},
|
||||
getQueryParameterValues: (param: string) => {
|
||||
return Observable.of({});
|
||||
},
|
||||
getQueryParamsWithPrefix: (param: string) => {
|
||||
return Observable.of({});
|
||||
}
|
||||
/* tslint:enable:no-empty */
|
||||
};
|
||||
|
||||
const activatedRoute: any = new ActivatedRouteStub();
|
||||
const searchServiceStub: any = {
|
||||
uiSearchRoute: '/search'
|
||||
};
|
||||
|
@@ -1,128 +1,82 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { debounceTime, distinctUntilChanged, map } from 'rxjs/operators';
|
||||
import { Injectable, InjectionToken } from '@angular/core';
|
||||
import { distinctUntilChanged, map } from 'rxjs/operators';
|
||||
import { SearchFiltersState, SearchFilterState } from './search-filter.reducer';
|
||||
import { createSelector, MemoizedSelector, Store } from '@ngrx/store';
|
||||
import { Observable } from 'rxjs/Observable';
|
||||
import {
|
||||
SearchFilterCollapseAction,
|
||||
SearchFilterDecrementPageAction, SearchFilterExpandAction,
|
||||
SearchFilterDecrementPageAction,
|
||||
SearchFilterExpandAction,
|
||||
SearchFilterIncrementPageAction,
|
||||
SearchFilterInitialCollapseAction,
|
||||
SearchFilterInitialExpandAction, SearchFilterResetPageAction,
|
||||
SearchFilterInitialExpandAction,
|
||||
SearchFilterResetPageAction,
|
||||
SearchFilterToggleAction
|
||||
} from './search-filter.actions';
|
||||
import { hasValue, isEmpty, isNotEmpty, } from '../../../shared/empty.util';
|
||||
import { SearchFilterConfig } from '../../search-service/search-filter-config.model';
|
||||
import { SearchService } from '../../search-service/search.service';
|
||||
import { RouteService } from '../../../shared/services/route.service';
|
||||
import ObjectExpression from 'rollup/dist/typings/ast/nodes/ObjectExpression';
|
||||
import { SortDirection, SortOptions } from '../../../core/cache/models/sort-options.model';
|
||||
import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model';
|
||||
import { SearchOptions } from '../../search-options.model';
|
||||
import { PaginatedSearchOptions } from '../../paginated-search-options.model';
|
||||
import { ActivatedRoute, Params } from '@angular/router';
|
||||
|
||||
const filterStateSelector = (state: SearchFiltersState) => state.searchFilter;
|
||||
|
||||
export const FILTER_CONFIG: InjectionToken<SearchFilterConfig> = new InjectionToken<SearchFilterConfig>('filterConfig');
|
||||
|
||||
/**
|
||||
* Service that performs all actions that have to do with search filters and facets
|
||||
*/
|
||||
@Injectable()
|
||||
export class SearchFilterService {
|
||||
|
||||
constructor(private store: Store<SearchFiltersState>,
|
||||
private routeService: RouteService) {
|
||||
private routeService: RouteService
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a given filter is active with a given value
|
||||
* @param {string} paramName The parameter name of the filter's configuration for which to search
|
||||
* @param {string} filterValue The value for which to search
|
||||
* @returns {Observable<boolean>} Emit true when the filter is active with the given value
|
||||
*/
|
||||
isFilterActiveWithValue(paramName: string, filterValue: string): Observable<boolean> {
|
||||
return this.routeService.hasQueryParamWithValue(paramName, filterValue);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a given filter is active with any value
|
||||
* @param {string} paramName The parameter name of the filter's configuration for which to search
|
||||
* @returns {Observable<boolean>} Emit true when the filter is active with any value
|
||||
*/
|
||||
isFilterActive(paramName: string): Observable<boolean> {
|
||||
return this.routeService.hasQueryParam(paramName);
|
||||
}
|
||||
|
||||
getCurrentScope() {
|
||||
return this.routeService.getQueryParameterValue('scope');
|
||||
}
|
||||
|
||||
getCurrentQuery() {
|
||||
return this.routeService.getQueryParameterValue('query');
|
||||
}
|
||||
|
||||
getCurrentPagination(pagination: any = {}): Observable<PaginationComponentOptions> {
|
||||
const page$ = this.routeService.getQueryParameterValue('page');
|
||||
const size$ = this.routeService.getQueryParameterValue('pageSize');
|
||||
return Observable.combineLatest(page$, size$, (page, size) => {
|
||||
return Object.assign(new PaginationComponentOptions(), pagination, {
|
||||
currentPage: page || 1,
|
||||
pageSize: size || pagination.pageSize
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
getCurrentSort(defaultSort: SortOptions): Observable<SortOptions> {
|
||||
const sortDirection$ = this.routeService.getQueryParameterValue('sortDirection');
|
||||
const sortField$ = this.routeService.getQueryParameterValue('sortField');
|
||||
return Observable.combineLatest(sortDirection$, sortField$, (sortDirection, sortField) => {
|
||||
const field = sortField || defaultSort.field;
|
||||
const direction = SortDirection[sortDirection] || defaultSort.direction;
|
||||
return new SortOptions(field, direction)
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
getCurrentFilters() {
|
||||
return this.routeService.getQueryParamsWithPrefix('f.');
|
||||
}
|
||||
|
||||
getCurrentView() {
|
||||
return this.routeService.getQueryParameterValue('view');
|
||||
}
|
||||
|
||||
getPaginatedSearchOptions(defaults: any = {}): Observable<PaginatedSearchOptions> {
|
||||
return Observable.combineLatest(
|
||||
this.getCurrentPagination(defaults.pagination),
|
||||
this.getCurrentSort(defaults.sort),
|
||||
this.getCurrentView(),
|
||||
this.getCurrentScope(),
|
||||
this.getCurrentQuery(),
|
||||
this.getCurrentFilters()).pipe(
|
||||
distinctUntilChanged((a, b) => JSON.stringify(a) === JSON.stringify(b)),
|
||||
map(([pagination, sort, view, scope, query, filters]) => {
|
||||
return Object.assign(new PaginatedSearchOptions(),
|
||||
defaults,
|
||||
{
|
||||
pagination: pagination,
|
||||
sort: sort,
|
||||
view: view,
|
||||
scope: scope || defaults.scope,
|
||||
query: query,
|
||||
filters: filters
|
||||
})
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
getSearchOptions(defaults: any = {}): Observable<SearchOptions> {
|
||||
return Observable.combineLatest(
|
||||
this.getCurrentView(),
|
||||
this.getCurrentScope(),
|
||||
this.getCurrentQuery(),
|
||||
this.getCurrentFilters(),
|
||||
(view, scope, query, filters) => {
|
||||
return Object.assign(new SearchOptions(),
|
||||
defaults,
|
||||
{
|
||||
view: view,
|
||||
scope: scope || defaults.scope,
|
||||
query: query,
|
||||
filters: filters
|
||||
})
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests the active filter values set for a given filter
|
||||
* @param {SearchFilterConfig} filterConfig The configuration for which the filters are active
|
||||
* @returns {Observable<string[]>} Emits the active filters for the given filter configuration
|
||||
*/
|
||||
getSelectedValuesForFilter(filterConfig: SearchFilterConfig): Observable<string[]> {
|
||||
return this.routeService.getQueryParameterValues(filterConfig.paramName);
|
||||
const values$ = this.routeService.getQueryParameterValues(filterConfig.paramName);
|
||||
const prefixValues$ = this.routeService.getQueryParamsWithPrefix(filterConfig.paramName + '.').map((params: Params) => [].concat(...Object.values(params)));
|
||||
return Observable.combineLatest(values$, prefixValues$, (values, prefixValues) => {
|
||||
if (isNotEmpty(values)) {
|
||||
return values;
|
||||
}
|
||||
return prefixValues;
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the state of a given filter is currently collapsed or not
|
||||
* @param {string} filterName The filtername for which the collapsed state is checked
|
||||
* @returns {Observable<boolean>} Emits the current collapsed state of the given filter, if it's unavailable, return false
|
||||
*/
|
||||
isCollapsed(filterName: string): Observable<boolean> {
|
||||
return this.store.select(filterByNameSelector(filterName))
|
||||
.map((object: SearchFilterState) => {
|
||||
@@ -134,6 +88,11 @@ export class SearchFilterService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Request the current page of a given filter
|
||||
* @param {string} filterName The filtername for which the page state is checked
|
||||
* @returns {Observable<boolean>} Emits the current page state of the given filter, if it's unavailable, return 1
|
||||
*/
|
||||
getPage(filterName: string): Observable<number> {
|
||||
return this.store.select(filterByNameSelector(filterName))
|
||||
.map((object: SearchFilterState) => {
|
||||
@@ -145,34 +104,65 @@ export class SearchFilterService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatches a collapse action to the store for a given filter
|
||||
* @param {string} filterName The filter for which the action is dispatched
|
||||
*/
|
||||
public collapse(filterName: string): void {
|
||||
this.store.dispatch(new SearchFilterCollapseAction(filterName));
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatches an expand action to the store for a given filter
|
||||
* @param {string} filterName The filter for which the action is dispatched
|
||||
*/
|
||||
public expand(filterName: string): void {
|
||||
this.store.dispatch(new SearchFilterExpandAction(filterName));
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatches a toggle action to the store for a given filter
|
||||
* @param {string} filterName The filter for which the action is dispatched
|
||||
*/
|
||||
public toggle(filterName: string): void {
|
||||
this.store.dispatch(new SearchFilterToggleAction(filterName));
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatches an initial collapse action to the store for a given filter
|
||||
* @param {string} filterName The filter for which the action is dispatched
|
||||
*/
|
||||
public initialCollapse(filterName: string): void {
|
||||
this.store.dispatch(new SearchFilterInitialCollapseAction(filterName));
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatches an initial expand action to the store for a given filter
|
||||
* @param {string} filterName The filter for which the action is dispatched
|
||||
*/
|
||||
public initialExpand(filterName: string): void {
|
||||
this.store.dispatch(new SearchFilterInitialExpandAction(filterName));
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatches a decrement action to the store for a given filter
|
||||
* @param {string} filterName The filter for which the action is dispatched
|
||||
*/
|
||||
public decrementPage(filterName: string): void {
|
||||
this.store.dispatch(new SearchFilterDecrementPageAction(filterName));
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatches an increment page action to the store for a given filter
|
||||
* @param {string} filterName The filter for which the action is dispatched
|
||||
*/
|
||||
public incrementPage(filterName: string): void {
|
||||
this.store.dispatch(new SearchFilterIncrementPageAction(filterName));
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatches a reset page action to the store for a given filter
|
||||
* @param {string} filterName The filter for which the action is dispatched
|
||||
*/
|
||||
public resetPage(filterName: string): void {
|
||||
this.store.dispatch(new SearchFilterResetPageAction(filterName));
|
||||
}
|
||||
|
@@ -0,0 +1,43 @@
|
||||
<div>
|
||||
<div class="filters py-2">
|
||||
<a *ngFor="let value of (selectedValues | async)" class="d-flex flex-row"
|
||||
[routerLink]="[getSearchLink()]"
|
||||
[queryParams]="getRemoveParams(value) | async" queryParamsHandling="merge">
|
||||
<input type="checkbox" [checked]="true" class="my-1 align-self-stretch"/>
|
||||
<span class="filter-value pl-1">{{value}}</span>
|
||||
</a>
|
||||
<ng-container *ngFor="let page of (filterValues$ | async)?.payload">
|
||||
<div [@facetLoad]="animationState">
|
||||
<ng-container *ngFor="let value of page.page; let i=index">
|
||||
<a *ngIf="!(selectedValues | async).includes(value.value)" class="d-flex flex-row"
|
||||
[routerLink]="[getSearchLink()]"
|
||||
[queryParams]="getAddParams(value.value) | async" queryParamsHandling="merge" >
|
||||
<input type="checkbox" [checked]="false" class="my-1 align-self-stretch"/>
|
||||
<span class="filter-value px-1">{{value.value}}</span>
|
||||
<span class="float-right filter-value-count ml-auto">
|
||||
<span class="badge badge-secondary badge-pill">{{value.count}}</span>
|
||||
</span>
|
||||
</a>
|
||||
</ng-container>
|
||||
</div>
|
||||
</ng-container>
|
||||
<div class="clearfix toggle-more-filters">
|
||||
<a class="float-left" *ngIf="!(isLastPage$ | async)"
|
||||
(click)="showMore()">{{"search.filters.filter.show-more"
|
||||
| translate}}</a>
|
||||
<a class="float-right" *ngIf="(currentPage | async) > 1"
|
||||
(click)="showFirstPageOnly()">{{"search.filters.filter.show-less"
|
||||
| translate}}</a>
|
||||
</div>
|
||||
</div>
|
||||
<ds-input-suggestions [suggestions]="(filterSearchResults | async)"
|
||||
[placeholder]="'search.filters.filter.' + filterConfig.name + '.placeholder'| translate"
|
||||
[action]="getCurrentUrl()"
|
||||
[name]="filterConfig.paramName"
|
||||
[(ngModel)]="filter"
|
||||
(submitSuggestion)="onSubmit($event)"
|
||||
(clickSuggestion)="onClick($event)"
|
||||
(findSuggestions)="findSuggestions($event)"
|
||||
ngDefaultControl
|
||||
></ds-input-suggestions>
|
||||
</div>
|
@@ -0,0 +1,23 @@
|
||||
@import '../../../../../styles/variables.scss';
|
||||
@import '../../../../../styles/mixins.scss';
|
||||
|
||||
.filters {
|
||||
a {
|
||||
color: $body-color;
|
||||
&:hover, &focus {
|
||||
text-decoration: none;
|
||||
}
|
||||
span.badge {
|
||||
vertical-align: text-top;
|
||||
}
|
||||
}
|
||||
.toggle-more-filters a {
|
||||
color: $link-color;
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
::ng-deep em {
|
||||
font-weight: bold;
|
||||
font-style: normal;
|
||||
}
|
@@ -0,0 +1,21 @@
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { FilterType } from '../../../search-service/filter-type.model';
|
||||
import { renderFacetFor } from '../search-filter-type-decorator';
|
||||
import {
|
||||
facetLoad,
|
||||
SearchFacetFilterComponent
|
||||
} from '../search-facet-filter/search-facet-filter.component';
|
||||
|
||||
@Component({
|
||||
selector: 'ds-search-hierarchy-filter',
|
||||
styleUrls: ['./search-hierarchy-filter.component.scss'],
|
||||
templateUrl: './search-hierarchy-filter.component.html',
|
||||
animations: [facetLoad]
|
||||
})
|
||||
|
||||
/**
|
||||
* Component that represents a hierarchy facet for a specific filter configuration
|
||||
*/
|
||||
@renderFacetFor(FilterType.hierarchy)
|
||||
export class SearchHierarchyFilterComponent extends SearchFacetFilterComponent implements OnInit {
|
||||
}
|
@@ -0,0 +1,40 @@
|
||||
<div>
|
||||
<div class="filters py-2">
|
||||
<form #form="ngForm" (ngSubmit)="onSubmit()" class="add-filter row"
|
||||
[action]="getCurrentUrl()">
|
||||
<div class="col-6">
|
||||
<input type="text" [(ngModel)]="range[0]" [name]="filterConfig.paramName + '.min'"
|
||||
class="form-control" (blur)="onSubmit()"
|
||||
aria-label="Mininum value"
|
||||
[placeholder]="'search.filters.filter.' + filterConfig.name + '.min.placeholder'| translate"/>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<input type="text" [(ngModel)]="range[1]" [name]="filterConfig.paramName + '.max'"
|
||||
class="form-control" (blur)="onSubmit()"
|
||||
aria-label="Maximum value"
|
||||
[placeholder]="'search.filters.filter.' + filterConfig.name + '.max.placeholder'| translate"/>
|
||||
</div>
|
||||
<input type="submit" class="d-none"/>
|
||||
</form>
|
||||
|
||||
<ng-container *ngIf="shouldShowSlider()">
|
||||
<nouislider [connect]="true" [min]="min" [max]="max" [step]="1"
|
||||
[(ngModel)]="range" (change)="onSubmit()" ngDefaultControl></nouislider>
|
||||
|
||||
</ng-container>
|
||||
<ng-container *ngFor="let page of (filterValues$ | async)?.payload">
|
||||
<div [@facetLoad]="animationState">
|
||||
<ng-container *ngFor="let value of page.page; let i=index">
|
||||
<a *ngIf="!(selectedValues | async).includes(value.value)" class="d-flex flex-row"
|
||||
[routerLink]="[getSearchLink()]"
|
||||
[queryParams]="getChangeParams(value.value) | async" queryParamsHandling="merge">
|
||||
<span class="filter-value px-1">{{value.value}}</span>
|
||||
<span class="float-right filter-value-count ml-auto">
|
||||
<span class="badge badge-secondary badge-pill">{{value.count}}</span>
|
||||
</span>
|
||||
</a>
|
||||
</ng-container>
|
||||
</div>
|
||||
</ng-container>
|
||||
</div>
|
||||
</div>
|
@@ -0,0 +1,42 @@
|
||||
@import '../../../../../styles/variables.scss';
|
||||
@import '../../../../../styles/mixins.scss';
|
||||
|
||||
|
||||
.filters {
|
||||
a {
|
||||
color: $link-color;
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
color: $link-hover-color;
|
||||
|
||||
}
|
||||
span.badge {
|
||||
vertical-align: text-top;
|
||||
}
|
||||
}
|
||||
.toggle-more-filters a {
|
||||
color: $link-color;
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
$slider-handle-width: 18px;
|
||||
::ng-deep
|
||||
{
|
||||
html:not([dir=rtl]) .noUi-horizontal .noUi-handle {
|
||||
right: -$slider-handle-width/2;
|
||||
}
|
||||
.noUi-horizontal .noUi-handle {
|
||||
width: $slider-handle-width;
|
||||
&:before {
|
||||
left: ($slider-handle-width - 2)/2 - 2;
|
||||
}
|
||||
&:after {
|
||||
left: ($slider-handle-width - 2)/2 + 2;
|
||||
}
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
}
|
@@ -0,0 +1,138 @@
|
||||
import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core';
|
||||
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
|
||||
import { FILTER_CONFIG, SearchFilterService } from '../search-filter.service';
|
||||
import { SearchFilterConfig } from '../../../search-service/search-filter-config.model';
|
||||
import { FilterType } from '../../../search-service/filter-type.model';
|
||||
import { FacetValue } from '../../../search-service/facet-value.model';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Observable } from 'rxjs/Observable';
|
||||
import { SearchService } from '../../../search-service/search.service';
|
||||
import { SearchServiceStub } from '../../../../shared/testing/search-service-stub';
|
||||
import { RemoteData } from '../../../../core/data/remote-data';
|
||||
import { PaginatedList } from '../../../../core/data/paginated-list';
|
||||
import { RouterStub } from '../../../../shared/testing/router-stub';
|
||||
import { Router } from '@angular/router';
|
||||
import { PageInfo } from '../../../../core/shared/page-info.model';
|
||||
import { SearchRangeFilterComponent } from './search-range-filter.component';
|
||||
import { RouteService } from '../../../../shared/services/route.service';
|
||||
import { RemoteDataBuildService } from '../../../../core/cache/builders/remote-data-build.service';
|
||||
import { SearchConfigurationService } from '../../../search-service/search-configuration.service';
|
||||
|
||||
describe('SearchRangeFilterComponent', () => {
|
||||
let comp: SearchRangeFilterComponent;
|
||||
let fixture: ComponentFixture<SearchRangeFilterComponent>;
|
||||
const minSuffix = '.min';
|
||||
const maxSuffix = '.max';
|
||||
const dateFormats = ['YYYY', 'YYYY-MM', 'YYYY-MM-DD'];
|
||||
const filterName1 = 'test name';
|
||||
const value1 = '2000 - 2012';
|
||||
const value2 = '1992 - 2000';
|
||||
const value3 = '1990 - 1992';
|
||||
const mockFilterConfig: SearchFilterConfig = Object.assign(new SearchFilterConfig(), {
|
||||
name: filterName1,
|
||||
type: FilterType.range,
|
||||
hasFacets: false,
|
||||
isOpenByDefault: false,
|
||||
pageSize: 2,
|
||||
minValue: 200,
|
||||
maxValue: 3000,
|
||||
});
|
||||
const values: FacetValue[] = [
|
||||
{
|
||||
value: value1,
|
||||
count: 52,
|
||||
search: ''
|
||||
}, {
|
||||
value: value2,
|
||||
count: 20,
|
||||
search: ''
|
||||
}, {
|
||||
value: value3,
|
||||
count: 5,
|
||||
search: ''
|
||||
}
|
||||
];
|
||||
|
||||
const searchLink = '/search';
|
||||
const selectedValues = Observable.of([value1]);
|
||||
let filterService;
|
||||
let searchService;
|
||||
let router;
|
||||
const page = Observable.of(0);
|
||||
|
||||
const mockValues = Observable.of(new RemoteData(false, false, true, null, new PaginatedList(new PageInfo(), values)));
|
||||
beforeEach(async(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [TranslateModule.forRoot(), NoopAnimationsModule, FormsModule],
|
||||
declarations: [SearchRangeFilterComponent],
|
||||
providers: [
|
||||
{ provide: SearchService, useValue: new SearchServiceStub(searchLink) },
|
||||
{ provide: Router, useValue: new RouterStub() },
|
||||
{ provide: FILTER_CONFIG, useValue: mockFilterConfig },
|
||||
{ provide: RemoteDataBuildService, useValue: {aggregate: () => Observable.of({})} },
|
||||
{ provide: RouteService, useValue: {getQueryParameterValue: () => Observable.of({})} },
|
||||
{ provide: SearchConfigurationService, useValue: {
|
||||
searchOptions: Observable.of({}) }
|
||||
},
|
||||
{
|
||||
provide: SearchFilterService, useValue: {
|
||||
getSelectedValuesForFilter: () => selectedValues,
|
||||
isFilterActiveWithValue: (paramName: string, filterValue: string) => true,
|
||||
getPage: (paramName: string) => page,
|
||||
/* tslint:disable:no-empty */
|
||||
incrementPage: (filterName: string) => {
|
||||
},
|
||||
resetPage: (filterName: string) => {
|
||||
}
|
||||
/* tslint:enable:no-empty */
|
||||
}
|
||||
}
|
||||
],
|
||||
schemas: [NO_ERRORS_SCHEMA]
|
||||
}).overrideComponent(SearchRangeFilterComponent, {
|
||||
set: { changeDetection: ChangeDetectionStrategy.Default }
|
||||
}).compileComponents();
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(SearchRangeFilterComponent);
|
||||
comp = fixture.componentInstance; // SearchPageComponent test instance
|
||||
filterService = (comp as any).filterService;
|
||||
searchService = (comp as any).searchService;
|
||||
spyOn(searchService, 'getFacetValuesFor').and.returnValue(mockValues);
|
||||
router = (comp as any).router;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
describe('when the getChangeParams method is called wih a value', () => {
|
||||
it('should return the selectedValue list with the new parameter value', () => {
|
||||
const result$ = comp.getChangeParams(value3);
|
||||
result$.subscribe((result) => {
|
||||
expect(result[mockFilterConfig.paramName + minSuffix]).toEqual(['1990']);
|
||||
expect(result[mockFilterConfig.paramName + maxSuffix]).toEqual(['1992']);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when the onSubmit method is called with data', () => {
|
||||
const searchUrl = '/search/path';
|
||||
// const data = { [mockFilterConfig.paramName + minSuffix]: '1900', [mockFilterConfig.paramName + maxSuffix]: '1950' };
|
||||
beforeEach(() => {
|
||||
comp.range = [1900, 1950];
|
||||
spyOn(comp, 'getSearchLink').and.returnValue(searchUrl);
|
||||
comp.onSubmit();
|
||||
});
|
||||
|
||||
it('should call navigate on the router with the right searchlink and parameters', () => {
|
||||
expect(router.navigate).toHaveBeenCalledWith([searchUrl], {
|
||||
queryParams: {
|
||||
[mockFilterConfig.paramName + minSuffix]: [1900],
|
||||
[mockFilterConfig.paramName + maxSuffix]: [1950]
|
||||
},
|
||||
queryParamsHandling: 'merge'
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
@@ -0,0 +1,148 @@
|
||||
import { isPlatformBrowser } from '@angular/common';
|
||||
import { Component, Inject, OnDestroy, OnInit, PLATFORM_ID } from '@angular/core';
|
||||
import { RemoteDataBuildService } from '../../../../core/cache/builders/remote-data-build.service';
|
||||
import { FilterType } from '../../../search-service/filter-type.model';
|
||||
import { renderFacetFor } from '../search-filter-type-decorator';
|
||||
import {
|
||||
facetLoad,
|
||||
SearchFacetFilterComponent
|
||||
} from '../search-facet-filter/search-facet-filter.component';
|
||||
import { SearchFilterConfig } from '../../../search-service/search-filter-config.model';
|
||||
import { FILTER_CONFIG, SearchFilterService } from '../search-filter.service';
|
||||
import { SearchService } from '../../../search-service/search.service';
|
||||
import { Router } from '@angular/router';
|
||||
import * as moment from 'moment';
|
||||
import { Observable } from 'rxjs/Observable';
|
||||
import { RouteService } from '../../../../shared/services/route.service';
|
||||
import { hasValue } from '../../../../shared/empty.util';
|
||||
import { Subscription } from 'rxjs/Subscription';
|
||||
import { SearchConfigurationService } from '../../../search-service/search-configuration.service';
|
||||
|
||||
/**
|
||||
* This component renders a simple item page.
|
||||
* The route parameter 'id' is used to request the item it represents.
|
||||
* All fields of the item that should be displayed, are defined in its template.
|
||||
*/
|
||||
const minSuffix = '.min';
|
||||
const maxSuffix = '.max';
|
||||
const dateFormats = ['YYYY', 'YYYY-MM', 'YYYY-MM-DD'];
|
||||
const rangeDelimiter = '-';
|
||||
|
||||
@Component({
|
||||
selector: 'ds-search-range-filter',
|
||||
styleUrls: ['./search-range-filter.component.scss'],
|
||||
templateUrl: './search-range-filter.component.html',
|
||||
animations: [facetLoad]
|
||||
})
|
||||
|
||||
/**
|
||||
* Component that represents a range facet for a specific filter configuration
|
||||
*/
|
||||
@renderFacetFor(FilterType.range)
|
||||
export class SearchRangeFilterComponent extends SearchFacetFilterComponent implements OnInit, OnDestroy {
|
||||
/**
|
||||
* Fallback minimum for the range
|
||||
*/
|
||||
min = 1950;
|
||||
|
||||
/**
|
||||
* Fallback maximum for the range
|
||||
*/
|
||||
max = 2018;
|
||||
|
||||
/**
|
||||
* The current range of the filter
|
||||
*/
|
||||
range;
|
||||
|
||||
/**
|
||||
* Subscription to unsubscribe from
|
||||
*/
|
||||
sub: Subscription;
|
||||
|
||||
constructor(protected searchService: SearchService,
|
||||
protected filterService: SearchFilterService,
|
||||
protected searchConfigService: SearchConfigurationService,
|
||||
protected router: Router,
|
||||
protected rdbs: RemoteDataBuildService,
|
||||
@Inject(FILTER_CONFIG) public filterConfig: SearchFilterConfig,
|
||||
@Inject(PLATFORM_ID) private platformId: any,
|
||||
private route: RouteService) {
|
||||
super(searchService, filterService, searchConfigService, rdbs, router, filterConfig);
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize with the min and max values as configured in the filter configuration
|
||||
* Set the initial values of the range
|
||||
*/
|
||||
ngOnInit(): void {
|
||||
super.ngOnInit();
|
||||
this.min = moment(this.filterConfig.minValue, dateFormats).year() || this.min;
|
||||
this.max = moment(this.filterConfig.maxValue, dateFormats).year() || this.max;
|
||||
const iniMin = this.route.getQueryParameterValue(this.filterConfig.paramName + minSuffix).startWith(undefined);
|
||||
const iniMax = this.route.getQueryParameterValue(this.filterConfig.paramName + maxSuffix).startWith(undefined);
|
||||
this.sub = Observable.combineLatest(iniMin, iniMax, (min, max) => {
|
||||
const minimum = hasValue(min) ? min : this.min;
|
||||
const maximum = hasValue(max) ? max : this.max;
|
||||
return [minimum, maximum]
|
||||
}).subscribe((minmax) => this.range = minmax);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the parameters that should change if a given values for this range filter would be changed
|
||||
* @param {string} value The values that are changed for this filter
|
||||
* @returns {Observable<any>} The changed filter parameters
|
||||
*/
|
||||
getChangeParams(value: string) {
|
||||
const parts = value.split(rangeDelimiter);
|
||||
const min = parts.length > 1 ? parts[0].trim() : value;
|
||||
const max = parts.length > 1 ? parts[1].trim() : value;
|
||||
return Observable.of(
|
||||
{
|
||||
[this.filterConfig.paramName + minSuffix]: [min],
|
||||
[this.filterConfig.paramName + maxSuffix]: [max],
|
||||
page: 1
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Submits new custom range values to the range filter from the widget
|
||||
*/
|
||||
onSubmit() {
|
||||
const newMin = this.range[0] !== this.min ? [this.range[0]] : null;
|
||||
const newMax = this.range[1] !== this.max ? [this.range[1]] : null;
|
||||
this.router.navigate([this.getSearchLink()], {
|
||||
queryParams:
|
||||
{
|
||||
[this.filterConfig.paramName + minSuffix]: newMin,
|
||||
[this.filterConfig.paramName + maxSuffix]: newMax
|
||||
},
|
||||
queryParamsHandling: 'merge'
|
||||
});
|
||||
this.filter = '';
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO when upgrading nouislider, verify that this check is still needed.
|
||||
* Prevents AoT bug
|
||||
* @returns {boolean} True if the platformId is a platform browser
|
||||
*/
|
||||
shouldShowSlider(): boolean {
|
||||
return isPlatformBrowser(this.platformId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsubscribe from all subscriptions
|
||||
*/
|
||||
ngOnDestroy() {
|
||||
super.ngOnDestroy();
|
||||
if (hasValue(this.sub)) {
|
||||
this.sub.unsubscribe();
|
||||
}
|
||||
}
|
||||
|
||||
out(call) {
|
||||
console.log(call);
|
||||
}
|
||||
}
|
@@ -0,0 +1,45 @@
|
||||
<div>
|
||||
<div class="filters py-2">
|
||||
<a *ngFor="let value of (selectedValues | async)" class="d-flex flex-row"
|
||||
[routerLink]="[getSearchLink()]"
|
||||
[queryParams]="getRemoveParams(value) | async" queryParamsHandling="merge">
|
||||
<input type="checkbox" [checked]="true" class="my-1 align-self-stretch"/>
|
||||
<span class="filter-value pl-1">{{value}}</span>
|
||||
</a>
|
||||
<ng-container *ngVar="(filterValues$ | async) as filterValuesRD">
|
||||
<div [@facetLoad]="animationState">
|
||||
<ng-container *ngFor="let page of filterValuesRD?.payload">
|
||||
<ng-container *ngFor="let value of page.page; let i=index">
|
||||
<a *ngIf="!(selectedValues | async).includes(value.value)" class="d-flex flex-row"
|
||||
[routerLink]="[getSearchLink()]"
|
||||
[queryParams]="getAddParams(value.value) | async" queryParamsHandling="merge" >
|
||||
<input type="checkbox" [checked]="false" class="my-1 align-self-stretch"/>
|
||||
<span class="filter-value px-1">{{value.value}}</span>
|
||||
<span class="float-right filter-value-count ml-auto">
|
||||
<span class="badge badge-secondary badge-pill">{{value.count}}</span>
|
||||
</span>
|
||||
</a>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
</div>
|
||||
</ng-container>
|
||||
<div class="clearfix toggle-more-filters">
|
||||
<a class="float-left" *ngIf="!(isLastPage$ | async)"
|
||||
(click)="showMore()">{{"search.filters.filter.show-more"
|
||||
| translate}}</a>
|
||||
<a class="float-right" *ngIf="(currentPage | async) > 1"
|
||||
(click)="showFirstPageOnly()">{{"search.filters.filter.show-less"
|
||||
| translate}}</a>
|
||||
</div>
|
||||
</div>
|
||||
<ds-input-suggestions [suggestions]="(filterSearchResults | async)"
|
||||
[placeholder]="'search.filters.filter.' + filterConfig.name + '.placeholder'| translate"
|
||||
[action]="getCurrentUrl()"
|
||||
[name]="filterConfig.paramName"
|
||||
[(ngModel)]="filter"
|
||||
(submitSuggestion)="onSubmit($event)"
|
||||
(clickSuggestion)="onClick($event)"
|
||||
(findSuggestions)="findSuggestions($event)"
|
||||
ngDefaultControl
|
||||
></ds-input-suggestions>
|
||||
</div>
|
@@ -2,17 +2,22 @@
|
||||
@import '../../../../../styles/mixins.scss';
|
||||
|
||||
.filters {
|
||||
margin-top: $spacer/2;
|
||||
margin-bottom: $spacer/2;
|
||||
a {
|
||||
color: $body-color;
|
||||
&:hover {
|
||||
&:hover, &focus {
|
||||
text-decoration: none;
|
||||
}
|
||||
span.badge {
|
||||
vertical-align: text-top;
|
||||
}
|
||||
}
|
||||
.toggle-more-filters a {
|
||||
color: $link-color;
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
}
|
||||
::ng-deep em {
|
||||
font-weight: bold;
|
||||
font-style: normal;
|
||||
}
|
@@ -0,0 +1,29 @@
|
||||
import { animate, state, style, transition, trigger } from '@angular/animations';
|
||||
import { Component, HostBinding, OnInit } from '@angular/core';
|
||||
import { Observable } from 'rxjs/Observable';
|
||||
import { FilterType } from '../../../search-service/filter-type.model';
|
||||
import {
|
||||
facetLoad,
|
||||
SearchFacetFilterComponent
|
||||
} from '../search-facet-filter/search-facet-filter.component';
|
||||
import { renderFacetFor } from '../search-filter-type-decorator';
|
||||
|
||||
/**
|
||||
* This component renders a simple item page.
|
||||
* The route parameter 'id' is used to request the item it represents.
|
||||
* All fields of the item that should be displayed, are defined in its template.
|
||||
*/
|
||||
|
||||
@Component({
|
||||
selector: 'ds-search-text-filter',
|
||||
styleUrls: ['./search-text-filter.component.scss'],
|
||||
templateUrl: './search-text-filter.component.html',
|
||||
animations: [facetLoad]
|
||||
})
|
||||
|
||||
/**
|
||||
* Component that represents a text facet for a specific filter configuration
|
||||
*/
|
||||
@renderFacetFor(FilterType.text)
|
||||
export class SearchTextFilterComponent extends SearchFacetFilterComponent implements OnInit {
|
||||
}
|
@@ -1,7 +1,7 @@
|
||||
<h3>{{"search.filters.head" | translate}}</h3>
|
||||
<div *ngIf="(filters | async)?.hasSucceeded">
|
||||
<div *ngFor="let filter of (filters | async).payload">
|
||||
<ds-search-filter class="d-block mb-3 p-3" [filter]="filter"></ds-search-filter>
|
||||
<div *ngFor="let filter of (filters | async)?.payload">
|
||||
<ds-search-filter *ngIf="isActive(filter) | async" class="d-block mb-3 p-3" [filter]="filter"></ds-search-filter>
|
||||
</div>
|
||||
</div>
|
||||
<a class="btn btn-primary" [routerLink]="[getSearchLink()]" [queryParams]="clearParams | async" queryParamsHandling="merge" role="button">{{"search.filters.reset" | translate}}</a>
|
@@ -8,6 +8,7 @@ import { SearchFilterService } from './search-filter/search-filter.service';
|
||||
import { SearchFiltersComponent } from './search-filters.component';
|
||||
import { SearchService } from '../search-service/search.service';
|
||||
import { Observable } from 'rxjs/Observable';
|
||||
import { SearchConfigurationService } from '../search-service/search-configuration.service';
|
||||
|
||||
describe('SearchFiltersComponent', () => {
|
||||
let comp: SearchFiltersComponent;
|
||||
@@ -23,8 +24,14 @@ describe('SearchFiltersComponent', () => {
|
||||
}
|
||||
/* tslint:enable:no-empty */
|
||||
};
|
||||
const searchFilterServiceStub = jasmine.createSpyObj('SearchFilterService', {
|
||||
getCurrentFilters: Observable.of({})
|
||||
|
||||
const searchFiltersStub = {
|
||||
getSelectedValuesForFilter: (filter) =>
|
||||
[]
|
||||
};
|
||||
|
||||
const searchConfigServiceStub = jasmine.createSpyObj('SearchConfigurationService', {
|
||||
getCurrentFrontendFilters: Observable.of({})
|
||||
});
|
||||
|
||||
beforeEach(async(() => {
|
||||
@@ -33,7 +40,8 @@ describe('SearchFiltersComponent', () => {
|
||||
declarations: [SearchFiltersComponent],
|
||||
providers: [
|
||||
{ provide: SearchService, useValue: searchServiceStub },
|
||||
{ provide: SearchFilterService, useValue: searchFilterServiceStub },
|
||||
{ provide: SearchConfigurationService, useValue: searchConfigServiceStub },
|
||||
{ provide: SearchFilterService, useValue: searchFiltersStub },
|
||||
|
||||
],
|
||||
schemas: [NO_ERRORS_SCHEMA]
|
||||
|
@@ -3,13 +3,10 @@ import { SearchService } from '../search-service/search.service';
|
||||
import { RemoteData } from '../../core/data/remote-data';
|
||||
import { SearchFilterConfig } from '../search-service/search-filter-config.model';
|
||||
import { Observable } from 'rxjs/Observable';
|
||||
import { SearchConfigurationService } from '../search-service/search-configuration.service';
|
||||
import { isNotEmpty } from '../../shared/empty.util';
|
||||
import { SearchFilterService } from './search-filter/search-filter.service';
|
||||
|
||||
/**
|
||||
* This component renders a simple item page.
|
||||
* The route parameter 'id' is used to request the item it represents.
|
||||
* All fields of the item that should be displayed, are defined in its template.
|
||||
*/
|
||||
import { getSucceededRemoteData } from '../../core/shared/operators';
|
||||
|
||||
@Component({
|
||||
selector: 'ds-search-filters',
|
||||
@@ -17,15 +14,63 @@ import { SearchFilterService } from './search-filter/search-filter.service';
|
||||
templateUrl: './search-filters.component.html',
|
||||
})
|
||||
|
||||
/**
|
||||
* This component represents the part of the search sidebar that contains filters.
|
||||
*/
|
||||
export class SearchFiltersComponent {
|
||||
/**
|
||||
* An observable containing configuration about which filters are shown and how they are shown
|
||||
*/
|
||||
filters: Observable<RemoteData<SearchFilterConfig[]>>;
|
||||
|
||||
/**
|
||||
* List of all filters that are currently active with their value set to null.
|
||||
* Used to reset all filters at once
|
||||
*/
|
||||
clearParams;
|
||||
constructor(private searchService: SearchService, private filterService: SearchFilterService) {
|
||||
this.filters = searchService.getConfig();
|
||||
this.clearParams = filterService.getCurrentFilters().map((filters) => {Object.keys(filters).forEach((f) => filters[f] = null); return filters;});
|
||||
|
||||
/**
|
||||
* Initialize instance variables
|
||||
* @param {SearchService} searchService
|
||||
* @param {SearchConfigurationService} searchConfigService
|
||||
* @param {SearchFilterService} filterService
|
||||
*/
|
||||
constructor(private searchService: SearchService, private searchConfigService: SearchConfigurationService, private filterService: SearchFilterService) {
|
||||
this.filters = searchService.getConfig().pipe(getSucceededRemoteData());
|
||||
this.clearParams = searchConfigService.getCurrentFrontendFilters().map((filters) => {
|
||||
Object.keys(filters).forEach((f) => filters[f] = null);
|
||||
return filters;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {string} The base path to the search page
|
||||
*/
|
||||
getSearchLink() {
|
||||
return this.searchService.getSearchLink();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a given filter is supposed to be shown or not
|
||||
* @param {SearchFilterConfig} filter The filter to check for
|
||||
* @returns {Observable<boolean>} Emits true whenever a given filter config should be shown
|
||||
*/
|
||||
isActive(filter: SearchFilterConfig): Observable<boolean> {
|
||||
return this.filterService.getSelectedValuesForFilter(filter)
|
||||
.flatMap((isActive) => {
|
||||
if (isNotEmpty(isActive)) {
|
||||
return Observable.of(true);
|
||||
} else {
|
||||
return this.searchConfigService.searchOptions
|
||||
.switchMap((options) => {
|
||||
return this.searchService.getFacetValuesFor(filter, 1, options)
|
||||
.filter((RD) => !RD.isLoading)
|
||||
.map((valuesRD) => {
|
||||
return valuesRD.payload.totalElements > 0
|
||||
})
|
||||
}
|
||||
)
|
||||
}
|
||||
}).startWith(true);
|
||||
}
|
||||
}
|
||||
|
@@ -0,0 +1,13 @@
|
||||
<div class="row mb-3 mb-md-1">
|
||||
<div class="labels col-sm-9 offset-sm-3">
|
||||
<ng-container *ngFor="let key of ((appliedFilters | async) | dsObjectKeys)"><!--Do not remove this to prevent uneven spacing
|
||||
--><a *ngFor="let values of (appliedFilters | async)[key]"
|
||||
class="badge badge-primary mr-1 mb-1"
|
||||
[routerLink]="getSearchLink()"
|
||||
[queryParams]="(getRemoveParams(key, values) | async)" queryParamsHandling="merge">
|
||||
{{('search.filters.applied.' + key) | translate}}: {{values}}
|
||||
<span> ×</span>
|
||||
</a><!--Do not remove this to prevent uneven spacing
|
||||
--></ng-container>
|
||||
</div>
|
||||
</div>
|
@@ -0,0 +1,3 @@
|
||||
:host {
|
||||
line-height: 1;
|
||||
}
|
@@ -0,0 +1,68 @@
|
||||
import { SearchLabelsComponent } from './search-labels.component';
|
||||
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { SearchService } from '../search-service/search.service';
|
||||
import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { SearchServiceStub } from '../../shared/testing/search-service-stub';
|
||||
import { Observable } from 'rxjs/Observable';
|
||||
import { Params } from '@angular/router';
|
||||
import { ObjectKeysPipe } from '../../shared/utils/object-keys-pipe';
|
||||
import { SearchConfigurationService } from '../search-service/search-configuration.service';
|
||||
|
||||
describe('SearchLabelsComponent', () => {
|
||||
let comp: SearchLabelsComponent;
|
||||
let fixture: ComponentFixture<SearchLabelsComponent>;
|
||||
|
||||
const searchLink = '/search';
|
||||
let searchService;
|
||||
|
||||
const field1 = 'author';
|
||||
const field2 = 'subject';
|
||||
const value1 = 'TestAuthor';
|
||||
const value2 = 'TestSubject';
|
||||
const filter1 = [field1, value1];
|
||||
const filter2 = [field2, value2];
|
||||
const mockFilters = [
|
||||
filter1,
|
||||
filter2
|
||||
];
|
||||
|
||||
beforeEach(async(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [TranslateModule.forRoot(), NoopAnimationsModule, FormsModule],
|
||||
declarations: [SearchLabelsComponent, ObjectKeysPipe],
|
||||
providers: [
|
||||
{ provide: SearchService, useValue: new SearchServiceStub(searchLink) },
|
||||
{ provide: SearchConfigurationService, useValue: {getCurrentFrontendFilters : () => Observable.of({})} }
|
||||
],
|
||||
schemas: [NO_ERRORS_SCHEMA]
|
||||
}).overrideComponent(SearchLabelsComponent, {
|
||||
set: { changeDetection: ChangeDetectionStrategy.Default }
|
||||
}).compileComponents();
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(SearchLabelsComponent);
|
||||
comp = fixture.componentInstance;
|
||||
searchService = (comp as any).searchService;
|
||||
(comp as any).appliedFilters = Observable.of(mockFilters);
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
describe('when getRemoveParams is called', () => {
|
||||
let obs: Observable<Params>;
|
||||
|
||||
beforeEach(() => {
|
||||
obs = comp.getRemoveParams(filter1[0], filter1[1]);
|
||||
});
|
||||
|
||||
it('should return all params but the provided filter', () => {
|
||||
obs.subscribe((params) => {
|
||||
// Should contain only filter2 and page: length == 2
|
||||
expect(Object.keys(params).length).toBe(2);
|
||||
});
|
||||
})
|
||||
});
|
||||
});
|
@@ -0,0 +1,56 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { SearchService } from '../search-service/search.service';
|
||||
import { Observable } from 'rxjs/Observable';
|
||||
import { Params } from '@angular/router';
|
||||
import { map } from 'rxjs/operators';
|
||||
import { hasValue, isNotEmpty } from '../../shared/empty.util';
|
||||
import { SearchConfigurationService } from '../search-service/search-configuration.service';
|
||||
|
||||
@Component({
|
||||
selector: 'ds-search-labels',
|
||||
styleUrls: ['./search-labels.component.scss'],
|
||||
templateUrl: './search-labels.component.html',
|
||||
})
|
||||
|
||||
/**
|
||||
* Component that represents the labels containing the currently active filters
|
||||
*/
|
||||
export class SearchLabelsComponent {
|
||||
/**
|
||||
* Emits the currently active filters
|
||||
*/
|
||||
appliedFilters: Observable<Params>;
|
||||
|
||||
/**
|
||||
* Initialize the instance variable
|
||||
*/
|
||||
constructor(private searchService: SearchService, private searchConfigService: SearchConfigurationService) {
|
||||
this.appliedFilters = this.searchConfigService.getCurrentFrontendFilters();
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the parameters that should change if a given value for the given filter would be removed from the active filters
|
||||
* @param {string} filterField The filter field parameter name from which the value should be removed
|
||||
* @param {string} filterValue The value that is removed for this given filter field
|
||||
* @returns {Observable<Params>} The changed filter parameters
|
||||
*/
|
||||
getRemoveParams(filterField: string, filterValue: string): Observable<Params> {
|
||||
return this.appliedFilters.pipe(
|
||||
map((filters) => {
|
||||
const field: string = Object.keys(filters).find((f) => f === filterField);
|
||||
const newValues = hasValue(filters[field]) ? filters[field].filter((v) => v !== filterValue) : null;
|
||||
return {
|
||||
[field]: isNotEmpty(newValues) ? newValues : null,
|
||||
page: 1
|
||||
};
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {string} The base path to the search page
|
||||
*/
|
||||
getSearchLink() {
|
||||
return this.searchService.getSearchLink();
|
||||
}
|
||||
}
|
32
src/app/+search-page/search-options.model.spec.ts
Normal file
32
src/app/+search-page/search-options.model.spec.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import 'rxjs/add/observable/of';
|
||||
import { PaginatedSearchOptions } from './paginated-search-options.model';
|
||||
import { SearchOptions } from './search-options.model';
|
||||
import { SearchFilter } from './search-filter.model';
|
||||
import { DSpaceObjectType } from '../core/shared/dspace-object-type.model';
|
||||
|
||||
describe('SearchOptions', () => {
|
||||
let options: PaginatedSearchOptions;
|
||||
const filters = [new SearchFilter('f.test', ['value']), new SearchFilter('f.example', ['another value', 'second value'])];
|
||||
const query = 'search query';
|
||||
const scope = '0fde1ecb-82cc-425a-b600-ac3576d76b47';
|
||||
const baseUrl = 'www.rest.com';
|
||||
beforeEach(() => {
|
||||
options = new SearchOptions({ filters: filters, query: query, scope: scope , dsoType: DSpaceObjectType.ITEM});
|
||||
});
|
||||
|
||||
describe('when toRestUrl is called', () => {
|
||||
|
||||
it('should generate a string with all parameters that are present', () => {
|
||||
const outcome = options.toRestUrl(baseUrl);
|
||||
expect(outcome).toEqual('www.rest.com?' +
|
||||
'query=search query&' +
|
||||
'scope=0fde1ecb-82cc-425a-b600-ac3576d76b47&' +
|
||||
'dsoType=ITEM&' +
|
||||
'f.test=value,query&' +
|
||||
'f.example=another value,query&' +
|
||||
'f.example=second value,query'
|
||||
);
|
||||
});
|
||||
|
||||
});
|
||||
});
|
@@ -1,30 +1,45 @@
|
||||
import { isNotEmpty } from '../shared/empty.util';
|
||||
import { URLCombiner } from '../core/url-combiner/url-combiner';
|
||||
import 'core-js/library/fn/object/entries';
|
||||
import { SearchFilter } from './search-filter.model';
|
||||
import { DSpaceObjectType } from '../core/shared/dspace-object-type.model';
|
||||
|
||||
export enum ViewMode {
|
||||
List = 'list',
|
||||
Grid = 'grid'
|
||||
}
|
||||
|
||||
/**
|
||||
* This model class represents all parameters needed to request information about a certain search request
|
||||
*/
|
||||
export class SearchOptions {
|
||||
view?: ViewMode = ViewMode.List;
|
||||
scope?: string;
|
||||
query?: string;
|
||||
filters?: any;
|
||||
dsoType?: DSpaceObjectType;
|
||||
filters?: SearchFilter[];
|
||||
|
||||
constructor(options: {scope?: string, query?: string, dsoType?: DSpaceObjectType, filters?: SearchFilter[]}) {
|
||||
this.scope = options.scope;
|
||||
this.query = options.query;
|
||||
this.dsoType = options.dsoType;
|
||||
this.filters = options.filters;
|
||||
}
|
||||
|
||||
/**
|
||||
* Method to generate the URL that can be used request information about a search request
|
||||
* @param {string} url The URL to the REST endpoint
|
||||
* @param {string[]} args A list of query arguments that should be included in the URL
|
||||
* @returns {string} URL with all search options and passed arguments as query parameters
|
||||
*/
|
||||
toRestUrl(url: string, args: string[] = []): string {
|
||||
|
||||
if (isNotEmpty(this.query)) {
|
||||
args.push(`query=${this.query}`);
|
||||
}
|
||||
|
||||
if (isNotEmpty(this.scope)) {
|
||||
args.push(`scope=${this.scope}`);
|
||||
}
|
||||
if (isNotEmpty(this.dsoType)) {
|
||||
args.push(`dsoType=${this.dsoType}`);
|
||||
}
|
||||
if (isNotEmpty(this.filters)) {
|
||||
Object.entries(this.filters).forEach(([key, values]) => {
|
||||
values.forEach((value) => args.push(`${key}=${value},equals`));
|
||||
this.filters.forEach((filter: SearchFilter) => {
|
||||
filter.values.forEach((value) => args.push(`${filter.key}=${value},${filter.operator}`));
|
||||
});
|
||||
}
|
||||
if (isNotEmpty(args)) {
|
||||
|
@@ -1,39 +1,40 @@
|
||||
<div class="container">
|
||||
<div class="search-page row">
|
||||
<div class="search-page row">
|
||||
<ds-search-sidebar *ngIf="!(isXsOrSm$ | async)" class="col-3 sidebar-md-sticky"
|
||||
id="search-sidebar"
|
||||
[resultCount]="(resultsRD$ | async)?.pageInfo?.totalElements"></ds-search-sidebar>
|
||||
[resultCount]="(resultsRD$ | async)?.payload.totalElements"></ds-search-sidebar>
|
||||
<div class="col-12 col-md-9">
|
||||
<ds-search-form id="search-form"
|
||||
[query]="(searchOptions$ | async)?.query"
|
||||
[scope]="(searchOptions$ | async)?.scope"
|
||||
[currentUrl]="getSearchLink()"
|
||||
[scopes]="(scopeListRD$ | async)?.payload?.page">
|
||||
</ds-search-form>
|
||||
<div class="row">
|
||||
<div id="search-body"
|
||||
class="row-offcanvas row-offcanvas-left"
|
||||
[@pushInOut]="(isSidebarCollapsed() | async) ? 'collapsed' : 'expanded'">
|
||||
<ds-search-sidebar *ngIf="(isXsOrSm$ | async)" class="col-12"
|
||||
id="search-sidebar-sm"
|
||||
[resultCount]="(resultsRD$ | async)?.pageInfo?.totalElements"
|
||||
(toggleSidebar)="closeSidebar()"
|
||||
[ngClass]="{'active': !(isSidebarCollapsed() | async)}">
|
||||
</ds-search-sidebar>
|
||||
<div id="search-content" class="col-12">
|
||||
<div class="d-block d-md-none search-controls clearfix">
|
||||
<ds-view-mode-switch></ds-view-mode-switch>
|
||||
<button (click)="openSidebar()" aria-controls="#search-body"
|
||||
class="btn btn-outline-primary float-right open-sidebar"><i
|
||||
class="fa fa-sliders"></i> {{"search.sidebar.open"
|
||||
| translate}}
|
||||
</button>
|
||||
<ds-search-form id="search-form"
|
||||
[query]="(searchOptions$ | async)?.query"
|
||||
[scope]="(searchOptions$ | async)?.scope"
|
||||
[currentUrl]="getSearchLink()"
|
||||
[scopes]="(scopeListRD$ | async)">
|
||||
</ds-search-form>
|
||||
<ds-search-labels></ds-search-labels>
|
||||
<div class="row">
|
||||
<div id="search-body"
|
||||
class="row-offcanvas row-offcanvas-left"
|
||||
[@pushInOut]="(isSidebarCollapsed() | async) ? 'collapsed' : 'expanded'">
|
||||
<ds-search-sidebar *ngIf="(isXsOrSm$ | async)" class="col-12"
|
||||
id="search-sidebar-sm"
|
||||
[resultCount]="(resultsRD$ | async)?.payload.totalElements"
|
||||
(toggleSidebar)="closeSidebar()"
|
||||
[ngClass]="{'active': !(isSidebarCollapsed() | async)}">
|
||||
</ds-search-sidebar>
|
||||
<div id="search-content" class="col-12">
|
||||
<div class="d-block d-md-none search-controls clearfix">
|
||||
<ds-view-mode-switch></ds-view-mode-switch>
|
||||
<button (click)="openSidebar()" aria-controls="#search-body"
|
||||
class="btn btn-outline-primary float-right open-sidebar"><i
|
||||
class="fa fa-sliders"></i> {{"search.sidebar.open"
|
||||
| translate}}
|
||||
</button>
|
||||
</div>
|
||||
<ds-search-results [searchResults]="resultsRD$ | async"
|
||||
[searchConfig]="searchOptions$ | async"></ds-search-results>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ds-search-results [searchResults]="resultsRD$ | async"
|
||||
[searchConfig]="searchOptions$ | async" [sortConfig]="sortConfig"></ds-search-results>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@@ -19,6 +19,8 @@ import { By } from '@angular/platform-browser';
|
||||
import { NgbCollapseModule } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { SearchSidebarService } from './search-sidebar/search-sidebar.service';
|
||||
import { SearchFilterService } from './search-filters/search-filter/search-filter.service';
|
||||
import { SearchConfigurationService } from './search-service/search-configuration.service';
|
||||
import { RemoteData } from '../core/data/remote-data';
|
||||
|
||||
describe('SearchPageComponent', () => {
|
||||
let comp: SearchPageComponent;
|
||||
@@ -35,10 +37,11 @@ describe('SearchPageComponent', () => {
|
||||
pagination.currentPage = 1;
|
||||
pagination.pageSize = 10;
|
||||
const sort: SortOptions = new SortOptions('score', SortDirection.DESC);
|
||||
const mockResults = Observable.of(['test', 'data']);
|
||||
const mockResults = Observable.of(new RemoteData(false, false, true, null, ['test', 'data']));
|
||||
const searchServiceStub = jasmine.createSpyObj('SearchService', {
|
||||
search: mockResults,
|
||||
getSearchLink: '/search'
|
||||
getSearchLink: '/search',
|
||||
getScopes: Observable.of(['test-scope'])
|
||||
});
|
||||
const queryParam = 'test query';
|
||||
const scopeParam = '7669c72a-3f2a-451f-a3b9-9210e7a4c02f';
|
||||
@@ -76,11 +79,11 @@ describe('SearchPageComponent', () => {
|
||||
},
|
||||
{
|
||||
provide: HostWindowService, useValue: jasmine.createSpyObj('hostWindowService',
|
||||
{
|
||||
isXs: Observable.of(true),
|
||||
isSm: Observable.of(false),
|
||||
isXsOrSm: Observable.of(true)
|
||||
})
|
||||
{
|
||||
isXs: Observable.of(true),
|
||||
isSm: Observable.of(false),
|
||||
isXsOrSm: Observable.of(true)
|
||||
})
|
||||
},
|
||||
{
|
||||
provide: SearchSidebarService,
|
||||
@@ -88,16 +91,20 @@ describe('SearchPageComponent', () => {
|
||||
},
|
||||
{
|
||||
provide: SearchFilterService,
|
||||
useValue: jasmine.createSpyObj('SearchFilterService', {
|
||||
getPaginatedSearchOptions: hot('a', {
|
||||
useValue: {}
|
||||
}, {
|
||||
provide: SearchConfigurationService,
|
||||
useValue: {
|
||||
paginatedSearchOptions: hot('a', {
|
||||
a: paginatedSearchOptions
|
||||
})
|
||||
})
|
||||
}),
|
||||
getCurrentScope: (a) => Observable.of('test-id')
|
||||
}
|
||||
},
|
||||
],
|
||||
schemas: [NO_ERRORS_SCHEMA]
|
||||
}).overrideComponent(SearchPageComponent, {
|
||||
set: { changeDetection: ChangeDetectionStrategy.Default }
|
||||
set: { changeDetection: ChangeDetectionStrategy.Default }
|
||||
}).compileComponents();
|
||||
}));
|
||||
|
||||
@@ -171,4 +178,4 @@ describe('SearchPageComponent', () => {
|
||||
});
|
||||
|
||||
});
|
||||
});
|
||||
})
|
||||
|
@@ -1,11 +1,8 @@
|
||||
import { ChangeDetectionStrategy, Component, OnDestroy, OnInit } from '@angular/core';
|
||||
import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
|
||||
import { Observable } from 'rxjs/Observable';
|
||||
import { flatMap, } from 'rxjs/operators';
|
||||
import { SortDirection, SortOptions } from '../core/cache/models/sort-options.model';
|
||||
import { CommunityDataService } from '../core/data/community-data.service';
|
||||
import { flatMap, switchMap, } from 'rxjs/operators';
|
||||
import { PaginatedList } from '../core/data/paginated-list';
|
||||
import { RemoteData } from '../core/data/remote-data';
|
||||
import { Community } from '../core/shared/community.model';
|
||||
import { DSpaceObject } from '../core/shared/dspace-object.model';
|
||||
import { pushInOut } from '../shared/animations/push';
|
||||
import { HostWindowService } from '../shared/host-window.service';
|
||||
@@ -14,6 +11,11 @@ import { SearchFilterService } from './search-filters/search-filter/search-filte
|
||||
import { SearchResult } from './search-result.model';
|
||||
import { SearchService } from './search-service/search.service';
|
||||
import { SearchSidebarService } from './search-sidebar/search-sidebar.service';
|
||||
import { Subscription } from 'rxjs/Subscription';
|
||||
import { hasValue } from '../shared/empty.util';
|
||||
import { BehaviorSubject } from 'rxjs/BehaviorSubject';
|
||||
import { SearchConfigurationService } from './search-service/search-configuration.service';
|
||||
import { getSucceededRemoteData } from '../core/shared/operators';
|
||||
|
||||
/**
|
||||
* This component renders a simple item page.
|
||||
@@ -28,54 +30,99 @@ import { SearchSidebarService } from './search-sidebar/search-sidebar.service';
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
animations: [pushInOut]
|
||||
})
|
||||
|
||||
/**
|
||||
* This component represents the whole search page
|
||||
*/
|
||||
export class SearchPageComponent implements OnInit {
|
||||
|
||||
resultsRD$: Observable<RemoteData<PaginatedList<SearchResult<DSpaceObject>>>>;
|
||||
/**
|
||||
* The current search results
|
||||
*/
|
||||
resultsRD$: BehaviorSubject<RemoteData<PaginatedList<SearchResult<DSpaceObject>>>> = new BehaviorSubject(null);
|
||||
|
||||
/**
|
||||
* The current paginated search options
|
||||
*/
|
||||
searchOptions$: Observable<PaginatedSearchOptions>;
|
||||
sortConfig: SortOptions;
|
||||
scopeListRD$: Observable<RemoteData<PaginatedList<Community>>>;
|
||||
|
||||
/**
|
||||
* The current relevant scopes
|
||||
*/
|
||||
scopeListRD$: Observable<DSpaceObject[]>;
|
||||
|
||||
/**
|
||||
* Emits true if were on a small screen
|
||||
*/
|
||||
isXsOrSm$: Observable<boolean>;
|
||||
pageSize;
|
||||
pageSizeOptions;
|
||||
defaults = {
|
||||
pagination: {
|
||||
id: 'search-results-pagination',
|
||||
pageSize: 10
|
||||
},
|
||||
sort: new SortOptions('score', SortDirection.DESC),
|
||||
query: '',
|
||||
scope: ''
|
||||
};
|
||||
|
||||
/**
|
||||
* Subscription to unsubscribe from
|
||||
*/
|
||||
sub: Subscription;
|
||||
|
||||
constructor(private service: SearchService,
|
||||
private communityService: CommunityDataService,
|
||||
private sidebarService: SearchSidebarService,
|
||||
private windowService: HostWindowService,
|
||||
private filterService: SearchFilterService) {
|
||||
private filterService: SearchFilterService,
|
||||
private searchConfigService: SearchConfigurationService) {
|
||||
this.isXsOrSm$ = this.windowService.isXsOrSm();
|
||||
this.scopeListRD$ = communityService.findAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Listening to changes in the paginated search options
|
||||
* If something changes, update the search results
|
||||
*
|
||||
* Listen to changes in the scope
|
||||
* If something changes, update the list of scopes for the dropdown
|
||||
*/
|
||||
ngOnInit(): void {
|
||||
this.searchOptions$ = this.filterService.getPaginatedSearchOptions(this.defaults);
|
||||
this.resultsRD$ = this.searchOptions$.pipe(
|
||||
flatMap((searchOptions) => this.service.search(searchOptions))
|
||||
this.searchOptions$ = this.searchConfigService.paginatedSearchOptions;
|
||||
this.sub = this.searchOptions$
|
||||
.switchMap((options) => this.service.search(options).pipe(getSucceededRemoteData()))
|
||||
.subscribe((results) => {
|
||||
this.resultsRD$.next(results);
|
||||
});
|
||||
this.scopeListRD$ = this.searchConfigService.getCurrentScope('').pipe(
|
||||
switchMap((scopeId) => this.service.getScopes(scopeId))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the sidebar to a collapsed state
|
||||
*/
|
||||
public closeSidebar(): void {
|
||||
this.sidebarService.collapse()
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the sidebar to an expanded state
|
||||
*/
|
||||
public openSidebar(): void {
|
||||
this.sidebarService.expand();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the sidebar is collapsed
|
||||
* @returns {Observable<boolean>} emits true if the sidebar is currently collapsed, false if it is expanded
|
||||
*/
|
||||
public isSidebarCollapsed(): Observable<boolean> {
|
||||
return this.sidebarService.isCollapsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {string} The base path to the search page
|
||||
*/
|
||||
public getSearchLink(): string {
|
||||
return this.service.getSearchLink();
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsubscribe from the subscription
|
||||
*/
|
||||
ngOnDestroy(): void {
|
||||
if (hasValue(this.sub)) {
|
||||
this.sub.unsubscribe();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -21,6 +21,13 @@ import { SearchFiltersComponent } from './search-filters/search-filters.componen
|
||||
import { SearchFilterComponent } from './search-filters/search-filter/search-filter.component';
|
||||
import { SearchFacetFilterComponent } from './search-filters/search-filter/search-facet-filter/search-facet-filter.component';
|
||||
import { SearchFilterService } from './search-filters/search-filter/search-filter.service';
|
||||
import { SearchLabelsComponent } from './search-labels/search-labels.component';
|
||||
import { SearchRangeFilterComponent } from './search-filters/search-filter/search-range-filter/search-range-filter.component';
|
||||
import { SearchTextFilterComponent } from './search-filters/search-filter/search-text-filter/search-text-filter.component';
|
||||
import { SearchFacetFilterWrapperComponent } from './search-filters/search-filter/search-facet-filter-wrapper/search-facet-filter-wrapper.component';
|
||||
import { SearchBooleanFilterComponent } from './search-filters/search-filter/search-boolean-filter/search-boolean-filter.component';
|
||||
import { SearchHierarchyFilterComponent } from './search-filters/search-filter/search-hierarchy-filter/search-hierarchy-filter.component';
|
||||
import { SearchConfigurationService } from './search-service/search-configuration.service';
|
||||
|
||||
const effects = [
|
||||
SearchSidebarEffects
|
||||
@@ -48,12 +55,20 @@ const effects = [
|
||||
CommunitySearchResultListElementComponent,
|
||||
SearchFiltersComponent,
|
||||
SearchFilterComponent,
|
||||
SearchFacetFilterComponent
|
||||
SearchFacetFilterComponent,
|
||||
SearchLabelsComponent,
|
||||
SearchFacetFilterComponent,
|
||||
SearchFacetFilterWrapperComponent,
|
||||
SearchRangeFilterComponent,
|
||||
SearchTextFilterComponent,
|
||||
SearchHierarchyFilterComponent,
|
||||
SearchBooleanFilterComponent,
|
||||
],
|
||||
providers: [
|
||||
SearchService,
|
||||
SearchSidebarService,
|
||||
SearchFilterService
|
||||
SearchFilterService,
|
||||
SearchConfigurationService
|
||||
],
|
||||
entryComponents: [
|
||||
ItemSearchResultListElementComponent,
|
||||
@@ -62,7 +77,16 @@ const effects = [
|
||||
ItemSearchResultGridElementComponent,
|
||||
CollectionSearchResultGridElementComponent,
|
||||
CommunitySearchResultGridElementComponent,
|
||||
SearchFacetFilterComponent,
|
||||
SearchRangeFilterComponent,
|
||||
SearchTextFilterComponent,
|
||||
SearchHierarchyFilterComponent,
|
||||
SearchBooleanFilterComponent,
|
||||
]
|
||||
})
|
||||
|
||||
/**
|
||||
* This module handles all components and pipes that are necessary for the search page
|
||||
*/
|
||||
export class SearchPageModule {
|
||||
}
|
||||
|
@@ -2,9 +2,18 @@ import { DSpaceObject } from '../core/shared/dspace-object.model';
|
||||
import { Metadatum } from '../core/shared/metadatum.model';
|
||||
import { ListableObject } from '../shared/object-collection/shared/listable-object.model';
|
||||
|
||||
/**
|
||||
* Represents a search result object of a certain (<T>) DSpaceObject
|
||||
*/
|
||||
export class SearchResult<T extends DSpaceObject> implements ListableObject {
|
||||
|
||||
/**
|
||||
* The DSpaceObject that was found
|
||||
*/
|
||||
dspaceObject: T;
|
||||
|
||||
/**
|
||||
* The metadata that was used to find this item, hithighlighted
|
||||
*/
|
||||
hitHighlights: Metadatum[];
|
||||
|
||||
}
|
||||
|
@@ -2,16 +2,11 @@ import { Component, Input } from '@angular/core';
|
||||
import { RemoteData } from '../../core/data/remote-data';
|
||||
import { DSpaceObject } from '../../core/shared/dspace-object.model';
|
||||
import { fadeIn, fadeInOut } from '../../shared/animations/fade';
|
||||
import { SearchOptions, ViewMode } from '../search-options.model';
|
||||
import { SortOptions } from '../../core/cache/models/sort-options.model';
|
||||
import { SearchOptions } from '../search-options.model';
|
||||
import { SearchResult } from '../search-result.model';
|
||||
import { PaginatedList } from '../../core/data/paginated-list';
|
||||
import { ViewMode } from '../../core/shared/view-mode.model';
|
||||
|
||||
/**
|
||||
* This component renders a simple item page.
|
||||
* The route parameter 'id' is used to request the item it represents.
|
||||
* All fields of the item that should be displayed, are defined in its template.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'ds-search-results',
|
||||
templateUrl: './search-results.component.html',
|
||||
@@ -20,9 +15,24 @@ import { PaginatedList } from '../../core/data/paginated-list';
|
||||
fadeInOut
|
||||
]
|
||||
})
|
||||
|
||||
/**
|
||||
* Component that represents all results from a search
|
||||
*/
|
||||
export class SearchResultsComponent {
|
||||
/**
|
||||
* The actual search result objects
|
||||
*/
|
||||
@Input() searchResults: RemoteData<PaginatedList<SearchResult<DSpaceObject>>>;
|
||||
|
||||
/**
|
||||
* The current configuration of the search
|
||||
*/
|
||||
@Input() searchConfig: SearchOptions;
|
||||
@Input() sortConfig: SortOptions;
|
||||
|
||||
/**
|
||||
* The current view mode for the search results
|
||||
*/
|
||||
@Input() viewMode: ViewMode;
|
||||
|
||||
}
|
||||
|
@@ -1,13 +1,25 @@
|
||||
|
||||
import { autoserialize, autoserializeAs } from 'cerialize';
|
||||
|
||||
/**
|
||||
* Class representing possible values for a certain filter
|
||||
*/
|
||||
export class FacetValue {
|
||||
/**
|
||||
* The display value of the facet value
|
||||
*/
|
||||
@autoserializeAs(String, 'label')
|
||||
value: string;
|
||||
|
||||
/**
|
||||
* The number of results this facet value would have if selected
|
||||
*/
|
||||
@autoserialize
|
||||
count: number;
|
||||
|
||||
/**
|
||||
* The REST url to add this filter value
|
||||
*/
|
||||
@autoserialize
|
||||
search: string;
|
||||
}
|
||||
|
@@ -1,6 +1,24 @@
|
||||
/**
|
||||
* Enumeration containing all possible types for filters
|
||||
*/
|
||||
export enum FilterType {
|
||||
text,
|
||||
date,
|
||||
hierarchical,
|
||||
standard
|
||||
/**
|
||||
* Represents simple text facets
|
||||
*/
|
||||
text = 'text',
|
||||
|
||||
/**
|
||||
* Represents date facets
|
||||
*/
|
||||
range = 'date',
|
||||
|
||||
/**
|
||||
* Represents hierarchically structured facets
|
||||
*/
|
||||
hierarchy = 'hierarchical',
|
||||
|
||||
/**
|
||||
* Represents binary facets
|
||||
*/
|
||||
boolean = 'standard'
|
||||
}
|
||||
|
@@ -0,0 +1,146 @@
|
||||
import { SearchConfigurationService } from './search-configuration.service';
|
||||
import { ActivatedRouteStub } from '../../shared/testing/active-router-stub';
|
||||
import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model';
|
||||
import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model';
|
||||
import { PaginatedSearchOptions } from '../paginated-search-options.model';
|
||||
import { Observable } from 'rxjs/Observable';
|
||||
import { SearchFilter } from '../search-filter.model';
|
||||
|
||||
describe('SearchConfigurationService', () => {
|
||||
let service: SearchConfigurationService;
|
||||
const value1 = 'random value';
|
||||
const prefixFilter = {
|
||||
'f.author': ['another value'],
|
||||
'f.date.min': ['2013'],
|
||||
'f.date.max': ['2018']
|
||||
};
|
||||
const defaults = new PaginatedSearchOptions({
|
||||
pagination: Object.assign(new PaginationComponentOptions(), { currentPage: 1, pageSize: 20 }),
|
||||
sort: new SortOptions('score', SortDirection.DESC),
|
||||
query: '',
|
||||
scope: ''
|
||||
});
|
||||
|
||||
const backendFilters = [new SearchFilter('f.author', ['another value']), new SearchFilter('f.date', ['[2013 TO 2018]'])];
|
||||
|
||||
const spy = jasmine.createSpyObj('RouteService', {
|
||||
getQueryParameterValue: Observable.of(value1),
|
||||
getQueryParamsWithPrefix: Observable.of(prefixFilter)
|
||||
});
|
||||
|
||||
const activatedRoute: any = new ActivatedRouteStub();
|
||||
|
||||
beforeEach(() => {
|
||||
service = new SearchConfigurationService(spy, activatedRoute);
|
||||
});
|
||||
|
||||
describe('when the scope is called', () => {
|
||||
beforeEach(() => {
|
||||
service.getCurrentScope('');
|
||||
});
|
||||
it('should call getQueryParameterValue on the routeService with parameter name \'scope\'', () => {
|
||||
expect((service as any).routeService.getQueryParameterValue).toHaveBeenCalledWith('scope');
|
||||
});
|
||||
});
|
||||
|
||||
describe('when getCurrentQuery is called', () => {
|
||||
beforeEach(() => {
|
||||
service.getCurrentQuery('');
|
||||
});
|
||||
it('should call getQueryParameterValue on the routeService with parameter name \'query\'', () => {
|
||||
expect((service as any).routeService.getQueryParameterValue).toHaveBeenCalledWith('query');
|
||||
});
|
||||
});
|
||||
|
||||
describe('when getCurrentDSOType is called', () => {
|
||||
beforeEach(() => {
|
||||
service.getCurrentDSOType();
|
||||
});
|
||||
it('should call getQueryParameterValue on the routeService with parameter name \'dsoType\'', () => {
|
||||
expect((service as any).routeService.getQueryParameterValue).toHaveBeenCalledWith('dsoType');
|
||||
});
|
||||
});
|
||||
|
||||
describe('when getCurrentFrontendFilters is called', () => {
|
||||
beforeEach(() => {
|
||||
service.getCurrentFrontendFilters();
|
||||
});
|
||||
it('should call getQueryParamsWithPrefix on the routeService with parameter prefix \'f.\'', () => {
|
||||
expect((service as any).routeService.getQueryParamsWithPrefix).toHaveBeenCalledWith('f.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('when getCurrentFilters is called', () => {
|
||||
let parsedValues$;
|
||||
beforeEach(() => {
|
||||
parsedValues$ = service.getCurrentFilters();
|
||||
});
|
||||
it('should call getQueryParamsWithPrefix on the routeService with parameter prefix \'f.\'', () => {
|
||||
expect((service as any).routeService.getQueryParamsWithPrefix).toHaveBeenCalledWith('f.');
|
||||
parsedValues$.subscribe((values) => {
|
||||
expect(values).toEqual(backendFilters);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when getCurrentSort is called', () => {
|
||||
beforeEach(() => {
|
||||
service.getCurrentSort({} as any);
|
||||
});
|
||||
it('should call getQueryParameterValue on the routeService with parameter name \'sortDirection\'', () => {
|
||||
expect((service as any).routeService.getQueryParameterValue).toHaveBeenCalledWith('sortDirection');
|
||||
});
|
||||
it('should call getQueryParameterValue on the routeService with parameter name \'sortField\'', () => {
|
||||
expect((service as any).routeService.getQueryParameterValue).toHaveBeenCalledWith('sortField');
|
||||
});
|
||||
});
|
||||
describe('when getCurrentPagination is called', () => {
|
||||
beforeEach(() => {
|
||||
service.getCurrentPagination({ currentPage: 1, pageSize: 10 } as any);
|
||||
});
|
||||
it('should call getQueryParameterValue on the routeService with parameter name \'page\'', () => {
|
||||
expect((service as any).routeService.getQueryParameterValue).toHaveBeenCalledWith('page');
|
||||
});
|
||||
it('should call getQueryParameterValue on the routeService with parameter name \'pageSize\'', () => {
|
||||
expect((service as any).routeService.getQueryParameterValue).toHaveBeenCalledWith('pageSize');
|
||||
});
|
||||
});
|
||||
describe('when subscribeToSearchOptions or subscribeToPaginatedSearchOptions is called', () => {
|
||||
beforeEach(() => {
|
||||
spyOn(service, 'getCurrentPagination').and.callThrough();
|
||||
spyOn(service, 'getCurrentSort').and.callThrough();
|
||||
spyOn(service, 'getCurrentScope').and.callThrough();
|
||||
spyOn(service, 'getCurrentQuery').and.callThrough();
|
||||
spyOn(service, 'getCurrentDSOType').and.callThrough();
|
||||
spyOn(service, 'getCurrentFilters').and.callThrough();
|
||||
});
|
||||
|
||||
describe('when subscribeToSearchOptions is called', () => {
|
||||
beforeEach(() => {
|
||||
service.subscribeToSearchOptions(defaults)
|
||||
});
|
||||
it('should call all getters it needs, but not call any others', () => {
|
||||
expect(service.getCurrentPagination).not.toHaveBeenCalled();
|
||||
expect(service.getCurrentSort).not.toHaveBeenCalled();
|
||||
expect(service.getCurrentScope).toHaveBeenCalled();
|
||||
expect(service.getCurrentQuery).toHaveBeenCalled();
|
||||
expect(service.getCurrentDSOType).toHaveBeenCalled();
|
||||
expect(service.getCurrentFilters).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when subscribeToPaginatedSearchOptions is called', () => {
|
||||
beforeEach(() => {
|
||||
service.subscribeToPaginatedSearchOptions(defaults);
|
||||
});
|
||||
it('should call all getters it needs', () => {
|
||||
expect(service.getCurrentPagination).toHaveBeenCalled();
|
||||
expect(service.getCurrentSort).toHaveBeenCalled();
|
||||
expect(service.getCurrentScope).toHaveBeenCalled();
|
||||
expect(service.getCurrentQuery).toHaveBeenCalled();
|
||||
expect(service.getCurrentDSOType).toHaveBeenCalled();
|
||||
expect(service.getCurrentFilters).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
@@ -0,0 +1,292 @@
|
||||
import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model';
|
||||
import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model';
|
||||
import { SearchOptions } from '../search-options.model';
|
||||
import { Observable } from 'rxjs/Observable';
|
||||
import { ActivatedRoute, Params } from '@angular/router';
|
||||
import { PaginatedSearchOptions } from '../paginated-search-options.model';
|
||||
import { Injectable, OnDestroy } from '@angular/core';
|
||||
import { RouteService } from '../../shared/services/route.service';
|
||||
import { hasNoValue, hasValue, isEmpty, isNotEmpty } from '../../shared/empty.util';
|
||||
import { RemoteData } from '../../core/data/remote-data';
|
||||
import { BehaviorSubject } from 'rxjs/BehaviorSubject';
|
||||
import { Subscription } from 'rxjs/Subscription';
|
||||
import { getSucceededRemoteData } from '../../core/shared/operators';
|
||||
import { SearchFilter } from '../search-filter.model';
|
||||
import { DSpaceObjectType } from '../../core/shared/dspace-object-type.model';
|
||||
|
||||
/**
|
||||
* Service that performs all actions that have to do with the current search configuration
|
||||
*/
|
||||
@Injectable()
|
||||
export class SearchConfigurationService implements OnDestroy {
|
||||
/**
|
||||
* Default pagination settings
|
||||
*/
|
||||
private defaultPagination = Object.assign(new PaginationComponentOptions(), {
|
||||
id: 'search-page-configuration',
|
||||
pageSize: 10,
|
||||
currentPage: 1
|
||||
});
|
||||
|
||||
/**
|
||||
* Default sort settings
|
||||
*/
|
||||
private defaultSort = new SortOptions('score', SortDirection.DESC);
|
||||
|
||||
/**
|
||||
* Default scope setting
|
||||
*/
|
||||
private defaultScope = '';
|
||||
|
||||
/**
|
||||
* Default query setting
|
||||
*/
|
||||
private defaultQuery = '';
|
||||
|
||||
/**
|
||||
* Emits the current default values
|
||||
*/
|
||||
private _defaults: Observable<RemoteData<PaginatedSearchOptions>>;
|
||||
|
||||
/**
|
||||
* Emits the current search options
|
||||
*/
|
||||
public searchOptions: BehaviorSubject<SearchOptions>;
|
||||
|
||||
/**
|
||||
* Emits the current search options including pagination and sort
|
||||
*/
|
||||
public paginatedSearchOptions: BehaviorSubject<PaginatedSearchOptions>;
|
||||
|
||||
/**
|
||||
* List of subscriptions to unsubscribe from on destroy
|
||||
*/
|
||||
private subs: Subscription[] = new Array();
|
||||
|
||||
/**
|
||||
* Initialize the search options
|
||||
* @param {RouteService} routeService
|
||||
* @param {ActivatedRoute} route
|
||||
*/
|
||||
constructor(private routeService: RouteService,
|
||||
private route: ActivatedRoute) {
|
||||
this.defaults
|
||||
.pipe(getSucceededRemoteData())
|
||||
.subscribe((defRD) => {
|
||||
const defs = defRD.payload;
|
||||
this.paginatedSearchOptions = new BehaviorSubject<SearchOptions>(defs);
|
||||
this.searchOptions = new BehaviorSubject<PaginatedSearchOptions>(defs);
|
||||
|
||||
this.subs.push(this.subscribeToSearchOptions(defs));
|
||||
this.subs.push(this.subscribeToPaginatedSearchOptions(defs));
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Observable<string>} Emits the current scope's identifier
|
||||
*/
|
||||
getCurrentScope(defaultScope: string) {
|
||||
return this.routeService.getQueryParameterValue('scope').map((scope) => {
|
||||
return scope || defaultScope;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Observable<string>} Emits the current query string
|
||||
*/
|
||||
getCurrentQuery(defaultQuery: string) {
|
||||
return this.routeService.getQueryParameterValue('query').map((query) => {
|
||||
return query || defaultQuery;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Observable<number>} Emits the current DSpaceObject type as a number
|
||||
*/
|
||||
getCurrentDSOType(): Observable<DSpaceObjectType> {
|
||||
return this.routeService.getQueryParameterValue('dsoType')
|
||||
.filter((type) => hasValue(type) && hasValue(DSpaceObjectType[type.toUpperCase()]))
|
||||
.map((type) => DSpaceObjectType[type.toUpperCase()]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Observable<string>} Emits the current pagination settings
|
||||
*/
|
||||
getCurrentPagination(defaultPagination: PaginationComponentOptions): Observable<PaginationComponentOptions> {
|
||||
const page$ = this.routeService.getQueryParameterValue('page');
|
||||
const size$ = this.routeService.getQueryParameterValue('pageSize');
|
||||
return Observable.combineLatest(page$, size$, (page, size) => {
|
||||
return Object.assign(new PaginationComponentOptions(), defaultPagination, {
|
||||
currentPage: page || defaultPagination.currentPage,
|
||||
pageSize: size || defaultPagination.pageSize
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Observable<string>} Emits the current sorting settings
|
||||
*/
|
||||
getCurrentSort(defaultSort: SortOptions): Observable<SortOptions> {
|
||||
const sortDirection$ = this.routeService.getQueryParameterValue('sortDirection');
|
||||
const sortField$ = this.routeService.getQueryParameterValue('sortField');
|
||||
return Observable.combineLatest(sortDirection$, sortField$, (sortDirection, sortField) => {
|
||||
// Dirty fix because sometimes the observable value is null somehow
|
||||
sortField = this.route.snapshot.queryParamMap.get('sortField');
|
||||
|
||||
const field = sortField || defaultSort.field;
|
||||
const direction = SortDirection[sortDirection] || defaultSort.direction;
|
||||
return new SortOptions(field, direction)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Observable<Params>} Emits the current active filters with their values as they are sent to the backend
|
||||
*/
|
||||
getCurrentFilters(): Observable<SearchFilter[]> {
|
||||
return this.routeService.getQueryParamsWithPrefix('f.').map((filterParams) => {
|
||||
if (isNotEmpty(filterParams)) {
|
||||
const filters = [];
|
||||
Object.keys(filterParams).forEach((key) => {
|
||||
if (key.endsWith('.min') || key.endsWith('.max')) {
|
||||
const realKey = key.slice(0, -4);
|
||||
if (hasNoValue(filters.find((filter) => filter.key === realKey))) {
|
||||
const min = filterParams[realKey + '.min'] ? filterParams[realKey + '.min'][0] : '*';
|
||||
const max = filterParams[realKey + '.max'] ? filterParams[realKey + '.max'][0] : '*';
|
||||
filters.push(new SearchFilter(realKey, ['[' + min + ' TO ' + max + ']']));
|
||||
}
|
||||
} else {
|
||||
filters.push(new SearchFilter(key, filterParams[key]));
|
||||
}
|
||||
});
|
||||
return filters;
|
||||
}
|
||||
return [];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Observable<Params>} Emits the current active filters with their values as they are displayed in the frontend URL
|
||||
*/
|
||||
getCurrentFrontendFilters(): Observable<Params> {
|
||||
return this.routeService.getQueryParamsWithPrefix('f.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up a subscription to all necessary parameters to make sure the searchOptions emits a new value every time they update
|
||||
* @param {SearchOptions} defaults Default values for when no parameters are available
|
||||
* @returns {Subscription} The subscription to unsubscribe from
|
||||
*/
|
||||
subscribeToSearchOptions(defaults: SearchOptions): Subscription {
|
||||
return Observable.merge(
|
||||
this.getScopePart(defaults.scope),
|
||||
this.getQueryPart(defaults.query),
|
||||
this.getDSOTypePart(),
|
||||
this.getFiltersPart()
|
||||
).subscribe((update) => {
|
||||
const currentValue: SearchOptions = this.searchOptions.getValue();
|
||||
const updatedValue: SearchOptions = Object.assign(currentValue, update);
|
||||
this.searchOptions.next(updatedValue);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up a subscription to all necessary parameters to make sure the paginatedSearchOptions emits a new value every time they update
|
||||
* @param {PaginatedSearchOptions} defaults Default values for when no parameters are available
|
||||
* @returns {Subscription} The subscription to unsubscribe from
|
||||
*/
|
||||
subscribeToPaginatedSearchOptions(defaults: PaginatedSearchOptions): Subscription {
|
||||
return Observable.merge(
|
||||
this.getPaginationPart(defaults.pagination),
|
||||
this.getSortPart(defaults.sort),
|
||||
this.getScopePart(defaults.scope),
|
||||
this.getQueryPart(defaults.query),
|
||||
this.getDSOTypePart(),
|
||||
this.getFiltersPart()
|
||||
).subscribe((update) => {
|
||||
const currentValue: PaginatedSearchOptions = this.paginatedSearchOptions.getValue();
|
||||
const updatedValue: PaginatedSearchOptions = Object.assign(currentValue, update);
|
||||
this.paginatedSearchOptions.next(updatedValue);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Default values for the Search Options
|
||||
*/
|
||||
get defaults(): Observable<RemoteData<PaginatedSearchOptions>> {
|
||||
if (hasNoValue(this._defaults)) {
|
||||
const options = new PaginatedSearchOptions({
|
||||
pagination: this.defaultPagination,
|
||||
sort: this.defaultSort,
|
||||
scope: this.defaultScope,
|
||||
query: this.defaultQuery
|
||||
});
|
||||
this._defaults = Observable.of(new RemoteData(false, false, true, null, options));
|
||||
}
|
||||
return this._defaults;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make sure to unsubscribe from all existing subscription to prevent memory leaks
|
||||
*/
|
||||
ngOnDestroy(): void {
|
||||
this.subs.forEach((sub) => {
|
||||
sub.unsubscribe();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Observable<string>} Emits the current scope's identifier
|
||||
*/
|
||||
private getScopePart(defaultScope: string): Observable<any> {
|
||||
return this.getCurrentScope(defaultScope).map((scope) => {
|
||||
return { scope }
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Observable<string>} Emits the current query string as a partial SearchOptions object
|
||||
*/
|
||||
private getQueryPart(defaultQuery: string): Observable<any> {
|
||||
return this.getCurrentQuery(defaultQuery).map((query) => {
|
||||
return { query }
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Observable<string>} Emits the current query string as a partial SearchOptions object
|
||||
*/
|
||||
private getDSOTypePart(): Observable<any> {
|
||||
return this.getCurrentDSOType().map((dsoType) => {
|
||||
return { dsoType }
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Observable<string>} Emits the current pagination settings as a partial SearchOptions object
|
||||
*/
|
||||
private getPaginationPart(defaultPagination: PaginationComponentOptions): Observable<any> {
|
||||
return this.getCurrentPagination(defaultPagination).map((pagination) => {
|
||||
return { pagination }
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Observable<string>} Emits the current sorting settings as a partial SearchOptions object
|
||||
*/
|
||||
private getSortPart(defaultSort: SortOptions): Observable<any> {
|
||||
return this.getCurrentSort(defaultSort).map((sort) => {
|
||||
return { sort }
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Observable<Params>} Emits the current active filters as a partial SearchOptions object
|
||||
*/
|
||||
private getFiltersPart(): Observable<any> {
|
||||
return this.getCurrentFilters().map((filters) => {
|
||||
return { filters }
|
||||
});
|
||||
}
|
||||
}
|
@@ -1,22 +1,53 @@
|
||||
import { FilterType } from './filter-type.model';
|
||||
import { autoserialize, autoserializeAs } from 'cerialize';
|
||||
|
||||
/**
|
||||
* The configuration for a search filter
|
||||
*/
|
||||
export class SearchFilterConfig {
|
||||
|
||||
/**
|
||||
* The name of this filter
|
||||
*/
|
||||
@autoserialize
|
||||
name: string;
|
||||
|
||||
/**
|
||||
* The FilterType of this filter
|
||||
*/
|
||||
@autoserializeAs(String, 'facetType')
|
||||
type: FilterType;
|
||||
|
||||
/**
|
||||
* True if the filter has facets
|
||||
*/
|
||||
@autoserialize
|
||||
hasFacets: boolean;
|
||||
|
||||
// @autoserializeAs(String, 'facetLimit') - uncomment when fixed in rest
|
||||
/**
|
||||
* @type {number} The page size used for this facet
|
||||
*/
|
||||
@autoserializeAs(String, 'facetLimit')
|
||||
pageSize = 5;
|
||||
|
||||
/**
|
||||
* Defines if the item facet is collapsed by default or not on the search page
|
||||
*/
|
||||
@autoserialize
|
||||
isOpenByDefault: boolean;
|
||||
|
||||
/**
|
||||
* Minimum value possible for this facet in the repository
|
||||
*/
|
||||
@autoserialize
|
||||
maxValue: string;
|
||||
|
||||
/**
|
||||
* Maximum value possible for this facet in the repository
|
||||
*/
|
||||
@autoserialize
|
||||
minValue: string;
|
||||
|
||||
/**
|
||||
* Name of this configuration that can be used in a url
|
||||
* @returns Parameter name
|
||||
|
@@ -2,46 +2,88 @@ import { autoserialize, autoserializeAs } from 'cerialize';
|
||||
import { PageInfo } from '../../core/shared/page-info.model';
|
||||
import { NormalizedSearchResult } from '../normalized-search-result.model';
|
||||
|
||||
/**
|
||||
* Class representing the response returned by the server when performing a search request
|
||||
*/
|
||||
export class SearchQueryResponse {
|
||||
/**
|
||||
* The scope used in the search request represented by the UUID of a DSpaceObject
|
||||
*/
|
||||
@autoserialize
|
||||
scope: string;
|
||||
|
||||
/**
|
||||
* The search query used in the search request
|
||||
*/
|
||||
@autoserialize
|
||||
query: string;
|
||||
|
||||
/**
|
||||
* The currently active filters used in the search request
|
||||
*/
|
||||
@autoserialize
|
||||
appliedFilters: any[]; // TODO
|
||||
|
||||
/**
|
||||
* The sort parameters used in the search request
|
||||
*/
|
||||
@autoserialize
|
||||
sort: any; // TODO
|
||||
|
||||
/**
|
||||
* The sort parameters used in the search request
|
||||
*/
|
||||
@autoserialize
|
||||
configurationName: string;
|
||||
|
||||
/**
|
||||
* The sort parameters used in the search request
|
||||
*/
|
||||
@autoserialize
|
||||
public type: string;
|
||||
|
||||
/**
|
||||
* Pagination configuration for this response
|
||||
*/
|
||||
@autoserialize
|
||||
page: PageInfo;
|
||||
|
||||
/**
|
||||
* The results for this query
|
||||
*/
|
||||
@autoserializeAs(NormalizedSearchResult)
|
||||
objects: NormalizedSearchResult[];
|
||||
|
||||
@autoserialize
|
||||
facets: any; // TODO
|
||||
|
||||
/**
|
||||
* The REST url to retrieve the current response
|
||||
*/
|
||||
@autoserialize
|
||||
self: string;
|
||||
|
||||
/**
|
||||
* The REST url to retrieve the next response
|
||||
*/
|
||||
@autoserialize
|
||||
next: string;
|
||||
|
||||
/**
|
||||
* The REST url to retrieve the previous response
|
||||
*/
|
||||
@autoserialize
|
||||
previous: string;
|
||||
|
||||
/**
|
||||
* The REST url to retrieve the first response
|
||||
*/
|
||||
@autoserialize
|
||||
first: string;
|
||||
|
||||
/**
|
||||
* The REST url to retrieve the last response
|
||||
*/
|
||||
@autoserialize
|
||||
last: string;
|
||||
}
|
||||
|
@@ -1,8 +1,16 @@
|
||||
import { GenericConstructor } from '../../core/shared/generic-constructor';
|
||||
import { ListableObject } from '../../shared/object-collection/shared/listable-object.model';
|
||||
|
||||
/**
|
||||
* Contains the mapping between a search result component and a DSpaceObject
|
||||
*/
|
||||
const searchResultMap = new Map();
|
||||
|
||||
/**
|
||||
* Used to map Search Result components to their matching DSpaceObject
|
||||
* @param {GenericConstructor<ListableObject>} domainConstructor The constructor of the DSpaceObject
|
||||
* @returns Decorator function that performs the actual mapping on initialization of the component
|
||||
*/
|
||||
export function searchResultFor(domainConstructor: GenericConstructor<ListableObject>) {
|
||||
return function decorator(searchResult: any) {
|
||||
if (!searchResult) {
|
||||
@@ -12,6 +20,11 @@ export function searchResultFor(domainConstructor: GenericConstructor<ListableOb
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests the matching component based on a given DSpaceObject's constructor
|
||||
* @param {GenericConstructor<ListableObject>} domainConstructor The DSpaceObject's constructor for which the search result component is requested
|
||||
* @returns The component's constructor that matches the given DSpaceObject
|
||||
*/
|
||||
export function getSearchResultFor(domainConstructor: GenericConstructor<ListableObject>) {
|
||||
return searchResultMap.get(domainConstructor);
|
||||
}
|
||||
|
@@ -5,7 +5,6 @@ import { CommonModule } from '@angular/common';
|
||||
import { Component } from '@angular/core';
|
||||
|
||||
import { SearchService } from './search.service';
|
||||
import { ViewMode } from '../../+search-page/search-options.model';
|
||||
import { RemoteDataBuildService } from '../../core/cache/builders/remote-data-build.service';
|
||||
import { ActivatedRoute, Router, UrlTree } from '@angular/router';
|
||||
import { RequestService } from '../../core/data/request.service';
|
||||
@@ -22,9 +21,15 @@ import { ResponseCacheEntry } from '../../core/cache/response-cache.reducer';
|
||||
import { RequestEntry } from '../../core/data/request.reducer';
|
||||
import { getMockRequestService } from '../../shared/mocks/mock-request.service';
|
||||
import { getMockResponseCacheService } from '../../shared/mocks/mock-response-cache.service';
|
||||
import { FacetConfigSuccessResponse, SearchSuccessResponse } from '../../core/cache/response-cache.models';
|
||||
import {
|
||||
FacetConfigSuccessResponse,
|
||||
SearchSuccessResponse
|
||||
} from '../../core/cache/response-cache.models';
|
||||
import { SearchQueryResponse } from './search-query-response.model';
|
||||
import { SearchFilterConfig } from './search-filter-config.model';
|
||||
import { CommunityDataService } from '../../core/data/community-data.service';
|
||||
import { ViewMode } from '../../core/shared/view-mode.model';
|
||||
import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service';
|
||||
|
||||
@Component({ template: '' })
|
||||
class DummyComponent {
|
||||
@@ -53,6 +58,8 @@ describe('SearchService', () => {
|
||||
{ provide: RequestService, useValue: getMockRequestService() },
|
||||
{ provide: RemoteDataBuildService, useValue: {} },
|
||||
{ provide: HALEndpointService, useValue: {} },
|
||||
{ provide: CommunityDataService, useValue: {}},
|
||||
{ provide: DSpaceObjectDataService, useValue: {}},
|
||||
SearchService
|
||||
],
|
||||
});
|
||||
@@ -108,6 +115,8 @@ describe('SearchService', () => {
|
||||
{ provide: RequestService, useValue: getMockRequestService() },
|
||||
{ provide: RemoteDataBuildService, useValue: remoteDataBuildService },
|
||||
{ provide: HALEndpointService, useValue: halService },
|
||||
{ provide: CommunityDataService, useValue: {}},
|
||||
{ provide: DSpaceObjectDataService, useValue: {}},
|
||||
SearchService
|
||||
],
|
||||
});
|
||||
@@ -148,9 +157,9 @@ describe('SearchService', () => {
|
||||
|
||||
describe('when search is called', () => {
|
||||
const endPoint = 'http://endpoint.com/test/test';
|
||||
const searchOptions = new PaginatedSearchOptions();
|
||||
const searchOptions = new PaginatedSearchOptions({});
|
||||
const queryResponse = Object.assign(new SearchQueryResponse(), { objects: [] });
|
||||
const response = new SearchSuccessResponse(queryResponse, 200,'OK');
|
||||
const response = new SearchSuccessResponse(queryResponse, 200, 'OK');
|
||||
const responseEntry = Object.assign(new ResponseCacheEntry(), { response: response });
|
||||
beforeEach(() => {
|
||||
spyOn((searchService as any).halService, 'getEndpoint').and.returnValue(Observable.of(endPoint));
|
||||
|
@@ -1,13 +1,14 @@
|
||||
import { Injectable, OnDestroy } from '@angular/core';
|
||||
import {
|
||||
ActivatedRoute, NavigationExtras, PRIMARY_OUTLET, Router,
|
||||
ActivatedRoute,
|
||||
NavigationExtras,
|
||||
PRIMARY_OUTLET,
|
||||
Router,
|
||||
UrlSegmentGroup
|
||||
} from '@angular/router';
|
||||
import { Observable } from 'rxjs/Observable';
|
||||
import { flatMap, map, tap } from 'rxjs/operators';
|
||||
import { ViewMode } from '../../+search-page/search-options.model';
|
||||
import { flatMap, map, switchMap } from 'rxjs/operators';
|
||||
import { RemoteDataBuildService } from '../../core/cache/builders/remote-data-build.service';
|
||||
import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model';
|
||||
import {
|
||||
FacetConfigSuccessResponse,
|
||||
FacetValueSuccessResponse,
|
||||
@@ -23,10 +24,9 @@ import { RequestService } from '../../core/data/request.service';
|
||||
import { DSpaceObject } from '../../core/shared/dspace-object.model';
|
||||
import { GenericConstructor } from '../../core/shared/generic-constructor';
|
||||
import { HALEndpointService } from '../../core/shared/hal-endpoint.service';
|
||||
import { configureRequest } from '../../core/shared/operators';
|
||||
import { configureRequest, getSucceededRemoteData } from '../../core/shared/operators';
|
||||
import { URLCombiner } from '../../core/url-combiner/url-combiner';
|
||||
import { hasValue, isEmpty, isNotEmpty } from '../../shared/empty.util';
|
||||
import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model';
|
||||
import { NormalizedSearchResult } from '../normalized-search-result.model';
|
||||
import { SearchOptions } from '../search-options.model';
|
||||
import { SearchResult } from '../search-result.model';
|
||||
@@ -40,32 +40,48 @@ import { ListableObject } from '../../shared/object-collection/shared/listable-o
|
||||
import { FacetValueResponseParsingService } from '../../core/data/facet-value-response-parsing.service';
|
||||
import { FacetConfigResponseParsingService } from '../../core/data/facet-config-response-parsing.service';
|
||||
import { PaginatedSearchOptions } from '../paginated-search-options.model';
|
||||
import { observable } from 'rxjs/symbol/observable';
|
||||
import { Community } from '../../core/shared/community.model';
|
||||
import { CommunityDataService } from '../../core/data/community-data.service';
|
||||
import { ViewMode } from '../../core/shared/view-mode.model';
|
||||
import { ResourceType } from '../../core/shared/resource-type';
|
||||
import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service';
|
||||
|
||||
/**
|
||||
* Service that performs all general actions that have to do with the search page
|
||||
*/
|
||||
@Injectable()
|
||||
export class SearchService implements OnDestroy {
|
||||
/**
|
||||
* Endpoint link path for retrieving general search results
|
||||
*/
|
||||
private searchLinkPath = 'discover/search/objects';
|
||||
private facetValueLinkPathPrefix = 'discover/facets/';
|
||||
private facetConfigLinkPath = 'discover/facets';
|
||||
|
||||
/**
|
||||
* Endpoint link path for retrieving facet config incl values
|
||||
*/
|
||||
private facetLinkPathPrefix = 'discover/facets/';
|
||||
|
||||
/**
|
||||
* Subscription to unsubscribe from
|
||||
*/
|
||||
private sub;
|
||||
|
||||
searchOptions: SearchOptions;
|
||||
|
||||
constructor(private router: Router,
|
||||
private route: ActivatedRoute,
|
||||
protected responseCache: ResponseCacheService,
|
||||
protected requestService: RequestService,
|
||||
private rdb: RemoteDataBuildService,
|
||||
private halService: HALEndpointService) {
|
||||
const pagination: PaginationComponentOptions = new PaginationComponentOptions();
|
||||
pagination.id = 'search-results-pagination';
|
||||
pagination.currentPage = 1;
|
||||
pagination.pageSize = 10;
|
||||
const sort: SortOptions = new SortOptions('score', SortDirection.DESC);
|
||||
this.searchOptions = Object.assign(new SearchOptions(), { pagination: pagination, sort: sort });
|
||||
private halService: HALEndpointService,
|
||||
private communityService: CommunityDataService,
|
||||
private dspaceObjectService: DSpaceObjectDataService
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Method to retrieve a paginated list of search results from the server
|
||||
* @param {PaginatedSearchOptions} searchOptions The configuration necessary to perform this search
|
||||
* @returns {Observable<RemoteData<PaginatedList<SearchResult<DSpaceObject>>>>} Emits a paginated list with all search results found
|
||||
*/
|
||||
search(searchOptions?: PaginatedSearchOptions): Observable<RemoteData<PaginatedList<SearchResult<DSpaceObject>>>> {
|
||||
const requestObs = this.halService.getEndpoint(this.searchLinkPath).pipe(
|
||||
map((url: string) => {
|
||||
@@ -134,8 +150,13 @@ export class SearchService implements OnDestroy {
|
||||
return this.rdb.toRemoteDataObservable(requestEntryObs, responseCacheObs, payloadObs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Request the filter configuration for a given scope or the whole repository
|
||||
* @param {string} scope UUID of the object for which config the filter config is requested, when no scope is provided the configuration for the whole repository is loaded
|
||||
* @returns {Observable<RemoteData<SearchFilterConfig[]>>} The found filter configuration
|
||||
*/
|
||||
getConfig(scope?: string): Observable<RemoteData<SearchFilterConfig[]>> {
|
||||
const requestObs = this.halService.getEndpoint(this.facetConfigLinkPath).pipe(
|
||||
const requestObs = this.halService.getEndpoint(this.facetLinkPathPrefix).pipe(
|
||||
map((url: string) => {
|
||||
const args: string[] = [];
|
||||
|
||||
@@ -175,13 +196,25 @@ export class SearchService implements OnDestroy {
|
||||
return this.rdb.toRemoteDataObservable(requestEntryObs, responseCacheObs, facetConfigObs);
|
||||
}
|
||||
|
||||
getFacetValuesFor(filterConfig: SearchFilterConfig, valuePage: number, searchOptions?: SearchOptions): Observable<RemoteData<PaginatedList<FacetValue>>> {
|
||||
const requestObs = this.halService.getEndpoint(this.facetValueLinkPathPrefix + filterConfig.name).pipe(
|
||||
/**
|
||||
* Method to request a single page of filter values for a given value
|
||||
* @param {SearchFilterConfig} filterConfig The filter config for which we want to request filter values
|
||||
* @param {number} valuePage The page number of the filter values
|
||||
* @param {SearchOptions} searchOptions The search configuration for the current search
|
||||
* @param {string} filterQuery The optional query used to filter out filter values
|
||||
* @returns {Observable<RemoteData<PaginatedList<FacetValue>>>} Emits the given page of facet values
|
||||
*/
|
||||
getFacetValuesFor(filterConfig: SearchFilterConfig, valuePage: number, searchOptions?: SearchOptions, filterQuery?: string): Observable<RemoteData<PaginatedList<FacetValue>>> {
|
||||
const requestObs = this.halService.getEndpoint(this.facetLinkPathPrefix + filterConfig.name).pipe(
|
||||
map((url: string) => {
|
||||
const args: string[] = [`page=${valuePage - 1}`, `size=${filterConfig.pageSize}`];
|
||||
if (hasValue(filterQuery)) {
|
||||
args.push(`prefix=${filterQuery}`);
|
||||
}
|
||||
if (hasValue(searchOptions)) {
|
||||
url = searchOptions.toRestUrl(url, args);
|
||||
}
|
||||
|
||||
const request = new GetRequest(this.requestService.generateRequestId(), url);
|
||||
return Object.assign(request, {
|
||||
getResponseParser(): GenericConstructor<ResponseParsingService> {
|
||||
@@ -218,6 +251,45 @@ export class SearchService implements OnDestroy {
|
||||
return this.rdb.toRemoteDataObservable(requestEntryObs, responseCacheObs, payloadObs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Request a list of DSpaceObjects that can be used as a scope, based on the current scope
|
||||
* @param {string} scopeId UUID of the current scope, if the scope is empty, the repository wide scopes will be returned
|
||||
* @returns {Observable<DSpaceObject[]>} Emits a list of DSpaceObjects which represent possible scopes
|
||||
*/
|
||||
getScopes(scopeId?: string): Observable<DSpaceObject[]> {
|
||||
|
||||
if (isEmpty(scopeId)) {
|
||||
const top: Observable<Community[]> = this.communityService.findTop({ elementsPerPage: 9999 }).pipe(
|
||||
map(
|
||||
(communities: RemoteData<PaginatedList<Community>>) => communities.payload.page
|
||||
)
|
||||
);
|
||||
return top;
|
||||
}
|
||||
|
||||
const scopeObject: Observable<RemoteData<DSpaceObject>> = this.dspaceObjectService.findById(scopeId).pipe(getSucceededRemoteData());
|
||||
const scopeList: Observable<DSpaceObject[]> = scopeObject.pipe(
|
||||
switchMap((dsoRD: RemoteData<DSpaceObject>) => {
|
||||
if (dsoRD.payload.type === ResourceType.Community) {
|
||||
const community: Community = dsoRD.payload as Community;
|
||||
return Observable.combineLatest(community.subcommunities, community.collections, (subCommunities, collections) => {
|
||||
/*if this is a community, we also need to show the direct children*/
|
||||
return [community, ...subCommunities.payload.page, ...collections.payload.page]
|
||||
})
|
||||
} else {
|
||||
return Observable.of([dsoRD.payload]);
|
||||
}
|
||||
}
|
||||
));
|
||||
|
||||
return scopeList;
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests the current view mode based on the current URL
|
||||
* @returns {Observable<ViewMode>} The current view mode
|
||||
*/
|
||||
getViewMode(): Observable<ViewMode> {
|
||||
return this.route.queryParams.map((params) => {
|
||||
if (isNotEmpty(params.view) && hasValue(params.view)) {
|
||||
@@ -228,6 +300,10 @@ export class SearchService implements OnDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Changes the current view mode in the current URL
|
||||
* @param {ViewMode} viewMode Mode to switch to
|
||||
*/
|
||||
setViewMode(viewMode: ViewMode) {
|
||||
const navigationExtras: NavigationExtras = {
|
||||
queryParams: { view: viewMode },
|
||||
@@ -237,12 +313,18 @@ export class SearchService implements OnDestroy {
|
||||
this.router.navigate([this.getSearchLink()], navigationExtras);
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {string} The base path to the search page
|
||||
*/
|
||||
getSearchLink(): string {
|
||||
const urlTree = this.router.parseUrl(this.router.url);
|
||||
const g: UrlSegmentGroup = urlTree.root.children[PRIMARY_OUTLET];
|
||||
return '/' + g.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsubscribe from the subscription
|
||||
*/
|
||||
ngOnDestroy(): void {
|
||||
if (this.sub !== undefined) {
|
||||
this.sub.unsubscribe();
|
||||
|
@@ -1,22 +1,24 @@
|
||||
<h3>{{ 'search.sidebar.settings.title' | translate}}</h3>
|
||||
<div *ngIf="[searchOptions].sort" class="setting-option result-order-settings mb-3 p-3">
|
||||
<ng-container *ngVar="(searchOptions$ | async) as config">
|
||||
<h3>{{ 'search.sidebar.settings.title' | translate}}</h3>
|
||||
<div *ngIf="config?.sort" class="setting-option result-order-settings mb-3 p-3">
|
||||
<h5>{{ 'search.sidebar.settings.sort-by' | translate}}</h5>
|
||||
<select class="form-control" (change)="reloadOrder($event)">
|
||||
<option *ngFor="let sortDirection of (sortDirections | dsKeys)"
|
||||
[value]="sortDirection.value"
|
||||
[selected]="sortDirection.value === direction? 'selected': null">
|
||||
{{'sorting.' + sortDirection.key | translate}}
|
||||
<option *ngFor="let sortOption of searchOptionPossibilities"
|
||||
[value]="sortOption.field + ',' + sortOption.direction.toString()"
|
||||
[selected]="sortOption.field === config?.sort.field && sortOption.direction === (config?.sort.direction)? 'selected': null">
|
||||
{{'sorting.' + sortOption.field + '.' + sortOption.direction | translate}}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-option page-size-settings mb-3 p-3">
|
||||
<h5>{{ 'search.sidebar.settings.rpp' | translate}}</h5>
|
||||
|
||||
<select class="form-control" (change)="reloadRPP($event)">
|
||||
<option *ngFor="let pageSizeOption of pageSizeOptions" [value]="pageSizeOption"
|
||||
[selected]="pageSizeOption === pageSize ? 'selected': null">
|
||||
{{pageSizeOption}}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="setting-option page-size-settings mb-3 p-3">
|
||||
<h5>{{ 'search.sidebar.settings.rpp' | translate}}</h5>
|
||||
<select class="form-control" (change)="reloadRPP($event)">
|
||||
<option *ngFor="let pageSizeOption of config?.pagination.pageSizeOptions"
|
||||
[value]="pageSizeOption"
|
||||
[selected]="pageSizeOption === +config?.pagination.pageSize ? 'selected': null">
|
||||
{{pageSizeOption}}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</ng-container>
|
@@ -11,6 +11,10 @@ import { SearchSidebarService } from '../search-sidebar/search-sidebar.service';
|
||||
import { NO_ERRORS_SCHEMA } from '@angular/core';
|
||||
import { EnumKeysPipe } from '../../shared/utils/enum-keys-pipe';
|
||||
import { By } from '@angular/platform-browser';
|
||||
import { SearchFilterService } from '../search-filters/search-filter/search-filter.service';
|
||||
import { hot } from 'jasmine-marbles';
|
||||
import { VarDirective } from '../../shared/utils/var.directive';
|
||||
import { SearchConfigurationService } from '../search-service/search-configuration.service';
|
||||
|
||||
describe('SearchSettingsComponent', () => {
|
||||
|
||||
@@ -23,13 +27,21 @@ describe('SearchSettingsComponent', () => {
|
||||
pagination.currentPage = 1;
|
||||
pagination.pageSize = 10;
|
||||
const sort: SortOptions = new SortOptions('score', SortDirection.DESC);
|
||||
const mockResults = [ 'test', 'data' ];
|
||||
const mockResults = ['test', 'data'];
|
||||
const searchServiceStub = {
|
||||
searchOptions: { pagination: pagination, sort: sort },
|
||||
search: () => mockResults
|
||||
};
|
||||
|
||||
const queryParam = 'test query';
|
||||
const scopeParam = '7669c72a-3f2a-451f-a3b9-9210e7a4c02f';
|
||||
const paginatedSearchOptions = {
|
||||
query: queryParam,
|
||||
scope: scopeParam,
|
||||
pagination,
|
||||
sort
|
||||
};
|
||||
|
||||
const activatedRouteStub = {
|
||||
queryParams: Observable.of({
|
||||
query: queryParam,
|
||||
@@ -41,12 +53,12 @@ describe('SearchSettingsComponent', () => {
|
||||
isCollapsed: Observable.of(true),
|
||||
collapse: () => this.isCollapsed = Observable.of(true),
|
||||
expand: () => this.isCollapsed = Observable.of(false)
|
||||
}
|
||||
};
|
||||
|
||||
beforeEach(async(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [ TranslateModule.forRoot(), RouterTestingModule.withRoutes([]) ],
|
||||
declarations: [ SearchSettingsComponent, EnumKeysPipe ],
|
||||
imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([])],
|
||||
declarations: [SearchSettingsComponent, EnumKeysPipe, VarDirective],
|
||||
providers: [
|
||||
{ provide: SearchService, useValue: searchServiceStub },
|
||||
|
||||
@@ -55,8 +67,23 @@ describe('SearchSettingsComponent', () => {
|
||||
provide: SearchSidebarService,
|
||||
useValue: sidebarService
|
||||
},
|
||||
{
|
||||
provide: SearchFilterService,
|
||||
useValue: {}
|
||||
},
|
||||
{
|
||||
provide: SearchConfigurationService,
|
||||
useValue: {
|
||||
paginatedSearchOptions: hot('a', {
|
||||
a: paginatedSearchOptions
|
||||
}),
|
||||
getCurrentScope: hot('a', {
|
||||
a: 'test-id'
|
||||
}),
|
||||
}
|
||||
},
|
||||
],
|
||||
schemas: [ NO_ERRORS_SCHEMA ]
|
||||
schemas: [NO_ERRORS_SCHEMA]
|
||||
}).compileComponents();
|
||||
}));
|
||||
|
||||
@@ -74,30 +101,42 @@ describe('SearchSettingsComponent', () => {
|
||||
});
|
||||
|
||||
it('it should show the order settings with the respective selectable options', () => {
|
||||
const orderSetting = fixture.debugElement.query(By.css('div.result-order-settings'));
|
||||
expect(orderSetting).toBeDefined();
|
||||
const childElements = orderSetting.query(By.css('.form-control')).children;
|
||||
expect(childElements.length).toEqual(2);
|
||||
|
||||
(comp as any).searchOptions$.first().subscribe((options) => {
|
||||
fixture.detectChanges();
|
||||
const orderSetting = fixture.debugElement.query(By.css('div.result-order-settings'));
|
||||
expect(orderSetting).toBeDefined();
|
||||
const childElements = orderSetting.query(By.css('.form-control')).children;
|
||||
expect(childElements.length).toEqual(comp.searchOptionPossibilities.length);
|
||||
});
|
||||
});
|
||||
|
||||
it('it should show the size settings with the respective selectable options', () => {
|
||||
const pageSizeSetting = fixture.debugElement.query(By.css('div.page-size-settings'));
|
||||
expect(pageSizeSetting).toBeDefined();
|
||||
const childElements = pageSizeSetting.query(By.css('.form-control')).children;
|
||||
expect(childElements.length).toEqual(7);
|
||||
(comp as any).searchOptions$.first().subscribe((options) => {
|
||||
fixture.detectChanges();
|
||||
const pageSizeSetting = fixture.debugElement.query(By.css('div.page-size-settings'));
|
||||
expect(pageSizeSetting).toBeDefined();
|
||||
const childElements = pageSizeSetting.query(By.css('.form-control')).children;
|
||||
expect(childElements.length).toEqual(options.pagination.pageSizeOptions.length);
|
||||
}
|
||||
)
|
||||
});
|
||||
|
||||
it('should have the proper order value selected by default', () => {
|
||||
const orderSetting = fixture.debugElement.query(By.css('div.result-order-settings'));
|
||||
const childElementToBeSelected = orderSetting.query(By.css('.form-control option[value="0"][selected="selected"]'))
|
||||
expect(childElementToBeSelected).toBeDefined();
|
||||
(comp as any).searchOptions$.first().subscribe((options) => {
|
||||
fixture.detectChanges();
|
||||
const orderSetting = fixture.debugElement.query(By.css('div.result-order-settings'));
|
||||
const childElementToBeSelected = orderSetting.query(By.css('.form-control option[value="0"][selected="selected"]'));
|
||||
expect(childElementToBeSelected).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('should have the proper rpp value selected by default', () => {
|
||||
const pageSizeSetting = fixture.debugElement.query(By.css('div.page-size-settings'));
|
||||
const childElementToBeSelected = pageSizeSetting.query(By.css('.form-control option[value="10"][selected="selected"]'))
|
||||
expect(childElementToBeSelected).toBeDefined();
|
||||
(comp as any).searchOptions$.first().subscribe((options) => {
|
||||
fixture.detectChanges();
|
||||
const pageSizeSetting = fixture.debugElement.query(By.css('div.page-size-settings'));
|
||||
const childElementToBeSelected = pageSizeSetting.query(By.css('.form-control option[value="10"][selected="selected"]'));
|
||||
expect(childElementToBeSelected).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
@@ -1,77 +1,75 @@
|
||||
import { Component, Input, OnInit, ChangeDetectionStrategy } from '@angular/core';
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { SearchService } from '../search-service/search.service';
|
||||
import { SearchOptions, ViewMode } from '../search-options.model';
|
||||
import { SortDirection } from '../../core/cache/models/sort-options.model';
|
||||
import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model';
|
||||
import { ActivatedRoute, NavigationExtras, Router } from '@angular/router';
|
||||
import { PaginatedSearchOptions } from '../paginated-search-options.model';
|
||||
import { SearchFilterService } from '../search-filters/search-filter/search-filter.service';
|
||||
import { Observable } from 'rxjs/Observable';
|
||||
import { SearchConfigurationService } from '../search-service/search-configuration.service';
|
||||
|
||||
@Component({
|
||||
selector: 'ds-search-settings',
|
||||
styleUrls: ['./search-settings.component.scss'],
|
||||
templateUrl: './search-settings.component.html'
|
||||
})
|
||||
|
||||
/**
|
||||
* This component represents the part of the search sidebar that contains the general search settings.
|
||||
*/
|
||||
export class SearchSettingsComponent implements OnInit {
|
||||
|
||||
@Input() searchOptions: PaginatedSearchOptions;
|
||||
/**
|
||||
* Declare SortDirection enumeration to use it in the template
|
||||
* The configuration for the current paginated search results
|
||||
*/
|
||||
public sortDirections = SortDirection;
|
||||
/**
|
||||
* Number of items per page.
|
||||
*/
|
||||
public pageSize;
|
||||
@Input() public pageSizeOptions;
|
||||
searchOptions$: Observable<PaginatedSearchOptions>;
|
||||
|
||||
private sub;
|
||||
private scope: string;
|
||||
query: string;
|
||||
page: number;
|
||||
direction: SortDirection;
|
||||
currentParams = {};
|
||||
/**
|
||||
* All sort options that are shown in the settings
|
||||
*/
|
||||
searchOptionPossibilities = [new SortOptions('score', SortDirection.DESC), new SortOptions('dc.title', SortDirection.ASC), new SortOptions('dc.title', SortDirection.DESC)];
|
||||
|
||||
constructor(private service: SearchService,
|
||||
private route: ActivatedRoute,
|
||||
private router: Router) {
|
||||
private router: Router,
|
||||
private searchConfigurationService: SearchConfigurationService) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize paginated search options
|
||||
*/
|
||||
ngOnInit(): void {
|
||||
this.searchOptions = this.service.searchOptions;
|
||||
this.pageSize = this.searchOptions.pagination.pageSize;
|
||||
this.pageSizeOptions = this.searchOptions.pagination.pageSizeOptions;
|
||||
this.sub = this.route
|
||||
.queryParams
|
||||
.subscribe((params) => {
|
||||
this.currentParams = params;
|
||||
this.query = params.query || '';
|
||||
this.scope = params.scope;
|
||||
this.page = +params.page || this.searchOptions.pagination.currentPage;
|
||||
this.pageSize = +params.pageSize || this.searchOptions.pagination.pageSize;
|
||||
this.direction = params.sortDirection || this.searchOptions.sort.direction;
|
||||
if (params.view === ViewMode.Grid) {
|
||||
this.pageSizeOptions = this.pageSizeOptions;
|
||||
} else {
|
||||
this.pageSizeOptions = this.pageSizeOptions;
|
||||
}
|
||||
});
|
||||
this.searchOptions$ = this.searchConfigurationService.paginatedSearchOptions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Method to change the current page size (results per page)
|
||||
* @param {Event} event Change event containing the new page size value
|
||||
*/
|
||||
reloadRPP(event: Event) {
|
||||
const value = (event.target as HTMLInputElement).value;
|
||||
const navigationExtras: NavigationExtras = {
|
||||
queryParams: Object.assign({}, this.currentParams, {
|
||||
pageSize: value
|
||||
})
|
||||
queryParams: {
|
||||
pageSize: value,
|
||||
page: 1
|
||||
},
|
||||
queryParamsHandling: 'merge'
|
||||
};
|
||||
this.router.navigate([ '/search' ], navigationExtras);
|
||||
}
|
||||
|
||||
/**
|
||||
* Method to change the current sort field and direction
|
||||
* @param {Event} event Change event containing the sort direction and sort field
|
||||
*/
|
||||
reloadOrder(event: Event) {
|
||||
const value = (event.target as HTMLInputElement).value;
|
||||
const values = (event.target as HTMLInputElement).value.split(',');
|
||||
const navigationExtras: NavigationExtras = {
|
||||
queryParams: Object.assign({}, this.currentParams, {
|
||||
sortDirection: value
|
||||
})
|
||||
queryParams: {
|
||||
sortDirection: values[1],
|
||||
sortField: values[0],
|
||||
page: 1
|
||||
},
|
||||
queryParamsHandling: 'merge'
|
||||
};
|
||||
this.router.navigate([ '/search' ], navigationExtras);
|
||||
}
|
||||
|
@@ -17,14 +17,23 @@ export const SearchSidebarActionTypes = {
|
||||
};
|
||||
|
||||
/* tslint:disable:max-classes-per-file */
|
||||
/**
|
||||
* Used to collapse the sidebar
|
||||
*/
|
||||
export class SearchSidebarCollapseAction implements Action {
|
||||
type = SearchSidebarActionTypes.COLLAPSE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Used to expand the sidebar
|
||||
*/
|
||||
export class SearchSidebarExpandAction implements Action {
|
||||
type = SearchSidebarActionTypes.EXPAND;
|
||||
}
|
||||
|
||||
/**
|
||||
* Used to collapse the sidebar when it's expanded and expand it when it's collapsed
|
||||
*/
|
||||
export class SearchSidebarToggleAction implements Action {
|
||||
type = SearchSidebarActionTypes.TOGGLE;
|
||||
}
|
||||
|
@@ -12,7 +12,18 @@ import { Component, EventEmitter, Input, Output } from '@angular/core';
|
||||
templateUrl: './search-sidebar.component.html',
|
||||
})
|
||||
|
||||
/**
|
||||
* Component representing the sidebar on the search page
|
||||
*/
|
||||
export class SearchSidebarComponent {
|
||||
|
||||
/**
|
||||
* The total amount of results
|
||||
*/
|
||||
@Input() resultCount;
|
||||
|
||||
/**
|
||||
* Emits event when the user clicks a button to open or close the sidebar
|
||||
*/
|
||||
@Output() toggleSidebar = new EventEmitter<boolean>();
|
||||
}
|
||||
|
@@ -5,6 +5,9 @@ import * as fromRouter from '@ngrx/router-store';
|
||||
import { SearchSidebarCollapseAction } from './search-sidebar.actions';
|
||||
import { URLBaser } from '../../core/url-baser/url-baser';
|
||||
|
||||
/**
|
||||
* Makes sure that if the user navigates to another route, the sidebar is collapsed
|
||||
*/
|
||||
@Injectable()
|
||||
export class SearchSidebarEffects {
|
||||
private previousPath: string;
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user