mirror of
https://github.com/DSpace/dspace-angular.git
synced 2025-10-08 18:44:14 +00:00
Merge branch 'master' into entities-master
Conflicts: src/app/+item-page/full/full-item-page.component.ts src/app/+item-page/simple/item-page.component.html src/app/+item-page/simple/item-page.component.ts src/app/+search-page/search-filters/search-filter/search-filter.service.spec.ts src/app/+search-page/search-filters/search-filter/search-filter.service.ts src/app/+search-page/search-options.model.ts src/app/+search-page/search-page.component.html src/app/+search-page/search-page.component.ts src/app/+search-page/search-page.module.ts src/app/+search-page/search-results/search-results.component.ts src/app/+search-page/search-service/search.service.spec.ts src/app/+search-page/search-service/search.service.ts src/app/+search-page/search-settings/search-settings.component.ts src/app/core/cache/response-cache.models.ts src/app/core/core.module.ts src/app/core/data/search-response-parsing.service.ts src/app/core/shared/operators.ts src/app/core/shared/resource-type.ts src/app/shared/object-collection/object-collection.component.spec.ts src/app/shared/object-collection/object-collection.component.ts src/app/shared/object-collection/shared/dso-element-decorator.spec.ts src/app/shared/object-collection/shared/dso-element-decorator.ts src/app/shared/object-grid/collection-grid-element/collection-grid-element.component.ts src/app/shared/object-grid/community-grid-element/community-grid-element.component.ts src/app/shared/object-grid/item-grid-element/item-grid-element.component.ts src/app/shared/object-grid/search-result-grid-element/collection-search-result/collection-search-result-grid-element.component.ts src/app/shared/object-grid/search-result-grid-element/community-search-result/community-search-result-grid-element.component.ts src/app/shared/object-grid/search-result-grid-element/item-search-result/item-search-result-grid-element.component.ts src/app/shared/object-grid/wrapper-grid-element/wrapper-grid-element.component.ts src/app/shared/object-list/collection-list-element/collection-list-element.component.ts src/app/shared/object-list/community-list-element/community-list-element.component.ts src/app/shared/object-list/item-list-element/item-list-element.component.html src/app/shared/object-list/item-list-element/item-list-element.component.ts src/app/shared/object-list/search-result-list-element/collection-search-result/collection-search-result-list-element.component.ts src/app/shared/object-list/search-result-list-element/community-search-result/community-search-result-list-element.component.ts src/app/shared/object-list/search-result-list-element/item-search-result/item-search-result-list-element.component.html src/app/shared/object-list/search-result-list-element/item-search-result/item-search-result-list-element.component.ts src/app/shared/object-list/wrapper-list-element/wrapper-list-element.component.ts src/app/shared/services/route.service.ts src/app/shared/shared.module.ts src/app/shared/testing/hal-endpoint-service-stub.ts src/app/shared/testing/search-service-stub.ts src/app/shared/view-mode-switch/view-mode-switch.component.spec.ts src/app/shared/view-mode-switch/view-mode-switch.component.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,8 +10,8 @@ addons:
|
|||||||
language: node_js
|
language: node_js
|
||||||
|
|
||||||
node_js:
|
node_js:
|
||||||
- "6"
|
|
||||||
- "8"
|
- "8"
|
||||||
|
- "9"
|
||||||
|
|
||||||
cache:
|
cache:
|
||||||
yarn: true
|
yarn: true
|
||||||
|
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
|
@@ -14,7 +14,7 @@ If you're looking for the 2016 Angular 2 DSpace UI prototype, you can find it [h
|
|||||||
Quick start
|
Quick start
|
||||||
-----------
|
-----------
|
||||||
|
|
||||||
**Ensure you're running [Node](https://nodejs.org) >= `v6.9.x`, [npm](https://www.npmjs.com/) >= `v3.x` and [yarn](https://yarnpkg.com) >= `v0.20.x`**
|
**Ensure you're running [Node](https://nodejs.org) >= `v8.0.x`, [npm](https://www.npmjs.com/) >= `v3.x` and [yarn](https://yarnpkg.com) >= `v0.20.x`**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# clone the repo
|
# clone the repo
|
||||||
@@ -23,9 +23,6 @@ git clone https://github.com/DSpace/dspace-angular.git
|
|||||||
# change directory to our repo
|
# change directory to our repo
|
||||||
cd dspace-angular
|
cd dspace-angular
|
||||||
|
|
||||||
# install the global dependencies
|
|
||||||
yarn run global
|
|
||||||
|
|
||||||
# install the local dependencies
|
# install the local dependencies
|
||||||
yarn install
|
yarn install
|
||||||
|
|
||||||
|
@@ -22,6 +22,14 @@ module.exports = {
|
|||||||
// msToLive: 1000, // 15 minutes
|
// msToLive: 1000, // 15 minutes
|
||||||
control: 'max-age=60' // revalidate browser
|
control: 'max-age=60' // revalidate browser
|
||||||
},
|
},
|
||||||
|
// Form settings
|
||||||
|
form: {
|
||||||
|
// NOTE: Map server-side validators to comparative Angular form validators
|
||||||
|
validatorMap: {
|
||||||
|
required: 'required',
|
||||||
|
regex: 'pattern'
|
||||||
|
}
|
||||||
|
},
|
||||||
// Notifications
|
// Notifications
|
||||||
notifications: {
|
notifications: {
|
||||||
rtl: false,
|
rtl: false,
|
||||||
|
22
package.json
22
package.json
@@ -8,7 +8,7 @@
|
|||||||
},
|
},
|
||||||
"license": "BSD-2-Clause",
|
"license": "BSD-2-Clause",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.0.0"
|
"node": ">=8.0.0"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"global": "npm install -g @angular/cli marked node-gyp nodemon node-nightly npm-check-updates npm-run-all rimraf typescript ts-node typedoc webpack webpack-bundle-analyzer pm2 rollup",
|
"global": "npm install -g @angular/cli marked node-gyp nodemon node-nightly npm-check-updates npm-run-all rimraf typescript ts-node typedoc webpack webpack-bundle-analyzer pm2 rollup",
|
||||||
@@ -80,16 +80,22 @@
|
|||||||
"@angular/router": "^5.2.5",
|
"@angular/router": "^5.2.5",
|
||||||
"@angularclass/bootloader": "1.0.1",
|
"@angularclass/bootloader": "1.0.1",
|
||||||
"@ng-bootstrap/ng-bootstrap": "^1.0.0",
|
"@ng-bootstrap/ng-bootstrap": "^1.0.0",
|
||||||
|
"@ng-dynamic-forms/core": "5.4.7",
|
||||||
|
"@ng-dynamic-forms/ui-ng-bootstrap": "5.4.7",
|
||||||
"@ngrx/effects": "^5.1.0",
|
"@ngrx/effects": "^5.1.0",
|
||||||
"@ngrx/router-store": "^5.0.1",
|
"@ngrx/router-store": "^5.0.1",
|
||||||
"@ngrx/store": "^5.1.0",
|
"@ngrx/store": "^5.1.0",
|
||||||
"@nguniversal/express-engine": "5.0.0-beta.5",
|
"@nguniversal/express-engine": "5.0.0",
|
||||||
"@ngx-translate/core": "9.1.1",
|
"@ngx-translate/core": "9.1.1",
|
||||||
"@ngx-translate/http-loader": "2.0.1",
|
"@ngx-translate/http-loader": "2.0.1",
|
||||||
|
"@nicky-lenaers/ngx-scroll-to": "^0.6.0",
|
||||||
"angular-idle-preload": "2.0.4",
|
"angular-idle-preload": "2.0.4",
|
||||||
|
"angular2-moment": "^1.9.0",
|
||||||
|
"angular-sortablejs": "^2.5.0",
|
||||||
|
"angular2-text-mask": "8.0.4",
|
||||||
"angulartics2": "^5.2.0",
|
"angulartics2": "^5.2.0",
|
||||||
"body-parser": "1.18.2",
|
"body-parser": "1.18.2",
|
||||||
"bootstrap": "^4.0.0",
|
"bootstrap": "4.1.1",
|
||||||
"cerialize": "0.1.18",
|
"cerialize": "0.1.18",
|
||||||
"compression": "1.7.1",
|
"compression": "1.7.1",
|
||||||
"cookie-parser": "1.4.3",
|
"cookie-parser": "1.4.3",
|
||||||
@@ -99,14 +105,23 @@
|
|||||||
"font-awesome": "4.7.0",
|
"font-awesome": "4.7.0",
|
||||||
"http-server": "0.11.1",
|
"http-server": "0.11.1",
|
||||||
"https": "1.0.0",
|
"https": "1.0.0",
|
||||||
|
"js-cookie": "2.2.0",
|
||||||
"js.clone": "0.0.3",
|
"js.clone": "0.0.3",
|
||||||
"jsonschema": "1.2.2",
|
"jsonschema": "1.2.2",
|
||||||
|
"jwt-decode": "^2.2.0",
|
||||||
"methods": "1.1.2",
|
"methods": "1.1.2",
|
||||||
|
"moment": "^2.22.1",
|
||||||
"morgan": "1.9.0",
|
"morgan": "1.9.0",
|
||||||
|
"ng2-nouislider": "^1.7.11",
|
||||||
|
"ng2-file-upload": "1.2.1",
|
||||||
|
"ngx-infinite-scroll": "0.8.2",
|
||||||
"ngx-pagination": "3.0.3",
|
"ngx-pagination": "3.0.3",
|
||||||
|
"nouislider": "^11.0.0",
|
||||||
"pem": "1.12.3",
|
"pem": "1.12.3",
|
||||||
"reflect-metadata": "0.1.12",
|
"reflect-metadata": "0.1.12",
|
||||||
"rxjs": "5.5.6",
|
"rxjs": "5.5.6",
|
||||||
|
"sortablejs": "1.7.0",
|
||||||
|
"text-mask-core": "5.0.1",
|
||||||
"ts-md5": "^1.2.4",
|
"ts-md5": "^1.2.4",
|
||||||
"uuid": "^3.2.1",
|
"uuid": "^3.2.1",
|
||||||
"webfontloader": "1.6.28",
|
"webfontloader": "1.6.28",
|
||||||
@@ -124,6 +139,7 @@
|
|||||||
"@types/express-serve-static-core": "4.11.1",
|
"@types/express-serve-static-core": "4.11.1",
|
||||||
"@types/hammerjs": "2.0.35",
|
"@types/hammerjs": "2.0.35",
|
||||||
"@types/jasmine": "^2.8.6",
|
"@types/jasmine": "^2.8.6",
|
||||||
|
"@types/js-cookie": "2.1.0",
|
||||||
"@types/memory-cache": "0.2.0",
|
"@types/memory-cache": "0.2.0",
|
||||||
"@types/mime": "2.0.0",
|
"@types/mime": "2.0.0",
|
||||||
"@types/node": "^9.4.6",
|
"@types/node": "^9.4.6",
|
||||||
|
@@ -114,7 +114,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"nav": {
|
"nav": {
|
||||||
"home": "Home"
|
"home": "Home",
|
||||||
|
"login": "Log In",
|
||||||
|
"logout": "Log Out"
|
||||||
},
|
},
|
||||||
"pagination": {
|
"pagination": {
|
||||||
"results-per-page": "Results Per Page",
|
"results-per-page": "Results Per Page",
|
||||||
@@ -125,8 +127,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"sorting": {
|
"sorting": {
|
||||||
"ASC": "Ascending",
|
"score": {
|
||||||
"DESC": "Descending"
|
"DESC": "Relevance"
|
||||||
|
},
|
||||||
|
"dc.title": {
|
||||||
|
"ASC": "Title Ascending",
|
||||||
|
"DESC": "Title Descending"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"title": "DSpace",
|
"title": "DSpace",
|
||||||
"404": {
|
"404": {
|
||||||
@@ -177,13 +184,13 @@
|
|||||||
"close": "Back to results",
|
"close": "Back to results",
|
||||||
"open": "Search Tools",
|
"open": "Search Tools",
|
||||||
"results": "results",
|
"results": "results",
|
||||||
"filters":{
|
"filters": {
|
||||||
"title":"Filters"
|
"title": "Filters"
|
||||||
},
|
},
|
||||||
"settings":{
|
"settings": {
|
||||||
"title":"Settings",
|
"title": "Settings",
|
||||||
"sort-by":"Sort By",
|
"sort-by": "Sort By",
|
||||||
"rpp":"Results per page"
|
"rpp": "Results per page"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"view-switch": {
|
"view-switch": {
|
||||||
@@ -193,6 +200,13 @@
|
|||||||
"filters": {
|
"filters": {
|
||||||
"head": "Filters",
|
"head": "Filters",
|
||||||
"reset": "Reset 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": {
|
"filter": {
|
||||||
"show-more": "Show more",
|
"show-more": "Show more",
|
||||||
"show-less": "Collapse",
|
"show-less": "Collapse",
|
||||||
@@ -209,11 +223,15 @@
|
|||||||
"head": "Subject"
|
"head": "Subject"
|
||||||
},
|
},
|
||||||
"dateIssued": {
|
"dateIssued": {
|
||||||
"placeholder": "Date",
|
"max": {
|
||||||
|
"placeholder": "Minimum Date"
|
||||||
|
},
|
||||||
|
"min": {
|
||||||
|
"placeholder": "Maximum Date"
|
||||||
|
},
|
||||||
"head": "Date"
|
"head": "Date"
|
||||||
},
|
},
|
||||||
"has_content_in_original_bundle": {
|
"has_content_in_original_bundle": {
|
||||||
"placeholder": "Has files",
|
|
||||||
"head": "Has files"
|
"head": "Has files"
|
||||||
},
|
},
|
||||||
"entityType": {
|
"entityType": {
|
||||||
@@ -223,6 +241,55 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"admin": {
|
||||||
|
"registries": {
|
||||||
|
"metadata": {
|
||||||
|
"title": "DSpace Angular :: Metadata Registry",
|
||||||
|
"head": "Metadata Registry",
|
||||||
|
"description": "The metadata registry maintains a list of all metadata fields available in the repository. These fields may be divided amongst multiple schemas. However, DSpace requires the qualified Dublin Core schema.",
|
||||||
|
"schemas": {
|
||||||
|
"table": {
|
||||||
|
"id": "ID",
|
||||||
|
"namespace": "Namespace",
|
||||||
|
"name": "Name"
|
||||||
|
},
|
||||||
|
"no-items": "No metadata schemas to show."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"schema": {
|
||||||
|
"title": "DSpace Angular :: Metadata Schema Registry",
|
||||||
|
"head": "Metadata Schema",
|
||||||
|
"description": "This is the metadata schema for \"{{namespace}}\".",
|
||||||
|
"fields": {
|
||||||
|
"head": "Schema metadata fields",
|
||||||
|
"table": {
|
||||||
|
"field": "Field",
|
||||||
|
"scopenote": "Scope Note"
|
||||||
|
},
|
||||||
|
"no-items": "No metadata fields to show."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"bitstream-formats": {
|
||||||
|
"title": "DSpace Angular :: Bitstream Format Registry",
|
||||||
|
"head": "Bitstream Format Registry",
|
||||||
|
"description": "This list of bitstream formats provides information about known formats and their support level.",
|
||||||
|
"formats": {
|
||||||
|
"table": {
|
||||||
|
"name": "Name",
|
||||||
|
"mimetype": "MIME Type",
|
||||||
|
"supportLevel": {
|
||||||
|
"head": "Support Level",
|
||||||
|
"0": "Unknown",
|
||||||
|
"1": "Known",
|
||||||
|
"2": "Support"
|
||||||
|
},
|
||||||
|
"internal": "internal"
|
||||||
|
},
|
||||||
|
"no-items": "No bitstream formats to show."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"loading": {
|
"loading": {
|
||||||
"default": "Loading...",
|
"default": "Loading...",
|
||||||
"top-level-communities": "Loading top level communities...",
|
"top-level-communities": "Loading top level communities...",
|
||||||
@@ -243,6 +310,53 @@
|
|||||||
"recent-submissions": "Error fetching recent submissions",
|
"recent-submissions": "Error fetching recent submissions",
|
||||||
"item": "Error fetching item",
|
"item": "Error fetching item",
|
||||||
"objects": "Error fetching objects",
|
"objects": "Error fetching objects",
|
||||||
"search-results": "Error fetching search results"
|
"search-results": "Error fetching search results",
|
||||||
|
"validation": {
|
||||||
|
"pattern": "This input is restricted by the current pattern: {{ pattern }}.",
|
||||||
|
"license": {
|
||||||
|
"notgranted": "You must grant this license to complete your submission. If you are unable to grant this license at this time you may save your work and return later or remove the submission."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"form": {
|
||||||
|
"submit": "Submit",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"search": "Search",
|
||||||
|
"remove": "Remove",
|
||||||
|
"first-name": "First name",
|
||||||
|
"last-name": "Last name",
|
||||||
|
"loading": "Loading...",
|
||||||
|
"no-results": "No results found",
|
||||||
|
"no-value": "No value entered",
|
||||||
|
"group-collapse": "Collapse",
|
||||||
|
"group-expand": "Expand",
|
||||||
|
"group-collapse-help": "Click here to collapse",
|
||||||
|
"group-expand-help": "Click here to expand and add more element"
|
||||||
|
},
|
||||||
|
"login": {
|
||||||
|
"title": "Login",
|
||||||
|
"form": {
|
||||||
|
"header": "Please log in to DSpace",
|
||||||
|
"email": "Email address",
|
||||||
|
"forgot-password": "Have you forgotten your password?",
|
||||||
|
"new-user": "New user? Click here to register.",
|
||||||
|
"password": "Password",
|
||||||
|
"submit": "Log in"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"logout": {
|
||||||
|
"title": "Logout",
|
||||||
|
"form": {
|
||||||
|
"header": "Log out from DSpace",
|
||||||
|
"submit": "Log out"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"auth": {
|
||||||
|
"messages": {
|
||||||
|
"expired": "Your session has expired. Please log in again."
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"invalid-user": "Invalid email or password."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -0,0 +1,18 @@
|
|||||||
|
import { MetadataRegistryComponent } from './metadata-registry/metadata-registry.component';
|
||||||
|
import { RouterModule } from '@angular/router';
|
||||||
|
import { NgModule } from '@angular/core';
|
||||||
|
import { MetadataSchemaComponent } from './metadata-schema/metadata-schema.component';
|
||||||
|
import { BitstreamFormatsComponent } from './bitstream-formats/bitstream-formats.component';
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
imports: [
|
||||||
|
RouterModule.forChild([
|
||||||
|
{ path: 'metadata', component: MetadataRegistryComponent, data: { title: 'admin.registries.metadata.title' } },
|
||||||
|
{ path: 'metadata/:schemaName', component: MetadataSchemaComponent, data: { title: 'admin.registries.schema.title' } },
|
||||||
|
{ path: 'bitstream-formats', component: BitstreamFormatsComponent, data: { title: 'admin.registries.bitstream-formats.title' } },
|
||||||
|
])
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class AdminRegistriesRoutingModule {
|
||||||
|
|
||||||
|
}
|
27
src/app/+admin/admin-registries/admin-registries.module.ts
Normal file
27
src/app/+admin/admin-registries/admin-registries.module.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { NgModule } from '@angular/core';
|
||||||
|
import { MetadataRegistryComponent } from './metadata-registry/metadata-registry.component';
|
||||||
|
import { AdminRegistriesRoutingModule } from './admin-registries-routing.module';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { MetadataSchemaComponent } from './metadata-schema/metadata-schema.component';
|
||||||
|
import { RouterModule } from '@angular/router';
|
||||||
|
import { TranslateModule } from '@ngx-translate/core';
|
||||||
|
import { BitstreamFormatsComponent } from './bitstream-formats/bitstream-formats.component';
|
||||||
|
import { SharedModule } from '../../shared/shared.module';
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
SharedModule,
|
||||||
|
RouterModule,
|
||||||
|
TranslateModule,
|
||||||
|
AdminRegistriesRoutingModule
|
||||||
|
],
|
||||||
|
declarations: [
|
||||||
|
MetadataRegistryComponent,
|
||||||
|
MetadataSchemaComponent,
|
||||||
|
BitstreamFormatsComponent
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class AdminRegistriesModule {
|
||||||
|
|
||||||
|
}
|
@@ -0,0 +1,42 @@
|
|||||||
|
<div class="container">
|
||||||
|
<div class="bitstream-formats row">
|
||||||
|
<div class="col-12">
|
||||||
|
|
||||||
|
<h2 id="header" class="border-bottom pb-2">{{'admin.registries.bitstream-formats.head' | translate}}</h2>
|
||||||
|
|
||||||
|
<p id="description" class="pb-2">{{'admin.registries.bitstream-formats.description' | translate}}</p>
|
||||||
|
|
||||||
|
<ds-pagination
|
||||||
|
*ngIf="(bitstreamFormats | async)?.payload?.totalElements > 0"
|
||||||
|
[paginationOptions]="config"
|
||||||
|
[pageInfoState]="(bitstreamFormats | async)?.payload"
|
||||||
|
[collectionSize]="(bitstreamFormats | async)?.payload?.totalElements"
|
||||||
|
[hideGear]="true"
|
||||||
|
[hidePagerWhenSinglePage]="true"
|
||||||
|
(pageChange)="onPageChange($event)">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table id="formats" class="table table-striped table-hover">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">{{'admin.registries.bitstream-formats.formats.table.name' | translate}}</th>
|
||||||
|
<th scope="col">{{'admin.registries.bitstream-formats.formats.table.mimetype' | translate}}</th>
|
||||||
|
<th scope="col">{{'admin.registries.bitstream-formats.formats.table.supportLevel.head' | translate}}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr *ngFor="let bitstreamFormat of (bitstreamFormats | async)?.payload?.page">
|
||||||
|
<td>{{bitstreamFormat.shortDescription}}</td>
|
||||||
|
<td>{{bitstreamFormat.mimetype}} <span *ngIf="bitstreamFormat.internal">({{'admin.registries.bitstream-formats.formats.table.internal' | translate}})</span></td>
|
||||||
|
<td>{{'admin.registries.bitstream-formats.formats.table.supportLevel.'+bitstreamFormat.supportLevel | translate}}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</ds-pagination>
|
||||||
|
<div *ngIf="(bitstreamFormats | async)?.payload?.totalElements == 0" class="alert alert-info" role="alert">
|
||||||
|
{{'admin.registries.bitstream-formats.formats.no-items' | translate}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
@@ -0,0 +1,98 @@
|
|||||||
|
import { BitstreamFormatsComponent } from './bitstream-formats.component';
|
||||||
|
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
import { RegistryService } from '../../../core/registry/registry.service';
|
||||||
|
import { Observable } from 'rxjs/Observable';
|
||||||
|
import { RemoteData } from '../../../core/data/remote-data';
|
||||||
|
import { PaginatedList } from '../../../core/data/paginated-list';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { RouterTestingModule } from '@angular/router/testing';
|
||||||
|
import { TranslateModule } from '@ngx-translate/core';
|
||||||
|
import { By } from '@angular/platform-browser';
|
||||||
|
import { SharedModule } from '../../../shared/shared.module';
|
||||||
|
import { PaginationComponent } from '../../../shared/pagination/pagination.component';
|
||||||
|
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
|
||||||
|
import { EnumKeysPipe } from '../../../shared/utils/enum-keys-pipe';
|
||||||
|
import { HostWindowService } from '../../../shared/host-window.service';
|
||||||
|
import { HostWindowServiceStub } from '../../../shared/testing/host-window-service-stub';
|
||||||
|
|
||||||
|
describe('BitstreamFormatsComponent', () => {
|
||||||
|
let comp: BitstreamFormatsComponent;
|
||||||
|
let fixture: ComponentFixture<BitstreamFormatsComponent>;
|
||||||
|
let registryService: RegistryService;
|
||||||
|
const mockFormatsList = [
|
||||||
|
{
|
||||||
|
shortDescription: 'Unknown',
|
||||||
|
description: 'Unknown data format',
|
||||||
|
mimetype: 'application/octet-stream',
|
||||||
|
supportLevel: 0,
|
||||||
|
internal: false,
|
||||||
|
extensions: null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
shortDescription: 'License',
|
||||||
|
description: 'Item-specific license agreed upon to submission',
|
||||||
|
mimetype: 'text/plain; charset=utf-8',
|
||||||
|
supportLevel: 1,
|
||||||
|
internal: true,
|
||||||
|
extensions: null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
shortDescription: 'CC License',
|
||||||
|
description: 'Item-specific Creative Commons license agreed upon to submission',
|
||||||
|
mimetype: 'text/html; charset=utf-8',
|
||||||
|
supportLevel: 2,
|
||||||
|
internal: true,
|
||||||
|
extensions: null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
shortDescription: 'Adobe PDF',
|
||||||
|
description: 'Adobe Portable Document Format',
|
||||||
|
mimetype: 'application/pdf',
|
||||||
|
supportLevel: 0,
|
||||||
|
internal: false,
|
||||||
|
extensions: null
|
||||||
|
}
|
||||||
|
];
|
||||||
|
const mockFormats = Observable.of(new RemoteData(false, false, true, undefined, new PaginatedList(null, mockFormatsList)));
|
||||||
|
const registryServiceStub = {
|
||||||
|
getBitstreamFormats: () => mockFormats
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(async(() => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
imports: [CommonModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule.forRoot()],
|
||||||
|
declarations: [BitstreamFormatsComponent, PaginationComponent, EnumKeysPipe],
|
||||||
|
providers: [
|
||||||
|
{ provide: RegistryService, useValue: registryServiceStub },
|
||||||
|
{ provide: HostWindowService, useValue: new HostWindowServiceStub(0) }
|
||||||
|
]
|
||||||
|
}).compileComponents();
|
||||||
|
}));
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fixture = TestBed.createComponent(BitstreamFormatsComponent);
|
||||||
|
comp = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
registryService = (comp as any).service;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should contain four formats', () => {
|
||||||
|
const tbody: HTMLElement = fixture.debugElement.query(By.css('#formats>tbody')).nativeElement;
|
||||||
|
expect(tbody.children.length).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should contain the correct formats', () => {
|
||||||
|
const unknownName: HTMLElement = fixture.debugElement.query(By.css('#formats tr:nth-child(1) td:nth-child(1)')).nativeElement;
|
||||||
|
expect(unknownName.textContent).toBe('Unknown');
|
||||||
|
|
||||||
|
const licenseName: HTMLElement = fixture.debugElement.query(By.css('#formats tr:nth-child(2) td:nth-child(1)')).nativeElement;
|
||||||
|
expect(licenseName.textContent).toBe('License');
|
||||||
|
|
||||||
|
const ccLicenseName: HTMLElement = fixture.debugElement.query(By.css('#formats tr:nth-child(3) td:nth-child(1)')).nativeElement;
|
||||||
|
expect(ccLicenseName.textContent).toBe('CC License');
|
||||||
|
|
||||||
|
const adobeName: HTMLElement = fixture.debugElement.query(By.css('#formats tr:nth-child(4) td:nth-child(1)')).nativeElement;
|
||||||
|
expect(adobeName.textContent).toBe('Adobe PDF');
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
@@ -0,0 +1,33 @@
|
|||||||
|
import { Component } from '@angular/core';
|
||||||
|
import { RegistryService } from '../../../core/registry/registry.service';
|
||||||
|
import { Observable } from 'rxjs/Observable';
|
||||||
|
import { RemoteData } from '../../../core/data/remote-data';
|
||||||
|
import { PaginatedList } from '../../../core/data/paginated-list';
|
||||||
|
import { BitstreamFormat } from '../../../core/registry/mock-bitstream-format.model';
|
||||||
|
import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'ds-bitstream-formats',
|
||||||
|
templateUrl: './bitstream-formats.component.html'
|
||||||
|
})
|
||||||
|
export class BitstreamFormatsComponent {
|
||||||
|
|
||||||
|
bitstreamFormats: Observable<RemoteData<PaginatedList<BitstreamFormat>>>;
|
||||||
|
config: PaginationComponentOptions = Object.assign(new PaginationComponentOptions(), {
|
||||||
|
id: 'registry-bitstreamformats-pagination',
|
||||||
|
pageSize: 10000
|
||||||
|
});
|
||||||
|
|
||||||
|
constructor(private registryService: RegistryService) {
|
||||||
|
this.updateFormats();
|
||||||
|
}
|
||||||
|
|
||||||
|
onPageChange(event) {
|
||||||
|
this.config.currentPage = event;
|
||||||
|
this.updateFormats();
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateFormats() {
|
||||||
|
this.bitstreamFormats = this.registryService.getBitstreamFormats(this.config);
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,42 @@
|
|||||||
|
<div class="container">
|
||||||
|
<div class="metadata-registry row">
|
||||||
|
<div class="col-12">
|
||||||
|
|
||||||
|
<h2 id="header" class="border-bottom pb-2">{{'admin.registries.metadata.head' | translate}}</h2>
|
||||||
|
|
||||||
|
<p id="description" class="pb-2">{{'admin.registries.metadata.description' | translate}}</p>
|
||||||
|
|
||||||
|
<ds-pagination
|
||||||
|
*ngIf="(metadataSchemas | async)?.payload?.totalElements > 0"
|
||||||
|
[paginationOptions]="config"
|
||||||
|
[pageInfoState]="(metadataSchemas | async)?.payload"
|
||||||
|
[collectionSize]="(metadataSchemas | async)?.payload?.totalElements"
|
||||||
|
[hideGear]="true"
|
||||||
|
[hidePagerWhenSinglePage]="true"
|
||||||
|
(pageChange)="onPageChange($event)">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table id="metadata-schemas" class="table table-striped table-hover">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">{{'admin.registries.metadata.schemas.table.id' | translate}}</th>
|
||||||
|
<th scope="col">{{'admin.registries.metadata.schemas.table.namespace' | translate}}</th>
|
||||||
|
<th scope="col">{{'admin.registries.metadata.schemas.table.name' | translate}}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr *ngFor="let schema of (metadataSchemas | async)?.payload?.page">
|
||||||
|
<td><a [routerLink]="[schema.prefix]">{{schema.id}}</a></td>
|
||||||
|
<td><a [routerLink]="[schema.prefix]">{{schema.namespace}}</a></td>
|
||||||
|
<td><a [routerLink]="[schema.prefix]">{{schema.prefix}}</a></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</ds-pagination>
|
||||||
|
<div *ngIf="(metadataSchemas | async)?.payload?.totalElements == 0" class="alert alert-info" role="alert">
|
||||||
|
{{'admin.registries.metadata.schemas.no-items' | translate}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
@@ -0,0 +1,72 @@
|
|||||||
|
import { MetadataRegistryComponent } from './metadata-registry.component';
|
||||||
|
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
import { Observable } from 'rxjs/Observable';
|
||||||
|
import { RemoteData } from '../../../core/data/remote-data';
|
||||||
|
import { PaginatedList } from '../../../core/data/paginated-list';
|
||||||
|
import { TranslateModule } from '@ngx-translate/core';
|
||||||
|
import { By } from '@angular/platform-browser';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { RouterTestingModule } from '@angular/router/testing';
|
||||||
|
import { RegistryService } from '../../../core/registry/registry.service';
|
||||||
|
import { SharedModule } from '../../../shared/shared.module';
|
||||||
|
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
|
||||||
|
import { EnumKeysPipe } from '../../../shared/utils/enum-keys-pipe';
|
||||||
|
import { PaginationComponent } from '../../../shared/pagination/pagination.component';
|
||||||
|
import { HostWindowServiceStub } from '../../../shared/testing/host-window-service-stub';
|
||||||
|
import { HostWindowService } from '../../../shared/host-window.service';
|
||||||
|
|
||||||
|
describe('MetadataRegistryComponent', () => {
|
||||||
|
let comp: MetadataRegistryComponent;
|
||||||
|
let fixture: ComponentFixture<MetadataRegistryComponent>;
|
||||||
|
let registryService: RegistryService;
|
||||||
|
const mockSchemasList = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
self: 'https://dspace7.4science.it/dspace-spring-rest/api/core/metadataschemas/1',
|
||||||
|
prefix: 'dc',
|
||||||
|
namespace: 'http://dublincore.org/documents/dcmi-terms/'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
self: 'https://dspace7.4science.it/dspace-spring-rest/api/core/metadataschemas/2',
|
||||||
|
prefix: 'mock',
|
||||||
|
namespace: 'http://dspace.org/mockschema'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
const mockSchemas = Observable.of(new RemoteData(false, false, true, undefined, new PaginatedList(null, mockSchemasList)));
|
||||||
|
const registryServiceStub = {
|
||||||
|
getMetadataSchemas: () => mockSchemas
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(async(() => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
imports: [CommonModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule.forRoot()],
|
||||||
|
declarations: [MetadataRegistryComponent, PaginationComponent, EnumKeysPipe],
|
||||||
|
providers: [
|
||||||
|
{ provide: RegistryService, useValue: registryServiceStub },
|
||||||
|
{ provide: HostWindowService, useValue: new HostWindowServiceStub(0) }
|
||||||
|
]
|
||||||
|
}).compileComponents();
|
||||||
|
}));
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fixture = TestBed.createComponent(MetadataRegistryComponent);
|
||||||
|
comp = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
registryService = (comp as any).service;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should contain two schemas', () => {
|
||||||
|
const tbody: HTMLElement = fixture.debugElement.query(By.css('#metadata-schemas>tbody')).nativeElement;
|
||||||
|
expect(tbody.children.length).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should contain the correct schemas', () => {
|
||||||
|
const dcName: HTMLElement = fixture.debugElement.query(By.css('#metadata-schemas tr:nth-child(1) td:nth-child(3)')).nativeElement;
|
||||||
|
expect(dcName.textContent).toBe('dc');
|
||||||
|
|
||||||
|
const mockName: HTMLElement = fixture.debugElement.query(By.css('#metadata-schemas tr:nth-child(2) td:nth-child(3)')).nativeElement;
|
||||||
|
expect(mockName.textContent).toBe('mock');
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
@@ -0,0 +1,34 @@
|
|||||||
|
import { Component } from '@angular/core';
|
||||||
|
import { RegistryService } from '../../../core/registry/registry.service';
|
||||||
|
import { Observable } from 'rxjs/Observable';
|
||||||
|
import { RemoteData } from '../../../core/data/remote-data';
|
||||||
|
import { PaginatedList } from '../../../core/data/paginated-list';
|
||||||
|
import { MetadataSchema } from '../../../core/metadata/metadataschema.model';
|
||||||
|
import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'ds-metadata-registry',
|
||||||
|
templateUrl: './metadata-registry.component.html'
|
||||||
|
})
|
||||||
|
export class MetadataRegistryComponent {
|
||||||
|
|
||||||
|
metadataSchemas: Observable<RemoteData<PaginatedList<MetadataSchema>>>;
|
||||||
|
config: PaginationComponentOptions = Object.assign(new PaginationComponentOptions(), {
|
||||||
|
id: 'registry-metadataschemas-pagination',
|
||||||
|
pageSize: 10000
|
||||||
|
});
|
||||||
|
|
||||||
|
constructor(private registryService: RegistryService) {
|
||||||
|
this.updateSchemas();
|
||||||
|
}
|
||||||
|
|
||||||
|
onPageChange(event) {
|
||||||
|
this.config.currentPage = event;
|
||||||
|
this.updateSchemas();
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateSchemas() {
|
||||||
|
this.metadataSchemas = this.registryService.getMetadataSchemas(this.config);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@@ -0,0 +1,41 @@
|
|||||||
|
<div class="container">
|
||||||
|
<div class="metadata-schema row">
|
||||||
|
<div class="col-12">
|
||||||
|
|
||||||
|
<h2 id="header" class="border-bottom pb-2">{{'admin.registries.schema.head' | translate}}: "{{(metadataSchema | async)?.payload?.prefix}}"</h2>
|
||||||
|
|
||||||
|
<p id="description" class="pb-2">{{'admin.registries.schema.description' | translate:namespace }}</p>
|
||||||
|
|
||||||
|
<h3>{{'admin.registries.schema.fields.head' | translate}}</h3>
|
||||||
|
<ds-pagination
|
||||||
|
*ngIf="(metadataFields | async)?.payload?.totalElements > 0"
|
||||||
|
[paginationOptions]="config"
|
||||||
|
[pageInfoState]="(metadataFields | async)?.payload"
|
||||||
|
[collectionSize]="(metadataFields | async)?.payload?.totalElements"
|
||||||
|
[hideGear]="true"
|
||||||
|
[hidePagerWhenSinglePage]="true"
|
||||||
|
(pageChange)="onPageChange($event)">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table id="metadata-fields" class="table table-striped table-hover">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">{{'admin.registries.schema.fields.table.field' | translate}}</th>
|
||||||
|
<th scope="col">{{'admin.registries.schema.fields.table.scopenote' | translate}}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr *ngFor="let field of (metadataFields | async)?.payload?.page">
|
||||||
|
<td>{{(metadataSchema | async)?.payload?.prefix}}.{{field.element}}<label *ngIf="field.qualifier">.</label>{{field.qualifier}}</td>
|
||||||
|
<td>{{field.scopeNote}}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</ds-pagination>
|
||||||
|
<div *ngIf="(metadataFields | async)?.payload?.totalElements == 0" class="alert alert-info" role="alert">
|
||||||
|
{{'admin.registries.schema.fields.no-items' | translate}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
@@ -0,0 +1,121 @@
|
|||||||
|
import { MetadataSchemaComponent } from './metadata-schema.component';
|
||||||
|
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
import { Observable } from 'rxjs/Observable';
|
||||||
|
import { RemoteData } from '../../../core/data/remote-data';
|
||||||
|
import { PaginatedList } from '../../../core/data/paginated-list';
|
||||||
|
import { MetadataSchema } from '../../../core/metadata/metadataschema.model';
|
||||||
|
import { TranslateLoader, TranslateModule } from '@ngx-translate/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
|
import { By } from '@angular/platform-browser';
|
||||||
|
import { MockTranslateLoader } from '../../../shared/testing/mock-translate-loader';
|
||||||
|
import { RegistryService } from '../../../core/registry/registry.service';
|
||||||
|
import { SharedModule } from '../../../shared/shared.module';
|
||||||
|
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
|
||||||
|
import { EnumKeysPipe } from '../../../shared/utils/enum-keys-pipe';
|
||||||
|
import { PaginationComponent } from '../../../shared/pagination/pagination.component';
|
||||||
|
import { HostWindowServiceStub } from '../../../shared/testing/host-window-service-stub';
|
||||||
|
import { HostWindowService } from '../../../shared/host-window.service';
|
||||||
|
import { RouterStub } from '../../../shared/testing/router-stub';
|
||||||
|
import { RouterTestingModule } from '@angular/router/testing';
|
||||||
|
import { ActivatedRouteStub } from '../../../shared/testing/active-router-stub';
|
||||||
|
|
||||||
|
describe('MetadataSchemaComponent', () => {
|
||||||
|
let comp: MetadataSchemaComponent;
|
||||||
|
let fixture: ComponentFixture<MetadataSchemaComponent>;
|
||||||
|
let registryService: RegistryService;
|
||||||
|
const mockSchemasList = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
self: 'https://dspace7.4science.it/dspace-spring-rest/api/core/metadataschemas/1',
|
||||||
|
prefix: 'dc',
|
||||||
|
namespace: 'http://dublincore.org/documents/dcmi-terms/'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
self: 'https://dspace7.4science.it/dspace-spring-rest/api/core/metadataschemas/2',
|
||||||
|
prefix: 'mock',
|
||||||
|
namespace: 'http://dspace.org/mockschema'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
const mockFieldsList = [
|
||||||
|
{
|
||||||
|
self: 'https://dspace7.4science.it/dspace-spring-rest/api/core/metadatafields/8',
|
||||||
|
element: 'contributor',
|
||||||
|
qualifier: 'advisor',
|
||||||
|
scopenote: null,
|
||||||
|
schema: mockSchemasList[0]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
self: 'https://dspace7.4science.it/dspace-spring-rest/api/core/metadatafields/9',
|
||||||
|
element: 'contributor',
|
||||||
|
qualifier: 'author',
|
||||||
|
scopenote: null,
|
||||||
|
schema: mockSchemasList[0]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
self: 'https://dspace7.4science.it/dspace-spring-rest/api/core/metadatafields/10',
|
||||||
|
element: 'contributor',
|
||||||
|
qualifier: 'editor',
|
||||||
|
scopenote: 'test scope note',
|
||||||
|
schema: mockSchemasList[1]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
self: 'https://dspace7.4science.it/dspace-spring-rest/api/core/metadatafields/11',
|
||||||
|
element: 'contributor',
|
||||||
|
qualifier: 'illustrator',
|
||||||
|
scopenote: null,
|
||||||
|
schema: mockSchemasList[1]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
const mockSchemas = Observable.of(new RemoteData(false, false, true, undefined, new PaginatedList(null, mockSchemasList)));
|
||||||
|
const registryServiceStub = {
|
||||||
|
getMetadataSchemas: () => mockSchemas,
|
||||||
|
getMetadataFieldsBySchema: (schema: MetadataSchema) => Observable.of(new RemoteData(false, false, true, undefined, new PaginatedList(null, mockFieldsList.filter((value) => value.schema === schema)))),
|
||||||
|
getMetadataSchemaByName: (schemaName: string) => Observable.of(new RemoteData(false, false, true, undefined, mockSchemasList.filter((value) => value.prefix === schemaName)[0]))
|
||||||
|
};
|
||||||
|
const schemaNameParam = 'mock';
|
||||||
|
const activatedRouteStub = Object.assign(new ActivatedRouteStub(), {
|
||||||
|
params: Observable.of({
|
||||||
|
schemaName: schemaNameParam
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async(() => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
imports: [CommonModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule.forRoot()],
|
||||||
|
declarations: [MetadataSchemaComponent, PaginationComponent, EnumKeysPipe],
|
||||||
|
providers: [
|
||||||
|
{ provide: RegistryService, useValue: registryServiceStub },
|
||||||
|
{ provide: ActivatedRoute, useValue: activatedRouteStub },
|
||||||
|
{ provide: HostWindowService, useValue: new HostWindowServiceStub(0) },
|
||||||
|
{ provide: Router, useValue: new RouterStub() }
|
||||||
|
]
|
||||||
|
}).compileComponents();
|
||||||
|
}));
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fixture = TestBed.createComponent(MetadataSchemaComponent);
|
||||||
|
comp = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
registryService = (comp as any).service;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should contain the schema prefix in the header', () => {
|
||||||
|
const header: HTMLElement = fixture.debugElement.query(By.css('.metadata-schema #header')).nativeElement;
|
||||||
|
expect(header.textContent).toContain('mock');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should contain two fields', () => {
|
||||||
|
const tbody: HTMLElement = fixture.debugElement.query(By.css('#metadata-fields>tbody')).nativeElement;
|
||||||
|
expect(tbody.children.length).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should contain the correct fields', () => {
|
||||||
|
const editorField: HTMLElement = fixture.debugElement.query(By.css('#metadata-fields tr:nth-child(1) td:nth-child(1)')).nativeElement;
|
||||||
|
expect(editorField.textContent).toBe('mock.contributor.editor');
|
||||||
|
|
||||||
|
const illustratorField: HTMLElement = fixture.debugElement.query(By.css('#metadata-fields tr:nth-child(2) td:nth-child(1)')).nativeElement;
|
||||||
|
expect(illustratorField.textContent).toBe('mock.contributor.illustrator');
|
||||||
|
});
|
||||||
|
});
|
@@ -0,0 +1,55 @@
|
|||||||
|
import { Component, OnInit } from '@angular/core';
|
||||||
|
import { RegistryService } from '../../../core/registry/registry.service';
|
||||||
|
import { ActivatedRoute } from '@angular/router';
|
||||||
|
import { Observable } from 'rxjs/Observable';
|
||||||
|
import { RemoteData } from '../../../core/data/remote-data';
|
||||||
|
import { PaginatedList } from '../../../core/data/paginated-list';
|
||||||
|
import { MetadataField } from '../../../core/metadata/metadatafield.model';
|
||||||
|
import { MetadataSchema } from '../../../core/metadata/metadataschema.model';
|
||||||
|
import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model';
|
||||||
|
import { SortOptions } from '../../../core/cache/models/sort-options.model';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'ds-metadata-schema',
|
||||||
|
templateUrl: './metadata-schema.component.html'
|
||||||
|
})
|
||||||
|
export class MetadataSchemaComponent implements OnInit {
|
||||||
|
|
||||||
|
namespace;
|
||||||
|
|
||||||
|
metadataSchema: Observable<RemoteData<MetadataSchema>>;
|
||||||
|
metadataFields: Observable<RemoteData<PaginatedList<MetadataField>>>;
|
||||||
|
config: PaginationComponentOptions = Object.assign(new PaginationComponentOptions(), {
|
||||||
|
id: 'registry-metadatafields-pagination',
|
||||||
|
pageSize: 10000
|
||||||
|
});
|
||||||
|
|
||||||
|
constructor(private registryService: RegistryService, private route: ActivatedRoute) {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.route.params.subscribe((params) => {
|
||||||
|
this.initialize(params);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
initialize(params) {
|
||||||
|
this.metadataSchema = this.registryService.getMetadataSchemaByName(params.schemaName);
|
||||||
|
this.updateFields();
|
||||||
|
}
|
||||||
|
|
||||||
|
onPageChange(event) {
|
||||||
|
this.config.currentPage = event;
|
||||||
|
this.updateFields();
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateFields() {
|
||||||
|
this.metadataSchema.subscribe((schemaData) => {
|
||||||
|
const schema = schemaData.payload;
|
||||||
|
this.metadataFields = this.registryService.getMetadataFieldsBySchema(schema, this.config);
|
||||||
|
this.namespace = { namespace: schemaData.payload.namespace };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
13
src/app/+admin/admin-routing.module.ts
Normal file
13
src/app/+admin/admin-routing.module.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { RouterModule } from '@angular/router';
|
||||||
|
import { NgModule } from '@angular/core';
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
imports: [
|
||||||
|
RouterModule.forChild([
|
||||||
|
{ path: 'registries', loadChildren: './admin-registries/admin-registries.module#AdminRegistriesModule' }
|
||||||
|
])
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class AdminRoutingModule {
|
||||||
|
|
||||||
|
}
|
13
src/app/+admin/admin.module.ts
Normal file
13
src/app/+admin/admin.module.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { NgModule } from '@angular/core';
|
||||||
|
import { AdminRegistriesModule } from './admin-registries/admin-registries.module';
|
||||||
|
import { AdminRoutingModule } from './admin-routing.module';
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
imports: [
|
||||||
|
AdminRegistriesModule,
|
||||||
|
AdminRoutingModule
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class AdminModule {
|
||||||
|
|
||||||
|
}
|
@@ -2,12 +2,23 @@ import { NgModule } from '@angular/core';
|
|||||||
import { RouterModule } from '@angular/router';
|
import { RouterModule } from '@angular/router';
|
||||||
|
|
||||||
import { CollectionPageComponent } from './collection-page.component';
|
import { CollectionPageComponent } from './collection-page.component';
|
||||||
|
import { CollectionPageResolver } from './collection-page.resolver';
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
imports: [
|
imports: [
|
||||||
RouterModule.forChild([
|
RouterModule.forChild([
|
||||||
{ path: ':id', component: CollectionPageComponent, pathMatch: 'full' }
|
{
|
||||||
|
path: ':id',
|
||||||
|
component: CollectionPageComponent,
|
||||||
|
pathMatch: 'full',
|
||||||
|
resolve: {
|
||||||
|
collection: CollectionPageResolver
|
||||||
|
}
|
||||||
|
}
|
||||||
])
|
])
|
||||||
|
],
|
||||||
|
providers: [
|
||||||
|
CollectionPageResolver,
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
export class CollectionPageRoutingModule {
|
export class CollectionPageRoutingModule {
|
||||||
|
@@ -1,6 +1,6 @@
|
|||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="collection-page"
|
<div class="collection-page"
|
||||||
*ngVar="(collectionRDObs | async) as collectionRD">
|
*ngVar="(collectionRD$ | async) as collectionRD">
|
||||||
<div *ngIf="collectionRD?.hasSucceeded" @fadeInOut>
|
<div *ngIf="collectionRD?.hasSucceeded" @fadeInOut>
|
||||||
<div *ngIf="collectionRD?.payload as collection">
|
<div *ngIf="collectionRD?.payload as collection">
|
||||||
<!-- Collection Name -->
|
<!-- Collection Name -->
|
||||||
@@ -8,8 +8,8 @@
|
|||||||
[name]="collection.name">
|
[name]="collection.name">
|
||||||
</ds-comcol-page-header>
|
</ds-comcol-page-header>
|
||||||
<!-- Collection logo -->
|
<!-- Collection logo -->
|
||||||
<ds-comcol-page-logo *ngIf="logoRDObs"
|
<ds-comcol-page-logo *ngIf="logoRD$"
|
||||||
[logo]="(logoRDObs | async)?.payload"
|
[logo]="(logoRD$ | async)?.payload"
|
||||||
[alternateText]="'Collection Logo'">
|
[alternateText]="'Collection Logo'">
|
||||||
</ds-comcol-page-logo>
|
</ds-comcol-page-logo>
|
||||||
<!-- Introductionary text -->
|
<!-- Introductionary text -->
|
||||||
@@ -38,14 +38,14 @@
|
|||||||
<ds-error *ngIf="collectionRD?.hasFailed" message="{{'error.collection' | translate}}"></ds-error>
|
<ds-error *ngIf="collectionRD?.hasFailed" message="{{'error.collection' | translate}}"></ds-error>
|
||||||
<ds-loading *ngIf="collectionRD?.isLoading" message="{{'loading.collection' | translate}}"></ds-loading>
|
<ds-loading *ngIf="collectionRD?.isLoading" message="{{'loading.collection' | translate}}"></ds-loading>
|
||||||
<br>
|
<br>
|
||||||
<ng-container *ngVar="(itemRDObs | async) as itemRD">
|
<ng-container *ngVar="(itemRD$ | async) as itemRD">
|
||||||
<div *ngIf="itemRD?.hasSucceeded" @fadeIn>
|
<div *ngIf="itemRD?.hasSucceeded" @fadeIn>
|
||||||
<h2>{{'collection.page.browse.recent.head' | translate}}</h2>
|
<h2>{{'collection.page.browse.recent.head' | translate}}</h2>
|
||||||
<ds-viewable-collection
|
<ds-viewable-collection
|
||||||
[config]="paginationConfig"
|
[config]="paginationConfig"
|
||||||
[sortConfig]="sortConfig"
|
[sortConfig]="sortConfig"
|
||||||
[objects]="itemRD"
|
[objects]="itemRD"
|
||||||
[hideGear]="false"
|
[hideGear]="true"
|
||||||
(paginationChange)="onPaginationChange($event)">
|
(paginationChange)="onPaginationChange($event)">
|
||||||
</ds-viewable-collection>
|
</ds-viewable-collection>
|
||||||
</div>
|
</div>
|
||||||
|
@@ -5,7 +5,6 @@ import { Observable } from 'rxjs/Observable';
|
|||||||
import { Subscription } from 'rxjs/Subscription';
|
import { Subscription } from 'rxjs/Subscription';
|
||||||
import { SortDirection, SortOptions } from '../core/cache/models/sort-options.model';
|
import { SortDirection, SortOptions } from '../core/cache/models/sort-options.model';
|
||||||
import { CollectionDataService } from '../core/data/collection-data.service';
|
import { CollectionDataService } from '../core/data/collection-data.service';
|
||||||
import { ItemDataService } from '../core/data/item-data.service';
|
|
||||||
import { PaginatedList } from '../core/data/paginated-list';
|
import { PaginatedList } from '../core/data/paginated-list';
|
||||||
import { RemoteData } from '../core/data/remote-data';
|
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 { fadeIn, fadeInOut } from '../shared/animations/fade';
|
||||||
import { hasValue, isNotEmpty } from '../shared/empty.util';
|
import { hasValue, isNotEmpty } from '../shared/empty.util';
|
||||||
import { PaginationComponentOptions } from '../shared/pagination/pagination-component-options.model';
|
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({
|
@Component({
|
||||||
selector: 'ds-collection-page',
|
selector: 'ds-collection-page',
|
||||||
@@ -30,9 +34,9 @@ import { PaginationComponentOptions } from '../shared/pagination/pagination-comp
|
|||||||
]
|
]
|
||||||
})
|
})
|
||||||
export class CollectionPageComponent implements OnInit, OnDestroy {
|
export class CollectionPageComponent implements OnInit, OnDestroy {
|
||||||
collectionRDObs: Observable<RemoteData<Collection>>;
|
collectionRD$: Observable<RemoteData<Collection>>;
|
||||||
itemRDObs: Observable<RemoteData<PaginatedList<Item>>>;
|
itemRD$: Observable<RemoteData<PaginatedList<Item>>>;
|
||||||
logoRDObs: Observable<RemoteData<Bitstream>>;
|
logoRD$: Observable<RemoteData<Bitstream>>;
|
||||||
paginationConfig: PaginationComponentOptions;
|
paginationConfig: PaginationComponentOptions;
|
||||||
sortConfig: SortOptions;
|
sortConfig: SortOptions;
|
||||||
private subs: Subscription[] = [];
|
private subs: Subscription[] = [];
|
||||||
@@ -40,7 +44,7 @@ export class CollectionPageComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private collectionDataService: CollectionDataService,
|
private collectionDataService: CollectionDataService,
|
||||||
private itemDataService: ItemDataService,
|
private searchService: SearchService,
|
||||||
private metadata: MetadataService,
|
private metadata: MetadataService,
|
||||||
private route: ActivatedRoute
|
private route: ActivatedRoute
|
||||||
) {
|
) {
|
||||||
@@ -48,52 +52,41 @@ export class CollectionPageComponent implements OnInit, OnDestroy {
|
|||||||
this.paginationConfig.id = 'collection-page-pagination';
|
this.paginationConfig.id = 'collection-page-pagination';
|
||||||
this.paginationConfig.pageSize = 5;
|
this.paginationConfig.pageSize = 5;
|
||||||
this.paginationConfig.currentPage = 1;
|
this.paginationConfig.currentPage = 1;
|
||||||
this.sortConfig = new SortOptions('dc.title', SortDirection.ASC);
|
this.sortConfig = new SortOptions('dc.date.accessioned', SortDirection.DESC);
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnInit(): void {
|
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(
|
this.subs.push(
|
||||||
Observable.combineLatest(
|
this.route.queryParams.subscribe((params) => {
|
||||||
this.route.params,
|
this.metadata.processRemoteData(this.collectionRD$);
|
||||||
this.route.queryParams,
|
const page = +params.page || this.paginationConfig.currentPage;
|
||||||
(params, queryParams, ) => {
|
const pageSize = +params.pageSize || this.paginationConfig.pageSize;
|
||||||
return Object.assign({}, params, queryParams);
|
const pagination = Object.assign({},
|
||||||
})
|
this.paginationConfig,
|
||||||
.subscribe((params) => {
|
{ currentPage: page, pageSize: pageSize }
|
||||||
this.collectionId = params.id;
|
);
|
||||||
this.collectionRDObs = this.collectionDataService.findById(this.collectionId);
|
this.updatePage({
|
||||||
this.metadata.processRemoteData(this.collectionRDObs);
|
pagination: pagination,
|
||||||
this.subs.push(this.collectionRDObs
|
sort: this.sortConfig
|
||||||
.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
|
|
||||||
});
|
|
||||||
}));
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
updatePage(searchOptions) {
|
updatePage(searchOptions) {
|
||||||
this.itemRDObs = this.itemDataService.findAll({
|
this.itemRD$ = this.searchService.search(
|
||||||
scopeID: this.collectionId,
|
new PaginatedSearchOptions({
|
||||||
currentPage: searchOptions.pagination.currentPage,
|
scope: this.collectionId,
|
||||||
elementsPerPage: searchOptions.pagination.pageSize,
|
pagination: searchOptions.pagination,
|
||||||
sort: searchOptions.sort
|
sort: searchOptions.sort,
|
||||||
});
|
dsoType: DSpaceObjectType.ITEM
|
||||||
|
})).pipe(toDSpaceObjectListRD()) as Observable<RemoteData<PaginatedList<Item>>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
|
@@ -5,11 +5,13 @@ import { SharedModule } from '../shared/shared.module';
|
|||||||
|
|
||||||
import { CollectionPageComponent } from './collection-page.component';
|
import { CollectionPageComponent } from './collection-page.component';
|
||||||
import { CollectionPageRoutingModule } from './collection-page-routing.module';
|
import { CollectionPageRoutingModule } from './collection-page-routing.module';
|
||||||
|
import { SearchPageModule } from '../+search-page/search-page.module';
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
imports: [
|
imports: [
|
||||||
CommonModule,
|
CommonModule,
|
||||||
SharedModule,
|
SharedModule,
|
||||||
|
SearchPageModule,
|
||||||
CollectionPageRoutingModule
|
CollectionPageRoutingModule
|
||||||
],
|
],
|
||||||
declarations: [
|
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 { RouterModule } from '@angular/router';
|
||||||
|
|
||||||
import { CommunityPageComponent } from './community-page.component';
|
import { CommunityPageComponent } from './community-page.component';
|
||||||
|
import { CommunityPageResolver } from './community-page.resolver';
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
imports: [
|
imports: [
|
||||||
RouterModule.forChild([
|
RouterModule.forChild([
|
||||||
{ path: ':id', component: CommunityPageComponent, pathMatch: 'full' }
|
{
|
||||||
|
path: ':id',
|
||||||
|
component: CommunityPageComponent,
|
||||||
|
pathMatch: 'full',
|
||||||
|
resolve: {
|
||||||
|
community: CommunityPageResolver
|
||||||
|
}
|
||||||
|
}
|
||||||
])
|
])
|
||||||
|
],
|
||||||
|
providers: [
|
||||||
|
CommunityPageResolver,
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
export class CommunityPageRoutingModule {
|
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 class="community-page" *ngIf="communityRD?.hasSucceeded" @fadeInOut>
|
||||||
<div *ngIf="communityRD?.payload; let communityPayload">
|
<div *ngIf="communityRD?.payload; let communityPayload">
|
||||||
<!-- Community name -->
|
<!-- Community name -->
|
||||||
<ds-comcol-page-header [name]="communityPayload.name"></ds-comcol-page-header>
|
<ds-comcol-page-header [name]="communityPayload.name"></ds-comcol-page-header>
|
||||||
<!-- Community logo -->
|
<!-- Community logo -->
|
||||||
<ds-comcol-page-logo *ngIf="logoRDObs"
|
<ds-comcol-page-logo *ngIf="logoRD$"
|
||||||
[logo]="(logoRDObs | async)?.payload"
|
[logo]="(logoRD$ | async)?.payload"
|
||||||
[alternateText]="'Community Logo'">
|
[alternateText]="'Community Logo'">
|
||||||
</ds-comcol-page-logo>
|
</ds-comcol-page-logo>
|
||||||
<!-- Introductory text -->
|
<!-- Introductory text -->
|
||||||
|
@@ -22,8 +22,8 @@ import { Observable } from 'rxjs/Observable';
|
|||||||
animations: [fadeInOut]
|
animations: [fadeInOut]
|
||||||
})
|
})
|
||||||
export class CommunityPageComponent implements OnInit, OnDestroy {
|
export class CommunityPageComponent implements OnInit, OnDestroy {
|
||||||
communityRDObs: Observable<RemoteData<Community>>;
|
communityRD$: Observable<RemoteData<Community>>;
|
||||||
logoRDObs: Observable<RemoteData<Bitstream>>;
|
logoRD$: Observable<RemoteData<Bitstream>>;
|
||||||
private subs: Subscription[] = [];
|
private subs: Subscription[] = [];
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@@ -35,14 +35,11 @@ export class CommunityPageComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.route.params.subscribe((params: Params) => {
|
this.communityRD$ = this.route.data.map((data) => data.community);
|
||||||
this.communityRDObs = this.communityDataService.findById(params.id);
|
this.logoRD$ = this.communityRD$
|
||||||
this.metadata.processRemoteData(this.communityRDObs);
|
.map((rd: RemoteData<Community>) => rd.payload)
|
||||||
this.subs.push(this.communityRDObs
|
.filter((community: Community) => hasValue(community))
|
||||||
.map((rd: RemoteData<Community>) => rd.payload)
|
.flatMap((community: Community) => community.logo);
|
||||||
.filter((community: Community) => hasValue(community))
|
|
||||||
.subscribe((community: Community) => this.logoRDObs = community.logo));
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
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()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@@ -38,7 +38,7 @@ export class TopLevelCommunityListComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
updatePage(data) {
|
updatePage(data) {
|
||||||
this.communitiesRDObs = this.cds.findAll({
|
this.communitiesRDObs = this.cds.findTop({
|
||||||
currentPage: data.page,
|
currentPage: data.page,
|
||||||
elementsPerPage: data.pageSize,
|
elementsPerPage: data.pageSize,
|
||||||
sort: { field: data.sortField, direction: data.sortDirection }
|
sort: { field: data.sortField, direction: data.sortDirection }
|
||||||
|
@@ -60,7 +60,7 @@ describe('CollectionsComponent', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('When the requested item request has succeeded', () => {
|
describe('When the requested item request has failed', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
collectionsComponent.item = failedMockItem;
|
collectionsComponent.item = failedMockItem;
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
|
@@ -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 class="item-page" *ngIf="itemRD?.hasSucceeded" @fadeInOut>
|
||||||
<div *ngIf="itemRD?.payload as entity">
|
<div *ngIf="itemRD?.payload as entity">
|
||||||
<ds-item-page-title-field [item]="item"></ds-item-page-title-field>
|
<ds-item-page-title-field [item]="item"></ds-item-page-title-field>
|
||||||
<div class="simple-view-link">
|
<div class="simple-view-link my-3">
|
||||||
<a class="btn btn-outline-primary col-4" [routerLink]="['/items/' + item.id]">
|
<a class="btn btn-outline-primary" [routerLink]="['/items/' + item.id]">
|
||||||
{{"item.page.link.simple" | translate}}
|
{{"item.page.link.simple" | translate}}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<table class="table table-responsive table-striped">
|
<table class="table table-responsive table-striped">
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr *ngFor="let metadatum of (metadataObs | async)">
|
<tr *ngFor="let metadatum of (metadata$ | async)">
|
||||||
<td>{{metadatum.key}}</td>
|
<td>{{metadatum.key}}</td>
|
||||||
<td>{{metadatum.value}}</td>
|
<td>{{metadatum.value}}</td>
|
||||||
<td>{{metadatum.language}}</td>
|
<td>{{metadatum.language}}</td>
|
||||||
|
@@ -1,7 +1,10 @@
|
|||||||
@import '../../../styles/variables.scss';
|
@import '../../../styles/variables.scss';
|
||||||
|
|
||||||
:host {
|
:host {
|
||||||
div.simple-view-link {
|
div.simple-view-link {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin: 20px;
|
a {
|
||||||
|
min-width: 25%;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
@@ -31,9 +31,9 @@ import { BehaviorSubject } from 'rxjs/BehaviorSubject';
|
|||||||
})
|
})
|
||||||
export class FullItemPageComponent extends ItemPageComponent implements OnInit {
|
export class FullItemPageComponent extends ItemPageComponent implements OnInit {
|
||||||
|
|
||||||
itemRDObs: BehaviorSubject<RemoteData<Item>>;
|
itemRD$: BehaviorSubject<RemoteData<Item>>;
|
||||||
|
|
||||||
metadataObs: Observable<Metadatum[]>;
|
metadata$: Observable<Metadatum[]>;
|
||||||
|
|
||||||
constructor(route: ActivatedRoute, items: ItemDataService, metadataService: MetadataService) {
|
constructor(route: ActivatedRoute, items: ItemDataService, metadataService: MetadataService) {
|
||||||
super(route, items, metadataService);
|
super(route, items, metadataService);
|
||||||
@@ -42,14 +42,9 @@ export class FullItemPageComponent extends ItemPageComponent implements OnInit {
|
|||||||
/*** AoT inheritance fix, will hopefully be resolved in the near future **/
|
/*** AoT inheritance fix, will hopefully be resolved in the near future **/
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
super.ngOnInit();
|
super.ngOnInit();
|
||||||
}
|
this.metadata$ = this.itemRD$
|
||||||
|
|
||||||
initialize(params) {
|
|
||||||
super.initialize(params);
|
|
||||||
this.metadataObs = this.itemRDObs
|
|
||||||
.map((rd: RemoteData<Item>) => rd.payload)
|
.map((rd: RemoteData<Item>) => rd.payload)
|
||||||
.filter((item: Item) => hasValue(item))
|
.filter((item: Item) => hasValue(item))
|
||||||
.map((item: Item) => item.metadata);
|
.map((item: Item) => item.metadata);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@@ -3,13 +3,30 @@ import { RouterModule } from '@angular/router';
|
|||||||
|
|
||||||
import { ItemPageComponent } from './simple/item-page.component';
|
import { ItemPageComponent } from './simple/item-page.component';
|
||||||
import { FullItemPageComponent } from './full/full-item-page.component';
|
import { FullItemPageComponent } from './full/full-item-page.component';
|
||||||
|
import { ItemPageResolver } from './item-page.resolver';
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
imports: [
|
imports: [
|
||||||
RouterModule.forChild([
|
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 {
|
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,4 +1,4 @@
|
|||||||
<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 class="item-page" *ngIf="itemRD?.hasSucceeded" @fadeInOut>
|
||||||
<div *ngIf="itemRD?.payload as item">
|
<div *ngIf="itemRD?.payload as item">
|
||||||
<ds-entity-type-switcher [object]="item" [viewMode]="ElementViewMode.Full"></ds-entity-type-switcher>
|
<ds-entity-type-switcher [object]="item" [viewMode]="ElementViewMode.Full"></ds-entity-type-switcher>
|
||||||
|
@@ -1,4 +1,4 @@
|
|||||||
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnInit } from '@angular/core';
|
import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
|
||||||
import { ActivatedRoute } from '@angular/router';
|
import { ActivatedRoute } from '@angular/router';
|
||||||
|
|
||||||
import { Observable } from 'rxjs/Observable';
|
import { Observable } from 'rxjs/Observable';
|
||||||
@@ -12,9 +12,6 @@ import { MetadataService } from '../../core/metadata/metadata.service';
|
|||||||
|
|
||||||
import { fadeInOut } from '../../shared/animations/fade';
|
import { fadeInOut } from '../../shared/animations/fade';
|
||||||
import { hasValue } from '../../shared/empty.util';
|
import { hasValue } from '../../shared/empty.util';
|
||||||
import * as viewMode from '../../shared/view-mode';
|
|
||||||
import { BehaviorSubject } from 'rxjs/BehaviorSubject';
|
|
||||||
import { Subscription } from 'rxjs/Subscription';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This component renders a simple item page.
|
* This component renders a simple item page.
|
||||||
@@ -32,13 +29,11 @@ export class ItemPageComponent implements OnInit {
|
|||||||
|
|
||||||
id: number;
|
id: number;
|
||||||
|
|
||||||
private sub: Subscription;
|
private sub: any;
|
||||||
private itemSub: Subscription;
|
|
||||||
thumbnailObs: Observable<Bitstream>;
|
|
||||||
|
|
||||||
itemRDObs: BehaviorSubject<RemoteData<Item>> = new BehaviorSubject(new RemoteData(true, true, undefined, null, null));
|
itemRD$: Observable<RemoteData<Item>>;
|
||||||
|
|
||||||
ElementViewMode = viewMode.ElementViewMode;
|
thumbnail$: Observable<Bitstream>;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private route: ActivatedRoute,
|
private route: ActivatedRoute,
|
||||||
@@ -47,20 +42,9 @@ export class ItemPageComponent implements OnInit {
|
|||||||
) { }
|
) { }
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.sub = this.route.params.subscribe((params) => {
|
this.itemRD$ = this.route.data.map((data) => data.item);
|
||||||
this.initialize(params);
|
this.metadataService.processRemoteData(this.itemRD$);
|
||||||
});
|
this.thumbnail$ = this.itemRD$
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
initialize(params) {
|
|
||||||
this.id = +params.id;
|
|
||||||
if (hasValue(this.itemSub)) {
|
|
||||||
this.itemSub.unsubscribe();
|
|
||||||
}
|
|
||||||
this.itemSub = this.items.findById(params.id).subscribe((item) => this.itemRDObs.next(item));
|
|
||||||
this.metadataService.processRemoteData(this.itemRDObs);
|
|
||||||
this.thumbnailObs = this.itemRDObs
|
|
||||||
.map((rd: RemoteData<Item>) => rd.payload)
|
.map((rd: RemoteData<Item>) => rd.payload)
|
||||||
.filter((item: Item) => hasValue(item))
|
.filter((item: Item) => hasValue(item))
|
||||||
.flatMap((item: Item) => item.getThumbnail());
|
.flatMap((item: Item) => item.getThumbnail());
|
||||||
|
13
src/app/+login-page/login-page-routing.module.ts
Normal file
13
src/app/+login-page/login-page-routing.module.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { NgModule } from '@angular/core';
|
||||||
|
import { RouterModule } from '@angular/router';
|
||||||
|
|
||||||
|
import { LoginPageComponent } from './login-page.component';
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
imports: [
|
||||||
|
RouterModule.forChild([
|
||||||
|
{ path: '', component: LoginPageComponent, data: { title: 'login.title' } }
|
||||||
|
])
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class LoginPageRoutingModule { }
|
9
src/app/+login-page/login-page.component.html
Normal file
9
src/app/+login-page/login-page.component.html
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<div class="container w-100 h-100">
|
||||||
|
<div class="text-center mt-5 row justify-content-center">
|
||||||
|
<div>
|
||||||
|
<img class="mb-4 login-logo" src="assets/images/dspace-logo.png">
|
||||||
|
<h1 class="h3 mb-0 font-weight-normal">{{"login.form.header" | translate}}</h1>
|
||||||
|
<ds-log-in></ds-log-in>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
6
src/app/+login-page/login-page.component.scss
Normal file
6
src/app/+login-page/login-page.component.scss
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
@import '../../styles/variables.scss';
|
||||||
|
|
||||||
|
.login-logo {
|
||||||
|
height: $login-logo-height;
|
||||||
|
width: $login-logo-width;
|
||||||
|
}
|
47
src/app/+login-page/login-page.component.spec.ts
Normal file
47
src/app/+login-page/login-page.component.spec.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { NO_ERRORS_SCHEMA } from '@angular/core';
|
||||||
|
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { Store } from '@ngrx/store';
|
||||||
|
import { TranslateModule } from '@ngx-translate/core';
|
||||||
|
import { Observable } from 'rxjs/Observable';
|
||||||
|
import 'rxjs/add/observable/of';
|
||||||
|
|
||||||
|
import { LoginPageComponent } from './login-page.component';
|
||||||
|
|
||||||
|
describe('LoginPageComponent', () => {
|
||||||
|
let comp: LoginPageComponent;
|
||||||
|
let fixture: ComponentFixture<LoginPageComponent>;
|
||||||
|
|
||||||
|
const store: Store<LoginPageComponent> = jasmine.createSpyObj('store', {
|
||||||
|
/* tslint:disable:no-empty */
|
||||||
|
dispatch: {},
|
||||||
|
/* tslint:enable:no-empty */
|
||||||
|
select: Observable.of(true)
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async(() => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
imports: [
|
||||||
|
TranslateModule.forRoot()
|
||||||
|
],
|
||||||
|
declarations: [LoginPageComponent],
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: Store, useValue: store
|
||||||
|
}
|
||||||
|
],
|
||||||
|
schemas: [NO_ERRORS_SCHEMA]
|
||||||
|
}).compileComponents();
|
||||||
|
}));
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fixture = TestBed.createComponent(LoginPageComponent);
|
||||||
|
comp = fixture.componentInstance; // SearchPageComponent test instance
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create instance', () => {
|
||||||
|
expect(comp).toBeDefined()
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
21
src/app/+login-page/login-page.component.ts
Normal file
21
src/app/+login-page/login-page.component.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { Component, OnDestroy } from '@angular/core';
|
||||||
|
|
||||||
|
import { Store } from '@ngrx/store';
|
||||||
|
|
||||||
|
import { AppState } from '../app.reducer';
|
||||||
|
import { ResetAuthenticationMessagesAction } from '../core/auth/auth.actions';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'ds-login-page',
|
||||||
|
styleUrls: ['./login-page.component.scss'],
|
||||||
|
templateUrl: './login-page.component.html'
|
||||||
|
})
|
||||||
|
export class LoginPageComponent implements OnDestroy {
|
||||||
|
|
||||||
|
constructor(private store: Store<AppState>) {}
|
||||||
|
|
||||||
|
ngOnDestroy() {
|
||||||
|
// Clear all authentication messages when leaving login page
|
||||||
|
this.store.dispatch(new ResetAuthenticationMessagesAction());
|
||||||
|
}
|
||||||
|
}
|
19
src/app/+login-page/login-page.module.ts
Normal file
19
src/app/+login-page/login-page.module.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { NgModule } from '@angular/core';
|
||||||
|
import { SharedModule } from '../shared/shared.module';
|
||||||
|
import { LoginPageComponent } from './login-page.component';
|
||||||
|
import { LoginPageRoutingModule } from './login-page-routing.module';
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
imports: [
|
||||||
|
LoginPageRoutingModule,
|
||||||
|
CommonModule,
|
||||||
|
SharedModule,
|
||||||
|
],
|
||||||
|
declarations: [
|
||||||
|
LoginPageComponent
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class LoginPageModule {
|
||||||
|
|
||||||
|
}
|
19
src/app/+logout-page/logout-page-routing.module.ts
Normal file
19
src/app/+logout-page/logout-page-routing.module.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { NgModule } from '@angular/core';
|
||||||
|
import { RouterModule } from '@angular/router';
|
||||||
|
|
||||||
|
import { LogoutPageComponent } from './logout-page.component';
|
||||||
|
import { AuthenticatedGuard } from '../core/auth/authenticated.guard';
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
imports: [
|
||||||
|
RouterModule.forChild([
|
||||||
|
{
|
||||||
|
canActivate: [AuthenticatedGuard],
|
||||||
|
path: '',
|
||||||
|
component: LogoutPageComponent,
|
||||||
|
data: { title: 'logout.title' }
|
||||||
|
}
|
||||||
|
])
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class LogoutPageRoutingModule { }
|
9
src/app/+logout-page/logout-page.component.html
Normal file
9
src/app/+logout-page/logout-page.component.html
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<div class="container w-100 h-100">
|
||||||
|
<div class="text-center mt-5 row justify-content-md-center">
|
||||||
|
<div>
|
||||||
|
<img class="mb-4 login-logo" src="assets/images/dspace-logo.png">
|
||||||
|
<h1 class="h3 mb-0 font-weight-normal">{{"logout.form.header" | translate}}</h1>
|
||||||
|
<ds-log-out></ds-log-out>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
1
src/app/+logout-page/logout-page.component.scss
Normal file
1
src/app/+logout-page/logout-page.component.scss
Normal file
@@ -0,0 +1 @@
|
|||||||
|
@import '../+login-page/login-page.component.scss';
|
31
src/app/+logout-page/logout-page.component.spec.ts
Normal file
31
src/app/+logout-page/logout-page.component.spec.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { NO_ERRORS_SCHEMA } from '@angular/core';
|
||||||
|
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
import { TranslateModule } from '@ngx-translate/core';
|
||||||
|
|
||||||
|
import { LogoutPageComponent } from './logout-page.component';
|
||||||
|
|
||||||
|
describe('LogoutPageComponent', () => {
|
||||||
|
let comp: LogoutPageComponent;
|
||||||
|
let fixture: ComponentFixture<LogoutPageComponent>;
|
||||||
|
|
||||||
|
beforeEach(async(() => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
imports: [
|
||||||
|
TranslateModule.forRoot()
|
||||||
|
],
|
||||||
|
declarations: [LogoutPageComponent],
|
||||||
|
schemas: [NO_ERRORS_SCHEMA]
|
||||||
|
}).compileComponents();
|
||||||
|
}));
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fixture = TestBed.createComponent(LogoutPageComponent);
|
||||||
|
comp = fixture.componentInstance; // SearchPageComponent test instance
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create instance', () => {
|
||||||
|
expect(comp).toBeDefined()
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
10
src/app/+logout-page/logout-page.component.ts
Normal file
10
src/app/+logout-page/logout-page.component.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { Component } from '@angular/core';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'ds-logout-page',
|
||||||
|
styleUrls: ['./logout-page.component.scss'],
|
||||||
|
templateUrl: './logout-page.component.html'
|
||||||
|
})
|
||||||
|
export class LogoutPageComponent {
|
||||||
|
|
||||||
|
}
|
19
src/app/+logout-page/logout-page.module.ts
Normal file
19
src/app/+logout-page/logout-page.module.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { NgModule } from '@angular/core';
|
||||||
|
import { SharedModule } from '../shared/shared.module';
|
||||||
|
import { LogoutPageComponent } from './logout-page.component';
|
||||||
|
import { LogoutPageRoutingModule } from './logout-page-routing.module';
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
imports: [
|
||||||
|
LogoutPageRoutingModule,
|
||||||
|
CommonModule,
|
||||||
|
SharedModule,
|
||||||
|
],
|
||||||
|
declarations: [
|
||||||
|
LogoutPageComponent
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class LogoutPageModule {
|
||||||
|
|
||||||
|
}
|
@@ -4,9 +4,10 @@ import { SearchFilterService } from './search-filters/search-filter/search-filte
|
|||||||
import { SearchService } from './search-service/search.service';
|
import { SearchService } from './search-service/search.service';
|
||||||
import { SearchSidebarService } from './search-sidebar/search-sidebar.service';
|
import { SearchSidebarService } from './search-sidebar/search-sidebar.service';
|
||||||
import { SearchPageComponent } from './search-page.component';
|
import { SearchPageComponent } from './search-page.component';
|
||||||
import { RouteService } from '../shared/route.service';
|
|
||||||
import { ChangeDetectionStrategy, Component, Injectable } from '@angular/core';
|
import { ChangeDetectionStrategy, Component, Injectable } from '@angular/core';
|
||||||
import { pushInOut } from '../shared/animations/push';
|
import { pushInOut } from '../shared/animations/push';
|
||||||
|
import { RouteService } from '../shared/services/route.service';
|
||||||
|
import { SearchConfigurationService } from './search-service/search-configuration.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This component renders a simple item page.
|
* This component renders a simple item page.
|
||||||
@@ -23,12 +24,11 @@ import { pushInOut } from '../shared/animations/push';
|
|||||||
export class FilteredSearchPageComponent extends SearchPageComponent {
|
export class FilteredSearchPageComponent extends SearchPageComponent {
|
||||||
|
|
||||||
constructor(protected service: SearchService,
|
constructor(protected service: SearchService,
|
||||||
protected communityService: CommunityDataService,
|
|
||||||
protected sidebarService: SearchSidebarService,
|
protected sidebarService: SearchSidebarService,
|
||||||
protected windowService: HostWindowService,
|
protected windowService: HostWindowService,
|
||||||
protected filterService: SearchFilterService,
|
protected filterService: SearchFilterService,
|
||||||
protected routeService: RouteService) {
|
protected searchConfigService: SearchConfigurationService) {
|
||||||
super(service, communityService, sidebarService, windowService, filterService, routeService);
|
super(service, sidebarService, windowService, filterService, searchConfigService);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@@ -2,11 +2,19 @@ import { autoserialize } from 'cerialize';
|
|||||||
import { Metadatum } from '../core/shared/metadatum.model';
|
import { Metadatum } from '../core/shared/metadatum.model';
|
||||||
import { ListableObject } from '../shared/object-collection/shared/listable-object.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 {
|
export class NormalizedSearchResult implements ListableObject {
|
||||||
|
/**
|
||||||
|
* The UUID of the DSpaceObject that was found
|
||||||
|
*/
|
||||||
@autoserialize
|
@autoserialize
|
||||||
dspaceObject: string;
|
dspaceObject: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The metadata that was used to find this item, hithighlighted
|
||||||
|
*/
|
||||||
@autoserialize
|
@autoserialize
|
||||||
hitHighlights: Metadatum[];
|
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'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
|
});
|
@@ -2,10 +2,28 @@ import { SortOptions } from '../core/cache/models/sort-options.model';
|
|||||||
import { PaginationComponentOptions } from '../shared/pagination/pagination-component-options.model';
|
import { PaginationComponentOptions } from '../shared/pagination/pagination-component-options.model';
|
||||||
import { isNotEmpty } from '../shared/empty.util';
|
import { isNotEmpty } from '../shared/empty.util';
|
||||||
import { SearchOptions } from './search-options.model';
|
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 {
|
export class PaginatedSearchOptions extends SearchOptions {
|
||||||
pagination?: PaginationComponentOptions;
|
pagination?: PaginationComponentOptions;
|
||||||
sort?: SortOptions;
|
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 {
|
toRestUrl(url: string, args: string[] = []): string {
|
||||||
if (isNotEmpty(this.sort)) {
|
if (isNotEmpty(this.sort)) {
|
||||||
args.push(`sort=${this.sort.field},${this.sort.direction}`);
|
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 { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core';
|
||||||
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
|
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
import { RouterTestingModule } from '@angular/router/testing';
|
|
||||||
import { TranslateModule } from '@ngx-translate/core';
|
import { TranslateModule } from '@ngx-translate/core';
|
||||||
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
|
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
|
||||||
import { SearchFacetFilterComponent } from './search-facet-filter.component';
|
import { FILTER_CONFIG, SearchFilterService } from '../search-filter.service';
|
||||||
import { SearchFilterService } from '../search-filter.service';
|
|
||||||
import { SearchFilterConfig } from '../../../search-service/search-filter-config.model';
|
import { SearchFilterConfig } from '../../../search-service/search-filter-config.model';
|
||||||
import { FilterType } from '../../../search-service/filter-type.model';
|
import { FilterType } from '../../../search-service/filter-type.model';
|
||||||
import { FacetValue } from '../../../search-service/facet-value.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 { SearchServiceStub } from '../../../../shared/testing/search-service-stub';
|
||||||
import { RemoteData } from '../../../../core/data/remote-data';
|
import { RemoteData } from '../../../../core/data/remote-data';
|
||||||
import { PaginatedList } from '../../../../core/data/paginated-list';
|
import { PaginatedList } from '../../../../core/data/paginated-list';
|
||||||
import { SearchOptions } from '../../../search-options.model';
|
|
||||||
import { RouterStub } from '../../../../shared/testing/router-stub';
|
import { RouterStub } from '../../../../shared/testing/router-stub';
|
||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
import { BehaviorSubject } from 'rxjs/BehaviorSubject';
|
|
||||||
import { PageInfo } from '../../../../core/shared/page-info.model';
|
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', () => {
|
describe('SearchFacetFilterComponent', () => {
|
||||||
let comp: SearchFacetFilterComponent;
|
let comp: SearchFacetFilterComponent;
|
||||||
@@ -65,18 +64,21 @@ describe('SearchFacetFilterComponent', () => {
|
|||||||
providers: [
|
providers: [
|
||||||
{ provide: SearchService, useValue: new SearchServiceStub(searchLink) },
|
{ provide: SearchService, useValue: new SearchServiceStub(searchLink) },
|
||||||
{ provide: Router, useValue: new RouterStub() },
|
{ 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: {
|
provide: SearchFilterService, useValue: {
|
||||||
isFilterActiveWithValue: (paramName: string, filterValue: string) => true,
|
getSelectedValuesForFilter: () => Observable.of(selectedValues),
|
||||||
getPage: (paramName: string) => page,
|
isFilterActiveWithValue: (paramName: string, filterValue: string) => true,
|
||||||
/* tslint:disable:no-empty */
|
getPage: (paramName: string) => page,
|
||||||
incrementPage: (filterName: string) => {
|
/* tslint:disable:no-empty */
|
||||||
},
|
incrementPage: (filterName: string) => {
|
||||||
resetPage: (filterName: string) => {
|
},
|
||||||
},
|
resetPage: (filterName: string) => {
|
||||||
getSearchOptions: () => Observable.of({}),
|
}
|
||||||
/* tslint:enable:no-empty */
|
/* tslint:enable:no-empty */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
schemas: [NO_ERRORS_SCHEMA]
|
schemas: [NO_ERRORS_SCHEMA]
|
||||||
@@ -89,9 +91,6 @@ describe('SearchFacetFilterComponent', () => {
|
|||||||
fixture = TestBed.createComponent(SearchFacetFilterComponent);
|
fixture = TestBed.createComponent(SearchFacetFilterComponent);
|
||||||
comp = fixture.componentInstance; // SearchPageComponent test instance
|
comp = fixture.componentInstance; // SearchPageComponent test instance
|
||||||
comp.filterConfig = mockFilterConfig;
|
comp.filterConfig = mockFilterConfig;
|
||||||
comp.filterValues = [mockValues];
|
|
||||||
comp.filterValues$ = new BehaviorSubject(comp.filterValues);
|
|
||||||
comp.selectedValues = selectedValues;
|
|
||||||
filterService = (comp as any).filterService;
|
filterService = (comp as any).filterService;
|
||||||
searchService = (comp as any).searchService;
|
searchService = (comp as any).searchService;
|
||||||
spyOn(searchService, 'getFacetValuesFor').and.returnValue(mockValues);
|
spyOn(searchService, 'getFacetValuesFor').and.returnValue(mockValues);
|
||||||
@@ -124,14 +123,14 @@ describe('SearchFacetFilterComponent', () => {
|
|||||||
describe('when the getAddParams method is called wih a value', () => {
|
describe('when the getAddParams method is called wih a value', () => {
|
||||||
it('should return the selectedValue list with the new parameter value', () => {
|
it('should return the selectedValue list with the new parameter value', () => {
|
||||||
const result = comp.getAddParams(value3);
|
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', () => {
|
describe('when the getRemoveParams method is called wih a value', () => {
|
||||||
it('should return the selectedValue list with the parameter value left out', () => {
|
it('should return the selectedValue list with the parameter value left out', () => {
|
||||||
const result = comp.getRemoveParams(value1);
|
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', () => {
|
describe('when the getCurrentUrl method is called', () => {
|
||||||
const url = 'test.url/test'
|
const url = 'test.url/test';
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
router.navigateByUrl(url);
|
router.navigateByUrl(url);
|
||||||
});
|
});
|
||||||
@@ -182,7 +181,7 @@ describe('SearchFacetFilterComponent', () => {
|
|||||||
describe('when the onSubmit method is called with data', () => {
|
describe('when the onSubmit method is called with data', () => {
|
||||||
const searchUrl = '/search/path';
|
const searchUrl = '/search/path';
|
||||||
const testValue = 'test';
|
const testValue = 'test';
|
||||||
const data = { [mockFilterConfig.paramName]: testValue };
|
const data = testValue;
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
spyOn(comp, 'getSearchLink').and.returnValue(searchUrl);
|
spyOn(comp, 'getSearchLink').and.returnValue(searchUrl);
|
||||||
comp.onSubmit(data);
|
comp.onSubmit(data);
|
||||||
@@ -197,46 +196,26 @@ describe('SearchFacetFilterComponent', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('when updateFilterValueList is called', () => {
|
describe('when updateFilterValueList is called', () => {
|
||||||
const cPage = 10;
|
|
||||||
const searchOptions = new SearchOptions();
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
// spyOn(searchService, 'getFacetValuesFor'); Already spied upon
|
spyOn(comp, 'showFirstPageOnly');
|
||||||
comp.currentPage = Observable.of(cPage);
|
comp.updateFilterValueList()
|
||||||
comp.updateFilterValueList(searchOptions);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should call getFacetValuesFor on the searchService with the correct parameters', () => {
|
it('should call showFirstPageOnly and empty the filter', () => {
|
||||||
expect(searchService.getFacetValuesFor).toHaveBeenCalledWith(mockFilterConfig, cPage, searchOptions);
|
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', () => {
|
describe('when findSuggestions is called with query \'test\'', () => {
|
||||||
const searchOptions = new SearchOptions();
|
const query = 'test';
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
comp.pageChange = true;
|
comp.findSuggestions(query);
|
||||||
spyOn(comp, 'showFirstPageOnly');
|
|
||||||
comp.updateFilterValueList(searchOptions);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not call showFirstPageOnly on the component', () => {
|
it('should call getFacetValuesFor on the component\'s SearchService with the right query', () => {
|
||||||
expect(comp.showFirstPageOnly).not.toHaveBeenCalled();
|
expect((comp as any).searchService.getFacetValuesFor).toHaveBeenCalledWith(comp.filterConfig, 1, {}, query);
|
||||||
});
|
|
||||||
|
|
||||||
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();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@@ -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 { FacetValue } from '../../../search-service/facet-value.model';
|
||||||
import { SearchFilterConfig } from '../../../search-service/search-filter-config.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 { SearchService } from '../../../search-service/search.service';
|
||||||
import { SearchOptions } from '../../../search-options.model';
|
import { FILTER_CONFIG, SearchFilterService } from '../search-filter.service';
|
||||||
import { BehaviorSubject } from 'rxjs/BehaviorSubject';
|
import { SearchConfigurationService } from '../../../search-service/search-configuration.service';
|
||||||
import { Subscription } from 'rxjs/Subscription';
|
import { getSucceededRemoteData } from '../../../../core/shared/operators';
|
||||||
|
import { map } from 'rxjs/operators';
|
||||||
/**
|
|
||||||
* 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({
|
@Component({
|
||||||
selector: 'ds-search-facet-filter',
|
selector: 'ds-search-facet-filter',
|
||||||
styleUrls: ['./search-facet-filter.component.scss'],
|
template: ``,
|
||||||
templateUrl: './search-facet-filter.component.html'
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Super class for all different representations of facets
|
||||||
|
*/
|
||||||
export class SearchFacetFilterComponent implements OnInit, OnDestroy {
|
export class SearchFacetFilterComponent implements OnInit, OnDestroy {
|
||||||
@Input() filterConfig: SearchFilterConfig;
|
/**
|
||||||
@Input() selectedValues: string[];
|
* Emits an array of pages with values found for this facet
|
||||||
filterValues: Array<Observable<RemoteData<PaginatedList<FacetValue>>>> = [];
|
*/
|
||||||
filterValues$: BehaviorSubject<any> = new BehaviorSubject(this.filterValues);
|
filterValues$: Subject<RemoteData<Array<PaginatedList<FacetValue>>>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emits the current last shown page of this facet's values
|
||||||
|
*/
|
||||||
currentPage: Observable<number>;
|
currentPage: Observable<number>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emits true if the current page is also the last page available
|
||||||
|
*/
|
||||||
isLastPage$: BehaviorSubject<boolean> = new BehaviorSubject(false);
|
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;
|
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 {
|
ngOnInit(): void {
|
||||||
this.currentPage = this.getCurrentPage();
|
this.filterValues$ = new BehaviorSubject(new RemoteData(true, false, undefined, undefined, undefined));
|
||||||
this.currentPage.distinctUntilChanged().subscribe((page) => this.pageChange = true);
|
this.currentPage = this.getCurrentPage().distinctUntilChanged();
|
||||||
this.filterService.getSearchOptions().distinctUntilChanged().subscribe((options) => this.updateFilterValueList(options));
|
this.selectedValues = this.filterService.getSelectedValuesForFilter(this.filterConfig);
|
||||||
}
|
const searchOptions = this.searchConfigService.searchOptions;
|
||||||
|
this.subs.push(this.searchConfigService.searchOptions.subscribe(() => this.updateFilterValueList()));
|
||||||
updateFilterValueList(options: SearchOptions) {
|
const facetValues = Observable.combineLatest(searchOptions, this.currentPage, (options, page) => {
|
||||||
if (!this.pageChange) {
|
return { options, page }
|
||||||
this.showFirstPageOnly();
|
}).switchMap(({ options, page }) => {
|
||||||
}
|
return this.searchService.getFacetValuesFor(this.filterConfig, page, options)
|
||||||
this.pageChange = false;
|
.pipe(
|
||||||
|
getSucceededRemoteData(),
|
||||||
this.unsubscribe();
|
map((results) => {
|
||||||
this.sub = this.currentPage.distinctUntilChanged().map((page) => {
|
return {
|
||||||
return this.searchService.getFacetValuesFor(this.filterConfig, page, options);
|
values: Observable.of(results),
|
||||||
}).subscribe((newValues$) => {
|
page: page
|
||||||
this.filterValues = [...this.filterValues, newValues$];
|
};
|
||||||
this.filterValues$.next(this.filterValues);
|
}
|
||||||
newValues$.first().subscribe((rd) => this.isLastPage$.next(hasNoValue(rd.payload.next)));
|
)
|
||||||
|
)
|
||||||
});
|
});
|
||||||
|
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> {
|
isChecked(value: FacetValue): Observable<boolean> {
|
||||||
return this.filterService.isFilterActiveWithValue(this.filterConfig.paramName, value.value);
|
return this.filterService.isFilterActiveWithValue(this.filterConfig.paramName, value.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns {string} The base path to the search page
|
||||||
|
*/
|
||||||
getSearchLink() {
|
getSearchLink() {
|
||||||
return this.searchService.getSearchLink();
|
return this.searchService.getSearchLink();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show the next page as well
|
||||||
|
*/
|
||||||
showMore() {
|
showMore() {
|
||||||
this.filterService.incrementPage(this.filterConfig.name);
|
this.filterService.incrementPage(this.filterConfig.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make sure only the first page is shown
|
||||||
|
*/
|
||||||
showFirstPageOnly() {
|
showFirstPageOnly() {
|
||||||
this.filterValues = [];
|
|
||||||
this.filterService.resetPage(this.filterConfig.name);
|
this.filterService.resetPage(this.filterConfig.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns {Observable<number>} The current page of this filter
|
||||||
|
*/
|
||||||
getCurrentPage(): Observable<number> {
|
getCurrentPage(): Observable<number> {
|
||||||
return this.filterService.getPage(this.filterConfig.name);
|
return this.filterService.getPage(this.filterConfig.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns {string} the current URL
|
||||||
|
*/
|
||||||
getCurrentUrl() {
|
getCurrentUrl() {
|
||||||
return this.router.url;
|
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) {
|
onSubmit(data: any) {
|
||||||
if (isNotEmpty(data)) {
|
this.selectedValues.first().subscribe((selectedValues) => {
|
||||||
this.router.navigate([this.getSearchLink()], {
|
if (isNotEmpty(data)) {
|
||||||
queryParams:
|
this.router.navigate([this.getSearchLink()], {
|
||||||
{ [this.filterConfig.paramName]: [...this.selectedValues, data[this.filterConfig.paramName]] },
|
queryParams:
|
||||||
queryParamsHandling: 'merge'
|
{ [this.filterConfig.paramName]: [...selectedValues, data] },
|
||||||
});
|
queryParamsHandling: 'merge'
|
||||||
this.filter = '';
|
});
|
||||||
}
|
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 {
|
hasValue(o: any): boolean {
|
||||||
return hasValue(o);
|
return hasValue(o);
|
||||||
}
|
}
|
||||||
getRemoveParams(value: string) {
|
|
||||||
return {
|
/**
|
||||||
[this.filterConfig.paramName]: this.selectedValues.filter((v) => v !== value),
|
* Calculates the parameters that should change if a given value for this filter would be removed from the active filters
|
||||||
page: 1
|
* @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 {
|
* Calculates the parameters that should change if a given value for this filter would be added to the active filters
|
||||||
[this.filterConfig.paramName]: [...this.selectedValues, value],
|
* @param {string} value The value that is added for this filter
|
||||||
page: 1
|
* @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 {
|
ngOnDestroy(): void {
|
||||||
this.unsubscribe();
|
this.subs
|
||||||
|
.filter((sub) => hasValue(sub))
|
||||||
|
.forEach((sub) => sub.unsubscribe());
|
||||||
}
|
}
|
||||||
|
|
||||||
unsubscribe(): void {
|
/**
|
||||||
if (hasValue(this.sub)) {
|
* Updates the found facet value suggestions for a given query
|
||||||
this.sub.unsubscribe();
|
* 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 {
|
export class SearchFilterAction implements Action {
|
||||||
|
/**
|
||||||
|
* Name of the filter the action is performed on, used to identify the filter
|
||||||
|
*/
|
||||||
filterName: string;
|
filterName: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type of action that will be performed
|
||||||
|
*/
|
||||||
type;
|
type;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize with the filter's name
|
||||||
|
* @param {string} name of the filter
|
||||||
|
*/
|
||||||
constructor(name: string) {
|
constructor(name: string) {
|
||||||
this.filterName = name;
|
this.filterName = name;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* tslint:disable:max-classes-per-file */
|
/* tslint:disable:max-classes-per-file */
|
||||||
|
/**
|
||||||
|
* Used to collapse a filter
|
||||||
|
*/
|
||||||
export class SearchFilterCollapseAction extends SearchFilterAction {
|
export class SearchFilterCollapseAction extends SearchFilterAction {
|
||||||
type = SearchFilterActionTypes.COLLAPSE;
|
type = SearchFilterActionTypes.COLLAPSE;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Used to expand a filter
|
||||||
|
*/
|
||||||
export class SearchFilterExpandAction extends SearchFilterAction {
|
export class SearchFilterExpandAction extends SearchFilterAction {
|
||||||
type = SearchFilterActionTypes.EXPAND;
|
type = SearchFilterActionTypes.EXPAND;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Used to collapse a filter when it's expanded and expand it when it's collapsed
|
||||||
|
*/
|
||||||
export class SearchFilterToggleAction extends SearchFilterAction {
|
export class SearchFilterToggleAction extends SearchFilterAction {
|
||||||
type = SearchFilterActionTypes.TOGGLE;
|
type = SearchFilterActionTypes.TOGGLE;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Used to set the initial state of a filter to collapsed
|
||||||
|
*/
|
||||||
export class SearchFilterInitialCollapseAction extends SearchFilterAction {
|
export class SearchFilterInitialCollapseAction extends SearchFilterAction {
|
||||||
type = SearchFilterActionTypes.INITIAL_COLLAPSE;
|
type = SearchFilterActionTypes.INITIAL_COLLAPSE;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Used to set the initial state of a filter to expanded
|
||||||
|
*/
|
||||||
export class SearchFilterInitialExpandAction extends SearchFilterAction {
|
export class SearchFilterInitialExpandAction extends SearchFilterAction {
|
||||||
type = SearchFilterActionTypes.INITIAL_EXPAND;
|
type = SearchFilterActionTypes.INITIAL_EXPAND;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Used to set the state of a filter to the previous page
|
||||||
|
*/
|
||||||
export class SearchFilterDecrementPageAction extends SearchFilterAction {
|
export class SearchFilterDecrementPageAction extends SearchFilterAction {
|
||||||
type = SearchFilterActionTypes.DECREMENT_PAGE;
|
type = SearchFilterActionTypes.DECREMENT_PAGE;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Used to set the state of a filter to the next page
|
||||||
|
*/
|
||||||
export class SearchFilterIncrementPageAction extends SearchFilterAction {
|
export class SearchFilterIncrementPageAction extends SearchFilterAction {
|
||||||
type = SearchFilterActionTypes.INCREMENT_PAGE;
|
type = SearchFilterActionTypes.INCREMENT_PAGE;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Used to set the state of a filter to the first page
|
||||||
|
*/
|
||||||
export class SearchFilterResetPageAction extends SearchFilterAction {
|
export class SearchFilterResetPageAction extends SearchFilterAction {
|
||||||
type = SearchFilterActionTypes.RESET_PAGE;
|
type = SearchFilterActionTypes.RESET_PAGE;
|
||||||
}
|
}
|
||||||
|
@@ -1,7 +1,7 @@
|
|||||||
<div>
|
<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"
|
<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>
|
[ngClass]="(isCollapsed() | async) ? 'fa-plus' : 'fa-minus'"></span></div>
|
||||||
<div [@slide]="(isCollapsed() | async) ? 'collapsed' : 'expanded'" class="search-filter-wrapper">
|
<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 [filterConfig]="filter" [selectedValues]="getSelectedValues() | async"></ds-search-facet-filter>
|
<ds-search-facet-filter-wrapper [filterConfig]="filter"></ds-search-facet-filter-wrapper>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
@@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
:host {
|
:host {
|
||||||
border: 1px solid map-get($theme-colors, light);
|
border: 1px solid map-get($theme-colors, light);
|
||||||
.search-filter-wrapper {
|
.search-filter-wrapper.closed {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.filter-toggle {
|
.filter-toggle {
|
||||||
|
@@ -1,18 +1,9 @@
|
|||||||
import { Component, Input, OnInit } from '@angular/core';
|
import { Component, Input, OnInit } from '@angular/core';
|
||||||
import { SearchFilterConfig } from '../../search-service/search-filter-config.model';
|
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 { SearchFilterService } from './search-filter.service';
|
||||||
import { Observable } from 'rxjs/Observable';
|
import { Observable } from 'rxjs/Observable';
|
||||||
import { slide } from '../../../shared/animations/slide';
|
import { slide } from '../../../shared/animations/slide';
|
||||||
import { PaginatedList } from '../../../core/data/paginated-list';
|
import { isNotEmpty } from '../../../shared/empty.util';
|
||||||
|
|
||||||
/**
|
|
||||||
* 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({
|
@Component({
|
||||||
selector: 'ds-search-filter',
|
selector: 'ds-search-filter',
|
||||||
@@ -21,15 +12,31 @@ import { PaginatedList } from '../../../core/data/paginated-list';
|
|||||||
animations: [slide],
|
animations: [slide],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a part of the filter section for a single type of filter
|
||||||
|
*/
|
||||||
export class SearchFilterComponent implements OnInit {
|
export class SearchFilterComponent implements OnInit {
|
||||||
|
/**
|
||||||
|
* The filter config for this component
|
||||||
|
*/
|
||||||
@Input() filter: SearchFilterConfig;
|
@Input() filter: SearchFilterConfig;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when the filter is 100% collapsed in the UI
|
||||||
|
*/
|
||||||
|
collapsed;
|
||||||
|
|
||||||
constructor(private filterService: SearchFilterService) {
|
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() {
|
ngOnInit() {
|
||||||
this.filterService.isFilterActive(this.filter.paramName).first().subscribe((isActive) => {
|
this.getSelectedValues().first().subscribe((isActive) => {
|
||||||
if (this.filter.isOpenByDefault || isActive) {
|
if (this.filter.isOpenByDefault || isNotEmpty(isActive)) {
|
||||||
this.initialExpand();
|
this.initialExpand();
|
||||||
} else {
|
} else {
|
||||||
this.initialCollapse();
|
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() {
|
toggle() {
|
||||||
this.filterService.toggle(this.filter.name);
|
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> {
|
isCollapsed(): Observable<boolean> {
|
||||||
return this.filterService.isCollapsed(this.filter.name);
|
return this.filterService.isCollapsed(this.filter.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Changes the initial state to collapsed
|
||||||
|
*/
|
||||||
initialCollapse() {
|
initialCollapse() {
|
||||||
this.filterService.initialCollapse(this.filter.name);
|
this.filterService.initialCollapse(this.filter.name);
|
||||||
|
this.collapsed = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Changes the initial state to expanded
|
||||||
|
*/
|
||||||
initialExpand() {
|
initialExpand() {
|
||||||
this.filterService.initialExpand(this.filter.name);
|
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[]> {
|
getSelectedValues(): Observable<string[]> {
|
||||||
return this.filterService.getSelectedValuesForFilter(this.filter);
|
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 { SearchFilterAction, SearchFilterActionTypes } from './search-filter.actions';
|
||||||
import { isEmpty } from '../../../shared/empty.util';
|
import { isEmpty } from '../../../shared/empty.util';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface that represents the state for a single filters
|
||||||
|
*/
|
||||||
export interface SearchFilterState {
|
export interface SearchFilterState {
|
||||||
filterCollapsed: boolean,
|
filterCollapsed: boolean,
|
||||||
page: number
|
page: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface that represents the state for all available filters
|
||||||
|
*/
|
||||||
export interface SearchFiltersState {
|
export interface SearchFiltersState {
|
||||||
[name: string]: SearchFilterState
|
[name: string]: SearchFilterState
|
||||||
}
|
}
|
||||||
|
|
||||||
const initialState: SearchFiltersState = Object.create(null);
|
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 {
|
export function filterReducer(state = initialState, action: SearchFilterAction): SearchFiltersState {
|
||||||
|
|
||||||
switch (action.type) {
|
switch (action.type) {
|
||||||
|
@@ -11,6 +11,7 @@ import { SearchFiltersState } from './search-filter.reducer';
|
|||||||
import { SearchFilterConfig } from '../../search-service/search-filter-config.model';
|
import { SearchFilterConfig } from '../../search-service/search-filter-config.model';
|
||||||
import { FilterType } from '../../search-service/filter-type.model';
|
import { FilterType } from '../../search-service/filter-type.model';
|
||||||
import { SearchFixedFilterService } from './search-fixed-filter.service';
|
import { SearchFixedFilterService } from './search-fixed-filter.service';
|
||||||
|
import { ActivatedRouteStub } from '../../../shared/testing/active-router-stub';
|
||||||
|
|
||||||
describe('SearchFilterService', () => {
|
describe('SearchFilterService', () => {
|
||||||
let service: SearchFilterService;
|
let service: SearchFilterService;
|
||||||
@@ -48,10 +49,14 @@ describe('SearchFilterService', () => {
|
|||||||
addQueryParameterValue: (param: string, value: string) => {
|
addQueryParameterValue: (param: string, value: string) => {
|
||||||
},
|
},
|
||||||
getQueryParameterValues: (param: string) => {
|
getQueryParameterValues: (param: string) => {
|
||||||
|
return Observable.of({});
|
||||||
|
},
|
||||||
|
getQueryParamsWithPrefix: (param: string) => {
|
||||||
|
return Observable.of({});
|
||||||
}
|
}
|
||||||
/* tslint:enable:no-empty */
|
/* tslint:enable:no-empty */
|
||||||
};
|
};
|
||||||
|
const activatedRoute: any = new ActivatedRouteStub();
|
||||||
const searchServiceStub: any = {
|
const searchServiceStub: any = {
|
||||||
uiSearchRoute: '/search'
|
uiSearchRoute: '/search'
|
||||||
};
|
};
|
||||||
|
@@ -1,29 +1,35 @@
|
|||||||
import { Injectable } from '@angular/core';
|
import { Injectable, InjectionToken } from '@angular/core';
|
||||||
import { debounceTime, distinctUntilChanged, map } from 'rxjs/operators';
|
import { distinctUntilChanged, map } from 'rxjs/operators';
|
||||||
import { SearchFiltersState, SearchFilterState } from './search-filter.reducer';
|
import { SearchFiltersState, SearchFilterState } from './search-filter.reducer';
|
||||||
import { createSelector, MemoizedSelector, Store } from '@ngrx/store';
|
import { createSelector, MemoizedSelector, Store } from '@ngrx/store';
|
||||||
import { Observable } from 'rxjs/Observable';
|
import { Observable } from 'rxjs/Observable';
|
||||||
import {
|
import {
|
||||||
SearchFilterCollapseAction,
|
SearchFilterCollapseAction,
|
||||||
SearchFilterDecrementPageAction, SearchFilterExpandAction,
|
SearchFilterDecrementPageAction,
|
||||||
|
SearchFilterExpandAction,
|
||||||
SearchFilterIncrementPageAction,
|
SearchFilterIncrementPageAction,
|
||||||
SearchFilterInitialCollapseAction,
|
SearchFilterInitialCollapseAction,
|
||||||
SearchFilterInitialExpandAction, SearchFilterResetPageAction,
|
SearchFilterInitialExpandAction,
|
||||||
|
SearchFilterResetPageAction,
|
||||||
SearchFilterToggleAction
|
SearchFilterToggleAction
|
||||||
} from './search-filter.actions';
|
} from './search-filter.actions';
|
||||||
import { hasValue, isEmpty, isNotEmpty, } from '../../../shared/empty.util';
|
import { hasValue, isEmpty, isNotEmpty, } from '../../../shared/empty.util';
|
||||||
import { SearchFilterConfig } from '../../search-service/search-filter-config.model';
|
import { SearchFilterConfig } from '../../search-service/search-filter-config.model';
|
||||||
import { SearchService } from '../../search-service/search.service';
|
import { RouteService } from '../../../shared/services/route.service';
|
||||||
import { RouteService } from '../../../shared/route.service';
|
|
||||||
import ObjectExpression from 'rollup/dist/typings/ast/nodes/ObjectExpression';
|
|
||||||
import { SortDirection, SortOptions } from '../../../core/cache/models/sort-options.model';
|
import { SortDirection, SortOptions } from '../../../core/cache/models/sort-options.model';
|
||||||
import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model';
|
import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model';
|
||||||
import { SearchOptions } from '../../search-options.model';
|
import { SearchOptions } from '../../search-options.model';
|
||||||
import { PaginatedSearchOptions } from '../../paginated-search-options.model';
|
import { PaginatedSearchOptions } from '../../paginated-search-options.model';
|
||||||
|
import { ActivatedRoute, Params } from '@angular/router';
|
||||||
import { SearchFixedFilterService } from './search-fixed-filter.service';
|
import { SearchFixedFilterService } from './search-fixed-filter.service';
|
||||||
|
|
||||||
const filterStateSelector = (state: SearchFiltersState) => state.searchFilter;
|
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()
|
@Injectable()
|
||||||
export class SearchFilterService {
|
export class SearchFilterService {
|
||||||
|
|
||||||
@@ -32,10 +38,21 @@ export class SearchFilterService {
|
|||||||
private fixedFilterService: SearchFixedFilterService) {
|
private fixedFilterService: SearchFixedFilterService) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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> {
|
isFilterActiveWithValue(paramName: string, filterValue: string): Observable<boolean> {
|
||||||
return this.routeService.hasQueryParamWithValue(paramName, filterValue);
|
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> {
|
isFilterActive(paramName: string): Observable<boolean> {
|
||||||
return this.routeService.hasQueryParam(paramName);
|
return this.routeService.hasQueryParam(paramName);
|
||||||
}
|
}
|
||||||
@@ -94,8 +111,7 @@ export class SearchFilterService {
|
|||||||
this.getCurrentFixedFilter()).pipe(
|
this.getCurrentFixedFilter()).pipe(
|
||||||
distinctUntilChanged((a, b) => JSON.stringify(a) === JSON.stringify(b)),
|
distinctUntilChanged((a, b) => JSON.stringify(a) === JSON.stringify(b)),
|
||||||
map(([pagination, sort, view, scope, query, filters, fixedFilter]) => {
|
map(([pagination, sort, view, scope, query, filters, fixedFilter]) => {
|
||||||
return Object.assign(new PaginatedSearchOptions(),
|
return Object.assign(new PaginatedSearchOptions(defaults),
|
||||||
defaults,
|
|
||||||
{
|
{
|
||||||
pagination: pagination,
|
pagination: pagination,
|
||||||
sort: sort,
|
sort: sort,
|
||||||
@@ -117,8 +133,7 @@ export class SearchFilterService {
|
|||||||
this.getCurrentFilters(),
|
this.getCurrentFilters(),
|
||||||
this.getCurrentFixedFilter(),
|
this.getCurrentFixedFilter(),
|
||||||
(view, scope, query, filters, fixedFilter) => {
|
(view, scope, query, filters, fixedFilter) => {
|
||||||
return Object.assign(new SearchOptions(),
|
return Object.assign(new SearchOptions(defaults),
|
||||||
defaults,
|
|
||||||
{
|
{
|
||||||
view: view,
|
view: view,
|
||||||
scope: scope || defaults.scope,
|
scope: scope || defaults.scope,
|
||||||
@@ -130,10 +145,27 @@ export class SearchFilterService {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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[]> {
|
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> {
|
isCollapsed(filterName: string): Observable<boolean> {
|
||||||
return this.store.select(filterByNameSelector(filterName))
|
return this.store.select(filterByNameSelector(filterName))
|
||||||
.map((object: SearchFilterState) => {
|
.map((object: SearchFilterState) => {
|
||||||
@@ -145,6 +177,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> {
|
getPage(filterName: string): Observable<number> {
|
||||||
return this.store.select(filterByNameSelector(filterName))
|
return this.store.select(filterByNameSelector(filterName))
|
||||||
.map((object: SearchFilterState) => {
|
.map((object: SearchFilterState) => {
|
||||||
@@ -156,34 +193,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 {
|
public collapse(filterName: string): void {
|
||||||
this.store.dispatch(new SearchFilterCollapseAction(filterName));
|
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 {
|
public expand(filterName: string): void {
|
||||||
this.store.dispatch(new SearchFilterExpandAction(filterName));
|
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 {
|
public toggle(filterName: string): void {
|
||||||
this.store.dispatch(new SearchFilterToggleAction(filterName));
|
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 {
|
public initialCollapse(filterName: string): void {
|
||||||
this.store.dispatch(new SearchFilterInitialCollapseAction(filterName));
|
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 {
|
public initialExpand(filterName: string): void {
|
||||||
this.store.dispatch(new SearchFilterInitialExpandAction(filterName));
|
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 {
|
public decrementPage(filterName: string): void {
|
||||||
this.store.dispatch(new SearchFilterDecrementPageAction(filterName));
|
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 {
|
public incrementPage(filterName: string): void {
|
||||||
this.store.dispatch(new SearchFilterIncrementPageAction(filterName));
|
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 {
|
public resetPage(filterName: string): void {
|
||||||
this.store.dispatch(new SearchFilterResetPageAction(filterName));
|
this.store.dispatch(new SearchFilterResetPageAction(filterName));
|
||||||
}
|
}
|
||||||
|
@@ -1,7 +1,6 @@
|
|||||||
import { Injectable } from '@angular/core';
|
import { Injectable } from '@angular/core';
|
||||||
import { flatMap, map } from 'rxjs/operators';
|
import { flatMap, map } from 'rxjs/operators';
|
||||||
import { Observable } from 'rxjs/Observable';
|
import { Observable } from 'rxjs/Observable';
|
||||||
import { RouteService } from '../../../shared/route.service';
|
|
||||||
import { HALEndpointService } from '../../../core/shared/hal-endpoint.service';
|
import { HALEndpointService } from '../../../core/shared/hal-endpoint.service';
|
||||||
import { GetRequest, RestRequest } from '../../../core/data/request.models';
|
import { GetRequest, RestRequest } from '../../../core/data/request.models';
|
||||||
import { FilteredDiscoveryQueryResponse } from '../../../core/cache/response-cache.models';
|
import { FilteredDiscoveryQueryResponse } from '../../../core/cache/response-cache.models';
|
||||||
@@ -13,6 +12,7 @@ import { GenericConstructor } from '../../../core/shared/generic-constructor';
|
|||||||
import { FilteredDiscoveryPageResponseParsingService } from '../../../core/data/filtered-discovery-page-response-parsing.service';
|
import { FilteredDiscoveryPageResponseParsingService } from '../../../core/data/filtered-discovery-page-response-parsing.service';
|
||||||
import { hasValue } from '../../../shared/empty.util';
|
import { hasValue } from '../../../shared/empty.util';
|
||||||
import { configureRequest } from '../../../core/shared/operators';
|
import { configureRequest } from '../../../core/shared/operators';
|
||||||
|
import { RouteService } from '../../../shared/services/route.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SearchFixedFilterService {
|
export class SearchFixedFilterService {
|
||||||
|
@@ -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';
|
@import '../../../../../styles/mixins.scss';
|
||||||
|
|
||||||
.filters {
|
.filters {
|
||||||
margin-top: $spacer/2;
|
|
||||||
margin-bottom: $spacer/2;
|
|
||||||
a {
|
a {
|
||||||
color: $body-color;
|
color: $body-color;
|
||||||
&:hover {
|
&:hover, &focus {
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
span.badge {
|
||||||
|
vertical-align: text-top;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.toggle-more-filters a {
|
.toggle-more-filters a {
|
||||||
color: $link-color;
|
color: $link-color;
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
cursor: pointer;
|
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>
|
<h3>{{"search.filters.head" | translate}}</h3>
|
||||||
<div *ngIf="(filters | async)?.hasSucceeded">
|
<div *ngIf="(filters | async)?.hasSucceeded">
|
||||||
<div *ngFor="let filter of (filters | async).payload">
|
<div *ngFor="let filter of (filters | async)?.payload">
|
||||||
<ds-search-filter class="d-block mb-3 p-3" [filter]="filter"></ds-search-filter>
|
<ds-search-filter *ngIf="isActive(filter) | async" class="d-block mb-3 p-3" [filter]="filter"></ds-search-filter>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<a class="btn btn-primary" [routerLink]="[getSearchLink()]" [queryParams]="clearParams | async" queryParamsHandling="merge" role="button">{{"search.filters.reset" | translate}}</a>
|
<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 { SearchFiltersComponent } from './search-filters.component';
|
||||||
import { SearchService } from '../search-service/search.service';
|
import { SearchService } from '../search-service/search.service';
|
||||||
import { Observable } from 'rxjs/Observable';
|
import { Observable } from 'rxjs/Observable';
|
||||||
|
import { SearchConfigurationService } from '../search-service/search-configuration.service';
|
||||||
|
|
||||||
describe('SearchFiltersComponent', () => {
|
describe('SearchFiltersComponent', () => {
|
||||||
let comp: SearchFiltersComponent;
|
let comp: SearchFiltersComponent;
|
||||||
@@ -23,8 +24,14 @@ describe('SearchFiltersComponent', () => {
|
|||||||
}
|
}
|
||||||
/* tslint:enable:no-empty */
|
/* 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(() => {
|
beforeEach(async(() => {
|
||||||
@@ -33,7 +40,8 @@ describe('SearchFiltersComponent', () => {
|
|||||||
declarations: [SearchFiltersComponent],
|
declarations: [SearchFiltersComponent],
|
||||||
providers: [
|
providers: [
|
||||||
{ provide: SearchService, useValue: searchServiceStub },
|
{ provide: SearchService, useValue: searchServiceStub },
|
||||||
{ provide: SearchFilterService, useValue: searchFilterServiceStub },
|
{ provide: SearchConfigurationService, useValue: searchConfigServiceStub },
|
||||||
|
{ provide: SearchFilterService, useValue: searchFiltersStub },
|
||||||
|
|
||||||
],
|
],
|
||||||
schemas: [NO_ERRORS_SCHEMA]
|
schemas: [NO_ERRORS_SCHEMA]
|
||||||
|
@@ -3,13 +3,10 @@ import { SearchService } from '../search-service/search.service';
|
|||||||
import { RemoteData } from '../../core/data/remote-data';
|
import { RemoteData } from '../../core/data/remote-data';
|
||||||
import { SearchFilterConfig } from '../search-service/search-filter-config.model';
|
import { SearchFilterConfig } from '../search-service/search-filter-config.model';
|
||||||
import { Observable } from 'rxjs/Observable';
|
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';
|
import { SearchFilterService } from './search-filter/search-filter.service';
|
||||||
|
import { getSucceededRemoteData } from '../../core/shared/operators';
|
||||||
/**
|
|
||||||
* 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({
|
@Component({
|
||||||
selector: 'ds-search-filters',
|
selector: 'ds-search-filters',
|
||||||
@@ -17,15 +14,64 @@ import { SearchFilterService } from './search-filter/search-filter.service';
|
|||||||
templateUrl: './search-filters.component.html',
|
templateUrl: './search-filters.component.html',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This component represents the part of the search sidebar that contains filters.
|
||||||
|
*/
|
||||||
export class SearchFiltersComponent {
|
export class SearchFiltersComponent {
|
||||||
|
/**
|
||||||
|
* An observable containing configuration about which filters are shown and how they are shown
|
||||||
|
*/
|
||||||
filters: Observable<RemoteData<SearchFilterConfig[]>>;
|
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;
|
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() {
|
getSearchLink() {
|
||||||
return this.searchService.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> {
|
||||||
|
// console.log(filter.name);
|
||||||
|
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,15 +1,34 @@
|
|||||||
import 'core-js/fn/object/entries';
|
|
||||||
import { URLCombiner } from '../core/url-combiner/url-combiner';
|
|
||||||
import { isNotEmpty } from '../shared/empty.util';
|
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';
|
||||||
import { SetViewMode } from '../shared/view-mode';
|
import { SetViewMode } from '../shared/view-mode';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This model class represents all parameters needed to request information about a certain search request
|
||||||
|
*/
|
||||||
export class SearchOptions {
|
export class SearchOptions {
|
||||||
view?: SetViewMode = SetViewMode.List;
|
view?: SetViewMode = SetViewMode.List;
|
||||||
scope?: string;
|
scope?: string;
|
||||||
query?: string;
|
query?: string;
|
||||||
|
dsoType?: DSpaceObjectType;
|
||||||
filters?: any;
|
filters?: any;
|
||||||
fixedFilter?: any;
|
fixedFilter?: any;
|
||||||
|
|
||||||
|
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 {
|
toRestUrl(url: string, args: string[] = []): string {
|
||||||
if (isNotEmpty(this.fixedFilter)) {
|
if (isNotEmpty(this.fixedFilter)) {
|
||||||
args.push(this.fixedFilter);
|
args.push(this.fixedFilter);
|
||||||
@@ -17,13 +36,15 @@ export class SearchOptions {
|
|||||||
if (isNotEmpty(this.query)) {
|
if (isNotEmpty(this.query)) {
|
||||||
args.push(`query=${this.query}`);
|
args.push(`query=${this.query}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isNotEmpty(this.scope)) {
|
if (isNotEmpty(this.scope)) {
|
||||||
args.push(`scope=${this.scope}`);
|
args.push(`scope=${this.scope}`);
|
||||||
}
|
}
|
||||||
|
if (isNotEmpty(this.dsoType)) {
|
||||||
|
args.push(`dsoType=${this.dsoType}`);
|
||||||
|
}
|
||||||
if (isNotEmpty(this.filters)) {
|
if (isNotEmpty(this.filters)) {
|
||||||
Object.entries(this.filters).forEach(([key, values]) => {
|
this.filters.forEach((filter: SearchFilter) => {
|
||||||
values.forEach((value) => args.push(`${key}=${value},equals`));
|
filter.values.forEach((value) => args.push(`${filter.key}=${value},${filter.operator}`));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (isNotEmpty(args)) {
|
if (isNotEmpty(args)) {
|
||||||
|
@@ -1,39 +1,40 @@
|
|||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="search-page row">
|
<div class="search-page row">
|
||||||
<ds-search-sidebar *ngIf="!(isMobileView$ | async)" class="col-3 sidebar-md-sticky"
|
<ds-search-sidebar *ngIf="!(isXsOrSm$ | async)" class="col-3 sidebar-md-sticky"
|
||||||
id="search-sidebar"
|
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">
|
<div class="col-12 col-md-9">
|
||||||
<ds-search-form id="search-form"
|
<ds-search-form id="search-form"
|
||||||
[query]="(searchOptions$ | async)?.query"
|
[query]="(searchOptions$ | async)?.query"
|
||||||
[scope]="(searchOptions$ | async)?.scope"
|
[scope]="(searchOptions$ | async)?.scope"
|
||||||
[currentUrl]="getSearchLink()"
|
[currentUrl]="getSearchLink()"
|
||||||
[scopes]="(scopeListRD$ | async)?.payload?.page">
|
[scopes]="(scopeListRD$ | async)">
|
||||||
</ds-search-form>
|
</ds-search-form>
|
||||||
<div class="row">
|
<ds-search-labels></ds-search-labels>
|
||||||
<div id="search-body"
|
<div class="row">
|
||||||
class="row-offcanvas row-offcanvas-left"
|
<div id="search-body"
|
||||||
[@pushInOut]="(isSidebarCollapsed() | async) ? 'collapsed' : 'expanded'">
|
class="row-offcanvas row-offcanvas-left"
|
||||||
<ds-search-sidebar *ngIf="(isMobileView$ | async)" class="col-12"
|
[@pushInOut]="(isSidebarCollapsed() | async) ? 'collapsed' : 'expanded'">
|
||||||
id="search-sidebar-sm"
|
<ds-search-sidebar *ngIf="(isXsOrSm$ | async)" class="col-12"
|
||||||
[resultCount]="(resultsRD$ | async)?.pageInfo?.totalElements"
|
id="search-sidebar-sm"
|
||||||
(toggleSidebar)="closeSidebar()"
|
[resultCount]="(resultsRD$ | async)?.payload.totalElements"
|
||||||
[ngClass]="{'active': !(isSidebarCollapsed() | async)}">
|
(toggleSidebar)="closeSidebar()"
|
||||||
</ds-search-sidebar>
|
[ngClass]="{'active': !(isSidebarCollapsed() | async)}">
|
||||||
<div id="search-content" class="col-12">
|
</ds-search-sidebar>
|
||||||
<div class="d-block d-md-none search-controls clearfix">
|
<div id="search-content" class="col-12">
|
||||||
<ds-view-mode-switch></ds-view-mode-switch>
|
<div class="d-block d-md-none search-controls clearfix">
|
||||||
<button (click)="openSidebar()" aria-controls="#search-body"
|
<ds-view-mode-switch></ds-view-mode-switch>
|
||||||
class="btn btn-outline-primary float-right open-sidebar"><i
|
<button (click)="openSidebar()" aria-controls="#search-body"
|
||||||
class="fa fa-sliders"></i> {{"search.sidebar.open"
|
class="btn btn-outline-primary float-right open-sidebar"><i
|
||||||
| translate}}
|
class="fa fa-sliders"></i> {{"search.sidebar.open"
|
||||||
</button>
|
| translate}}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<ds-search-results [searchResults]="resultsRD$ | async"
|
||||||
|
[searchConfig]="searchOptions$ | async"></ds-search-results>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ds-search-results [searchResults]="resultsRD$ | async"
|
|
||||||
[searchConfig]="searchOptions$ | async" [sortConfig]="sortConfig" [fixedFilter]="fixedFilter | async"></ds-search-results>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
@@ -6,6 +6,7 @@ import { Store } from '@ngrx/store';
|
|||||||
import { TranslateModule } from '@ngx-translate/core';
|
import { TranslateModule } from '@ngx-translate/core';
|
||||||
import { cold, hot } from 'jasmine-marbles';
|
import { cold, hot } from 'jasmine-marbles';
|
||||||
import { Observable } from 'rxjs/Observable';
|
import { Observable } from 'rxjs/Observable';
|
||||||
|
import 'rxjs/add/observable/of';
|
||||||
import { SortDirection, SortOptions } from '../core/cache/models/sort-options.model';
|
import { SortDirection, SortOptions } from '../core/cache/models/sort-options.model';
|
||||||
import { CommunityDataService } from '../core/data/community-data.service';
|
import { CommunityDataService } from '../core/data/community-data.service';
|
||||||
import { HostWindowService } from '../shared/host-window.service';
|
import { HostWindowService } from '../shared/host-window.service';
|
||||||
@@ -18,6 +19,8 @@ import { By } from '@angular/platform-browser';
|
|||||||
import { NgbCollapseModule } from '@ng-bootstrap/ng-bootstrap';
|
import { NgbCollapseModule } from '@ng-bootstrap/ng-bootstrap';
|
||||||
import { SearchSidebarService } from './search-sidebar/search-sidebar.service';
|
import { SearchSidebarService } from './search-sidebar/search-sidebar.service';
|
||||||
import { SearchFilterService } from './search-filters/search-filter/search-filter.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', () => {
|
describe('SearchPageComponent', () => {
|
||||||
let comp: SearchPageComponent;
|
let comp: SearchPageComponent;
|
||||||
@@ -34,10 +37,11 @@ describe('SearchPageComponent', () => {
|
|||||||
pagination.currentPage = 1;
|
pagination.currentPage = 1;
|
||||||
pagination.pageSize = 10;
|
pagination.pageSize = 10;
|
||||||
const sort: SortOptions = new SortOptions('score', SortDirection.DESC);
|
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', {
|
const searchServiceStub = jasmine.createSpyObj('SearchService', {
|
||||||
search: mockResults,
|
search: mockResults,
|
||||||
getSearchLink: '/search'
|
getSearchLink: '/search',
|
||||||
|
getScopes: Observable.of(['test-scope'])
|
||||||
});
|
});
|
||||||
const queryParam = 'test query';
|
const queryParam = 'test query';
|
||||||
const scopeParam = '7669c72a-3f2a-451f-a3b9-9210e7a4c02f';
|
const scopeParam = '7669c72a-3f2a-451f-a3b9-9210e7a4c02f';
|
||||||
@@ -75,10 +79,11 @@ describe('SearchPageComponent', () => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
provide: HostWindowService, useValue: jasmine.createSpyObj('hostWindowService',
|
provide: HostWindowService, useValue: jasmine.createSpyObj('hostWindowService',
|
||||||
{
|
{
|
||||||
isXs: Observable.of(true),
|
isXs: Observable.of(true),
|
||||||
isSm: Observable.of(false)
|
isSm: Observable.of(false),
|
||||||
})
|
isXsOrSm: Observable.of(true)
|
||||||
|
})
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
provide: SearchSidebarService,
|
provide: SearchSidebarService,
|
||||||
@@ -86,16 +91,20 @@ describe('SearchPageComponent', () => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
provide: SearchFilterService,
|
provide: SearchFilterService,
|
||||||
useValue: jasmine.createSpyObj('SearchFilterService', {
|
useValue: {}
|
||||||
getPaginatedSearchOptions: hot('a', {
|
}, {
|
||||||
|
provide: SearchConfigurationService,
|
||||||
|
useValue: {
|
||||||
|
paginatedSearchOptions: hot('a', {
|
||||||
a: paginatedSearchOptions
|
a: paginatedSearchOptions
|
||||||
})
|
}),
|
||||||
})
|
getCurrentScope: (a) => Observable.of('test-id')
|
||||||
|
}
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
schemas: [NO_ERRORS_SCHEMA]
|
schemas: [NO_ERRORS_SCHEMA]
|
||||||
}).overrideComponent(SearchPageComponent, {
|
}).overrideComponent(SearchPageComponent, {
|
||||||
set: { changeDetection: ChangeDetectionStrategy.Default }
|
set: { changeDetection: ChangeDetectionStrategy.Default }
|
||||||
}).compileComponents();
|
}).compileComponents();
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -169,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 { Observable } from 'rxjs/Observable';
|
||||||
import { flatMap, } from 'rxjs/operators';
|
import { flatMap, switchMap, } from 'rxjs/operators';
|
||||||
import { SortDirection, SortOptions } from '../core/cache/models/sort-options.model';
|
|
||||||
import { CommunityDataService } from '../core/data/community-data.service';
|
|
||||||
import { PaginatedList } from '../core/data/paginated-list';
|
import { PaginatedList } from '../core/data/paginated-list';
|
||||||
import { RemoteData } from '../core/data/remote-data';
|
import { RemoteData } from '../core/data/remote-data';
|
||||||
import { Community } from '../core/shared/community.model';
|
|
||||||
import { DSpaceObject } from '../core/shared/dspace-object.model';
|
import { DSpaceObject } from '../core/shared/dspace-object.model';
|
||||||
import { pushInOut } from '../shared/animations/push';
|
import { pushInOut } from '../shared/animations/push';
|
||||||
import { HostWindowService } from '../shared/host-window.service';
|
import { HostWindowService } from '../shared/host-window.service';
|
||||||
@@ -14,7 +11,11 @@ import { SearchFilterService } from './search-filters/search-filter/search-filte
|
|||||||
import { SearchResult } from './search-result.model';
|
import { SearchResult } from './search-result.model';
|
||||||
import { SearchService } from './search-service/search.service';
|
import { SearchService } from './search-service/search.service';
|
||||||
import { SearchSidebarService } from './search-sidebar/search-sidebar.service';
|
import { SearchSidebarService } from './search-sidebar/search-sidebar.service';
|
||||||
import { RouteService } from '../shared/route.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.
|
* This component renders a simple item page.
|
||||||
@@ -29,61 +30,99 @@ import { RouteService } from '../shared/route.service';
|
|||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
animations: [pushInOut]
|
animations: [pushInOut]
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This component represents the whole search page
|
||||||
|
*/
|
||||||
export class SearchPageComponent implements OnInit {
|
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>;
|
searchOptions$: Observable<PaginatedSearchOptions>;
|
||||||
sortConfig: SortOptions;
|
|
||||||
scopeListRD$: Observable<RemoteData<PaginatedList<Community>>>;
|
/**
|
||||||
isMobileView$: Observable<boolean>;
|
* The current relevant scopes
|
||||||
pageSize;
|
*/
|
||||||
pageSizeOptions;
|
scopeListRD$: Observable<DSpaceObject[]>;
|
||||||
defaults = {
|
|
||||||
pagination: {
|
/**
|
||||||
id: 'search-results-pagination',
|
* Emits true if were on a small screen
|
||||||
pageSize: 10
|
*/
|
||||||
},
|
isXsOrSm$: Observable<boolean>;
|
||||||
sort: new SortOptions('score', SortDirection.DESC),
|
|
||||||
query: '',
|
/**
|
||||||
scope: ''
|
* Subscription to unsubscribe from
|
||||||
};
|
*/
|
||||||
fixedFilter;
|
sub: Subscription;
|
||||||
|
|
||||||
constructor(protected service: SearchService,
|
constructor(protected service: SearchService,
|
||||||
protected communityService: CommunityDataService,
|
|
||||||
protected sidebarService: SearchSidebarService,
|
protected sidebarService: SearchSidebarService,
|
||||||
protected windowService: HostWindowService,
|
protected windowService: HostWindowService,
|
||||||
protected filterService: SearchFilterService,
|
protected filterService: SearchFilterService,
|
||||||
protected routeService: RouteService) {
|
protected searchConfigService: SearchConfigurationService) {
|
||||||
this.isMobileView$ = Observable.combineLatest(
|
this.isXsOrSm$ = this.windowService.isXsOrSm();
|
||||||
this.windowService.isXs(),
|
|
||||||
this.windowService.isSm(),
|
|
||||||
((isXs, isSm) => isXs || isSm)
|
|
||||||
);
|
|
||||||
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 {
|
ngOnInit(): void {
|
||||||
this.searchOptions$ = this.filterService.getPaginatedSearchOptions(this.defaults);
|
this.searchOptions$ = this.searchConfigService.paginatedSearchOptions;
|
||||||
this.resultsRD$ = this.searchOptions$.pipe(
|
this.sub = this.searchOptions$
|
||||||
flatMap((searchOptions) => this.service.search(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))
|
||||||
);
|
);
|
||||||
this.fixedFilter = this.routeService.getRouteParameterValue('filter');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the sidebar to a collapsed state
|
||||||
|
*/
|
||||||
public closeSidebar(): void {
|
public closeSidebar(): void {
|
||||||
this.sidebarService.collapse()
|
this.sidebarService.collapse()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the sidebar to an expanded state
|
||||||
|
*/
|
||||||
public openSidebar(): void {
|
public openSidebar(): void {
|
||||||
this.sidebarService.expand();
|
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> {
|
public isSidebarCollapsed(): Observable<boolean> {
|
||||||
return this.sidebarService.isCollapsed;
|
return this.sidebarService.isCollapsed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns {string} The base path to the search page
|
||||||
|
*/
|
||||||
public getSearchLink(): string {
|
public getSearchLink(): string {
|
||||||
return this.service.getSearchLink();
|
return this.service.getSearchLink();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unsubscribe from the subscription
|
||||||
|
*/
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
if (hasValue(this.sub)) {
|
||||||
|
this.sub.unsubscribe();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@@ -24,6 +24,13 @@ import { SearchFilterService } from './search-filters/search-filter/search-filte
|
|||||||
import { FilteredSearchPageComponent } from './filtered-search-page.component';
|
import { FilteredSearchPageComponent } from './filtered-search-page.component';
|
||||||
import { SearchFixedFilterService } from './search-filters/search-filter/search-fixed-filter.service';
|
import { SearchFixedFilterService } from './search-filters/search-filter/search-fixed-filter.service';
|
||||||
import { FilteredSearchPageGuard } from './filtered-search-page.guard';
|
import { FilteredSearchPageGuard } from './filtered-search-page.guard';
|
||||||
|
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 = [
|
const effects = [
|
||||||
SearchSidebarEffects
|
SearchSidebarEffects
|
||||||
@@ -52,14 +59,23 @@ const effects = [
|
|||||||
CommunitySearchResultListElementComponent,
|
CommunitySearchResultListElementComponent,
|
||||||
SearchFiltersComponent,
|
SearchFiltersComponent,
|
||||||
SearchFilterComponent,
|
SearchFilterComponent,
|
||||||
SearchFacetFilterComponent
|
SearchFacetFilterComponent,
|
||||||
|
SearchLabelsComponent,
|
||||||
|
SearchFacetFilterComponent,
|
||||||
|
SearchFacetFilterWrapperComponent,
|
||||||
|
SearchRangeFilterComponent,
|
||||||
|
SearchTextFilterComponent,
|
||||||
|
SearchHierarchyFilterComponent,
|
||||||
|
SearchBooleanFilterComponent,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
SearchService,
|
SearchService,
|
||||||
SearchSidebarService,
|
SearchSidebarService,
|
||||||
SearchFilterService,
|
SearchFilterService,
|
||||||
SearchFixedFilterService,
|
SearchFixedFilterService,
|
||||||
FilteredSearchPageGuard
|
FilteredSearchPageGuard,
|
||||||
|
SearchFilterService,
|
||||||
|
SearchConfigurationService
|
||||||
],
|
],
|
||||||
entryComponents: [
|
entryComponents: [
|
||||||
ItemSearchResultListElementComponent,
|
ItemSearchResultListElementComponent,
|
||||||
@@ -68,7 +84,16 @@ const effects = [
|
|||||||
ItemSearchResultGridElementComponent,
|
ItemSearchResultGridElementComponent,
|
||||||
CollectionSearchResultGridElementComponent,
|
CollectionSearchResultGridElementComponent,
|
||||||
CommunitySearchResultGridElementComponent,
|
CommunitySearchResultGridElementComponent,
|
||||||
|
SearchFacetFilterComponent,
|
||||||
|
SearchRangeFilterComponent,
|
||||||
|
SearchTextFilterComponent,
|
||||||
|
SearchHierarchyFilterComponent,
|
||||||
|
SearchBooleanFilterComponent,
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This module handles all components and pipes that are necessary for the search page
|
||||||
|
*/
|
||||||
export class SearchPageModule {
|
export class SearchPageModule {
|
||||||
}
|
}
|
||||||
|
@@ -2,9 +2,18 @@ import { DSpaceObject } from '../core/shared/dspace-object.model';
|
|||||||
import { Metadatum } from '../core/shared/metadatum.model';
|
import { Metadatum } from '../core/shared/metadatum.model';
|
||||||
import { ListableObject } from '../shared/object-collection/shared/listable-object.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 {
|
export class SearchResult<T extends DSpaceObject> implements ListableObject {
|
||||||
|
/**
|
||||||
|
* The DSpaceObject that was found
|
||||||
|
*/
|
||||||
dspaceObject: T;
|
dspaceObject: T;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The metadata that was used to find this item, hithighlighted
|
||||||
|
*/
|
||||||
hitHighlights: Metadatum[];
|
hitHighlights: Metadatum[];
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@@ -22,8 +22,19 @@ import { hasValue, isNotEmpty } from '../../shared/empty.util';
|
|||||||
fadeInOut
|
fadeInOut
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component that represents all results from a search
|
||||||
|
*/
|
||||||
export class SearchResultsComponent {
|
export class SearchResultsComponent {
|
||||||
|
/**
|
||||||
|
* The actual search result objects
|
||||||
|
*/
|
||||||
@Input() searchResults: RemoteData<PaginatedList<SearchResult<DSpaceObject>>>;
|
@Input() searchResults: RemoteData<PaginatedList<SearchResult<DSpaceObject>>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The current configuration of the search
|
||||||
|
*/
|
||||||
@Input() searchConfig: SearchOptions;
|
@Input() searchConfig: SearchOptions;
|
||||||
@Input() sortConfig: SortOptions;
|
@Input() sortConfig: SortOptions;
|
||||||
@Input() viewMode: SetViewMode;
|
@Input() viewMode: SetViewMode;
|
||||||
|
@@ -1,13 +1,25 @@
|
|||||||
|
|
||||||
import { autoserialize, autoserializeAs } from 'cerialize';
|
import { autoserialize, autoserializeAs } from 'cerialize';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class representing possible values for a certain filter
|
||||||
|
*/
|
||||||
export class FacetValue {
|
export class FacetValue {
|
||||||
|
/**
|
||||||
|
* The display value of the facet value
|
||||||
|
*/
|
||||||
@autoserializeAs(String, 'label')
|
@autoserializeAs(String, 'label')
|
||||||
value: string;
|
value: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The number of results this facet value would have if selected
|
||||||
|
*/
|
||||||
@autoserialize
|
@autoserialize
|
||||||
count: number;
|
count: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The REST url to add this filter value
|
||||||
|
*/
|
||||||
@autoserialize
|
@autoserialize
|
||||||
search: string;
|
search: string;
|
||||||
}
|
}
|
||||||
|
@@ -1,6 +1,24 @@
|
|||||||
|
/**
|
||||||
|
* Enumeration containing all possible types for filters
|
||||||
|
*/
|
||||||
export enum FilterType {
|
export enum FilterType {
|
||||||
text,
|
/**
|
||||||
date,
|
* Represents simple text facets
|
||||||
hierarchical,
|
*/
|
||||||
standard
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user