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:
Kristof De Langhe
2018-09-17 14:10:46 +02:00
423 changed files with 23852 additions and 782 deletions

21
.dockerignore Normal file
View 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

View File

@@ -10,8 +10,8 @@ addons:
language: node_js
node_js:
- "6"
- "8"
- "9"
cache:
yarn: true

10
Dockerfile Normal file
View 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

View File

@@ -14,7 +14,7 @@ If you're looking for the 2016 Angular 2 DSpace UI prototype, you can find it [h
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
# clone the repo
@@ -23,9 +23,6 @@ git clone https://github.com/DSpace/dspace-angular.git
# change directory to our repo
cd dspace-angular
# install the global dependencies
yarn run global
# install the local dependencies
yarn install

View File

@@ -22,6 +22,14 @@ module.exports = {
// msToLive: 1000, // 15 minutes
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: {
rtl: false,

View File

@@ -8,7 +8,7 @@
},
"license": "BSD-2-Clause",
"engines": {
"node": ">=6.0.0"
"node": ">=8.0.0"
},
"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",
@@ -80,16 +80,22 @@
"@angular/router": "^5.2.5",
"@angularclass/bootloader": "1.0.1",
"@ng-bootstrap/ng-bootstrap": "^1.0.0",
"@ng-dynamic-forms/core": "5.4.7",
"@ng-dynamic-forms/ui-ng-bootstrap": "5.4.7",
"@ngrx/effects": "^5.1.0",
"@ngrx/router-store": "^5.0.1",
"@ngrx/store": "^5.1.0",
"@nguniversal/express-engine": "5.0.0-beta.5",
"@nguniversal/express-engine": "5.0.0",
"@ngx-translate/core": "9.1.1",
"@ngx-translate/http-loader": "2.0.1",
"@nicky-lenaers/ngx-scroll-to": "^0.6.0",
"angular-idle-preload": "2.0.4",
"angular2-moment": "^1.9.0",
"angular-sortablejs": "^2.5.0",
"angular2-text-mask": "8.0.4",
"angulartics2": "^5.2.0",
"body-parser": "1.18.2",
"bootstrap": "^4.0.0",
"bootstrap": "4.1.1",
"cerialize": "0.1.18",
"compression": "1.7.1",
"cookie-parser": "1.4.3",
@@ -99,14 +105,23 @@
"font-awesome": "4.7.0",
"http-server": "0.11.1",
"https": "1.0.0",
"js-cookie": "2.2.0",
"js.clone": "0.0.3",
"jsonschema": "1.2.2",
"jwt-decode": "^2.2.0",
"methods": "1.1.2",
"moment": "^2.22.1",
"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",
"nouislider": "^11.0.0",
"pem": "1.12.3",
"reflect-metadata": "0.1.12",
"rxjs": "5.5.6",
"sortablejs": "1.7.0",
"text-mask-core": "5.0.1",
"ts-md5": "^1.2.4",
"uuid": "^3.2.1",
"webfontloader": "1.6.28",
@@ -124,6 +139,7 @@
"@types/express-serve-static-core": "4.11.1",
"@types/hammerjs": "2.0.35",
"@types/jasmine": "^2.8.6",
"@types/js-cookie": "2.1.0",
"@types/memory-cache": "0.2.0",
"@types/mime": "2.0.0",
"@types/node": "^9.4.6",

View File

@@ -114,7 +114,9 @@
}
},
"nav": {
"home": "Home"
"home": "Home",
"login": "Log In",
"logout": "Log Out"
},
"pagination": {
"results-per-page": "Results Per Page",
@@ -125,8 +127,13 @@
}
},
"sorting": {
"ASC": "Ascending",
"DESC": "Descending"
"score": {
"DESC": "Relevance"
},
"dc.title": {
"ASC": "Title Ascending",
"DESC": "Title Descending"
}
},
"title": "DSpace",
"404": {
@@ -177,13 +184,13 @@
"close": "Back to results",
"open": "Search Tools",
"results": "results",
"filters":{
"title":"Filters"
"filters": {
"title": "Filters"
},
"settings":{
"title":"Settings",
"sort-by":"Sort By",
"rpp":"Results per page"
"settings": {
"title": "Settings",
"sort-by": "Sort By",
"rpp": "Results per page"
}
},
"view-switch": {
@@ -193,6 +200,13 @@
"filters": {
"head": "Filters",
"reset": "Reset filters",
"applied": {
"f.author": "Author",
"f.dateIssued.min": "Start date",
"f.dateIssued.max": "End date",
"f.subject": "Subject",
"f.has_content_in_original_bundle": "Has files"
},
"filter": {
"show-more": "Show more",
"show-less": "Collapse",
@@ -209,11 +223,15 @@
"head": "Subject"
},
"dateIssued": {
"placeholder": "Date",
"max": {
"placeholder": "Minimum Date"
},
"min": {
"placeholder": "Maximum Date"
},
"head": "Date"
},
"has_content_in_original_bundle": {
"placeholder": "Has files",
"head": "Has files"
},
"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": {
"default": "Loading...",
"top-level-communities": "Loading top level communities...",
@@ -243,6 +310,53 @@
"recent-submissions": "Error fetching recent submissions",
"item": "Error fetching item",
"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."
}
}
}

View File

@@ -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 {
}

View 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 {
}

View File

@@ -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>

View File

@@ -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');
});
});

View File

@@ -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);
}
}

View File

@@ -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>

View File

@@ -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');
});
});

View File

@@ -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);
}
}

View File

@@ -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>

View File

@@ -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');
});
});

View File

@@ -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 };
});
}
}

View 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 {
}

View 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 {
}

View File

@@ -2,12 +2,23 @@ import { NgModule } from '@angular/core';
import { RouterModule } from '@angular/router';
import { CollectionPageComponent } from './collection-page.component';
import { CollectionPageResolver } from './collection-page.resolver';
@NgModule({
imports: [
RouterModule.forChild([
{ path: ':id', component: CollectionPageComponent, pathMatch: 'full' }
{
path: ':id',
component: CollectionPageComponent,
pathMatch: 'full',
resolve: {
collection: CollectionPageResolver
}
}
])
],
providers: [
CollectionPageResolver,
]
})
export class CollectionPageRoutingModule {

View File

@@ -1,6 +1,6 @@
<div class="container">
<div class="collection-page"
*ngVar="(collectionRDObs | async) as collectionRD">
*ngVar="(collectionRD$ | async) as collectionRD">
<div *ngIf="collectionRD?.hasSucceeded" @fadeInOut>
<div *ngIf="collectionRD?.payload as collection">
<!-- Collection Name -->
@@ -8,8 +8,8 @@
[name]="collection.name">
</ds-comcol-page-header>
<!-- Collection logo -->
<ds-comcol-page-logo *ngIf="logoRDObs"
[logo]="(logoRDObs | async)?.payload"
<ds-comcol-page-logo *ngIf="logoRD$"
[logo]="(logoRD$ | async)?.payload"
[alternateText]="'Collection Logo'">
</ds-comcol-page-logo>
<!-- Introductionary text -->
@@ -38,14 +38,14 @@
<ds-error *ngIf="collectionRD?.hasFailed" message="{{'error.collection' | translate}}"></ds-error>
<ds-loading *ngIf="collectionRD?.isLoading" message="{{'loading.collection' | translate}}"></ds-loading>
<br>
<ng-container *ngVar="(itemRDObs | async) as itemRD">
<ng-container *ngVar="(itemRD$ | async) as itemRD">
<div *ngIf="itemRD?.hasSucceeded" @fadeIn>
<h2>{{'collection.page.browse.recent.head' | translate}}</h2>
<ds-viewable-collection
[config]="paginationConfig"
[sortConfig]="sortConfig"
[objects]="itemRD"
[hideGear]="false"
[hideGear]="true"
(paginationChange)="onPaginationChange($event)">
</ds-viewable-collection>
</div>

View File

@@ -5,7 +5,6 @@ import { Observable } from 'rxjs/Observable';
import { Subscription } from 'rxjs/Subscription';
import { SortDirection, SortOptions } from '../core/cache/models/sort-options.model';
import { CollectionDataService } from '../core/data/collection-data.service';
import { ItemDataService } from '../core/data/item-data.service';
import { PaginatedList } from '../core/data/paginated-list';
import { RemoteData } from '../core/data/remote-data';
@@ -18,6 +17,11 @@ import { Item } from '../core/shared/item.model';
import { fadeIn, fadeInOut } from '../shared/animations/fade';
import { hasValue, isNotEmpty } from '../shared/empty.util';
import { PaginationComponentOptions } from '../shared/pagination/pagination-component-options.model';
import { filter, flatMap, map } from 'rxjs/operators';
import { SearchService } from '../+search-page/search-service/search.service';
import { PaginatedSearchOptions } from '../+search-page/paginated-search-options.model';
import { toDSpaceObjectListRD } from '../core/shared/operators';
import { DSpaceObjectType } from '../core/shared/dspace-object-type.model';
@Component({
selector: 'ds-collection-page',
@@ -30,9 +34,9 @@ import { PaginationComponentOptions } from '../shared/pagination/pagination-comp
]
})
export class CollectionPageComponent implements OnInit, OnDestroy {
collectionRDObs: Observable<RemoteData<Collection>>;
itemRDObs: Observable<RemoteData<PaginatedList<Item>>>;
logoRDObs: Observable<RemoteData<Bitstream>>;
collectionRD$: Observable<RemoteData<Collection>>;
itemRD$: Observable<RemoteData<PaginatedList<Item>>>;
logoRD$: Observable<RemoteData<Bitstream>>;
paginationConfig: PaginationComponentOptions;
sortConfig: SortOptions;
private subs: Subscription[] = [];
@@ -40,7 +44,7 @@ export class CollectionPageComponent implements OnInit, OnDestroy {
constructor(
private collectionDataService: CollectionDataService,
private itemDataService: ItemDataService,
private searchService: SearchService,
private metadata: MetadataService,
private route: ActivatedRoute
) {
@@ -48,52 +52,41 @@ export class CollectionPageComponent implements OnInit, OnDestroy {
this.paginationConfig.id = 'collection-page-pagination';
this.paginationConfig.pageSize = 5;
this.paginationConfig.currentPage = 1;
this.sortConfig = new SortOptions('dc.title', SortDirection.ASC);
this.sortConfig = new SortOptions('dc.date.accessioned', SortDirection.DESC);
}
ngOnInit(): void {
this.collectionRD$ = this.route.data.map((data) => data.collection);
this.logoRD$ = this.collectionRD$.pipe(
map((rd: RemoteData<Collection>) => rd.payload),
filter((collection: Collection) => hasValue(collection)),
flatMap((collection: Collection) => collection.logo)
);
this.subs.push(
Observable.combineLatest(
this.route.params,
this.route.queryParams,
(params, queryParams, ) => {
return Object.assign({}, params, queryParams);
})
.subscribe((params) => {
this.collectionId = params.id;
this.collectionRDObs = this.collectionDataService.findById(this.collectionId);
this.metadata.processRemoteData(this.collectionRDObs);
this.subs.push(this.collectionRDObs
.map((rd: RemoteData<Collection>) => rd.payload)
.filter((collection: Collection) => hasValue(collection))
.subscribe((collection: Collection) => this.logoRDObs = collection.logo));
const page = +params.page || this.paginationConfig.currentPage;
const pageSize = +params.pageSize || this.paginationConfig.pageSize;
const sortDirection = +params.page || this.sortConfig.direction;
const pagination = Object.assign({},
this.paginationConfig,
{ currentPage: page, pageSize: pageSize }
);
const sort = Object.assign({},
this.sortConfig,
{ direction: sortDirection, field: params.sortField }
);
this.updatePage({
pagination: pagination,
sort: sort
});
}));
this.route.queryParams.subscribe((params) => {
this.metadata.processRemoteData(this.collectionRD$);
const page = +params.page || this.paginationConfig.currentPage;
const pageSize = +params.pageSize || this.paginationConfig.pageSize;
const pagination = Object.assign({},
this.paginationConfig,
{ currentPage: page, pageSize: pageSize }
);
this.updatePage({
pagination: pagination,
sort: this.sortConfig
});
}));
}
updatePage(searchOptions) {
this.itemRDObs = this.itemDataService.findAll({
scopeID: this.collectionId,
currentPage: searchOptions.pagination.currentPage,
elementsPerPage: searchOptions.pagination.pageSize,
sort: searchOptions.sort
});
this.itemRD$ = this.searchService.search(
new PaginatedSearchOptions({
scope: this.collectionId,
pagination: searchOptions.pagination,
sort: searchOptions.sort,
dsoType: DSpaceObjectType.ITEM
})).pipe(toDSpaceObjectListRD()) as Observable<RemoteData<PaginatedList<Item>>>;
}
ngOnDestroy(): void {

View File

@@ -5,11 +5,13 @@ import { SharedModule } from '../shared/shared.module';
import { CollectionPageComponent } from './collection-page.component';
import { CollectionPageRoutingModule } from './collection-page-routing.module';
import { SearchPageModule } from '../+search-page/search-page.module';
@NgModule({
imports: [
CommonModule,
SharedModule,
SearchPageModule,
CollectionPageRoutingModule
],
declarations: [

View 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()
);
}
}

View File

@@ -2,12 +2,23 @@ import { NgModule } from '@angular/core';
import { RouterModule } from '@angular/router';
import { CommunityPageComponent } from './community-page.component';
import { CommunityPageResolver } from './community-page.resolver';
@NgModule({
imports: [
RouterModule.forChild([
{ path: ':id', component: CommunityPageComponent, pathMatch: 'full' }
{
path: ':id',
component: CommunityPageComponent,
pathMatch: 'full',
resolve: {
community: CommunityPageResolver
}
}
])
],
providers: [
CommunityPageResolver,
]
})
export class CommunityPageRoutingModule {

View File

@@ -1,11 +1,11 @@
<div class="container" *ngVar="(communityRDObs | async) as communityRD">
<div class="container" *ngVar="(communityRD$ | async) as communityRD">
<div class="community-page" *ngIf="communityRD?.hasSucceeded" @fadeInOut>
<div *ngIf="communityRD?.payload; let communityPayload">
<!-- Community name -->
<ds-comcol-page-header [name]="communityPayload.name"></ds-comcol-page-header>
<!-- Community logo -->
<ds-comcol-page-logo *ngIf="logoRDObs"
[logo]="(logoRDObs | async)?.payload"
<ds-comcol-page-logo *ngIf="logoRD$"
[logo]="(logoRD$ | async)?.payload"
[alternateText]="'Community Logo'">
</ds-comcol-page-logo>
<!-- Introductory text -->

View File

@@ -22,8 +22,8 @@ import { Observable } from 'rxjs/Observable';
animations: [fadeInOut]
})
export class CommunityPageComponent implements OnInit, OnDestroy {
communityRDObs: Observable<RemoteData<Community>>;
logoRDObs: Observable<RemoteData<Bitstream>>;
communityRD$: Observable<RemoteData<Community>>;
logoRD$: Observable<RemoteData<Bitstream>>;
private subs: Subscription[] = [];
constructor(
@@ -35,14 +35,11 @@ export class CommunityPageComponent implements OnInit, OnDestroy {
}
ngOnInit(): void {
this.route.params.subscribe((params: Params) => {
this.communityRDObs = this.communityDataService.findById(params.id);
this.metadata.processRemoteData(this.communityRDObs);
this.subs.push(this.communityRDObs
.map((rd: RemoteData<Community>) => rd.payload)
.filter((community: Community) => hasValue(community))
.subscribe((community: Community) => this.logoRDObs = community.logo));
});
this.communityRD$ = this.route.data.map((data) => data.community);
this.logoRD$ = this.communityRD$
.map((rd: RemoteData<Community>) => rd.payload)
.filter((community: Community) => hasValue(community))
.flatMap((community: Community) => community.logo);
}
ngOnDestroy(): void {

View 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()
);
}
}

View File

@@ -38,7 +38,7 @@ export class TopLevelCommunityListComponent {
}
updatePage(data) {
this.communitiesRDObs = this.cds.findAll({
this.communitiesRDObs = this.cds.findTop({
currentPage: data.page,
elementsPerPage: data.pageSize,
sort: { field: data.sortField, direction: data.sortDirection }

View File

@@ -60,7 +60,7 @@ describe('CollectionsComponent', () => {
});
});
describe('When the requested item request has succeeded', () => {
describe('When the requested item request has failed', () => {
beforeEach(() => {
collectionsComponent.item = failedMockItem;
fixture.detectChanges();

View File

@@ -1,15 +1,15 @@
<div class="container" *ngVar="(itemRDObs | async) as itemRD">
<div class="container" *ngVar="(itemRD$ | async) as itemRD">
<div class="item-page" *ngIf="itemRD?.hasSucceeded" @fadeInOut>
<div *ngIf="itemRD?.payload as entity">
<ds-item-page-title-field [item]="item"></ds-item-page-title-field>
<div class="simple-view-link">
<a class="btn btn-outline-primary col-4" [routerLink]="['/items/' + item.id]">
<div class="simple-view-link my-3">
<a class="btn btn-outline-primary" [routerLink]="['/items/' + item.id]">
{{"item.page.link.simple" | translate}}
</a>
</div>
<table class="table table-responsive table-striped">
<tbody>
<tr *ngFor="let metadatum of (metadataObs | async)">
<tr *ngFor="let metadatum of (metadata$ | async)">
<td>{{metadatum.key}}</td>
<td>{{metadatum.value}}</td>
<td>{{metadatum.language}}</td>

View File

@@ -1,7 +1,10 @@
@import '../../../styles/variables.scss';
:host {
div.simple-view-link {
text-align: center;
margin: 20px;
a {
min-width: 25%;
}
}
}

View File

@@ -31,9 +31,9 @@ import { BehaviorSubject } from 'rxjs/BehaviorSubject';
})
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) {
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 **/
ngOnInit(): void {
super.ngOnInit();
}
initialize(params) {
super.initialize(params);
this.metadataObs = this.itemRDObs
this.metadata$ = this.itemRD$
.map((rd: RemoteData<Item>) => rd.payload)
.filter((item: Item) => hasValue(item))
.map((item: Item) => item.metadata);
}
}

View File

@@ -3,13 +3,30 @@ import { RouterModule } from '@angular/router';
import { ItemPageComponent } from './simple/item-page.component';
import { FullItemPageComponent } from './full/full-item-page.component';
import { ItemPageResolver } from './item-page.resolver';
@NgModule({
imports: [
RouterModule.forChild([
{ path: ':id', component: ItemPageComponent, pathMatch: 'full' },
{ path: ':id/full', component: FullItemPageComponent }
{
path: ':id',
component: ItemPageComponent,
pathMatch: 'full',
resolve: {
item: ItemPageResolver
}
},
{
path: ':id/full',
component: FullItemPageComponent,
resolve: {
item: ItemPageResolver
}
}
])
],
providers: [
ItemPageResolver,
]
})
export class ItemPageRoutingModule {

View 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()
);
}
}

View File

@@ -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 *ngIf="itemRD?.payload as item">
<ds-entity-type-switcher [object]="item" [viewMode]="ElementViewMode.Full"></ds-entity-type-switcher>

View File

@@ -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 { Observable } from 'rxjs/Observable';
@@ -12,9 +12,6 @@ import { MetadataService } from '../../core/metadata/metadata.service';
import { fadeInOut } from '../../shared/animations/fade';
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.
@@ -32,13 +29,11 @@ export class ItemPageComponent implements OnInit {
id: number;
private sub: Subscription;
private itemSub: Subscription;
thumbnailObs: Observable<Bitstream>;
private sub: any;
itemRDObs: BehaviorSubject<RemoteData<Item>> = new BehaviorSubject(new RemoteData(true, true, undefined, null, null));
itemRD$: Observable<RemoteData<Item>>;
ElementViewMode = viewMode.ElementViewMode;
thumbnail$: Observable<Bitstream>;
constructor(
private route: ActivatedRoute,
@@ -47,20 +42,9 @@ export class ItemPageComponent implements OnInit {
) { }
ngOnInit(): void {
this.sub = this.route.params.subscribe((params) => {
this.initialize(params);
});
}
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
this.itemRD$ = this.route.data.map((data) => data.item);
this.metadataService.processRemoteData(this.itemRD$);
this.thumbnail$ = this.itemRD$
.map((rd: RemoteData<Item>) => rd.payload)
.filter((item: Item) => hasValue(item))
.flatMap((item: Item) => item.getThumbnail());

View 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 { }

View 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>

View File

@@ -0,0 +1,6 @@
@import '../../styles/variables.scss';
.login-logo {
height: $login-logo-height;
width: $login-logo-width;
}

View 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()
});
});

View 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());
}
}

View 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 {
}

View 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 { }

View 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>

View File

@@ -0,0 +1 @@
@import '../+login-page/login-page.component.scss';

View 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()
});
});

View 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 {
}

View 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 {
}

View File

@@ -4,9 +4,10 @@ import { SearchFilterService } from './search-filters/search-filter/search-filte
import { SearchService } from './search-service/search.service';
import { SearchSidebarService } from './search-sidebar/search-sidebar.service';
import { SearchPageComponent } from './search-page.component';
import { RouteService } from '../shared/route.service';
import { ChangeDetectionStrategy, Component, Injectable } from '@angular/core';
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.
@@ -23,12 +24,11 @@ import { pushInOut } from '../shared/animations/push';
export class FilteredSearchPageComponent extends SearchPageComponent {
constructor(protected service: SearchService,
protected communityService: CommunityDataService,
protected sidebarService: SearchSidebarService,
protected windowService: HostWindowService,
protected filterService: SearchFilterService,
protected routeService: RouteService) {
super(service, communityService, sidebarService, windowService, filterService, routeService);
protected searchConfigService: SearchConfigurationService) {
super(service, sidebarService, windowService, filterService, searchConfigService);
}
}

View File

@@ -2,11 +2,19 @@ import { autoserialize } from 'cerialize';
import { Metadatum } from '../core/shared/metadatum.model';
import { ListableObject } from '../shared/object-collection/shared/listable-object.model';
/**
* Represents a normalized version of a search result object of a certain DSpaceObject
*/
export class NormalizedSearchResult implements ListableObject {
/**
* The UUID of the DSpaceObject that was found
*/
@autoserialize
dspaceObject: string;
/**
* The metadata that was used to find this item, hithighlighted
*/
@autoserialize
hitHighlights: Metadatum[];

View 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'
);
});
});
});

View File

@@ -2,10 +2,28 @@ import { SortOptions } from '../core/cache/models/sort-options.model';
import { PaginationComponentOptions } from '../shared/pagination/pagination-component-options.model';
import { isNotEmpty } from '../shared/empty.util';
import { SearchOptions } from './search-options.model';
import { SearchFilter } from './search-filter.model';
import { DSpaceObjectType } from '../core/shared/dspace-object-type.model';
/**
* This model class represents all parameters needed to request information about a certain page of a search request, in a certain order
*/
export class PaginatedSearchOptions extends SearchOptions {
pagination?: PaginationComponentOptions;
sort?: SortOptions;
constructor(options: {scope?: string, query?: string, dsoType?: DSpaceObjectType, filters?: SearchFilter[], pagination?: PaginationComponentOptions, sort?: SortOptions}) {
super(options);
this.pagination = options.pagination;
this.sort = options.sort;
}
/**
* Method to generate the URL that can be used to request a certain page with specific sort options
* @param {string} url The URL to the REST endpoint
* @param {string[]} args A list of query arguments that should be included in the URL
* @returns {string} URL with all paginated search options and passed arguments as query parameters
*/
toRestUrl(url: string, args: string[] = []): string {
if (isNotEmpty(this.sort)) {
args.push(`sort=${this.sort.field},${this.sort.direction}`);

View 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';
}
}
}

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -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 {
}

View File

@@ -0,0 +1 @@
<ng-container *ngComponentOutlet="getSearchFilter(); injector: objectInjector;"></ng-container>

View File

@@ -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);
}
}

View File

@@ -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>

View File

@@ -1,10 +1,8 @@
import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core';
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { RouterTestingModule } from '@angular/router/testing';
import { TranslateModule } from '@ngx-translate/core';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { SearchFacetFilterComponent } from './search-facet-filter.component';
import { SearchFilterService } from '../search-filter.service';
import { FILTER_CONFIG, SearchFilterService } from '../search-filter.service';
import { SearchFilterConfig } from '../../../search-service/search-filter-config.model';
import { FilterType } from '../../../search-service/filter-type.model';
import { FacetValue } from '../../../search-service/facet-value.model';
@@ -14,11 +12,12 @@ import { SearchService } from '../../../search-service/search.service';
import { SearchServiceStub } from '../../../../shared/testing/search-service-stub';
import { RemoteData } from '../../../../core/data/remote-data';
import { PaginatedList } from '../../../../core/data/paginated-list';
import { SearchOptions } from '../../../search-options.model';
import { RouterStub } from '../../../../shared/testing/router-stub';
import { Router } from '@angular/router';
import { BehaviorSubject } from 'rxjs/BehaviorSubject';
import { PageInfo } from '../../../../core/shared/page-info.model';
import { SearchFacetFilterComponent } from './search-facet-filter.component';
import { RemoteDataBuildService } from '../../../../core/cache/builders/remote-data-build.service';
import { SearchConfigurationService } from '../../../search-service/search-configuration.service';
describe('SearchFacetFilterComponent', () => {
let comp: SearchFacetFilterComponent;
@@ -65,18 +64,21 @@ describe('SearchFacetFilterComponent', () => {
providers: [
{ provide: SearchService, useValue: new SearchServiceStub(searchLink) },
{ provide: Router, useValue: new RouterStub() },
{ provide: FILTER_CONFIG, useValue: new SearchFilterConfig() },
{ provide: RemoteDataBuildService, useValue: {aggregate: () => Observable.of({})} },
{ provide: SearchConfigurationService, useValue: {searchOptions: Observable.of({})} },
{
provide: SearchFilterService, useValue: {
isFilterActiveWithValue: (paramName: string, filterValue: string) => true,
getPage: (paramName: string) => page,
/* tslint:disable:no-empty */
incrementPage: (filterName: string) => {
},
resetPage: (filterName: string) => {
},
getSearchOptions: () => Observable.of({}),
/* tslint:enable:no-empty */
}
getSelectedValuesForFilter: () => Observable.of(selectedValues),
isFilterActiveWithValue: (paramName: string, filterValue: string) => true,
getPage: (paramName: string) => page,
/* tslint:disable:no-empty */
incrementPage: (filterName: string) => {
},
resetPage: (filterName: string) => {
}
/* tslint:enable:no-empty */
}
}
],
schemas: [NO_ERRORS_SCHEMA]
@@ -89,9 +91,6 @@ describe('SearchFacetFilterComponent', () => {
fixture = TestBed.createComponent(SearchFacetFilterComponent);
comp = fixture.componentInstance; // SearchPageComponent test instance
comp.filterConfig = mockFilterConfig;
comp.filterValues = [mockValues];
comp.filterValues$ = new BehaviorSubject(comp.filterValues);
comp.selectedValues = selectedValues;
filterService = (comp as any).filterService;
searchService = (comp as any).searchService;
spyOn(searchService, 'getFacetValuesFor').and.returnValue(mockValues);
@@ -124,14 +123,14 @@ describe('SearchFacetFilterComponent', () => {
describe('when the getAddParams method is called wih a value', () => {
it('should return the selectedValue list with the new parameter value', () => {
const result = comp.getAddParams(value3);
expect(result[mockFilterConfig.paramName]).toEqual([value1, value2, value3]);
result.subscribe((r) => expect(r[mockFilterConfig.paramName]).toEqual([value1, value2, value3]));
});
});
describe('when the getRemoveParams method is called wih a value', () => {
it('should return the selectedValue list with the parameter value left out', () => {
const result = comp.getRemoveParams(value1);
expect(result[mockFilterConfig.paramName]).toEqual([value2]);
result.subscribe((r) => expect(r[mockFilterConfig.paramName]).toEqual([value2]));
});
});
@@ -169,7 +168,7 @@ describe('SearchFacetFilterComponent', () => {
});
describe('when the getCurrentUrl method is called', () => {
const url = 'test.url/test'
const url = 'test.url/test';
beforeEach(() => {
router.navigateByUrl(url);
});
@@ -182,7 +181,7 @@ describe('SearchFacetFilterComponent', () => {
describe('when the onSubmit method is called with data', () => {
const searchUrl = '/search/path';
const testValue = 'test';
const data = { [mockFilterConfig.paramName]: testValue };
const data = testValue;
beforeEach(() => {
spyOn(comp, 'getSearchLink').and.returnValue(searchUrl);
comp.onSubmit(data);
@@ -197,46 +196,26 @@ describe('SearchFacetFilterComponent', () => {
});
describe('when updateFilterValueList is called', () => {
const cPage = 10;
const searchOptions = new SearchOptions();
beforeEach(() => {
// spyOn(searchService, 'getFacetValuesFor'); Already spied upon
comp.currentPage = Observable.of(cPage);
comp.updateFilterValueList(searchOptions);
spyOn(comp, 'showFirstPageOnly');
comp.updateFilterValueList()
});
it('should call getFacetValuesFor on the searchService with the correct parameters', () => {
expect(searchService.getFacetValuesFor).toHaveBeenCalledWith(mockFilterConfig, cPage, searchOptions);
it('should call showFirstPageOnly and empty the filter', () => {
expect(comp.animationState).toEqual('loading');
expect((comp as any).collapseNextUpdate).toBeTruthy();
expect(comp.filter).toEqual('');
});
});
describe('when updateFilterValueList is called and pageChange is set to true', () => {
const searchOptions = new SearchOptions();
describe('when findSuggestions is called with query \'test\'', () => {
const query = 'test';
beforeEach(() => {
comp.pageChange = true;
spyOn(comp, 'showFirstPageOnly');
comp.updateFilterValueList(searchOptions);
comp.findSuggestions(query);
});
it('should not call showFirstPageOnly on the component', () => {
expect(comp.showFirstPageOnly).not.toHaveBeenCalled();
});
it('should set pageChange to false', () => {
expect(comp.pageChange).toBeFalsy();
});
});
describe('when updateFilterValueList is called and pageChange is set to false', () => {
const searchOptions = new SearchOptions();
beforeEach(() => {
comp.pageChange = false;
spyOn(comp, 'showFirstPageOnly');
comp.updateFilterValueList(searchOptions);
});
it('should call showFirstPageOnly on the component', () => {
expect(comp.showFirstPageOnly).toHaveBeenCalled();
it('should call getFacetValuesFor on the component\'s SearchService with the right query', () => {
expect((comp as any).searchService.getFacetValuesFor).toHaveBeenCalledWith(comp.filterConfig, 1, {}, query);
});
});
});

View File

@@ -1,125 +1,289 @@
import { Component, Input, OnDestroy, OnInit } from '@angular/core';
import { animate, state, style, transition, trigger } from '@angular/animations';
import { Component, Inject, OnDestroy, OnInit } from '@angular/core';
import { Router } from '@angular/router';
import { BehaviorSubject } from 'rxjs/BehaviorSubject';
import { Observable } from 'rxjs/Observable';
import { Subject } from 'rxjs/Subject';
import { Subscription } from 'rxjs/Subscription';
import { RemoteDataBuildService } from '../../../../core/cache/builders/remote-data-build.service';
import { PaginatedList } from '../../../../core/data/paginated-list';
import { RemoteData } from '../../../../core/data/remote-data';
import { hasNoValue, hasValue, isNotEmpty } from '../../../../shared/empty.util';
import { EmphasizePipe } from '../../../../shared/utils/emphasize.pipe';
import { SearchOptions } from '../../../search-options.model';
import { FacetValue } from '../../../search-service/facet-value.model';
import { SearchFilterConfig } from '../../../search-service/search-filter-config.model';
import { Router } from '@angular/router';
import { Observable } from 'rxjs/Observable';
import { SearchFilterService } from '../search-filter.service';
import { hasNoValue, hasValue, isNotEmpty } from '../../../../shared/empty.util';
import { RemoteData } from '../../../../core/data/remote-data';
import { PaginatedList } from '../../../../core/data/paginated-list';
import { SearchService } from '../../../search-service/search.service';
import { SearchOptions } from '../../../search-options.model';
import { BehaviorSubject } from 'rxjs/BehaviorSubject';
import { Subscription } from 'rxjs/Subscription';
/**
* This component renders a simple item page.
* The route parameter 'id' is used to request the item it represents.
* All fields of the item that should be displayed, are defined in its template.
*/
import { FILTER_CONFIG, SearchFilterService } from '../search-filter.service';
import { SearchConfigurationService } from '../../../search-service/search-configuration.service';
import { getSucceededRemoteData } from '../../../../core/shared/operators';
import { map } from 'rxjs/operators';
@Component({
selector: 'ds-search-facet-filter',
styleUrls: ['./search-facet-filter.component.scss'],
templateUrl: './search-facet-filter.component.html'
template: ``,
})
/**
* Super class for all different representations of facets
*/
export class SearchFacetFilterComponent implements OnInit, OnDestroy {
@Input() filterConfig: SearchFilterConfig;
@Input() selectedValues: string[];
filterValues: Array<Observable<RemoteData<PaginatedList<FacetValue>>>> = [];
filterValues$: BehaviorSubject<any> = new BehaviorSubject(this.filterValues);
/**
* Emits an array of pages with values found for this facet
*/
filterValues$: Subject<RemoteData<Array<PaginatedList<FacetValue>>>>;
/**
* Emits the current last shown page of this facet's values
*/
currentPage: Observable<number>;
/**
* Emits true if the current page is also the last page available
*/
isLastPage$: BehaviorSubject<boolean> = new BehaviorSubject(false);
/**
* The value of the input field that is used to query for possible values for this filter
*/
filter: string;
pageChange = false;
sub: Subscription;
constructor(private searchService: SearchService, private filterService: SearchFilterService, private router: Router) {
/**
* List of subscriptions to unsubscribe from
*/
private subs: Subscription[] = [];
/**
* Emits the result values for this filter found by the current filter query
*/
filterSearchResults: Observable<any[]> = Observable.of([]);
/**
* Emits the active values for this filter
*/
selectedValues: Observable<string[]>;
private collapseNextUpdate = true;
/**
* State of the requested facets used to time the animation
*/
animationState = 'loading';
constructor(protected searchService: SearchService,
protected filterService: SearchFilterService,
protected searchConfigService: SearchConfigurationService,
protected rdbs: RemoteDataBuildService,
protected router: Router,
@Inject(FILTER_CONFIG) public filterConfig: SearchFilterConfig) {
}
/**
* Initializes all observable instance variables and starts listening to them
*/
ngOnInit(): void {
this.currentPage = this.getCurrentPage();
this.currentPage.distinctUntilChanged().subscribe((page) => this.pageChange = true);
this.filterService.getSearchOptions().distinctUntilChanged().subscribe((options) => this.updateFilterValueList(options));
}
updateFilterValueList(options: SearchOptions) {
if (!this.pageChange) {
this.showFirstPageOnly();
}
this.pageChange = false;
this.unsubscribe();
this.sub = this.currentPage.distinctUntilChanged().map((page) => {
return this.searchService.getFacetValuesFor(this.filterConfig, page, options);
}).subscribe((newValues$) => {
this.filterValues = [...this.filterValues, newValues$];
this.filterValues$.next(this.filterValues);
newValues$.first().subscribe((rd) => this.isLastPage$.next(hasNoValue(rd.payload.next)));
this.filterValues$ = new BehaviorSubject(new RemoteData(true, false, undefined, undefined, undefined));
this.currentPage = this.getCurrentPage().distinctUntilChanged();
this.selectedValues = this.filterService.getSelectedValuesForFilter(this.filterConfig);
const searchOptions = this.searchConfigService.searchOptions;
this.subs.push(this.searchConfigService.searchOptions.subscribe(() => this.updateFilterValueList()));
const facetValues = Observable.combineLatest(searchOptions, this.currentPage, (options, page) => {
return { options, page }
}).switchMap(({ options, page }) => {
return this.searchService.getFacetValuesFor(this.filterConfig, page, options)
.pipe(
getSucceededRemoteData(),
map((results) => {
return {
values: Observable.of(results),
page: page
};
}
)
)
});
let filterValues = [];
this.subs.push(facetValues.subscribe((facetOutcome) => {
const newValues$ = facetOutcome.values;
if (this.collapseNextUpdate) {
this.showFirstPageOnly();
facetOutcome.page = 1;
this.collapseNextUpdate = false;
}
if (facetOutcome.page === 1) {
filterValues = [];
}
filterValues = [...filterValues, newValues$];
this.subs.push(this.rdbs.aggregate(filterValues).subscribe((rd: RemoteData<Array<PaginatedList<FacetValue>>>) => {
this.animationState = 'ready';
this.filterValues$.next(rd);
}));
this.subs.push(newValues$.first().subscribe((rd) => {
this.isLastPage$.next(hasNoValue(rd.payload.next))
}));
}));
}
/**
* Prepare for refreshing the values of this filter
*/
updateFilterValueList() {
this.animationState = 'loading';
this.collapseNextUpdate = true;
this.filter = '';
}
/**
* Checks if a value for this filter is currently active
*/
isChecked(value: FacetValue): Observable<boolean> {
return this.filterService.isFilterActiveWithValue(this.filterConfig.paramName, value.value);
}
/**
* @returns {string} The base path to the search page
*/
getSearchLink() {
return this.searchService.getSearchLink();
}
/**
* Show the next page as well
*/
showMore() {
this.filterService.incrementPage(this.filterConfig.name);
}
/**
* Make sure only the first page is shown
*/
showFirstPageOnly() {
this.filterValues = [];
this.filterService.resetPage(this.filterConfig.name);
}
/**
* @returns {Observable<number>} The current page of this filter
*/
getCurrentPage(): Observable<number> {
return this.filterService.getPage(this.filterConfig.name);
}
/**
* @returns {string} the current URL
*/
getCurrentUrl() {
return this.router.url;
}
/**
* Submits a new active custom value to the filter from the input field
* @param data The string from the input field
*/
onSubmit(data: any) {
if (isNotEmpty(data)) {
this.router.navigate([this.getSearchLink()], {
queryParams:
{ [this.filterConfig.paramName]: [...this.selectedValues, data[this.filterConfig.paramName]] },
queryParamsHandling: 'merge'
});
this.filter = '';
}
this.selectedValues.first().subscribe((selectedValues) => {
if (isNotEmpty(data)) {
this.router.navigate([this.getSearchLink()], {
queryParams:
{ [this.filterConfig.paramName]: [...selectedValues, data] },
queryParamsHandling: 'merge'
});
this.filter = '';
}
this.filterSearchResults = Observable.of([]);
}
)
}
onClick(data: any) {
this.filter = data;
}
/**
* For usage of the hasValue function in the template
*/
hasValue(o: any): boolean {
return hasValue(o);
}
getRemoveParams(value: string) {
return {
[this.filterConfig.paramName]: this.selectedValues.filter((v) => v !== value),
page: 1
};
/**
* Calculates the parameters that should change if a given value for this filter would be removed from the active filters
* @param {string} value The value that is removed for this filter
* @returns {Observable<any>} The changed filter parameters
*/
getRemoveParams(value: string): Observable<any> {
return this.selectedValues.map((selectedValues) => {
return {
[this.filterConfig.paramName]: selectedValues.filter((v) => v !== value),
page: 1
};
});
}
getAddParams(value: string) {
return {
[this.filterConfig.paramName]: [...this.selectedValues, value],
page: 1
};
/**
* Calculates the parameters that should change if a given value for this filter would be added to the active filters
* @param {string} value The value that is added for this filter
* @returns {Observable<any>} The changed filter parameters
*/
getAddParams(value: string): Observable<any> {
return this.selectedValues.map((selectedValues) => {
return {
[this.filterConfig.paramName]: [...selectedValues, value],
page: 1
};
});
}
/**
* Unsubscribe from all subscriptions
*/
ngOnDestroy(): void {
this.unsubscribe();
this.subs
.filter((sub) => hasValue(sub))
.forEach((sub) => sub.unsubscribe());
}
unsubscribe(): void {
if (hasValue(this.sub)) {
this.sub.unsubscribe();
/**
* Updates the found facet value suggestions for a given query
* Transforms the found values into display values
* @param data The query for which is being searched
*/
findSuggestions(data): void {
if (isNotEmpty(data)) {
this.searchConfigService.searchOptions.first().subscribe(
(options) => {
this.filterSearchResults = this.searchService.getFacetValuesFor(this.filterConfig, 1, options, data.toLowerCase())
.pipe(
getSucceededRemoteData(),
map(
(rd: RemoteData<PaginatedList<FacetValue>>) => {
return rd.payload.page.map((facet) => {
return { displayValue: this.getDisplayValue(facet, data), value: facet.value }
})
}
))
}
)
} else {
this.filterSearchResults = Observable.of([]);
}
}
/**
* Transforms the facet value string, so if the query matches part of the value, it's emphasized in the value
* @param {FacetValue} facet The value of the facet as returned by the server
* @param {string} query The query that was used to search facet values
* @returns {string} The facet value with the query part emphasized
*/
getDisplayValue(facet: FacetValue, query: string): string {
return new EmphasizePipe().transform(facet.value, query) + ' (' + facet.count + ')';
}
}
export const facetLoad = trigger('facetLoad', [
state('ready', style({ opacity: 1 })),
state('loading', style({ opacity: 0 })),
transition('loading <=> ready', animate(100)),
]);

View File

@@ -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);
}

View File

@@ -22,41 +22,78 @@ export const SearchFilterActionTypes = {
};
export class SearchFilterAction implements Action {
/**
* Name of the filter the action is performed on, used to identify the filter
*/
filterName: string;
/**
* Type of action that will be performed
*/
type;
/**
* Initialize with the filter's name
* @param {string} name of the filter
*/
constructor(name: string) {
this.filterName = name;
}
}
/* tslint:disable:max-classes-per-file */
/**
* Used to collapse a filter
*/
export class SearchFilterCollapseAction extends SearchFilterAction {
type = SearchFilterActionTypes.COLLAPSE;
}
/**
* Used to expand a filter
*/
export class SearchFilterExpandAction extends SearchFilterAction {
type = SearchFilterActionTypes.EXPAND;
}
/**
* Used to collapse a filter when it's expanded and expand it when it's collapsed
*/
export class SearchFilterToggleAction extends SearchFilterAction {
type = SearchFilterActionTypes.TOGGLE;
}
/**
* Used to set the initial state of a filter to collapsed
*/
export class SearchFilterInitialCollapseAction extends SearchFilterAction {
type = SearchFilterActionTypes.INITIAL_COLLAPSE;
}
/**
* Used to set the initial state of a filter to expanded
*/
export class SearchFilterInitialExpandAction extends SearchFilterAction {
type = SearchFilterActionTypes.INITIAL_EXPAND;
}
/**
* Used to set the state of a filter to the previous page
*/
export class SearchFilterDecrementPageAction extends SearchFilterAction {
type = SearchFilterActionTypes.DECREMENT_PAGE;
}
/**
* Used to set the state of a filter to the next page
*/
export class SearchFilterIncrementPageAction extends SearchFilterAction {
type = SearchFilterActionTypes.INCREMENT_PAGE;
}
/**
* Used to set the state of a filter to the first page
*/
export class SearchFilterResetPageAction extends SearchFilterAction {
type = SearchFilterActionTypes.RESET_PAGE;
}

View File

@@ -1,7 +1,7 @@
<div>
<div (click)="toggle()" class="filter-name"><h5 class="d-inline-block mb-0">{{'search.filters.filter.' + filter.name + '.head'| translate}}</h5> <span class="filter-toggle fa float-right"
[ngClass]="(isCollapsed() | async) ? 'fa-plus' : 'fa-minus'"></span></div>
<div [@slide]="(isCollapsed() | async) ? 'collapsed' : 'expanded'" class="search-filter-wrapper">
<ds-search-facet-filter [filterConfig]="filter" [selectedValues]="getSelectedValues() | async"></ds-search-facet-filter>
<div [@slide]="(isCollapsed() | async) ? 'collapsed' : 'expanded'" (@slide.start)="startSlide($event)" (@slide.done)="finishSlide($event)" class="search-filter-wrapper" [ngClass]="{'closed' : collapsed}">
<ds-search-facet-filter-wrapper [filterConfig]="filter"></ds-search-facet-filter-wrapper>
</div>
</div>

View File

@@ -3,7 +3,7 @@
:host {
border: 1px solid map-get($theme-colors, light);
.search-filter-wrapper {
.search-filter-wrapper.closed {
overflow: hidden;
}
.filter-toggle {

View File

@@ -1,18 +1,9 @@
import { Component, Input, OnInit } from '@angular/core';
import { SearchFilterConfig } from '../../search-service/search-filter-config.model';
import { SearchService } from '../../search-service/search.service';
import { RemoteData } from '../../../core/data/remote-data';
import { FacetValue } from '../../search-service/facet-value.model';
import { SearchFilterService } from './search-filter.service';
import { Observable } from 'rxjs/Observable';
import { slide } from '../../../shared/animations/slide';
import { PaginatedList } from '../../../core/data/paginated-list';
/**
* This component renders a simple item page.
* The route parameter 'id' is used to request the item it represents.
* All fields of the item that should be displayed, are defined in its template.
*/
import { isNotEmpty } from '../../../shared/empty.util';
@Component({
selector: 'ds-search-filter',
@@ -21,15 +12,31 @@ import { PaginatedList } from '../../../core/data/paginated-list';
animations: [slide],
})
/**
* Represents a part of the filter section for a single type of filter
*/
export class SearchFilterComponent implements OnInit {
/**
* The filter config for this component
*/
@Input() filter: SearchFilterConfig;
/**
* True when the filter is 100% collapsed in the UI
*/
collapsed;
constructor(private filterService: SearchFilterService) {
}
/**
* Requests the current set values for this filter
* If the filter config is open by default OR the filter has at least one value, the filter should be initially expanded
* Else, the filter should initially be collapsed
*/
ngOnInit() {
this.filterService.isFilterActive(this.filter.paramName).first().subscribe((isActive) => {
if (this.filter.isOpenByDefault || isActive) {
this.getSelectedValues().first().subscribe((isActive) => {
if (this.filter.isOpenByDefault || isNotEmpty(isActive)) {
this.initialExpand();
} else {
this.initialCollapse();
@@ -37,23 +44,61 @@ export class SearchFilterComponent implements OnInit {
});
}
/**
* Changes the state for this filter to collapsed when it's expanded and to expanded it when it's collapsed
*/
toggle() {
this.filterService.toggle(this.filter.name);
}
/**
* Checks if the filter is currently collapsed
* @returns {Observable<boolean>} Emits true when the current state of the filter is collapsed, false when it's expanded
*/
isCollapsed(): Observable<boolean> {
return this.filterService.isCollapsed(this.filter.name);
}
/**
* Changes the initial state to collapsed
*/
initialCollapse() {
this.filterService.initialCollapse(this.filter.name);
this.collapsed = true;
}
/**
* Changes the initial state to expanded
*/
initialExpand() {
this.filterService.initialExpand(this.filter.name);
this.collapsed = false;
}
/**
* @returns {Observable<string[]>} Emits a list of all values that are currently active for this filter
*/
getSelectedValues(): Observable<string[]> {
return this.filterService.getSelectedValuesForFilter(this.filter);
}
/**
* Method to change this.collapsed to false when the slide animation ends and is sliding open
* @param event The animation event
*/
finishSlide(event: any): void {
if (event.fromState === 'collapsed') {
this.collapsed = false;
}
}
/**
* Method to change this.collapsed to true when the slide animation starts and is sliding closed
* @param event The animation event
*/
startSlide(event: any): void {
if (event.toState === 'collapsed') {
this.collapsed = true;
}
}
}

View File

@@ -1,17 +1,29 @@
import { SearchFilterAction, SearchFilterActionTypes } from './search-filter.actions';
import { isEmpty } from '../../../shared/empty.util';
/**
* Interface that represents the state for a single filters
*/
export interface SearchFilterState {
filterCollapsed: boolean,
page: number
}
/**
* Interface that represents the state for all available filters
*/
export interface SearchFiltersState {
[name: string]: SearchFilterState
}
const initialState: SearchFiltersState = Object.create(null);
/**
* Performs a search filter action on the current state
* @param {SearchFiltersState} state The state before the action is performed
* @param {SearchFilterAction} action The action that should be performed
* @returns {SearchFiltersState} The state after the action is performed
*/
export function filterReducer(state = initialState, action: SearchFilterAction): SearchFiltersState {
switch (action.type) {

View File

@@ -11,6 +11,7 @@ import { SearchFiltersState } from './search-filter.reducer';
import { SearchFilterConfig } from '../../search-service/search-filter-config.model';
import { FilterType } from '../../search-service/filter-type.model';
import { SearchFixedFilterService } from './search-fixed-filter.service';
import { ActivatedRouteStub } from '../../../shared/testing/active-router-stub';
describe('SearchFilterService', () => {
let service: SearchFilterService;
@@ -48,10 +49,14 @@ describe('SearchFilterService', () => {
addQueryParameterValue: (param: string, value: string) => {
},
getQueryParameterValues: (param: string) => {
return Observable.of({});
},
getQueryParamsWithPrefix: (param: string) => {
return Observable.of({});
}
/* tslint:enable:no-empty */
};
const activatedRoute: any = new ActivatedRouteStub();
const searchServiceStub: any = {
uiSearchRoute: '/search'
};

View File

@@ -1,29 +1,35 @@
import { Injectable } from '@angular/core';
import { debounceTime, distinctUntilChanged, map } from 'rxjs/operators';
import { Injectable, InjectionToken } from '@angular/core';
import { distinctUntilChanged, map } from 'rxjs/operators';
import { SearchFiltersState, SearchFilterState } from './search-filter.reducer';
import { createSelector, MemoizedSelector, Store } from '@ngrx/store';
import { Observable } from 'rxjs/Observable';
import {
SearchFilterCollapseAction,
SearchFilterDecrementPageAction, SearchFilterExpandAction,
SearchFilterDecrementPageAction,
SearchFilterExpandAction,
SearchFilterIncrementPageAction,
SearchFilterInitialCollapseAction,
SearchFilterInitialExpandAction, SearchFilterResetPageAction,
SearchFilterInitialExpandAction,
SearchFilterResetPageAction,
SearchFilterToggleAction
} from './search-filter.actions';
import { hasValue, isEmpty, isNotEmpty, } from '../../../shared/empty.util';
import { SearchFilterConfig } from '../../search-service/search-filter-config.model';
import { SearchService } from '../../search-service/search.service';
import { RouteService } from '../../../shared/route.service';
import ObjectExpression from 'rollup/dist/typings/ast/nodes/ObjectExpression';
import { RouteService } from '../../../shared/services/route.service';
import { SortDirection, SortOptions } from '../../../core/cache/models/sort-options.model';
import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model';
import { SearchOptions } from '../../search-options.model';
import { PaginatedSearchOptions } from '../../paginated-search-options.model';
import { ActivatedRoute, Params } from '@angular/router';
import { SearchFixedFilterService } from './search-fixed-filter.service';
const filterStateSelector = (state: SearchFiltersState) => state.searchFilter;
export const FILTER_CONFIG: InjectionToken<SearchFilterConfig> = new InjectionToken<SearchFilterConfig>('filterConfig');
/**
* Service that performs all actions that have to do with search filters and facets
*/
@Injectable()
export class SearchFilterService {
@@ -32,10 +38,21 @@ export class SearchFilterService {
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> {
return this.routeService.hasQueryParamWithValue(paramName, filterValue);
}
/**
* Checks if a given filter is active with any value
* @param {string} paramName The parameter name of the filter's configuration for which to search
* @returns {Observable<boolean>} Emit true when the filter is active with any value
*/
isFilterActive(paramName: string): Observable<boolean> {
return this.routeService.hasQueryParam(paramName);
}
@@ -94,8 +111,7 @@ export class SearchFilterService {
this.getCurrentFixedFilter()).pipe(
distinctUntilChanged((a, b) => JSON.stringify(a) === JSON.stringify(b)),
map(([pagination, sort, view, scope, query, filters, fixedFilter]) => {
return Object.assign(new PaginatedSearchOptions(),
defaults,
return Object.assign(new PaginatedSearchOptions(defaults),
{
pagination: pagination,
sort: sort,
@@ -117,8 +133,7 @@ export class SearchFilterService {
this.getCurrentFilters(),
this.getCurrentFixedFilter(),
(view, scope, query, filters, fixedFilter) => {
return Object.assign(new SearchOptions(),
defaults,
return Object.assign(new SearchOptions(defaults),
{
view: view,
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[]> {
return this.routeService.getQueryParameterValues(filterConfig.paramName);
const values$ = this.routeService.getQueryParameterValues(filterConfig.paramName);
const prefixValues$ = this.routeService.getQueryParamsWithPrefix(filterConfig.paramName + '.').map((params: Params) => [].concat(...Object.values(params)));
return Observable.combineLatest(values$, prefixValues$, (values, prefixValues) => {
if (isNotEmpty(values)) {
return values;
}
return prefixValues;
})
}
/**
* Checks if the state of a given filter is currently collapsed or not
* @param {string} filterName The filtername for which the collapsed state is checked
* @returns {Observable<boolean>} Emits the current collapsed state of the given filter, if it's unavailable, return false
*/
isCollapsed(filterName: string): Observable<boolean> {
return this.store.select(filterByNameSelector(filterName))
.map((object: SearchFilterState) => {
@@ -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> {
return this.store.select(filterByNameSelector(filterName))
.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 {
this.store.dispatch(new SearchFilterCollapseAction(filterName));
}
/**
* Dispatches an expand action to the store for a given filter
* @param {string} filterName The filter for which the action is dispatched
*/
public expand(filterName: string): void {
this.store.dispatch(new SearchFilterExpandAction(filterName));
}
/**
* Dispatches a toggle action to the store for a given filter
* @param {string} filterName The filter for which the action is dispatched
*/
public toggle(filterName: string): void {
this.store.dispatch(new SearchFilterToggleAction(filterName));
}
/**
* Dispatches an initial collapse action to the store for a given filter
* @param {string} filterName The filter for which the action is dispatched
*/
public initialCollapse(filterName: string): void {
this.store.dispatch(new SearchFilterInitialCollapseAction(filterName));
}
/**
* Dispatches an initial expand action to the store for a given filter
* @param {string} filterName The filter for which the action is dispatched
*/
public initialExpand(filterName: string): void {
this.store.dispatch(new SearchFilterInitialExpandAction(filterName));
}
/**
* Dispatches a decrement action to the store for a given filter
* @param {string} filterName The filter for which the action is dispatched
*/
public decrementPage(filterName: string): void {
this.store.dispatch(new SearchFilterDecrementPageAction(filterName));
}
/**
* Dispatches an increment page action to the store for a given filter
* @param {string} filterName The filter for which the action is dispatched
*/
public incrementPage(filterName: string): void {
this.store.dispatch(new SearchFilterIncrementPageAction(filterName));
}
/**
* Dispatches a reset page action to the store for a given filter
* @param {string} filterName The filter for which the action is dispatched
*/
public resetPage(filterName: string): void {
this.store.dispatch(new SearchFilterResetPageAction(filterName));
}

View File

@@ -1,7 +1,6 @@
import { Injectable } from '@angular/core';
import { flatMap, map } from 'rxjs/operators';
import { Observable } from 'rxjs/Observable';
import { RouteService } from '../../../shared/route.service';
import { HALEndpointService } from '../../../core/shared/hal-endpoint.service';
import { GetRequest, RestRequest } from '../../../core/data/request.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 { hasValue } from '../../../shared/empty.util';
import { configureRequest } from '../../../core/shared/operators';
import { RouteService } from '../../../shared/services/route.service';
@Injectable()
export class SearchFixedFilterService {

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -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 {
}

View File

@@ -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>

View File

@@ -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;
}
}
}

View File

@@ -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'
});
});
});
});

View File

@@ -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);
}
}

View File

@@ -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>

View File

@@ -2,13 +2,14 @@
@import '../../../../../styles/mixins.scss';
.filters {
margin-top: $spacer/2;
margin-bottom: $spacer/2;
a {
color: $body-color;
&:hover {
&:hover, &focus {
text-decoration: none;
}
span.badge {
vertical-align: text-top;
}
}
.toggle-more-filters a {
color: $link-color;
@@ -16,3 +17,7 @@
cursor: pointer;
}
}
::ng-deep em {
font-weight: bold;
font-style: normal;
}

View File

@@ -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 {
}

View File

@@ -1,7 +1,7 @@
<h3>{{"search.filters.head" | translate}}</h3>
<div *ngIf="(filters | async)?.hasSucceeded">
<div *ngFor="let filter of (filters | async).payload">
<ds-search-filter class="d-block mb-3 p-3" [filter]="filter"></ds-search-filter>
<div *ngFor="let filter of (filters | async)?.payload">
<ds-search-filter *ngIf="isActive(filter) | async" class="d-block mb-3 p-3" [filter]="filter"></ds-search-filter>
</div>
</div>
<a class="btn btn-primary" [routerLink]="[getSearchLink()]" [queryParams]="clearParams | async" queryParamsHandling="merge" role="button">{{"search.filters.reset" | translate}}</a>

View File

@@ -8,6 +8,7 @@ import { SearchFilterService } from './search-filter/search-filter.service';
import { SearchFiltersComponent } from './search-filters.component';
import { SearchService } from '../search-service/search.service';
import { Observable } from 'rxjs/Observable';
import { SearchConfigurationService } from '../search-service/search-configuration.service';
describe('SearchFiltersComponent', () => {
let comp: SearchFiltersComponent;
@@ -23,8 +24,14 @@ describe('SearchFiltersComponent', () => {
}
/* tslint:enable:no-empty */
};
const searchFilterServiceStub = jasmine.createSpyObj('SearchFilterService', {
getCurrentFilters: Observable.of({})
const searchFiltersStub = {
getSelectedValuesForFilter: (filter) =>
[]
};
const searchConfigServiceStub = jasmine.createSpyObj('SearchConfigurationService', {
getCurrentFrontendFilters: Observable.of({})
});
beforeEach(async(() => {
@@ -33,7 +40,8 @@ describe('SearchFiltersComponent', () => {
declarations: [SearchFiltersComponent],
providers: [
{ provide: SearchService, useValue: searchServiceStub },
{ provide: SearchFilterService, useValue: searchFilterServiceStub },
{ provide: SearchConfigurationService, useValue: searchConfigServiceStub },
{ provide: SearchFilterService, useValue: searchFiltersStub },
],
schemas: [NO_ERRORS_SCHEMA]

View File

@@ -3,13 +3,10 @@ import { SearchService } from '../search-service/search.service';
import { RemoteData } from '../../core/data/remote-data';
import { SearchFilterConfig } from '../search-service/search-filter-config.model';
import { Observable } from 'rxjs/Observable';
import { SearchConfigurationService } from '../search-service/search-configuration.service';
import { isNotEmpty } from '../../shared/empty.util';
import { SearchFilterService } from './search-filter/search-filter.service';
/**
* This component renders a simple item page.
* The route parameter 'id' is used to request the item it represents.
* All fields of the item that should be displayed, are defined in its template.
*/
import { getSucceededRemoteData } from '../../core/shared/operators';
@Component({
selector: 'ds-search-filters',
@@ -17,15 +14,64 @@ import { SearchFilterService } from './search-filter/search-filter.service';
templateUrl: './search-filters.component.html',
})
/**
* This component represents the part of the search sidebar that contains filters.
*/
export class SearchFiltersComponent {
/**
* An observable containing configuration about which filters are shown and how they are shown
*/
filters: Observable<RemoteData<SearchFilterConfig[]>>;
/**
* List of all filters that are currently active with their value set to null.
* Used to reset all filters at once
*/
clearParams;
constructor(private searchService: SearchService, private filterService: SearchFilterService) {
this.filters = searchService.getConfig();
this.clearParams = filterService.getCurrentFilters().map((filters) => {Object.keys(filters).forEach((f) => filters[f] = null); return filters;});
/**
* Initialize instance variables
* @param {SearchService} searchService
* @param {SearchConfigurationService} searchConfigService
* @param {SearchFilterService} filterService
*/
constructor(private searchService: SearchService, private searchConfigService: SearchConfigurationService, private filterService: SearchFilterService) {
this.filters = searchService.getConfig().pipe(getSucceededRemoteData());
this.clearParams = searchConfigService.getCurrentFrontendFilters().map((filters) => {
Object.keys(filters).forEach((f) => filters[f] = null);
return filters;
});
}
/**
* @returns {string} The base path to the search page
*/
getSearchLink() {
return this.searchService.getSearchLink();
}
/**
* Check if a given filter is supposed to be shown or not
* @param {SearchFilterConfig} filter The filter to check for
* @returns {Observable<boolean>} Emits true whenever a given filter config should be shown
*/
isActive(filter: SearchFilterConfig): Observable<boolean> {
// 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);
}
}

View File

@@ -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>

View File

@@ -0,0 +1,3 @@
:host {
line-height: 1;
}

View File

@@ -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);
});
})
});
});

View File

@@ -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();
}
}

View 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'
);
});
});
});

View File

@@ -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 { 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';
/**
* This model class represents all parameters needed to request information about a certain search request
*/
export class SearchOptions {
view?: SetViewMode = SetViewMode.List;
scope?: string;
query?: string;
dsoType?: DSpaceObjectType;
filters?: 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 {
if (isNotEmpty(this.fixedFilter)) {
args.push(this.fixedFilter);
@@ -17,13 +36,15 @@ export class SearchOptions {
if (isNotEmpty(this.query)) {
args.push(`query=${this.query}`);
}
if (isNotEmpty(this.scope)) {
args.push(`scope=${this.scope}`);
}
if (isNotEmpty(this.dsoType)) {
args.push(`dsoType=${this.dsoType}`);
}
if (isNotEmpty(this.filters)) {
Object.entries(this.filters).forEach(([key, values]) => {
values.forEach((value) => args.push(`${key}=${value},equals`));
this.filters.forEach((filter: SearchFilter) => {
filter.values.forEach((value) => args.push(`${filter.key}=${value},${filter.operator}`));
});
}
if (isNotEmpty(args)) {

View File

@@ -1,39 +1,40 @@
<div class="container">
<div class="search-page row">
<ds-search-sidebar *ngIf="!(isMobileView$ | async)" class="col-3 sidebar-md-sticky"
id="search-sidebar"
[resultCount]="(resultsRD$ | async)?.pageInfo?.totalElements"></ds-search-sidebar>
<div class="search-page row">
<ds-search-sidebar *ngIf="!(isXsOrSm$ | async)" class="col-3 sidebar-md-sticky"
id="search-sidebar"
[resultCount]="(resultsRD$ | async)?.payload.totalElements"></ds-search-sidebar>
<div class="col-12 col-md-9">
<ds-search-form id="search-form"
[query]="(searchOptions$ | async)?.query"
[scope]="(searchOptions$ | async)?.scope"
[currentUrl]="getSearchLink()"
[scopes]="(scopeListRD$ | async)?.payload?.page">
</ds-search-form>
<div class="row">
<div id="search-body"
class="row-offcanvas row-offcanvas-left"
[@pushInOut]="(isSidebarCollapsed() | async) ? 'collapsed' : 'expanded'">
<ds-search-sidebar *ngIf="(isMobileView$ | async)" class="col-12"
id="search-sidebar-sm"
[resultCount]="(resultsRD$ | async)?.pageInfo?.totalElements"
(toggleSidebar)="closeSidebar()"
[ngClass]="{'active': !(isSidebarCollapsed() | async)}">
</ds-search-sidebar>
<div id="search-content" class="col-12">
<div class="d-block d-md-none search-controls clearfix">
<ds-view-mode-switch></ds-view-mode-switch>
<button (click)="openSidebar()" aria-controls="#search-body"
class="btn btn-outline-primary float-right open-sidebar"><i
class="fa fa-sliders"></i> {{"search.sidebar.open"
| translate}}
</button>
<ds-search-form id="search-form"
[query]="(searchOptions$ | async)?.query"
[scope]="(searchOptions$ | async)?.scope"
[currentUrl]="getSearchLink()"
[scopes]="(scopeListRD$ | async)">
</ds-search-form>
<ds-search-labels></ds-search-labels>
<div class="row">
<div id="search-body"
class="row-offcanvas row-offcanvas-left"
[@pushInOut]="(isSidebarCollapsed() | async) ? 'collapsed' : 'expanded'">
<ds-search-sidebar *ngIf="(isXsOrSm$ | async)" class="col-12"
id="search-sidebar-sm"
[resultCount]="(resultsRD$ | async)?.payload.totalElements"
(toggleSidebar)="closeSidebar()"
[ngClass]="{'active': !(isSidebarCollapsed() | async)}">
</ds-search-sidebar>
<div id="search-content" class="col-12">
<div class="d-block d-md-none search-controls clearfix">
<ds-view-mode-switch></ds-view-mode-switch>
<button (click)="openSidebar()" aria-controls="#search-body"
class="btn btn-outline-primary float-right open-sidebar"><i
class="fa fa-sliders"></i> {{"search.sidebar.open"
| translate}}
</button>
</div>
<ds-search-results [searchResults]="resultsRD$ | async"
[searchConfig]="searchOptions$ | async"></ds-search-results>
</div>
</div>
</div>
<ds-search-results [searchResults]="resultsRD$ | async"
[searchConfig]="searchOptions$ | async" [sortConfig]="sortConfig" [fixedFilter]="fixedFilter | async"></ds-search-results>
</div>
</div>
</div>
</div>
</div>
</div>

View File

@@ -6,6 +6,7 @@ import { Store } from '@ngrx/store';
import { TranslateModule } from '@ngx-translate/core';
import { cold, hot } from 'jasmine-marbles';
import { Observable } from 'rxjs/Observable';
import 'rxjs/add/observable/of';
import { SortDirection, SortOptions } from '../core/cache/models/sort-options.model';
import { CommunityDataService } from '../core/data/community-data.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 { SearchSidebarService } from './search-sidebar/search-sidebar.service';
import { SearchFilterService } from './search-filters/search-filter/search-filter.service';
import { SearchConfigurationService } from './search-service/search-configuration.service';
import { RemoteData } from '../core/data/remote-data';
describe('SearchPageComponent', () => {
let comp: SearchPageComponent;
@@ -34,10 +37,11 @@ describe('SearchPageComponent', () => {
pagination.currentPage = 1;
pagination.pageSize = 10;
const sort: SortOptions = new SortOptions('score', SortDirection.DESC);
const mockResults = Observable.of(['test', 'data']);
const mockResults = Observable.of(new RemoteData(false, false, true, null, ['test', 'data']));
const searchServiceStub = jasmine.createSpyObj('SearchService', {
search: mockResults,
getSearchLink: '/search'
getSearchLink: '/search',
getScopes: Observable.of(['test-scope'])
});
const queryParam = 'test query';
const scopeParam = '7669c72a-3f2a-451f-a3b9-9210e7a4c02f';
@@ -75,10 +79,11 @@ describe('SearchPageComponent', () => {
},
{
provide: HostWindowService, useValue: jasmine.createSpyObj('hostWindowService',
{
isXs: Observable.of(true),
isSm: Observable.of(false)
})
{
isXs: Observable.of(true),
isSm: Observable.of(false),
isXsOrSm: Observable.of(true)
})
},
{
provide: SearchSidebarService,
@@ -86,16 +91,20 @@ describe('SearchPageComponent', () => {
},
{
provide: SearchFilterService,
useValue: jasmine.createSpyObj('SearchFilterService', {
getPaginatedSearchOptions: hot('a', {
useValue: {}
}, {
provide: SearchConfigurationService,
useValue: {
paginatedSearchOptions: hot('a', {
a: paginatedSearchOptions
})
})
}),
getCurrentScope: (a) => Observable.of('test-id')
}
},
],
schemas: [NO_ERRORS_SCHEMA]
}).overrideComponent(SearchPageComponent, {
set: { changeDetection: ChangeDetectionStrategy.Default }
set: { changeDetection: ChangeDetectionStrategy.Default }
}).compileComponents();
}));
@@ -169,4 +178,4 @@ describe('SearchPageComponent', () => {
});
});
});
})

View File

@@ -1,11 +1,8 @@
import { ChangeDetectionStrategy, Component, OnDestroy, OnInit } from '@angular/core';
import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
import { Observable } from 'rxjs/Observable';
import { flatMap, } from 'rxjs/operators';
import { SortDirection, SortOptions } from '../core/cache/models/sort-options.model';
import { CommunityDataService } from '../core/data/community-data.service';
import { flatMap, switchMap, } from 'rxjs/operators';
import { PaginatedList } from '../core/data/paginated-list';
import { RemoteData } from '../core/data/remote-data';
import { Community } from '../core/shared/community.model';
import { DSpaceObject } from '../core/shared/dspace-object.model';
import { pushInOut } from '../shared/animations/push';
import { HostWindowService } from '../shared/host-window.service';
@@ -14,7 +11,11 @@ import { SearchFilterService } from './search-filters/search-filter/search-filte
import { SearchResult } from './search-result.model';
import { SearchService } from './search-service/search.service';
import { SearchSidebarService } from './search-sidebar/search-sidebar.service';
import { 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.
@@ -29,61 +30,99 @@ import { RouteService } from '../shared/route.service';
changeDetection: ChangeDetectionStrategy.OnPush,
animations: [pushInOut]
})
/**
* This component represents the whole search page
*/
export class SearchPageComponent implements OnInit {
resultsRD$: Observable<RemoteData<PaginatedList<SearchResult<DSpaceObject>>>>;
/**
* The current search results
*/
resultsRD$: BehaviorSubject<RemoteData<PaginatedList<SearchResult<DSpaceObject>>>> = new BehaviorSubject(null);
/**
* The current paginated search options
*/
searchOptions$: Observable<PaginatedSearchOptions>;
sortConfig: SortOptions;
scopeListRD$: Observable<RemoteData<PaginatedList<Community>>>;
isMobileView$: Observable<boolean>;
pageSize;
pageSizeOptions;
defaults = {
pagination: {
id: 'search-results-pagination',
pageSize: 10
},
sort: new SortOptions('score', SortDirection.DESC),
query: '',
scope: ''
};
fixedFilter;
/**
* The current relevant scopes
*/
scopeListRD$: Observable<DSpaceObject[]>;
/**
* Emits true if were on a small screen
*/
isXsOrSm$: Observable<boolean>;
/**
* Subscription to unsubscribe from
*/
sub: Subscription;
constructor(protected service: SearchService,
protected communityService: CommunityDataService,
protected sidebarService: SearchSidebarService,
protected windowService: HostWindowService,
protected filterService: SearchFilterService,
protected routeService: RouteService) {
this.isMobileView$ = Observable.combineLatest(
this.windowService.isXs(),
this.windowService.isSm(),
((isXs, isSm) => isXs || isSm)
);
this.scopeListRD$ = communityService.findAll();
protected searchConfigService: SearchConfigurationService) {
this.isXsOrSm$ = this.windowService.isXsOrSm();
}
/**
* Listening to changes in the paginated search options
* If something changes, update the search results
*
* Listen to changes in the scope
* If something changes, update the list of scopes for the dropdown
*/
ngOnInit(): void {
this.searchOptions$ = this.filterService.getPaginatedSearchOptions(this.defaults);
this.resultsRD$ = this.searchOptions$.pipe(
flatMap((searchOptions) => this.service.search(searchOptions))
this.searchOptions$ = this.searchConfigService.paginatedSearchOptions;
this.sub = this.searchOptions$
.switchMap((options) => this.service.search(options).pipe(getSucceededRemoteData()))
.subscribe((results) => {
this.resultsRD$.next(results);
});
this.scopeListRD$ = this.searchConfigService.getCurrentScope('').pipe(
switchMap((scopeId) => this.service.getScopes(scopeId))
);
this.fixedFilter = this.routeService.getRouteParameterValue('filter');
}
/**
* Set the sidebar to a collapsed state
*/
public closeSidebar(): void {
this.sidebarService.collapse()
}
/**
* Set the sidebar to an expanded state
*/
public openSidebar(): void {
this.sidebarService.expand();
}
/**
* Check if the sidebar is collapsed
* @returns {Observable<boolean>} emits true if the sidebar is currently collapsed, false if it is expanded
*/
public isSidebarCollapsed(): Observable<boolean> {
return this.sidebarService.isCollapsed;
}
/**
* @returns {string} The base path to the search page
*/
public getSearchLink(): string {
return this.service.getSearchLink();
}
/**
* Unsubscribe from the subscription
*/
ngOnDestroy(): void {
if (hasValue(this.sub)) {
this.sub.unsubscribe();
}
}
}

View File

@@ -24,6 +24,13 @@ import { SearchFilterService } from './search-filters/search-filter/search-filte
import { FilteredSearchPageComponent } from './filtered-search-page.component';
import { SearchFixedFilterService } from './search-filters/search-filter/search-fixed-filter.service';
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 = [
SearchSidebarEffects
@@ -52,14 +59,23 @@ const effects = [
CommunitySearchResultListElementComponent,
SearchFiltersComponent,
SearchFilterComponent,
SearchFacetFilterComponent
SearchFacetFilterComponent,
SearchLabelsComponent,
SearchFacetFilterComponent,
SearchFacetFilterWrapperComponent,
SearchRangeFilterComponent,
SearchTextFilterComponent,
SearchHierarchyFilterComponent,
SearchBooleanFilterComponent,
],
providers: [
SearchService,
SearchSidebarService,
SearchFilterService,
SearchFixedFilterService,
FilteredSearchPageGuard
FilteredSearchPageGuard,
SearchFilterService,
SearchConfigurationService
],
entryComponents: [
ItemSearchResultListElementComponent,
@@ -68,7 +84,16 @@ const effects = [
ItemSearchResultGridElementComponent,
CollectionSearchResultGridElementComponent,
CommunitySearchResultGridElementComponent,
SearchFacetFilterComponent,
SearchRangeFilterComponent,
SearchTextFilterComponent,
SearchHierarchyFilterComponent,
SearchBooleanFilterComponent,
]
})
/**
* This module handles all components and pipes that are necessary for the search page
*/
export class SearchPageModule {
}

View File

@@ -2,9 +2,18 @@ import { DSpaceObject } from '../core/shared/dspace-object.model';
import { Metadatum } from '../core/shared/metadatum.model';
import { ListableObject } from '../shared/object-collection/shared/listable-object.model';
/**
* Represents a search result object of a certain (<T>) DSpaceObject
*/
export class SearchResult<T extends DSpaceObject> implements ListableObject {
/**
* The DSpaceObject that was found
*/
dspaceObject: T;
/**
* The metadata that was used to find this item, hithighlighted
*/
hitHighlights: Metadatum[];
}

View File

@@ -22,8 +22,19 @@ import { hasValue, isNotEmpty } from '../../shared/empty.util';
fadeInOut
]
})
/**
* Component that represents all results from a search
*/
export class SearchResultsComponent {
/**
* The actual search result objects
*/
@Input() searchResults: RemoteData<PaginatedList<SearchResult<DSpaceObject>>>;
/**
* The current configuration of the search
*/
@Input() searchConfig: SearchOptions;
@Input() sortConfig: SortOptions;
@Input() viewMode: SetViewMode;

View File

@@ -1,13 +1,25 @@
import { autoserialize, autoserializeAs } from 'cerialize';
/**
* Class representing possible values for a certain filter
*/
export class FacetValue {
/**
* The display value of the facet value
*/
@autoserializeAs(String, 'label')
value: string;
/**
* The number of results this facet value would have if selected
*/
@autoserialize
count: number;
/**
* The REST url to add this filter value
*/
@autoserialize
search: string;
}

View File

@@ -1,6 +1,24 @@
/**
* Enumeration containing all possible types for filters
*/
export enum FilterType {
text,
date,
hierarchical,
standard
/**
* Represents simple text facets
*/
text = 'text',
/**
* Represents date facets
*/
range = 'date',
/**
* Represents hierarchically structured facets
*/
hierarchy = 'hierarchical',
/**
* Represents binary facets
*/
boolean = 'standard'
}

View File

@@ -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