Merge remote-tracking branch 'remotes/origin/master' into configuration

# Conflicts:
#	src/app/core/core.module.ts
This commit is contained in:
Giuseppe Digilio
2017-11-24 15:20:45 +01:00
50 changed files with 2093 additions and 237 deletions

View File

@@ -17,13 +17,13 @@ export class ProtractorPage {
} }
getCurrentScope(): promise.Promise<string> { getCurrentScope(): promise.Promise<string> {
const scopeSelect = element(by.tagName('select')); const scopeSelect = element(by.css('#search-form select'));
browser.wait(protractor.ExpectedConditions.presenceOf(scopeSelect), 10000); browser.wait(protractor.ExpectedConditions.presenceOf(scopeSelect), 10000);
return scopeSelect.getAttribute('value'); return scopeSelect.getAttribute('value');
} }
getCurrentQuery(): promise.Promise<string> { getCurrentQuery(): promise.Promise<string> {
return element(by.tagName('input')).getAttribute('value'); return element(by.css('#search-form input')).getAttribute('value');
} }
setCurrentScope(scope: string) { setCurrentScope(scope: string) {

View File

@@ -80,16 +80,48 @@
"search_dspace": "Search DSpace" "search_dspace": "Search DSpace"
}, },
"results": { "results": {
"title": "Search Results" "head": "Search Results"
}, },
"sidebar": { "sidebar": {
"close": "Back to results", "close": "Back to results",
"open": "Search Tools", "open": "Search Tools",
"results": "results" "results": "results",
"filters":{
"title":"Filters"
},
"settings":{
"title":"Settings",
"sort-by":"Sort By",
"rpp":"Results per page"
}
}, },
"view-switch": { "view-switch": {
"show-list": "Show as list", "show-list": "Show as list",
"show-grid": "Show as grid" "show-grid": "Show as grid"
},
"filters": {
"head": "Filters",
"reset": "Reset filters",
"filter": {
"show-more": "Show more",
"show-less": "Collapse",
"author": {
"placeholder": "Author name",
"head": "Author"
},
"scope": {
"placeholder": "Scope filter",
"head": "Scope"
},
"subject": {
"placeholder": "Subject",
"head": "Subject"
},
"date": {
"placeholder": "Date",
"head": "Date"
}
}
} }
}, },
"loading": { "loading": {

View File

@@ -0,0 +1,35 @@
<div>
<div class="filters">
<a *ngFor="let value of selectedValues" class="d-block"
[routerLink]="[getSearchLink()]"
[queryParams]="getQueryParamsWithout(value) | async">
<input type="checkbox" [checked]="true"/>
<span class="filter-value">{{value}}</span>
</a>
<a *ngFor="let value of filterValues; let i=index" class="d-block clearfix"
[routerLink]="[getSearchLink()]"
[queryParams]="getQueryParamsWith(value.value) | async">
<ng-template [ngIf]="i < (facetCount | async)">
<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>
</ng-template>
</a>
<div class="clearfix toggle-more-filters">
<a class="float-left" *ngIf="filterValues.length > (facetCount | 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"/>
<input type="submit" class="d-none"/>
</form>
</div>

View File

@@ -0,0 +1,18 @@
@import '../../../../../styles/variables.scss';
@import '../../../../../styles/mixins.scss';
.filters {
margin-top: $spacer/2;
margin-bottom: $spacer/2;
a {
color: $body-color;
&:hover {
text-decoration: none;
}
}
.toggle-more-filters a {
color: $link-color;
text-decoration: underline;
cursor: pointer;
}
}

View File

@@ -0,0 +1,191 @@
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 { 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';
describe('SearchFacetFilterComponent', () => {
let comp: SearchFacetFilterComponent;
let fixture: ComponentFixture<SearchFacetFilterComponent>;
const filterName1 = 'test name';
const value1 = 'testvalue1';
const value2 = 'test2';
const value3 = 'another value3';
const mockFilterConfig: SearchFilterConfig = Object.assign(new SearchFilterConfig(), {
name: filterName1,
type: FilterType.text,
hasFacets: false,
isOpenByDefault: false,
pageSize: 2
});
const values: FacetValue[] = [
{
value: value1,
count: 52,
search: ''
}, {
value: value2,
count: 20,
search: ''
}, {
value: value3,
count: 5,
search: ''
}
];
let filterService;
let page = Observable.of(0)
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([]), NoopAnimationsModule, FormsModule],
declarations: [SearchFacetFilterComponent],
providers: [
{
provide: SearchFilterService,
useValue: {
isFilterActiveWithValue: (paramName: string, filterValue: string) => true,
getQueryParamsWith: (paramName: string, filterValue: string) => '',
getQueryParamsWithout: (paramName: string, filterValue: string) => '',
getPage: (paramName: string) => page,
/* tslint:disable:no-empty */
incrementPage: (filterName: string) => {
},
resetPage: (filterName: string) => {
},
/* tslint:enable:no-empty */
searchLink: '/search',
}
},
],
schemas: [NO_ERRORS_SCHEMA]
}).overrideComponent(SearchFacetFilterComponent, {
set: { changeDetection: ChangeDetectionStrategy.Default }
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(SearchFacetFilterComponent);
comp = fixture.componentInstance; // SearchPageComponent test instance
comp.filterConfig = mockFilterConfig;
comp.filterValues = values;
filterService = (comp as any).filterService;
fixture.detectChanges();
});
describe('when the isChecked method is called with a value', () => {
beforeEach(() => {
spyOn(filterService, 'isFilterActiveWithValue');
comp.isChecked(values[1]);
});
it('should call isFilterActiveWithValue on the filterService with the correct filter parameter name and the passed value', () => {
expect(filterService.isFilterActiveWithValue).toHaveBeenCalledWith(mockFilterConfig.paramName, values[1].value)
});
});
describe('when the getSearchLink method is triggered', () => {
let link: string;
beforeEach(() => {
link = comp.getSearchLink();
});
it('should return the value of the searchLink variable in the filter service', () => {
expect(link).toEqual(filterService.searchLink);
});
});
describe('when the getQueryParamsWith method is called wih a value', () => {
beforeEach(() => {
spyOn(filterService, 'getQueryParamsWith');
comp.getQueryParamsWith(values[1].value);
});
it('should call getQueryParamsWith on the filterService with the correct filter parameter name and the passed value', () => {
expect(filterService.getQueryParamsWith).toHaveBeenCalledWith(mockFilterConfig, values[1].value)
});
});
describe('when the getQueryParamsWithout method is called wih a value', () => {
beforeEach(() => {
spyOn(filterService, 'getQueryParamsWithout');
comp.getQueryParamsWithout(values[1].value);
});
it('should call getQueryParamsWithout on the filterService with the correct filter parameter name and the passed value', () => {
expect(filterService.getQueryParamsWithout).toHaveBeenCalledWith(mockFilterConfig, values[1].value)
});
});
describe('when the facetCount method is triggered when there are less items than the amount of pages should display', () => {
let count: Observable<number>;
beforeEach(() => {
comp.currentPage = Observable.of(3);
// 2 x 3 = 6, there are only 3 values
count = comp.facetCount;
});
it('should return the correct number of items shown (this equals the total amount of values for this filter)', () => {
const sub = count.subscribe((c) => expect(c).toBe(values.length));
sub.unsubscribe();
});
});
describe('when the facetCount method is triggered when there are more items than the amount of pages should display', () => {
let count: Observable<number>;
beforeEach(() => {
comp.currentPage = Observable.of(1);
// 2 x 1 = 2, there are more than 2 (3) items
count = comp.facetCount;
});
it('should return the correct number of items shown (this equals the page count x page size)', () => {
const sub = count.subscribe((c) => {
const subsub = comp.currentPage.subscribe((page) => {
expect(c).toBe(page * mockFilterConfig.pageSize);
});
subsub.unsubscribe()
});
sub.unsubscribe();
});
});
describe('when the showMore method is called', () => {
beforeEach(() => {
spyOn(filterService, 'incrementPage');
comp.showMore();
});
it('should call incrementPage on the filterService with the correct filter parameter name', () => {
expect(filterService.incrementPage).toHaveBeenCalledWith(mockFilterConfig.name)
});
});
describe('when the showFirstPageOnly method is called', () => {
beforeEach(() => {
spyOn(filterService, 'resetPage');
comp.showFirstPageOnly();
});
it('should call resetPage on the filterService with the correct filter parameter name', () => {
expect(filterService.resetPage).toHaveBeenCalledWith(mockFilterConfig.name)
});
});
describe('when the getCurrentPage method is called', () => {
beforeEach(() => {
spyOn(filterService, 'getPage');
comp.getCurrentPage();
});
it('should call getPage on the filterService with the correct filter parameter name', () => {
expect(filterService.getPage).toHaveBeenCalledWith(mockFilterConfig.name)
});
});
});

View File

@@ -0,0 +1,86 @@
import { Component, Input, OnInit } from '@angular/core';
import { FacetValue } from '../../../search-service/facet-value.model';
import { SearchFilterConfig } from '../../../search-service/search-filter-config.model';
import { Params, Router } from '@angular/router';
import { Observable } from 'rxjs/Observable';
import { SearchFilterService } from '../search-filter.service';
import { isNotEmpty } from '../../../../shared/empty.util';
/**
* This component renders a simple item page.
* The route parameter 'id' is used to request the item it represents.
* All fields of the item that should be displayed, are defined in its template.
*/
@Component({
selector: 'ds-search-facet-filter',
styleUrls: ['./search-facet-filter.component.scss'],
templateUrl: './search-facet-filter.component.html',
})
export class SearchFacetFilterComponent implements OnInit {
@Input() filterValues: FacetValue[];
@Input() filterConfig: SearchFilterConfig;
@Input() selectedValues: string[];
currentPage: Observable<number>;
filter: string;
constructor(private filterService: SearchFilterService, private router: Router) {
}
ngOnInit(): void {
this.currentPage = this.filterService.getPage(this.filterConfig.name);
}
isChecked(value: FacetValue): Observable<boolean> {
return this.filterService.isFilterActiveWithValue(this.filterConfig.paramName, value.value);
}
getSearchLink() {
return this.filterService.searchLink;
}
getQueryParamsWith(value: string): Observable<Params> {
return this.filterService.getQueryParamsWith(this.filterConfig, value);
}
getQueryParamsWithout(value: string): Observable<Params> {
return this.filterService.getQueryParamsWithout(this.filterConfig, value);
}
get facetCount(): Observable<number> {
const resultCount = this.filterValues.length;
return this.currentPage.map((page: number) => {
const max = page * this.filterConfig.pageSize;
return max > resultCount ? resultCount : max;
});
}
showMore() {
this.filterService.incrementPage(this.filterConfig.name);
}
showFirstPageOnly() {
this.filterService.resetPage(this.filterConfig.name);
}
getCurrentPage(): Observable<number> {
return this.filterService.getPage(this.filterConfig.name);
}
getCurrentUrl() {
return this.router.url;
}
onSubmit(data: any) {
if (isNotEmpty(data)) {
const sub = this.getQueryParamsWith(data[this.filterConfig.paramName]).first().subscribe((params) => {
this.router.navigate([this.getSearchLink()], { queryParams: params }
);
}
);
this.filter = '';
sub.unsubscribe();
}
}
}

View File

@@ -0,0 +1,63 @@
import { Action } from '@ngrx/store';
import { type } from '../../../shared/ngrx/type';
/**
* For each action type in an action group, make a simple
* enum object for all of this group's action types.
*
* The 'type' utility function coerces strings into string
* literal types and runs a simple check to guarantee all
* action types in the application are unique.
*/
export const SearchFilterActionTypes = {
COLLAPSE: type('dspace/search-filter/COLLAPSE'),
INITIAL_COLLAPSE: type('dspace/search-filter/INITIAL_COLLAPSE'),
EXPAND: type('dspace/search-filter/EXPAND'),
INITIAL_EXPAND: type('dspace/search-filter/INITIAL_EXPAND'),
TOGGLE: type('dspace/search-filter/TOGGLE'),
DECREMENT_PAGE: type('dspace/search-filter/DECREMENT_PAGE'),
INCREMENT_PAGE: type('dspace/search-filter/INCREMENT_PAGE'),
RESET_PAGE: type('dspace/search-filter/RESET_PAGE')
};
export class SearchFilterAction implements Action {
filterName: string;
type;
constructor(name: string) {
this.filterName = name;
}
}
/* tslint:disable:max-classes-per-file */
export class SearchFilterCollapseAction extends SearchFilterAction {
type = SearchFilterActionTypes.COLLAPSE;
}
export class SearchFilterExpandAction extends SearchFilterAction {
type = SearchFilterActionTypes.EXPAND;
}
export class SearchFilterToggleAction extends SearchFilterAction {
type = SearchFilterActionTypes.TOGGLE;
}
export class SearchFilterInitialCollapseAction extends SearchFilterAction {
type = SearchFilterActionTypes.INITIAL_COLLAPSE;
}
export class SearchFilterInitialExpandAction extends SearchFilterAction {
type = SearchFilterActionTypes.INITIAL_EXPAND;
}
export class SearchFilterDecrementPageAction extends SearchFilterAction {
type = SearchFilterActionTypes.DECREMENT_PAGE;
}
export class SearchFilterIncrementPageAction extends SearchFilterAction {
type = SearchFilterActionTypes.INCREMENT_PAGE;
}
export class SearchFilterResetPageAction extends SearchFilterAction {
type = SearchFilterActionTypes.RESET_PAGE;
}
/* tslint:enable:max-classes-per-file */

View File

@@ -0,0 +1,8 @@
<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"
[filterValues]="(filterValues | async)?.payload" [selectedValues]="getSelectedValues() | async"></ds-search-facet-filter>
</div>
</div>

View File

@@ -0,0 +1,12 @@
@import '../../../../styles/variables.scss';
@import '../../../../styles/mixins.scss';
:host {
border: 1px solid map-get($theme-colors, light);
.search-filter-wrapper {
overflow: hidden;
}
.filter-toggle {
line-height: $line-height-base;
}
}

View File

@@ -0,0 +1,171 @@
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 { Observable } from 'rxjs/Observable';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { SearchFilterService } from './search-filter.service';
import { SearchService } from '../../search-service/search.service';
import { SearchFilterComponent } from './search-filter.component';
import { SearchFilterConfig } from '../../search-service/search-filter-config.model';
import { FilterType } from '../../search-service/filter-type.model';
describe('SearchFilterComponent', () => {
let comp: SearchFilterComponent;
let fixture: ComponentFixture<SearchFilterComponent>;
const filterName1 = 'test name';
const filterName2 = 'test2';
const filterName3 = 'another name3';
const nonExistingFilter1 = 'non existing 1';
const nonExistingFilter2 = 'non existing 2';
const mockFilterConfig: SearchFilterConfig = Object.assign(new SearchFilterConfig(), {
name: filterName1,
type: FilterType.text,
hasFacets: false,
isOpenByDefault: false
});
const mockFilterService = {
/* tslint:disable:no-empty */
toggle: (filter) => {
},
collapse: (filter) => {
},
expand: (filter) => {
},
initialCollapse: (filter) => {
},
initialExpand: (filter) => {
},
getSelectedValuesForFilter: (filter) => {
return Observable.of([filterName1, filterName2, filterName3])
},
isFilterActive: (filter) => {
return Observable.of([filterName1, filterName2, filterName3].indexOf(filter) >= 0);
},
isCollapsed: (filter) => {
return Observable.of(true)
}
/* tslint:enable:no-empty */
};
let filterService;
const mockResults = Observable.of(['test', 'data']);
const searchServiceStub = {
getFacetValuesFor: (filter) => mockResults
};
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([]), NoopAnimationsModule],
declarations: [SearchFilterComponent],
providers: [
{ provide: SearchService, useValue: searchServiceStub },
{
provide: SearchFilterService,
useValue: mockFilterService
},
],
schemas: [NO_ERRORS_SCHEMA]
}).overrideComponent(SearchFilterComponent, {
set: { changeDetection: ChangeDetectionStrategy.Default }
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(SearchFilterComponent);
comp = fixture.componentInstance; // SearchPageComponent test instance
comp.filter = mockFilterConfig;
fixture.detectChanges();
filterService = (comp as any).filterService;
});
describe('when the toggle method is triggered', () => {
beforeEach(() => {
spyOn(filterService, 'toggle');
comp.toggle();
});
it('should call toggle with the correct filter configuration name', () => {
expect(filterService.toggle).toHaveBeenCalledWith(mockFilterConfig.name)
});
});
describe('when the initialCollapse method is triggered', () => {
beforeEach(() => {
spyOn(filterService, 'initialCollapse');
comp.initialCollapse();
});
it('should call initialCollapse with the correct filter configuration name', () => {
expect(filterService.initialCollapse).toHaveBeenCalledWith(mockFilterConfig.name)
});
});
describe('when the initialExpand method is triggered', () => {
beforeEach(() => {
spyOn(filterService, 'initialExpand');
comp.initialExpand();
});
it('should call initialCollapse with the correct filter configuration name', () => {
expect(filterService.initialExpand).toHaveBeenCalledWith(mockFilterConfig.name)
});
});
describe('when getSelectedValues is called', () => {
let valuesObservable: Observable<string[]>;
beforeEach(() => {
valuesObservable = comp.getSelectedValues();
});
it('should return an observable containing the existing filters', () => {
const sub = valuesObservable.subscribe((values) => {
expect(values).toContain(filterName1);
expect(values).toContain(filterName2);
expect(values).toContain(filterName3);
});
sub.unsubscribe();
});
it('should return an observable that does not contain the non-existing filters', () => {
const sub = valuesObservable.subscribe((values) => {
expect(values).not.toContain(nonExistingFilter1);
expect(values).not.toContain(nonExistingFilter2);
});
sub.unsubscribe();
});
});
describe('when isCollapsed is called and the filter is collapsed', () => {
let isActive: Observable<boolean>;
beforeEach(() => {
filterService.isCollapsed = () => Observable.of(true);
isActive = comp.isCollapsed();
});
it('should return an observable containing true', () => {
const sub = isActive.subscribe((value) => {
expect(value).toBeTruthy();
});
sub.unsubscribe();
});
});
describe('when isCollapsed is called and the filter is not collapsed', () => {
let isActive: Observable<boolean>;
beforeEach(() => {
filterService.isCollapsed = () => Observable.of(false);
isActive = comp.isCollapsed();
});
it('should return an observable containing false', () => {
const sub = isActive.subscribe((value) => {
expect(value).toBeFalsy();
});
sub.unsubscribe();
});
});
});

View File

@@ -0,0 +1,63 @@
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 { RouteService } from '../../../shared/route.service';
import { first } from 'rxjs/operator/first';
/**
* 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-filter',
styleUrls: ['./search-filter.component.scss'],
templateUrl: './search-filter.component.html',
animations: [slide],
})
export class SearchFilterComponent implements OnInit {
@Input() filter: SearchFilterConfig;
filterValues: Observable<RemoteData<FacetValue[]>>;
constructor(private searchService: SearchService, private filterService: SearchFilterService) {
}
ngOnInit() {
this.filterValues = this.searchService.getFacetValuesFor(this.filter.name);
const sub = this.filterService.isFilterActive(this.filter.paramName).first().subscribe((isActive) => {
if (this.filter.isOpenByDefault || isActive) {
this.initialExpand();
} else {
this.initialCollapse();
}
});
sub.unsubscribe();
}
toggle() {
this.filterService.toggle(this.filter.name);
}
isCollapsed(): Observable<boolean> {
return this.filterService.isCollapsed(this.filter.name);
}
initialCollapse() {
this.filterService.initialCollapse(this.filter.name);
}
initialExpand() {
this.filterService.initialExpand(this.filter.name);
}
getSelectedValues(): Observable<string[]> {
return this.filterService.getSelectedValuesForFilter(this.filter);
}
}

View File

@@ -0,0 +1,165 @@
import * as deepFreeze from 'deep-freeze';
import {
SearchFilterCollapseAction, SearchFilterExpandAction, SearchFilterIncrementPageAction,
SearchFilterInitialCollapseAction,
SearchFilterInitialExpandAction,
SearchFilterToggleAction,
SearchFilterDecrementPageAction, SearchFilterResetPageAction
} from './search-filter.actions';
import { filterReducer } from './search-filter.reducer';
const filterName1 = 'author';
const filterName2 = 'scope';
class NullAction extends SearchFilterCollapseAction {
type = null;
constructor() {
super(undefined);
}
}
describe('filterReducer', () => {
it('should return the current state when no valid actions have been made', () => {
const state = { author: { filterCollapsed: true, page: 1 } };
const action = new NullAction();
const newState = filterReducer(state, action);
expect(newState).toEqual(state);
});
it('should start with an empty object', () => {
const state = Object.create({});
const action = new NullAction();
const initialState = filterReducer(undefined, action);
// The search filter starts collapsed
expect(initialState).toEqual(state);
});
it('should set filterCollapsed to true in response to the COLLAPSE action', () => {
const state = {};
state[filterName1] = { filterCollapsed: false, page: 1 };
const action = new SearchFilterCollapseAction(filterName1);
const newState = filterReducer(state, action);
expect(newState[filterName1].filterCollapsed).toEqual(true);
});
it('should perform the COLLAPSE action without affecting the previous state', () => {
const state = {};
state[filterName1] = { filterCollapsed: false, page: 1 };
deepFreeze([state]);
const action = new SearchFilterCollapseAction(filterName1);
filterReducer(state, action);
// no expect required, deepFreeze will ensure an exception is thrown if the state
// is mutated, and any uncaught exception will cause the test to fail
});
it('should set filterCollapsed to false in response to the EXPAND action', () => {
const state = {};
state[filterName1] = { filterCollapsed: true, page: 1 };
const action = new SearchFilterExpandAction(filterName1);
const newState = filterReducer(state, action);
expect(newState[filterName1].filterCollapsed).toEqual(false);
});
it('should perform the EXPAND action without affecting the previous state', () => {
const state = {};
state[filterName1] = { filterCollapsed: true, page: 1 };
deepFreeze([state]);
const action = new SearchFilterExpandAction(filterName1);
filterReducer(state, action);
});
it('should flip the value of filterCollapsed in response to the TOGGLE action', () => {
const state1 = {};
state1[filterName1] = { filterCollapsed: true, page: 1 };
const action = new SearchFilterToggleAction(filterName1);
const state2 = filterReducer(state1, action);
const state3 = filterReducer(state2, action);
expect(state2[filterName1].filterCollapsed).toEqual(false);
expect(state3[filterName1].filterCollapsed).toEqual(true);
});
it('should perform the TOGGLE action without affecting the previous state', () => {
const state = {};
state[filterName1] = { filterCollapsed: true, page: 1 };
deepFreeze([state]);
const action = new SearchFilterToggleAction(filterName1);
filterReducer(state, action);
});
it('should set filterCollapsed to true in response to the INITIAL_COLLAPSE action when no state has been set for this filter', () => {
const state = {};
state[filterName2] = { filterCollapsed: false, page: 1 };
const action = new SearchFilterInitialCollapseAction(filterName1);
const newState = filterReducer(state, action);
expect(newState[filterName1].filterCollapsed).toEqual(true);
});
it('should set filterCollapsed to true in response to the INITIAL_EXPAND action when no state has been set for this filter', () => {
const state = {};
state[filterName2] = { filterCollapsed: true, page: 1 };
const action = new SearchFilterInitialExpandAction(filterName1);
const newState = filterReducer(state, action);
expect(newState[filterName1].filterCollapsed).toEqual(false);
});
it('should not change the state in response to the INITIAL_COLLAPSE action when the state has already been set for this filter', () => {
const state = {};
state[filterName1] = { filterCollapsed: false, page: 1 };
const action = new SearchFilterInitialCollapseAction(filterName1);
const newState = filterReducer(state, action);
expect(newState).toEqual(state);
});
it('should not change the state in response to the INITIAL_EXPAND action when the state has already been set for this filter', () => {
const state = {};
state[filterName1] = { filterCollapsed: true, page: 1 };
const action = new SearchFilterInitialExpandAction(filterName1);
const newState = filterReducer(state, action);
expect(newState).toEqual(state);
});
it('should increment with 1 for the specified filter in response to the INCREMENT_PAGE action', () => {
const state = {};
state[filterName1] = { filterCollapsed: true, page: 5 };
const action = new SearchFilterIncrementPageAction(filterName1);
const newState = filterReducer(state, action);
expect(newState[filterName1].page).toEqual(6);
});
it('should decrement with 1 for the specified filter in response to the DECREMENT_PAGE action', () => {
const state = {};
state[filterName1] = { filterCollapsed: true, page: 12 };
const action = new SearchFilterDecrementPageAction(filterName1);
const newState = filterReducer(state, action);
expect(newState[filterName1].page).toEqual(11);
});
it('should not decrement when page is 1 for the specified filter in response to the DECREMENT_PAGE action', () => {
const state = {};
state[filterName1] = { filterCollapsed: true, page: 1 };
const action = new SearchFilterDecrementPageAction(filterName1);
const newState = filterReducer(state, action);
expect(newState[filterName1].page).toEqual(1);
});
it('should reset the page to 1 for the specified filter in response to the RESET_PAGE action', () => {
const state = {};
state[filterName1] = { filterCollapsed: true, page: 20 };
const action = new SearchFilterResetPageAction(filterName1);
const newState = filterReducer(state, action);
expect(newState[filterName1].page).toEqual(1);
});
});

View File

@@ -0,0 +1,105 @@
import { SearchFilterAction, SearchFilterActionTypes } from './search-filter.actions';
import { isEmpty } from '../../../shared/empty.util';
export interface SearchFilterState {
filterCollapsed: boolean,
page: number
}
export interface SearchFiltersState {
[name: string]: SearchFilterState
}
const initialState: SearchFiltersState = Object.create(null);
export function filterReducer(state = initialState, action: SearchFilterAction): SearchFiltersState {
switch (action.type) {
case SearchFilterActionTypes.INITIAL_COLLAPSE: {
if (isEmpty(state) || isEmpty(state[action.filterName])) {
return Object.assign({}, state, {
[action.filterName]: {
filterCollapsed: true,
page: 1
}
});
}
return state;
}
case SearchFilterActionTypes.INITIAL_EXPAND: {
if (isEmpty(state) || isEmpty(state[action.filterName])) {
return Object.assign({}, state, {
[action.filterName]: {
filterCollapsed: false,
page: 1
}
});
}
return state;
}
case SearchFilterActionTypes.COLLAPSE: {
return Object.assign({}, state, {
[action.filterName]: {
filterCollapsed: true,
page: state[action.filterName].page
}
});
}
case SearchFilterActionTypes.EXPAND: {
return Object.assign({}, state, {
[action.filterName]: {
filterCollapsed: false,
page: state[action.filterName].page
}
});
}
case SearchFilterActionTypes.DECREMENT_PAGE: {
const page = state[action.filterName].page - 1;
return Object.assign({}, state, {
[action.filterName]: {
filterCollapsed: state[action.filterName].filterCollapsed,
page: (page >= 1 ? page : 1)
}
});
}
case SearchFilterActionTypes.INCREMENT_PAGE: {
return Object.assign({}, state, {
[action.filterName]: {
filterCollapsed: state[action.filterName].filterCollapsed,
page: state[action.filterName].page + 1
}
});
}
case SearchFilterActionTypes.RESET_PAGE: {
return Object.assign({}, state, {
[action.filterName]: {
filterCollapsed: state[action.filterName].filterCollapsed,
page: 1
}
});
}
case SearchFilterActionTypes.TOGGLE: {
return Object.assign({}, state, {
[action.filterName]: {
filterCollapsed: !state[action.filterName].filterCollapsed,
page: state[action.filterName].page
}
});
}
default: {
return state;
}
}
}

View File

@@ -0,0 +1,205 @@
import { Observable } from 'rxjs/Observable';
import { SearchFilterService } from './search-filter.service';
import { Store } from '@ngrx/store';
import {
SearchFilterCollapseAction, SearchFilterDecrementPageAction, SearchFilterExpandAction,
SearchFilterIncrementPageAction,
SearchFilterInitialCollapseAction, SearchFilterInitialExpandAction, SearchFilterResetPageAction,
SearchFilterToggleAction
} from './search-filter.actions';
import { SearchFiltersState } from './search-filter.reducer';
import { SearchFilterConfig } from '../../search-service/search-filter-config.model';
import { FilterType } from '../../search-service/filter-type.model';
describe('SearchFilterService', () => {
let service: SearchFilterService;
const filterName1 = 'test name';
const mockFilterConfig: SearchFilterConfig = Object.assign(new SearchFilterConfig(), {
name: filterName1,
type: FilterType.text,
hasFacets: false,
isOpenByDefault: false,
pageSize: 2
});
const value1 = 'random value';
// const value2 = 'another value';
const store: Store<SearchFiltersState> = jasmine.createSpyObj('store', {
/* tslint:disable:no-empty */
dispatch: {},
/* tslint:enable:no-empty */
select: Observable.of(true)
});
const routeServiceStub: any = {
/* tslint:disable:no-empty */
hasQueryParamWithValue: (param: string, value: string) => {
},
hasQueryParam: (param: string) => {
},
removeQueryParameterValue: (param: string, value: string) => {
},
addQueryParameterValue: (param: string, value: string) => {
},
getQueryParameterValues: (param: string) => {
}
/* tslint:enable:no-empty */
};
const searchServiceStub: any = {
searchLink: '/search'
};
beforeEach(() => {
service = new SearchFilterService(store, routeServiceStub, searchServiceStub);
});
describe('when the initialCollapse method is triggered', () => {
beforeEach(() => {
service.initialCollapse(mockFilterConfig.name);
});
it('SearchFilterInitialCollapseAction should be dispatched to the store', () => {
expect(store.dispatch).toHaveBeenCalledWith(new SearchFilterInitialCollapseAction(mockFilterConfig.name));
});
});
describe('when the initialExpand method is triggered', () => {
beforeEach(() => {
service.initialExpand(mockFilterConfig.name);
});
it('SearchFilterInitialExpandAction should be dispatched to the store', () => {
expect(store.dispatch).toHaveBeenCalledWith(new SearchFilterInitialExpandAction(mockFilterConfig.name));
});
});
describe('when the collapse method is triggered', () => {
beforeEach(() => {
service.collapse(mockFilterConfig.name);
});
it('SearchFilterCollapseAction should be dispatched to the store', () => {
expect(store.dispatch).toHaveBeenCalledWith(new SearchFilterCollapseAction(mockFilterConfig.name));
});
});
describe('when the toggle method is triggered', () => {
beforeEach(() => {
service.toggle(mockFilterConfig.name);
});
it('SearchFilterInitialExpandAction should be dispatched to the store', () => {
expect(store.dispatch).toHaveBeenCalledWith(new SearchFilterToggleAction(mockFilterConfig.name));
});
});
describe('when the decreasePage method is triggered', () => {
beforeEach(() => {
service.decrementPage(mockFilterConfig.name);
});
it('SearchFilterDecrementPageAction should be dispatched to the store', () => {
expect(store.dispatch).toHaveBeenCalledWith(new SearchFilterDecrementPageAction(mockFilterConfig.name));
});
});
describe('when the increasePage method is triggered', () => {
beforeEach(() => {
service.incrementPage(mockFilterConfig.name);
});
it('SearchFilterCollapseAction should be dispatched to the store', () => {
expect(store.dispatch).toHaveBeenCalledWith(new SearchFilterIncrementPageAction(mockFilterConfig.name));
});
});
describe('when the resetPage method is triggered', () => {
beforeEach(() => {
service.resetPage(mockFilterConfig.name);
});
it('SearchFilterDecrementPageAction should be dispatched to the store', () => {
expect(store.dispatch).toHaveBeenCalledWith(new SearchFilterResetPageAction(mockFilterConfig.name));
});
});
describe('when the expand method is triggered', () => {
beforeEach(() => {
service.expand(mockFilterConfig.name);
});
it('SearchSidebarExpandAction should be dispatched to the store', () => {
expect(store.dispatch).toHaveBeenCalledWith(new SearchFilterExpandAction(mockFilterConfig.name));
});
});
describe('when the isFilterActiveWithValue method is called', () => {
beforeEach(() => {
spyOn(routeServiceStub, 'hasQueryParamWithValue');
service.isFilterActiveWithValue(mockFilterConfig.paramName, value1);
});
it('should call hasQueryParamWithValue on the route service with the same parameters', () => {
expect(routeServiceStub.hasQueryParamWithValue).toHaveBeenCalledWith(mockFilterConfig.paramName, value1);
});
});
describe('when the isFilterActive method is called', () => {
beforeEach(() => {
spyOn(routeServiceStub, 'hasQueryParam');
service.isFilterActive(mockFilterConfig.paramName);
});
it('should call hasQueryParam on the route service with the same parameters', () => {
expect(routeServiceStub.hasQueryParam).toHaveBeenCalledWith(mockFilterConfig.paramName);
});
});
describe('when the getQueryParamsWithout method is called', () => {
beforeEach(() => {
spyOn(routeServiceStub, 'removeQueryParameterValue');
service.getQueryParamsWithout(mockFilterConfig, value1);
});
it('should call removeQueryParameterValue on the route service with the same parameters', () => {
expect(routeServiceStub.removeQueryParameterValue).toHaveBeenCalledWith(mockFilterConfig.paramName, value1);
});
});
describe('when the getQueryParamsWith method is called', () => {
beforeEach(() => {
spyOn(routeServiceStub, 'addQueryParameterValue');
service.getQueryParamsWith(mockFilterConfig, value1);
});
it('should call addQueryParameterValue on the route service with the same parameters', () => {
expect(routeServiceStub.addQueryParameterValue).toHaveBeenCalledWith(mockFilterConfig.paramName, value1);
});
});
describe('when the getSelectedValuesForFilter method is called', () => {
beforeEach(() => {
spyOn(routeServiceStub, 'getQueryParameterValues');
service.getSelectedValuesForFilter(mockFilterConfig);
});
it('should call getQueryParameterValues on the route service with the same parameters', () => {
expect(routeServiceStub.getQueryParameterValues).toHaveBeenCalledWith(mockFilterConfig.paramName);
});
});
describe('when the searchLink method is called', () => {
let link: string;
beforeEach(() => {
link = service.searchLink;
});
it('should return the value of searchLink in the search service', () => {
expect(link).toEqual(searchServiceStub.searchLink);
});
});
});

View File

@@ -0,0 +1,119 @@
import { Injectable } from '@angular/core';
import { SearchFiltersState, SearchFilterState } from './search-filter.reducer';
import { createSelector, MemoizedSelector, Store } from '@ngrx/store';
import { Observable } from 'rxjs/Observable';
import {
SearchFilterCollapseAction,
SearchFilterDecrementPageAction, SearchFilterExpandAction,
SearchFilterIncrementPageAction,
SearchFilterInitialCollapseAction,
SearchFilterInitialExpandAction, SearchFilterResetPageAction,
SearchFilterToggleAction
} from './search-filter.actions';
import { hasValue, } 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';
const filterStateSelector = (state: SearchFiltersState) => state.searchFilter;
@Injectable()
export class SearchFilterService {
constructor(private store: Store<SearchFiltersState>,
private routeService: RouteService,
private searchService: SearchService) {
}
isFilterActiveWithValue(paramName: string, filterValue: string): Observable<boolean> {
return this.routeService.hasQueryParamWithValue(paramName, filterValue);
}
isFilterActive(paramName: string): Observable<boolean> {
return this.routeService.hasQueryParam(paramName);
}
getQueryParamsWithout(filterConfig: SearchFilterConfig, value: string) {
return this.routeService.removeQueryParameterValue(filterConfig.paramName, value);
}
getQueryParamsWith(filterConfig: SearchFilterConfig, value: string) {
return this.routeService.addQueryParameterValue(filterConfig.paramName, value);
}
getSelectedValuesForFilter(filterConfig: SearchFilterConfig): Observable<string[]> {
return this.routeService.getQueryParameterValues(filterConfig.paramName);
}
get searchLink() {
return this.searchService.searchLink;
}
isCollapsed(filterName: string): Observable<boolean> {
return this.store.select(filterByNameSelector(filterName))
.map((object: SearchFilterState) => {
if (object) {
return object.filterCollapsed;
} else {
return false;
}
});
}
getPage(filterName: string): Observable<number> {
return this.store.select(filterByNameSelector(filterName))
.map((object: SearchFilterState) => {
if (object) {
return object.page;
} else {
return 1;
}
});
}
public collapse(filterName: string): void {
this.store.dispatch(new SearchFilterCollapseAction(filterName));
}
public expand(filterName: string): void {
this.store.dispatch(new SearchFilterExpandAction(filterName));
}
public toggle(filterName: string): void {
this.store.dispatch(new SearchFilterToggleAction(filterName));
}
public initialCollapse(filterName: string): void {
this.store.dispatch(new SearchFilterInitialCollapseAction(filterName));
}
public initialExpand(filterName: string): void {
this.store.dispatch(new SearchFilterInitialExpandAction(filterName));
}
public decrementPage(filterName: string): void {
this.store.dispatch(new SearchFilterDecrementPageAction(filterName));
}
public incrementPage(filterName: string): void {
this.store.dispatch(new SearchFilterIncrementPageAction(filterName));
}
public resetPage(filterName: string): void {
this.store.dispatch(new SearchFilterResetPageAction(filterName));
}
}
function filterByNameSelector(name: string): MemoizedSelector<SearchFiltersState, SearchFilterState> {
return keySelector<SearchFilterState>(name);
}
export function keySelector<T>(key: string): MemoizedSelector<SearchFiltersState, T> {
return createSelector(filterStateSelector, (state: SearchFilterState) => {
if (hasValue(state)) {
return state[key];
} else {
return undefined;
}
});
}

View File

@@ -0,0 +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>
</div>
<a class="btn btn-primary" [routerLink]="[getSearchLink()]" [queryParams]="getClearFiltersQueryParams()" role="button">{{"search.filters.reset" | translate}}</a>

View File

@@ -0,0 +1,2 @@
@import '../../../styles/variables.scss';
@import '../../../styles/mixins.scss';

View File

@@ -0,0 +1,69 @@
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 { SearchFiltersComponent } from './search-filters.component';
import { SearchService } from '../search-service/search.service';
import { Observable } from 'rxjs/Observable';
describe('SearchFiltersComponent', () => {
let comp: SearchFiltersComponent;
let fixture: ComponentFixture<SearchFiltersComponent>;
let searchService: SearchService;
const searchServiceStub = {
/* tslint:disable:no-empty */
getConfig: () =>
Observable.of({ hasSucceeded: true, payload: [] }),
getClearFiltersQueryParams: () => {
},
getSearchLink: () => {
}
/* tslint:enable:no-empty */
};
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([]), NoopAnimationsModule],
declarations: [SearchFiltersComponent],
providers: [
{ provide: SearchService, useValue: searchServiceStub },
],
schemas: [NO_ERRORS_SCHEMA]
}).overrideComponent(SearchFiltersComponent, {
set: { changeDetection: ChangeDetectionStrategy.Default }
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(SearchFiltersComponent);
comp = fixture.componentInstance; // SearchFiltersComponent test instance
fixture.detectChanges();
searchService = (comp as any).searchService;
});
describe('when the getClearFiltersQueryParams method is called', () => {
beforeEach(() => {
spyOn(searchService, 'getClearFiltersQueryParams');
comp.getClearFiltersQueryParams();
});
it('should call getClearFiltersQueryParams on the searchService', () => {
expect(searchService.getClearFiltersQueryParams).toHaveBeenCalled()
});
});
describe('when the getSearchLink method is called', () => {
beforeEach(() => {
spyOn(searchService, 'getSearchLink');
comp.getSearchLink();
});
it('should call getSearchLink on the searchService', () => {
expect(searchService.getSearchLink).toHaveBeenCalled()
});
});
});

View File

@@ -0,0 +1,32 @@
import { Component } from '@angular/core';
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';
/**
* 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-filters',
styleUrls: ['./search-filters.component.scss'],
templateUrl: './search-filters.component.html',
})
export class SearchFiltersComponent {
filters: Observable<RemoteData<SearchFilterConfig[]>>;
constructor(private searchService: SearchService) {
this.filters = searchService.getConfig();
}
getClearFiltersQueryParams(): any {
return this.searchService.getClearFiltersQueryParams();
}
getSearchLink() {
return this.searchService.getSearchLink();
}
}

View File

@@ -1,37 +1,41 @@
<div class="container"> <div class="container">
<div class="search-page"> <div class="search-page row">
<ds-search-sidebar dsStick *ngIf="!(isMobileView | async)" class="col-3 sidebar-sm-fixed" <ds-search-sidebar *ngIf="!(isMobileView | async)" class="col-3 sidebar-md-sticky"
id="search-sidebar" id="search-sidebar"
resultCount="{{(resultsRDObs | async)?.pageInfo?.totalElements}}"></ds-search-sidebar> [resultCount]="(resultsRDObs | async)?.pageInfo?.totalElements"></ds-search-sidebar>
<div id="search-header" class="row"> <div class="col-12 col-md-9">
<ds-search-form id="search-form" class="col-12 col-sm-9 ml-sm-auto" <ds-search-form id="search-form"
[query]="query" [query]="query"
[scope]="(scopeObjectRDObs | async)?.payload" [scope]="(scopeObjectRDObs | async)?.payload"
[currentParams]="currentParams" [currentParams]="currentParams"
[scopes]="(scopeListRDObs | async)?.payload"> [scopes]="(scopeListRDObs | async)?.payload">
</ds-search-form> </ds-search-form>
</div> <div class="row">
<div class="row"> <div id="search-body"
<div id="search-body" class="row-offcanvas row-offcanvas-left"
class="row-offcanvas row-offcanvas-left" [@pushInOut]="(isSidebarCollapsed() | async) ? 'collapsed' : 'expanded'">
[@slideInOut]="(isSidebarCollapsed() | async) ? 'collapsed' : 'expanded'"> <ds-search-sidebar *ngIf="(isMobileView | async)" class="col-12"
<ds-search-sidebar *ngIf="(isMobileView | async)" class="col-12" id="search-sidebar-xs" id="search-sidebar-sm"
resultCount="{{(resultsRDObs | async)?.pageInfo?.totalElements}}" [resultCount]="(resultsRDObs | async)?.pageInfo?.totalElements"
(toggleSidebar)="closeSidebar()" [ngClass]="{'active': !(isSidebarCollapsed() | async)}"></ds-search-sidebar> (toggleSidebar)="closeSidebar()"
<div id="search-content" class="col-12 col-sm-9 ml-sm-auto"> [ngClass]="{'active': !(isSidebarCollapsed() | async)}">
<div class="d-block d-sm-none search-controls clearfix"> </ds-search-sidebar>
<div id="search-content" class="col-12">
<ds-view-mode-switch></ds-view-mode-switch> <div class="d-block d-md-none search-controls clearfix">
<button (click)="openSidebar()" aria-controls="#search-body" <ds-view-mode-switch></ds-view-mode-switch>
class="btn btn-outline-primary float-right open-sidebar"><i <button (click)="openSidebar()" aria-controls="#search-body"
class="fa fa-sliders"></i> {{"search.sidebar.open" class="btn btn-outline-primary float-right open-sidebar"><i
| translate}} class="fa fa-sliders"></i> {{"search.sidebar.open"
</button> | translate}}
</div> </button>
<ds-search-results [searchResults]="resultsRDObs | async"
[searchConfig]="searchOptions"></ds-search-results>
</div>
</div> </div>
<ds-search-results [searchResults]="resultsRDObs | async"
[searchConfig]="searchOptions"></ds-search-results>
</div>
</div> </div>
</div>
</div> </div>
</div>
</div> </div>

View File

@@ -1,14 +1,10 @@
@import '../../styles/variables.scss'; @import '../../styles/variables.scss';
@import '../../styles/mixins.scss'; @import '../../styles/mixins.scss';
#search-body { @include media-breakpoint-down(md) {
position: relative; .container {
} width: 100%;
max-width: none;
#search-content, #search-form {
display: block;
@include media-breakpoint-down(xs) {
margin-left: 0;
} }
} }
@@ -20,22 +16,22 @@
&.row-offcanvas { &.row-offcanvas {
width: 100%; width: 100%;
} }
@include media-breakpoint-down(xs) { @include media-breakpoint-down(sm) {
position: relative; position: relative;
&.row-offcanvas { &.row-offcanvas {
position: relative; position: relative;
} }
&.row-offcanvas-right #search-sidebar-xs { &.row-offcanvas-right #search-sidebar-sm {
right: -100%; right: -100%;
} }
&.row-offcanvas-left #search-sidebar-xs { &.row-offcanvas-left #search-sidebar-sm {
left: -100%; left: -100%;
} }
#search-sidebar-xs { #search-sidebar-sm {
position: absolute; position: absolute;
top: 0; top: 0;
width: 100%; width: 100%;
@@ -43,15 +39,16 @@
} }
} }
.sidebar-sm-fixed { @include media-breakpoint-up(md) {
@include media-breakpoint-up(sm) { .sidebar-md-sticky {
position: absolute; position: sticky;
margin-top: -$content-spacing; position: -webkit-sticky;
top: 0;
z-index: $zindex-sticky;
padding-top: $content-spacing; padding-top: $content-spacing;
&.stick { margin-top: -$content-spacing;
top: 0; align-self: flex-start;
margin-top: 0px; display: block;
position: fixed;
}
} }
} }

View File

@@ -28,8 +28,14 @@ describe('SearchPageComponent', () => {
/* tslint:enable:no-empty */ /* tslint:enable:no-empty */
select: Observable.of(true) select: Observable.of(true)
}); });
const pagination: PaginationComponentOptions = new PaginationComponentOptions();
pagination.id = 'search-results-pagination';
pagination.currentPage = 1;
pagination.pageSize = 10;
const sort: SortOptions = new SortOptions();
const mockResults = Observable.of(['test', 'data']); const mockResults = Observable.of(['test', 'data']);
const searchServiceStub = { const searchServiceStub = {
searchOptions:{ pagination: pagination, sort: sort },
search: () => mockResults search: () => mockResults
}; };
const queryParam = 'test query'; const queryParam = 'test query';
@@ -151,7 +157,7 @@ describe('SearchPageComponent', () => {
beforeEach(() => { beforeEach(() => {
spyOn(comp, 'closeSidebar'); spyOn(comp, 'closeSidebar');
const closeSidebarButton = fixture.debugElement.query(By.css('#search-sidebar-xs')); const closeSidebarButton = fixture.debugElement.query(By.css('#search-sidebar-sm'));
closeSidebarButton.triggerEventHandler('toggleSidebar', null); closeSidebarButton.triggerEventHandler('toggleSidebar', null);
}); });
@@ -179,7 +185,7 @@ describe('SearchPageComponent', () => {
let menu: HTMLElement; let menu: HTMLElement;
beforeEach(() => { beforeEach(() => {
menu = fixture.debugElement.query(By.css('#search-sidebar-xs')).nativeElement; menu = fixture.debugElement.query(By.css('#search-sidebar-sm')).nativeElement;
comp.isSidebarCollapsed = () => Observable.of(true); comp.isSidebarCollapsed = () => Observable.of(true);
fixture.detectChanges(); fixture.detectChanges();
}); });
@@ -194,7 +200,7 @@ describe('SearchPageComponent', () => {
let menu: HTMLElement; let menu: HTMLElement;
beforeEach(() => { beforeEach(() => {
menu = fixture.debugElement.query(By.css('#search-sidebar-xs')).nativeElement; menu = fixture.debugElement.query(By.css('#search-sidebar-sm')).nativeElement;
comp.isSidebarCollapsed = () => Observable.of(false); comp.isSidebarCollapsed = () => Observable.of(false);
fixture.detectChanges(); fixture.detectChanges();
}); });

View File

@@ -1,17 +1,15 @@
import { ChangeDetectionStrategy, Component, OnDestroy, OnInit } from '@angular/core'; import { ChangeDetectionStrategy, Component, OnDestroy, OnInit } from '@angular/core';
import { ActivatedRoute } from '@angular/router'; import { ActivatedRoute } from '@angular/router';
import { Observable } from 'rxjs/Observable'; import { Observable } from 'rxjs/Observable';
import { SortOptions } from '../core/cache/models/sort-options.model';
import { CommunityDataService } from '../core/data/community-data.service'; import { CommunityDataService } from '../core/data/community-data.service';
import { RemoteData } from '../core/data/remote-data'; import { RemoteData } from '../core/data/remote-data';
import { Community } from '../core/shared/community.model'; import { Community } from '../core/shared/community.model';
import { DSpaceObject } from '../core/shared/dspace-object.model'; import { DSpaceObject } from '../core/shared/dspace-object.model';
import { isNotEmpty } from '../shared/empty.util'; import { isNotEmpty } from '../shared/empty.util';
import { PaginationComponentOptions } from '../shared/pagination/pagination-component-options.model';
import { SearchOptions } from './search-options.model'; import { SearchOptions } from './search-options.model';
import { SearchResult } from './search-result.model'; import { SearchResult } from './search-result.model';
import { SearchService } from './search-service/search.service'; import { SearchService } from './search-service/search.service';
import { slideInOut } from '../shared/animations/slide'; import { pushInOut } from '../shared/animations/push';
import { HostWindowService } from '../shared/host-window.service'; import { HostWindowService } from '../shared/host-window.service';
import { SearchSidebarService } from './search-sidebar/search-sidebar.service'; import { SearchSidebarService } from './search-sidebar/search-sidebar.service';
@@ -26,7 +24,7 @@ import { SearchSidebarService } from './search-sidebar/search-sidebar.service';
styleUrls: ['./search-page.component.scss'], styleUrls: ['./search-page.component.scss'],
templateUrl: './search-page.component.html', templateUrl: './search-page.component.html',
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
animations: [slideInOut] animations: [pushInOut]
}) })
export class SearchPageComponent implements OnInit, OnDestroy { export class SearchPageComponent implements OnInit, OnDestroy {
@@ -46,15 +44,14 @@ export class SearchPageComponent implements OnInit, OnDestroy {
private communityService: CommunityDataService, private communityService: CommunityDataService,
private sidebarService: SearchSidebarService, private sidebarService: SearchSidebarService,
private windowService: HostWindowService) { private windowService: HostWindowService) {
this.isMobileView = this.windowService.isXs(); this.isMobileView = Observable.combineLatest(
this.windowService.isXs(),
this.windowService.isSm(),
((isXs, isSm) => isXs || isSm)
);
this.scopeListRDObs = communityService.findAll(); this.scopeListRDObs = communityService.findAll();
// Initial pagination config // Initial pagination config
const pagination: PaginationComponentOptions = new PaginationComponentOptions(); this.searchOptions = this.service.searchOptions;
pagination.id = 'search-results-pagination';
pagination.currentPage = 1;
pagination.pageSize = 10;
const sort: SortOptions = new SortOptions();
this.searchOptions = { pagination: pagination, sort: sort };
} }
ngOnInit(): void { ngOnInit(): void {
@@ -90,7 +87,6 @@ export class SearchPageComponent implements OnInit, OnDestroy {
} }
private updateSearchResults(searchOptions) { private updateSearchResults(searchOptions) {
// Resolve search results
this.resultsRDObs = this.service.search(this.query, this.scope, searchOptions); this.resultsRDObs = this.service.search(this.query, this.scope, searchOptions);
} }

View File

@@ -11,7 +11,12 @@ import { SearchService } from './search-service/search.service';
import { SearchSidebarComponent } from './search-sidebar/search-sidebar.component'; import { SearchSidebarComponent } from './search-sidebar/search-sidebar.component';
import { SearchSidebarService } from './search-sidebar/search-sidebar.service'; import { SearchSidebarService } from './search-sidebar/search-sidebar.service';
import { SearchSidebarEffects } from './search-sidebar/search-sidebar.effects'; import { SearchSidebarEffects } from './search-sidebar/search-sidebar.effects';
import { SearchSettingsComponent } from './search-settings/search-settings.component';
import { EffectsModule } from '@ngrx/effects'; import { EffectsModule } from '@ngrx/effects';
import { SearchFiltersComponent } from './search-filters/search-filters.component';
import { SearchFilterComponent } from './search-filters/search-filter/search-filter.component';
import { SearchFacetFilterComponent } from './search-filters/search-filter/search-facet-filter/search-facet-filter.component';
import { SearchFilterService } from './search-filters/search-filter/search-filter.service';
const effects = [ const effects = [
SearchSidebarEffects SearchSidebarEffects
@@ -28,13 +33,18 @@ const effects = [
SearchPageComponent, SearchPageComponent,
SearchResultsComponent, SearchResultsComponent,
SearchSidebarComponent, SearchSidebarComponent,
SearchSettingsComponent,
ItemSearchResultListElementComponent, ItemSearchResultListElementComponent,
CollectionSearchResultListElementComponent, CollectionSearchResultListElementComponent,
CommunitySearchResultListElementComponent CommunitySearchResultListElementComponent,
SearchFiltersComponent,
SearchFilterComponent,
SearchFacetFilterComponent
], ],
providers: [ providers: [
SearchService, SearchService,
SearchSidebarService SearchSidebarService,
SearchFilterService
], ],
entryComponents: [ entryComponents: [
ItemSearchResultListElementComponent, ItemSearchResultListElementComponent,

View File

@@ -1,11 +1,10 @@
<div *ngIf="searchResults?.hasSucceeded" @fadeIn> <div *ngIf="searchResults?.hasSucceeded" @fadeIn>
<h2 *ngIf="searchResults?.payload?.length > 0">{{ 'search.results.title' | translate }}</h2> <h2 *ngIf="searchResults?.payload ?.length > 0">{{ 'search.results.head' | translate }}</h2>
<ds-object-list <ds-object-list
[config]="searchConfig.pagination" [config]="searchConfig.pagination"
[sortConfig]="searchConfig.sort" [sortConfig]="searchConfig.sort"
[objects]="searchResults" [objects]="searchResults"
[hideGear]="false"> [hideGear]="true">
</ds-object-list> </ds-object-list></div>
</div>
<ds-loading *ngIf="searchResults?.isLoading" message="{{'loading.search-results' | translate}}"></ds-loading> <ds-loading *ngIf="searchResults?.isLoading" message="{{'loading.search-results' | translate}}"></ds-loading>
<ds-error *ngIf="searchResults?.hasFailed" message="{{'error.search-results' | translate}}"></ds-error> <ds-error *ngIf="searchResults?.hasFailed" message="{{'error.search-results' | translate}}"></ds-error>

View File

@@ -5,6 +5,7 @@ export class SearchFilterConfig {
name: string; name: string;
type: FilterType; type: FilterType;
hasFacets: boolean; hasFacets: boolean;
pageSize = 5;
isOpenByDefault: boolean; isOpenByDefault: boolean;
/** /**
* Name of this configuration that can be used in a url * Name of this configuration that can be used in a url

View File

@@ -3,11 +3,11 @@ import { RouterTestingModule } from '@angular/router/testing';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { Component } from '@angular/core'; import { Component } from '@angular/core';
import { Router } from '@angular/router';
import { SearchService } from './search.service'; import { SearchService } from './search.service';
import { ItemDataService } from './../../core/data/item-data.service'; import { ItemDataService } from './../../core/data/item-data.service';
import { ViewMode } from '../../+search-page/search-options.model'; import { ViewMode } from '../../+search-page/search-options.model';
import { RouteService } from '../../shared/route.service';
@Component({ template: '' }) @Component({ template: '' })
class DummyComponent { } class DummyComponent { }
@@ -28,6 +28,7 @@ describe('SearchService', () => {
], ],
providers: [ providers: [
{ provide: ItemDataService, useValue: {} }, { provide: ItemDataService, useValue: {} },
{ provide: RouteService, useValue: {} },
SearchService SearchService
], ],
}); });

View File

@@ -1,4 +1,4 @@
import { Injectable } from '@angular/core'; import { Injectable, OnDestroy } from '@angular/core';
import { RemoteData } from '../../core/data/remote-data'; import { RemoteData } from '../../core/data/remote-data';
import { Observable } from 'rxjs/Observable'; import { Observable } from 'rxjs/Observable';
import { SearchResult } from '../search-result.model'; import { SearchResult } from '../search-result.model';
@@ -15,6 +15,9 @@ import { FilterType } from './filter-type.model';
import { FacetValue } from './facet-value.model'; import { FacetValue } from './facet-value.model';
import { ViewMode } from '../../+search-page/search-options.model'; import { ViewMode } from '../../+search-page/search-options.model';
import { Router, NavigationExtras, ActivatedRoute } from '@angular/router'; import { Router, NavigationExtras, ActivatedRoute } from '@angular/router';
import { RouteService } from '../../shared/route.service';
import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model';
import { SortOptions } from '../../core/cache/models/sort-options.model';
function shuffle(array: any[]) { function shuffle(array: any[]) {
let i = 0; let i = 0;
@@ -31,7 +34,7 @@ function shuffle(array: any[]) {
} }
@Injectable() @Injectable()
export class SearchService { export class SearchService implements OnDestroy {
totalPages = 5; totalPages = 5;
mockedHighlights: string[] = new Array( mockedHighlights: string[] = new Array(
@@ -46,46 +49,58 @@ export class SearchService {
'<em>This was blank in the actual item, no abstract</em>', '<em>This was blank in the actual item, no abstract</em>',
'<em>The QSAR DataBank (QsarDB) repository</em>', '<em>The QSAR DataBank (QsarDB) repository</em>',
); );
private sub;
searchLink = '/search';
config: SearchFilterConfig[] = [ config: SearchFilterConfig[] = [
Object.assign(new SearchFilterConfig(), Object.assign(new SearchFilterConfig(),
{ {
name: 'scope', name: 'scope',
type: FilterType.hierarchy, type: FilterType.hierarchy,
hasFacets: true, hasFacets: true,
isOpenByDefault: true isOpenByDefault: true
}), }),
Object.assign(new SearchFilterConfig(), Object.assign(new SearchFilterConfig(),
{ {
name: 'author', name: 'author',
type: FilterType.text, type: FilterType.text,
hasFacets: true, hasFacets: true,
isOpenByDefault: false isOpenByDefault: false
}), }),
Object.assign(new SearchFilterConfig(), Object.assign(new SearchFilterConfig(),
{ {
name: 'date', name: 'date',
type: FilterType.range, type: FilterType.range,
hasFacets: true, hasFacets: true,
isOpenByDefault: false isOpenByDefault: false
}), }),
Object.assign(new SearchFilterConfig(), Object.assign(new SearchFilterConfig(),
{ {
name: 'subject', name: 'subject',
type: FilterType.text, type: FilterType.text,
hasFacets: false, hasFacets: false,
isOpenByDefault: false isOpenByDefault: false
}) })
]; ];
// searchOptions: BehaviorSubject<SearchOptions>;
searchOptions: SearchOptions;
constructor( constructor(private itemDataService: ItemDataService,
private itemDataService: ItemDataService, private routeService: RouteService,
private route: ActivatedRoute, private route: ActivatedRoute,
private router: Router) { private router: Router) {
const pagination: PaginationComponentOptions = new PaginationComponentOptions();
pagination.id = 'search-results-pagination';
pagination.currentPage = 1;
pagination.pageSize = 10;
const sort: SortOptions = new SortOptions();
this.searchOptions = { pagination: pagination, sort: sort };
// this.searchOptions = new BehaviorSubject<SearchOptions>(searchOptions);
} }
search(query: string, scopeId?: string, searchOptions?: SearchOptions): Observable<RemoteData<Array<SearchResult<DSpaceObject>>>> { search(query: string, scopeId?: string, searchOptions?: SearchOptions): Observable<RemoteData<Array<SearchResult<DSpaceObject>>>> {
this.searchOptions = this.searchOptions;
let self = `https://dspace7.4science.it/dspace-spring-rest/api/search?query=${query}`; let self = `https://dspace7.4science.it/dspace-spring-rest/api/search?query=${query}`;
if (hasValue(scopeId)) { if (hasValue(scopeId)) {
self += `&scope=${scopeId}`; self += `&scope=${scopeId}`;
@@ -125,31 +140,31 @@ export class SearchService {
.filter((rd: RemoteData<Item[]>) => rd.hasSucceeded) .filter((rd: RemoteData<Item[]>) => rd.hasSucceeded)
.map((rd: RemoteData<Item[]>) => { .map((rd: RemoteData<Item[]>) => {
const totalElements = rd.pageInfo.totalElements > 20 ? 20 : rd.pageInfo.totalElements; const totalElements = rd.pageInfo.totalElements > 20 ? 20 : rd.pageInfo.totalElements;
const pageInfo = Object.assign({}, rd.pageInfo, { totalElements: totalElements }); const pageInfo = Object.assign({}, rd.pageInfo, { totalElements: totalElements });
const payload = shuffle(rd.payload) const payload = shuffle(rd.payload)
.map((item: Item, index: number) => { .map((item: Item, index: number) => {
const mockResult: SearchResult<DSpaceObject> = new ItemSearchResult(); const mockResult: SearchResult<DSpaceObject> = new ItemSearchResult();
mockResult.dspaceObject = item; mockResult.dspaceObject = item;
const highlight = new Metadatum(); const highlight = new Metadatum();
highlight.key = 'dc.description.abstract'; highlight.key = 'dc.description.abstract';
highlight.value = this.mockedHighlights[index % this.mockedHighlights.length]; highlight.value = this.mockedHighlights[index % this.mockedHighlights.length];
mockResult.hitHighlights = new Array(highlight); mockResult.hitHighlights = new Array(highlight);
return mockResult; return mockResult;
}); });
return new RemoteData( return new RemoteData(
self, self,
rd.isRequestPending, rd.isRequestPending,
rd.isResponsePending, rd.isResponsePending,
rd.hasSucceeded, rd.hasSucceeded,
errorMessage, errorMessage,
statusCode, statusCode,
pageInfo, pageInfo,
payload payload
) )
}).startWith(new RemoteData( }).startWith(new RemoteData(
'', '',
true, true,
false, false,
@@ -181,31 +196,38 @@ export class SearchService {
} }
getFacetValuesFor(searchFilterConfigName: string): Observable<RemoteData<FacetValue[]>> { getFacetValuesFor(searchFilterConfigName: string): Observable<RemoteData<FacetValue[]>> {
const values: FacetValue[] = []; const filterConfig = this.config.find((config: SearchFilterConfig) => config.name === searchFilterConfigName);
for (let i = 0; i < 5; i++) { return this.routeService.getQueryParameterValues(filterConfig.paramName).map((selectedValues: string[]) => {
const value = searchFilterConfigName + ' ' + (i + 1); const values: FacetValue[] = [];
values.push({ const totalFilters = 13;
value: value, for (let i = 0; i < totalFilters; i++) {
count: Math.floor(Math.random() * 20) + 20 * (5 - i), // make sure first results have the highest (random) count const value = searchFilterConfigName + ' ' + (i + 1);
search: 'https://dspace7.4science.it/dspace-spring-rest/api/search?f.' + searchFilterConfigName + '=' + encodeURI(value) if (!selectedValues.includes(value)) {
}); values.push({
} value: value,
const requestPending = false; count: Math.floor(Math.random() * 20) + 20 * (totalFilters - i), // make sure first results have the highest (random) count
const responsePending = false; search: decodeURI(this.router.url) + (this.router.url.includes('?') ? '&' : '?') + filterConfig.paramName + '=' + value
const isSuccessful = true; });
const errorMessage = undefined; }
const statusCode = '200'; }
const returningPageInfo = new PageInfo(); const requestPending = false;
return Observable.of(new RemoteData( const responsePending = false;
'https://dspace7.4science.it/dspace-spring-rest/api/search', const isSuccessful = true;
requestPending, const errorMessage = undefined;
responsePending, const statusCode = '200';
isSuccessful, const returningPageInfo = new PageInfo();
errorMessage, return new RemoteData(
statusCode, 'https://dspace7.4science.it/dspace-spring-rest/api/search',
returningPageInfo, requestPending,
values responsePending,
)); isSuccessful,
errorMessage,
statusCode,
returningPageInfo,
values
)
}
)
} }
getViewMode(): Observable<ViewMode> { getViewMode(): Observable<ViewMode> {
@@ -220,10 +242,34 @@ export class SearchService {
setViewMode(viewMode: ViewMode) { setViewMode(viewMode: ViewMode) {
const navigationExtras: NavigationExtras = { const navigationExtras: NavigationExtras = {
queryParams: {view: viewMode}, queryParams: { view: viewMode },
queryParamsHandling: 'merge' queryParamsHandling: 'merge'
}; };
this.router.navigate(['/search'], navigationExtras); this.router.navigate([this.searchLink], navigationExtras);
}
getClearFiltersQueryParams(): any {
const params = {};
this.sub = this.route.queryParamMap
.subscribe((map) => {
map.keys
.filter((key) => this.config
.findIndex((conf: SearchFilterConfig) => conf.paramName === key) < 0)
.forEach((key) => {
params[key] = map.get(key);
})
});
return params;
}
getSearchLink() {
return this.searchLink;
}
ngOnDestroy(): void {
if (this.sub !== undefined) {
this.sub.unsubscribe();
}
} }
} }

View File

@@ -0,0 +1,22 @@
<h3>{{ 'search.sidebar.settings.title' | translate}}</h3>
<div *ngIf="[searchOptions].sort" class="setting-option result-order-settings mb-3 p-3">
<h5>{{ 'search.sidebar.settings.sort-by' | translate}}</h5>
<select class="form-control" (change)="reloadOrder($event)">
<option *ngFor="let direction of (sortDirections | dsKeys); let currentElementIndex = index"
[value]="currentElementIndex"
[selected]="direction === searchOptions.sort? 'selected': null">
{{direction.value}}
</option>
</select>
</div>
<div *ngIf="searchOptions.pagination.pageSize" class="setting-option page-size-settings mb-3 p-3">
<h5>{{ 'search.sidebar.settings.rpp' | translate}}</h5>
<select class="form-control" (change)="reloadRPP($event)">
<option *ngFor="let item of searchOptions.pagination.pageSizeOptions" [value]="item"
[selected]="item === searchOptions.pagination.pageSize ? 'selected': null">
{{item}}
</option>
</select>
</div>

View File

@@ -0,0 +1,5 @@
@import '../../../styles/variables.scss';
.setting-option {
border: 1px solid map-get($theme-colors, light);
}

View File

@@ -0,0 +1,103 @@
import { SearchService } from '../search-service/search.service';
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { SearchSettingsComponent } from './search-settings.component';
import { Observable } from 'rxjs/Observable';
import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model';
import { SortOptions } from '../../core/cache/models/sort-options.model';
import { TranslateModule } from '@ngx-translate/core';
import { RouterTestingModule } from '@angular/router/testing';
import { ActivatedRoute } from '@angular/router';
import { SearchSidebarService } from '../search-sidebar/search-sidebar.service';
import { NO_ERRORS_SCHEMA } from '@angular/core';
import { EnumKeysPipe } from '../../shared/utils/enum-keys-pipe';
import { By } from '@angular/platform-browser';
describe('SearchSettingsComponent', () => {
let comp: SearchSettingsComponent;
let fixture: ComponentFixture<SearchSettingsComponent>;
let searchServiceObject: SearchService;
const pagination: PaginationComponentOptions = new PaginationComponentOptions();
pagination.id = 'search-results-pagination';
pagination.currentPage = 1;
pagination.pageSize = 10;
const sort: SortOptions = new SortOptions();
const mockResults = [ 'test', 'data' ];
const searchServiceStub = {
searchOptions: { pagination: pagination, sort: sort },
search: () => mockResults
};
const queryParam = 'test query';
const scopeParam = '7669c72a-3f2a-451f-a3b9-9210e7a4c02f';
const activatedRouteStub = {
queryParams: Observable.of({
query: queryParam,
scope: scopeParam
})
};
const sidebarService = {
isCollapsed: Observable.of(true),
collapse: () => this.isCollapsed = Observable.of(true),
expand: () => this.isCollapsed = Observable.of(false)
}
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [ TranslateModule.forRoot(), RouterTestingModule.withRoutes([]) ],
declarations: [ SearchSettingsComponent, EnumKeysPipe ],
providers: [
{ provide: SearchService, useValue: searchServiceStub },
{ provide: ActivatedRoute, useValue: activatedRouteStub },
{
provide: SearchSidebarService,
useValue: sidebarService
},
],
schemas: [ NO_ERRORS_SCHEMA ]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(SearchSettingsComponent);
comp = fixture.componentInstance;
// SearchPageComponent test instance
fixture.detectChanges();
searchServiceObject = (comp as any).service;
spyOn(comp, 'reloadRPP');
spyOn(comp, 'reloadOrder');
spyOn(searchServiceObject, 'search').and.callThrough();
});
it('it should show the order settings with the respective selectable options', () => {
const orderSetting = fixture.debugElement.query(By.css('div.result-order-settings'));
expect(orderSetting).toBeDefined();
const childElements = orderSetting.query(By.css('.form-control')).children;
expect(childElements.length).toEqual(2);
});
it('it should show the size settings with the respective selectable options', () => {
const pageSizeSetting = fixture.debugElement.query(By.css('div.page-size-settings'));
expect(pageSizeSetting).toBeDefined();
const childElements = pageSizeSetting.query(By.css('.form-control')).children;
expect(childElements.length).toEqual(7);
});
it('should have the proper order value selected by default', () => {
const orderSetting = fixture.debugElement.query(By.css('div.result-order-settings'));
const childElementToBeSelected = orderSetting.query(By.css('.form-control option[value="0"][selected="selected"]'))
expect(childElementToBeSelected).toBeDefined();
});
it('should have the proper rpp value selected by default', () => {
const pageSizeSetting = fixture.debugElement.query(By.css('div.page-size-settings'));
const childElementToBeSelected = pageSizeSetting.query(By.css('.form-control option[value="10"][selected="selected"]'))
expect(childElementToBeSelected).toBeDefined();
});
});

View File

@@ -0,0 +1,70 @@
import { Component, Input, OnInit } from '@angular/core';
import { SearchService } from '../search-service/search.service';
import { SearchOptions } from '../search-options.model';
import { SortDirection } from '../../core/cache/models/sort-options.model';
import { ActivatedRoute, NavigationExtras, Router } from '@angular/router';
@Component({
selector: 'ds-search-settings',
styleUrls: ['./search-settings.component.scss'],
templateUrl: './search-settings.component.html',
})
export class SearchSettingsComponent implements OnInit {
@Input() searchOptions: SearchOptions;
/**
* Declare SortDirection enumeration to use it in the template
*/
public sortDirections = SortDirection;
/**
* Number of items per page.
*/
public pageSize;
private sub;
private scope: string;
query: string;
page: number;
direction: SortDirection;
currentParams = {};
constructor(private service: SearchService,
private route: ActivatedRoute,
private router: Router) {
}
ngOnInit(): void {
this.searchOptions = this.service.searchOptions;
this.pageSize = this.searchOptions.pagination.pageSize;
this.sub = this.route
.queryParams
.subscribe((params) => {
this.currentParams = params;
this.query = params.query || '';
this.scope = params.scope;
this.page = +params.page || this.searchOptions.pagination.currentPage;
this.pageSize = +params.pageSize || this.searchOptions.pagination.pageSize;
this.direction = +params.sortDirection || this.searchOptions.sort.direction;
});
}
reloadRPP(event: Event) {
const value = (event.target as HTMLInputElement).value;
const navigationExtras: NavigationExtras = {
queryParams: Object.assign({}, this.currentParams, {
pageSize: value
})
};
this.router.navigate([ '/search' ], navigationExtras);
}
reloadOrder(event: Event) {
const value = (event.target as HTMLInputElement).value;
const navigationExtras: NavigationExtras = {
queryParams: Object.assign({}, this.currentParams, {
sortDirection: value
})
};
this.router.navigate([ '/search' ], navigationExtras);
}
}

View File

@@ -1,13 +1,17 @@
<div> <div>
<div id="sidebar-options" class="d-block d-sm-none search-controls clearfix"> <div id="sidebar-options" class="d-block d-md-none search-controls clearfix">
<small class="results">{{resultCount}} {{"search.sidebar.results" | translate}}</small> <small class="results">{{resultCount}} {{"search.sidebar.results" | translate}}</small>
<button (click)="toggleSidebar.emit()" <button (click)="toggleSidebar.emit()"
aria-controls="#search-body" class="btn btn-outline-primary float-right close-sidebar"><i aria-controls="#search-body"
class="btn btn-outline-primary float-right close-sidebar"><i
class="fa fa-arrow-right"></i> {{"search.sidebar.close" | translate}} class="fa fa-arrow-right"></i> {{"search.sidebar.close" | translate}}
</button> </button>
</div> </div>
<div id="search-sidebar-content"> <div id="search-sidebar-content">
<ds-view-mode-switch class="d-none d-sm-block"></ds-view-mode-switch> <ds-view-mode-switch class="d-none d-md-block"></ds-view-mode-switch>
Filters <div class="sidebar-content">
<ds-search-filters></ds-search-filters>
<ds-search-settings></ds-search-settings>
</div>
</div> </div>
</div> </div>

View File

@@ -5,4 +5,11 @@
.results { .results {
line-height: $button-height; line-height: $button-height;
} }
ds-view-mode-switch {
margin-bottom: $spacer;
}
.sidebar-content > *:not(:last-child) {
margin-bottom: 4*$spacer;
display: block;
}
} }

View File

@@ -9,6 +9,7 @@ import { SearchSidebarEffects } from './search-sidebar.effects';
describe('SearchSidebarEffects', () => { describe('SearchSidebarEffects', () => {
let sidebarEffects: SearchSidebarEffects; let sidebarEffects: SearchSidebarEffects;
let actions: Observable<any>; let actions: Observable<any>;
const dummyURL = 'http://f4fb15e2-1bd3-4e63-8d0d-486ad8bc714a';
beforeEach(() => { beforeEach(() => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
@@ -24,13 +25,12 @@ describe('SearchSidebarEffects', () => {
describe('routeChange$', () => { describe('routeChange$', () => {
it('should return a COLLAPSE action in response to an UPDATE_LOCATION action', () => { it('should return a COLLAPSE action in response to an UPDATE_LOCATION action to a new route', () => {
actions = hot('--a-', { a: { type: fromRouter.ROUTER_NAVIGATION } }); actions = hot('--a-', { a: { type: fromRouter.ROUTER_NAVIGATION, payload: {routerState: {url: dummyURL}} } });
const expected = cold('--b-', { b: new SearchSidebarCollapseAction() }); const expected = cold('--b-', { b: new SearchSidebarCollapseAction() });
expect(sidebarEffects.routeChange$).toBeObservable(expected); expect(sidebarEffects.routeChange$).toBeObservable(expected);
}); });
}); });
}); });

View File

@@ -2,18 +2,27 @@ import { Injectable } from '@angular/core';
import { Effect, Actions } from '@ngrx/effects' import { Effect, Actions } from '@ngrx/effects'
import * as fromRouter from '@ngrx/router-store'; import * as fromRouter from '@ngrx/router-store';
import { HostWindowActionTypes } from '../../shared/host-window.actions';
import { SearchSidebarCollapseAction } from './search-sidebar.actions'; import { SearchSidebarCollapseAction } from './search-sidebar.actions';
import { URLBaser } from '../../core/url-baser/url-baser';
@Injectable() @Injectable()
export class SearchSidebarEffects { export class SearchSidebarEffects {
private previousPath: string;
@Effect() routeChange$ = this.actions$ @Effect() routeChange$ = this.actions$
.ofType(fromRouter.ROUTER_NAVIGATION) .ofType(fromRouter.ROUTER_NAVIGATION)
.filter((action) => this.previousPath !== this.getBaseUrl(action))
.do((action) => {this.previousPath = this.getBaseUrl(action)})
.map(() => new SearchSidebarCollapseAction()); .map(() => new SearchSidebarCollapseAction());
constructor(private actions$: Actions) { constructor(private actions$: Actions) {
} }
getBaseUrl(action: any): string {
/* tslint:disable:no-string-literal */
const url: string = action['payload'].routerState.url;
return new URLBaser(url).toString();
/* tslint:enable:no-string-literal */
}
} }

View File

@@ -15,7 +15,12 @@ export class SearchSidebarService {
private isCollapsdeInStored: Observable<boolean>; private isCollapsdeInStored: Observable<boolean>;
constructor(private store: Store<AppState>, private windowService: HostWindowService) { constructor(private store: Store<AppState>, private windowService: HostWindowService) {
this.isMobileView = this.windowService.isXs(); this.isMobileView =
Observable.combineLatest(
this.windowService.isXs(),
this.windowService.isSm(),
((isXs, isSm) => isXs || isSm)
);
this.isCollapsdeInStored = this.store.select(sidebarCollapsedSelector); this.isCollapsdeInStored = this.store.select(sidebarCollapsedSelector);
} }

View File

@@ -15,6 +15,7 @@ import { PageNotFoundComponent } from './pagenotfound/pagenotfound.component';
{ path: '**', pathMatch: 'full', component: PageNotFoundComponent }, { path: '**', pathMatch: 'full', component: PageNotFoundComponent },
]) ])
], ],
exports: [RouterModule]
}) })
export class AppRoutingModule { export class AppRoutingModule {

View File

@@ -1,7 +1,6 @@
import { NgModule } from '@angular/core'; import { NgModule } from '@angular/core';
import { CommonModule, APP_BASE_HREF } from '@angular/common'; import { CommonModule, APP_BASE_HREF } from '@angular/common';
import { HttpModule } from '@angular/http'; import { HttpModule } from '@angular/http';
import { RouterModule } from '@angular/router';
import { EffectsModule } from '@ngrx/effects'; import { EffectsModule } from '@ngrx/effects';
import { StoreModule, MetaReducer, META_REDUCERS } from '@ngrx/store'; import { StoreModule, MetaReducer, META_REDUCERS } from '@ngrx/store';
@@ -46,14 +45,13 @@ export function getMetaReducers(config: GlobalConfig): Array<MetaReducer<AppStat
const DEV_MODULES: any[] = []; const DEV_MODULES: any[] = [];
if (!ENV_CONFIG.production) { if (!ENV_CONFIG.production) {
DEV_MODULES.push(StoreDevtoolsModule.instrument({ maxAge: 50 })); DEV_MODULES.push(StoreDevtoolsModule.instrument({ maxAge: 500 }));
} }
@NgModule({ @NgModule({
imports: [ imports: [
CommonModule, CommonModule,
HttpModule, HttpModule,
RouterModule,
AppRoutingModule, AppRoutingModule,
CoreModule.forRoot(), CoreModule.forRoot(),
NgbModule.forRoot(), NgbModule.forRoot(),

View File

@@ -7,12 +7,17 @@ import {
SearchSidebarState, SearchSidebarState,
sidebarReducer sidebarReducer
} from './+search-page/search-sidebar/search-sidebar.reducer'; } from './+search-page/search-sidebar/search-sidebar.reducer';
import {
filterReducer,
SearchFiltersState
} from './+search-page/search-filters/search-filter/search-filter.reducer';
export interface AppState { export interface AppState {
router: fromRouter.RouterReducerState; router: fromRouter.RouterReducerState;
hostWindow: HostWindowState; hostWindow: HostWindowState;
header: HeaderState; header: HeaderState;
searchSidebar: SearchSidebarState; searchSidebar: SearchSidebarState;
searchFilter: SearchFiltersState;
} }
export const appReducers: ActionReducerMap<AppState> = { export const appReducers: ActionReducerMap<AppState> = {
@@ -20,4 +25,5 @@ export const appReducers: ActionReducerMap<AppState> = {
hostWindow: hostWindowReducer, hostWindow: hostWindowReducer,
header: headerReducer, header: headerReducer,
searchSidebar: sidebarReducer, searchSidebar: sidebarReducer,
searchFilter: filterReducer
}; };

View File

@@ -32,8 +32,10 @@ import { ServerResponseService } from '../shared/server-response.service';
import { NativeWindowFactory, NativeWindowService } from '../shared/window.service'; import { NativeWindowFactory, NativeWindowService } from '../shared/window.service';
import { BrowseService } from './browse/browse.service'; import { BrowseService } from './browse/browse.service';
import { BrowseResponseParsingService } from './data/browse-response-parsing.service'; import { BrowseResponseParsingService } from './data/browse-response-parsing.service';
import { SubmissionDefinitionsConfigService } from './config/submission-definitions-config.service';
import { ConfigResponseParsingService } from './data/config-response-parsing.service'; import { ConfigResponseParsingService } from './data/config-response-parsing.service';
import { RouteService } from '../shared/route.service';
import { SubmissionDefinitionsConfigService } from './config/submission-definitions-config.service';
import { SubmissionFormsConfigService } from './config/submission-forms-config.service'; import { SubmissionFormsConfigService } from './config/submission-forms-config.service';
import { SubmissionSectionsConfigService } from './config/submission-sections-config.service'; import { SubmissionSectionsConfigService } from './config/submission-sections-config.service';
@@ -69,7 +71,9 @@ const PROVIDERS = [
ServerResponseService, ServerResponseService,
BrowseResponseParsingService, BrowseResponseParsingService,
BrowseService, BrowseService,
ConfigResponseParsingService, ConfigResponseParsingService,
RouteService,
SubmissionDefinitionsConfigService, SubmissionDefinitionsConfigService,
SubmissionFormsConfigService, SubmissionFormsConfigService,
SubmissionSectionsConfigService, SubmissionSectionsConfigService,

View File

@@ -0,0 +1,39 @@
import { isEmpty } from '../../shared/empty.util';
/**
* Extracts the base URL
* from a URL with query parameters
*/
export class URLBaser {
private original: string;
/**
* Creates a new URLBaser
*
* @param originalURL
* a string representing the original URL with possible query parameters
*/
constructor(originalURL: string) {
this.original = originalURL;
}
/**
* Removes the query parameters from the original URL of this URLBaser
*
* @return {string}
* The base URL
*/
toString(): string {
if (isEmpty(this.original)) {
return '';
} else {
const index = this.original.indexOf('?');
if (index < 0) {
return this.original;
} else {
return this.original.substring(0, index);
}
}
}
}

View File

@@ -0,0 +1,16 @@
import { animate, state, transition, trigger, style } from '@angular/animations';
export const pushInOut = trigger('pushInOut', [
/*
state('expanded', style({ right: '100%' }));
state('collapsed', style({ right: 0 }));
*/
state('expanded', style({ left: '100%' })),
state('collapsed', style({ left: 0 })),
transition('expanded <=> collapsed', animate(250)),
]);

View File

@@ -1,16 +1,10 @@
import { animate, state, transition, trigger, style } from '@angular/animations'; import { animate, state, transition, trigger, style, stagger, query } from '@angular/animations';
export const slideInOut = trigger('slideInOut', [ export const slide = trigger('slide', [
/* state('expanded', style({ height: '*' })),
state('expanded', style({ right: '100%' }));
state('collapsed', style({ right: 0 })); state('collapsed', style({ height: 0 })),
*/
state('expanded', style({ left: '100%' })), transition('expanded <=> collapsed', animate(250))
state('collapsed', style({ left: 0 })),
transition('expanded <=> collapsed', animate(250)),
]); ]);

View File

@@ -1,12 +1,12 @@
<div *ngIf="currentPageState == undefined || currentPageState == currentPage"> <div *ngIf="currentPageState == undefined || currentPageState == currentPage">
<div *ngIf="!hideGear" class="pagination-masked clearfix top"> <div class="pagination-masked clearfix top">
<div class="row"> <div class="row">
<div class="col pagination-info"> <div class="col pagination-info">
<span class="align-middle hidden-xs-down">{{ 'pagination.showing.label' | translate }}</span> <span class="align-middle hidden-xs-down">{{ 'pagination.showing.label' | translate }}</span>
<span class="align-middle" *ngIf="collectionSize">{{ 'pagination.showing.detail' | translate:getShowingDetails(collectionSize)}}</span> <span class="align-middle" *ngIf="collectionSize">{{ 'pagination.showing.detail' | translate:getShowingDetails(collectionSize)}}</span>
</div> </div>
<div class="col"> <div class="col">
<div ngbDropdown #paginationControls="ngbDropdown" placement="bottom-right" class="d-inline-block float-right"> <div *ngIf="!hideGear" ngbDropdown #paginationControls="ngbDropdown" placement="bottom-right" class="d-inline-block float-right">
<button class="btn btn-outline-primary" id="paginationControls" ngbDropdownToggle><i class="fa fa-cog" aria-hidden="true"></i></button> <button class="btn btn-outline-primary" id="paginationControls" ngbDropdownToggle><i class="fa fa-cog" aria-hidden="true"></i></button>
<div id="paginationControlsDropdownMenu" aria-labelledby="paginationControls" ngbDropdownMenu> <div id="paginationControlsDropdownMenu" aria-labelledby="paginationControls" ngbDropdownMenu>
<h6 class="dropdown-header">{{ 'pagination.results-per-page' | translate}}</h6> <h6 class="dropdown-header">{{ 'pagination.results-per-page' | translate}}</h6>

View File

@@ -0,0 +1,118 @@
import { RouteService } from './route.service';
import { async, TestBed } from '@angular/core/testing';
import { ActivatedRoute, convertToParamMap, Params } from '@angular/router';
import { Observable } from 'rxjs/Observable';
describe('RouteService', () => {
let service: RouteService;
const paramName1 = 'name';
const paramValue1 = 'Test Name';
const paramName2 = 'id';
const paramValue2a = 'Test id';
const paramValue2b = 'another id';
const nonExistingParamName = 'non existing name';
const nonExistingParamValue = 'non existing value';
const paramObject: Params = {};
paramObject[paramName1] = paramValue1;
paramObject[paramName2] = [paramValue2a, paramValue2b];
beforeEach(async(() => {
return TestBed.configureTestingModule({
providers: [
{
provide: ActivatedRoute,
useValue: {
queryParams: Observable.of(paramObject),
queryParamMap: Observable.of(convertToParamMap(paramObject))
},
},
]
});
}));
beforeEach(() => {
service = new RouteService(TestBed.get(ActivatedRoute));
});
describe('hasQueryParam', () => {
it('should return true when the parameter name exists', () => {
service.hasQueryParam(paramName1).subscribe((status) => {
expect(status).toBeTruthy();
});
});
it('should return false when the parameter name does not exists', () => {
service.hasQueryParam(nonExistingParamName).subscribe((status) => {
expect(status).toBeFalsy();
});
});
});
describe('hasQueryParamWithValue', () => {
it('should return true when the parameter name exists and contains the specified value', () => {
service.hasQueryParamWithValue(paramName2, paramValue2a).subscribe((status) => {
expect(status).toBeTruthy();
});
});
it('should return false when the parameter name exists and does not contain the specified value', () => {
service.hasQueryParamWithValue(paramName1, nonExistingParamValue).subscribe((status) => {
expect(status).toBeFalsy();
});
});
it('should return false when the parameter name does not exists', () => {
service.hasQueryParamWithValue(nonExistingParamName, nonExistingParamValue).subscribe((status) => {
expect(status).toBeFalsy();
});
});
});
describe('addQueryParameterValue', () => {
it('should return a list of values that contains the added value when a new value is added and the parameter did not exist yet', () => {
service.addQueryParameterValue(nonExistingParamName, nonExistingParamValue).subscribe((params) => {
expect(params[nonExistingParamName]).toContain(nonExistingParamValue);
});
});
it('should return a list of values that contains the existing values and the added value when a new value is added and the parameter already has values', () => {
service.addQueryParameterValue(paramName1, nonExistingParamValue).subscribe((params) => {
const values = params[paramName1];
expect(values).toContain(paramValue1);
expect(values).toContain(nonExistingParamValue);
});
});
});
describe('removeQueryParameterValue', () => {
it('should return a list of values that does not contain the removed value when the parameter value exists', () => {
service.removeQueryParameterValue(paramName2, paramValue2a).subscribe((params) => {
const values = params[paramName2];
expect(values).toContain(paramValue2b);
expect(values).not.toContain(paramValue2a);
});
});
it('should return a list of values that does contain all existing values when the removed parameter does not exist', () => {
service.removeQueryParameterValue(paramName2, nonExistingParamValue).subscribe((params) => {
const values = params[paramName2];
expect(values).toContain(paramValue2a);
expect(values).toContain(paramValue2b);
});
});
});
describe('removeQueryParameter', () => {
it('should return a list of values that does not contain any values for the parameter anymore when the parameter exists', () => {
service.removeQueryParameter(paramName2).subscribe((params) => {
const values = params[paramName2];
expect(values).toEqual({});
});
});
it('should return a list of values that does not contain any values for the parameter when the parameter does not exist', () => {
service.removeQueryParameter(nonExistingParamName).subscribe((params) => {
const values = params[nonExistingParamName];
expect(values).toEqual({});
});
});
});
});

View File

@@ -0,0 +1,55 @@
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs/Observable';
import { ActivatedRoute, convertToParamMap, Params, } from '@angular/router';
import { isNotEmpty } from './empty.util';
@Injectable()
export class RouteService {
constructor(private route: ActivatedRoute) {
}
getQueryParameterValues(paramName: string): Observable<string[]> {
return this.route.queryParamMap.map((map) => map.getAll(paramName));
}
getQueryParameterValue(paramName: string): Observable<string> {
return this.route.queryParamMap.map((map) => map.get(paramName));
}
hasQueryParam(paramName: string): Observable<boolean> {
return this.route.queryParamMap.map((map) => {return map.has(paramName);});
}
hasQueryParamWithValue(paramName: string, paramValue: string): Observable<boolean> {
return this.route.queryParamMap.map((map) => map.getAll(paramName).indexOf(paramValue) > -1);
}
addQueryParameterValue(paramName: string, paramValue: string): Observable<Params> {
return this.route.queryParams.map((currentParams) => {
const newParam = {};
newParam[paramName] = [...convertToParamMap(currentParams).getAll(paramName), paramValue];
return Object.assign({}, currentParams, newParam);
});
}
removeQueryParameterValue(paramName: string, paramValue: string): Observable<Params> {
return this.route.queryParams.map((currentParams) => {
const newParam = {};
const currentFilterParams = convertToParamMap(currentParams).getAll(paramName);
if (isNotEmpty(currentFilterParams)) {
newParam[paramName] = currentFilterParams.filter((param) => (param !== paramValue));
}
return Object.assign({}, currentParams, newParam);
});
}
removeQueryParameter(paramName: string): Observable<Params> {
return this.route.queryParams.map((currentParams) => {
const newParam = {};
newParam[paramName] = {};
return Object.assign({}, currentParams, newParam);
});
}
}

View File

@@ -31,7 +31,6 @@ import { SearchFormComponent } from './search-form/search-form.component';
import { WrapperListElementComponent } from '../object-list/wrapper-list-element/wrapper-list-element.component'; import { WrapperListElementComponent } from '../object-list/wrapper-list-element/wrapper-list-element.component';
import { ViewModeSwitchComponent } from './view-mode-switch/view-mode-switch.component'; import { ViewModeSwitchComponent } from './view-mode-switch/view-mode-switch.component';
import { VarDirective } from './utils/var.directive'; import { VarDirective } from './utils/var.directive';
import { ScrollAndStickDirective } from './utils/scroll-and-stick.directive';
const MODULES = [ const MODULES = [
// Do NOT include UniversalModule, HttpModule, or JsonpModule here // Do NOT include UniversalModule, HttpModule, or JsonpModule here
@@ -68,7 +67,6 @@ const COMPONENTS = [
ViewModeSwitchComponent ViewModeSwitchComponent
]; ];
const ENTRY_COMPONENTS = [ const ENTRY_COMPONENTS = [
// put shared entry components (components that are created dynamically) here // put shared entry components (components that are created dynamically) here
CollectionListElementComponent, CollectionListElementComponent,
@@ -78,8 +76,7 @@ const ENTRY_COMPONENTS = [
]; ];
const DIRECTIVES = [ const DIRECTIVES = [
VarDirective, VarDirective
ScrollAndStickDirective,
]; ];
@NgModule({ @NgModule({

View File

@@ -1,40 +0,0 @@
import { NativeWindowRef, NativeWindowService } from '../window.service';
import { Observable } from 'rxjs/Observable';
import { AfterViewInit, Directive, ElementRef, Inject } from '@angular/core';
@Directive({
selector: '[dsStick]'
})
export class ScrollAndStickDirective implements AfterViewInit {
private initialY: number;
constructor(private _element: ElementRef, @Inject(NativeWindowService) private _window: NativeWindowRef) {
this.subscribeForScrollEvent();
}
ngAfterViewInit(): void {
this.initialY = this._element.nativeElement.getBoundingClientRect().top;
}
subscribeForScrollEvent() {
const obs = Observable.fromEvent(window, 'scroll');
obs.subscribe((e) => this.handleScrollEvent(e));
}
handleScrollEvent(e) {
if (this._window.nativeWindow.pageYOffset >= this.initialY) {
this._element.nativeElement.classList.add('stick');
} else {
this._element.nativeElement.classList.remove('stick');
}
}
}

View File

@@ -1,4 +1,3 @@
import { DebugElement } from '@angular/core';
import { TranslateModule, TranslateLoader } from '@ngx-translate/core'; import { TranslateModule, TranslateLoader } from '@ngx-translate/core';
import { async, ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing'; import { async, ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
import { By } from '@angular/platform-browser'; import { By } from '@angular/platform-browser';
@@ -6,12 +5,12 @@ import { MockTranslateLoader } from '../mocks/mock-translate-loader';
import { RouterTestingModule } from '@angular/router/testing'; import { RouterTestingModule } from '@angular/router/testing';
import { Component } from '@angular/core'; import { Component } from '@angular/core';
import { Observable } from 'rxjs/Observable';
import { SearchService } from '../../+search-page/search-service/search.service'; import { SearchService } from '../../+search-page/search-service/search.service';
import { ItemDataService } from './../../core/data/item-data.service'; import { ItemDataService } from './../../core/data/item-data.service';
import { ViewModeSwitchComponent } from './view-mode-switch.component'; import { ViewModeSwitchComponent } from './view-mode-switch.component';
import { ViewMode } from '../../+search-page/search-options.model'; import { ViewMode } from '../../+search-page/search-options.model';
import { RouteService } from '../route.service';
@Component({ template: '' }) @Component({ template: '' })
class DummyComponent { } class DummyComponent { }
@@ -42,6 +41,7 @@ describe('ViewModeSwitchComponent', () => {
], ],
providers: [ providers: [
{ provide: ItemDataService, useValue: {} }, { provide: ItemDataService, useValue: {} },
{ provide: RouteService, useValue: {} },
SearchService SearchService
], ],
}).compileComponents(); }).compileComponents();