mirror of
https://github.com/DSpace/dspace-angular.git
synced 2025-10-10 11:33:04 +00:00
Merge remote-tracking branch 'remotes/origin/master' into configuration
# Conflicts: # src/app/core/core.module.ts
This commit is contained in:
@@ -17,13 +17,13 @@ export class ProtractorPage {
|
||||
}
|
||||
|
||||
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);
|
||||
return scopeSelect.getAttribute('value');
|
||||
}
|
||||
|
||||
getCurrentQuery(): promise.Promise<string> {
|
||||
return element(by.tagName('input')).getAttribute('value');
|
||||
return element(by.css('#search-form input')).getAttribute('value');
|
||||
}
|
||||
|
||||
setCurrentScope(scope: string) {
|
||||
|
@@ -80,16 +80,48 @@
|
||||
"search_dspace": "Search DSpace"
|
||||
},
|
||||
"results": {
|
||||
"title": "Search Results"
|
||||
"head": "Search Results"
|
||||
},
|
||||
"sidebar": {
|
||||
"close": "Back to results",
|
||||
"open": "Search Tools",
|
||||
"results": "results"
|
||||
"results": "results",
|
||||
"filters":{
|
||||
"title":"Filters"
|
||||
},
|
||||
"settings":{
|
||||
"title":"Settings",
|
||||
"sort-by":"Sort By",
|
||||
"rpp":"Results per page"
|
||||
}
|
||||
},
|
||||
"view-switch": {
|
||||
"show-list": "Show as list",
|
||||
"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": {
|
||||
|
@@ -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>
|
@@ -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;
|
||||
}
|
||||
}
|
@@ -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)
|
||||
});
|
||||
});
|
||||
});
|
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
@@ -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 */
|
@@ -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>
|
@@ -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;
|
||||
}
|
||||
}
|
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
});
|
@@ -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);
|
||||
}
|
||||
}
|
@@ -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);
|
||||
});
|
||||
});
|
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
@@ -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;
|
||||
}
|
||||
});
|
||||
}
|
@@ -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>
|
@@ -0,0 +1,2 @@
|
||||
@import '../../../styles/variables.scss';
|
||||
@import '../../../styles/mixins.scss';
|
@@ -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()
|
||||
});
|
||||
});
|
||||
|
||||
});
|
@@ -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();
|
||||
}
|
||||
}
|
@@ -1,26 +1,27 @@
|
||||
<div class="container">
|
||||
<div class="search-page">
|
||||
<ds-search-sidebar dsStick *ngIf="!(isMobileView | async)" class="col-3 sidebar-sm-fixed"
|
||||
<div class="search-page row">
|
||||
<ds-search-sidebar *ngIf="!(isMobileView | async)" class="col-3 sidebar-md-sticky"
|
||||
id="search-sidebar"
|
||||
resultCount="{{(resultsRDObs | async)?.pageInfo?.totalElements}}"></ds-search-sidebar>
|
||||
<div id="search-header" class="row">
|
||||
<ds-search-form id="search-form" class="col-12 col-sm-9 ml-sm-auto"
|
||||
[resultCount]="(resultsRDObs | async)?.pageInfo?.totalElements"></ds-search-sidebar>
|
||||
<div class="col-12 col-md-9">
|
||||
<ds-search-form id="search-form"
|
||||
[query]="query"
|
||||
[scope]="(scopeObjectRDObs | async)?.payload"
|
||||
[currentParams]="currentParams"
|
||||
[scopes]="(scopeListRDObs | async)?.payload">
|
||||
</ds-search-form>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div id="search-body"
|
||||
class="row-offcanvas row-offcanvas-left"
|
||||
[@slideInOut]="(isSidebarCollapsed() | async) ? 'collapsed' : 'expanded'">
|
||||
<ds-search-sidebar *ngIf="(isMobileView | async)" class="col-12" id="search-sidebar-xs"
|
||||
resultCount="{{(resultsRDObs | async)?.pageInfo?.totalElements}}"
|
||||
(toggleSidebar)="closeSidebar()" [ngClass]="{'active': !(isSidebarCollapsed() | async)}"></ds-search-sidebar>
|
||||
<div id="search-content" class="col-12 col-sm-9 ml-sm-auto">
|
||||
<div class="d-block d-sm-none search-controls clearfix">
|
||||
|
||||
[@pushInOut]="(isSidebarCollapsed() | async) ? 'collapsed' : 'expanded'">
|
||||
<ds-search-sidebar *ngIf="(isMobileView | async)" class="col-12"
|
||||
id="search-sidebar-sm"
|
||||
[resultCount]="(resultsRDObs | async)?.pageInfo?.totalElements"
|
||||
(toggleSidebar)="closeSidebar()"
|
||||
[ngClass]="{'active': !(isSidebarCollapsed() | async)}">
|
||||
</ds-search-sidebar>
|
||||
<div id="search-content" class="col-12">
|
||||
<div class="d-block d-md-none search-controls clearfix">
|
||||
<ds-view-mode-switch></ds-view-mode-switch>
|
||||
<button (click)="openSidebar()" aria-controls="#search-body"
|
||||
class="btn btn-outline-primary float-right open-sidebar"><i
|
||||
@@ -34,4 +35,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
@@ -1,14 +1,10 @@
|
||||
@import '../../styles/variables.scss';
|
||||
@import '../../styles/mixins.scss';
|
||||
|
||||
#search-body {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
#search-content, #search-form {
|
||||
display: block;
|
||||
@include media-breakpoint-down(xs) {
|
||||
margin-left: 0;
|
||||
@include media-breakpoint-down(md) {
|
||||
.container {
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,22 +16,22 @@
|
||||
&.row-offcanvas {
|
||||
width: 100%;
|
||||
}
|
||||
@include media-breakpoint-down(xs) {
|
||||
@include media-breakpoint-down(sm) {
|
||||
position: relative;
|
||||
|
||||
&.row-offcanvas {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
&.row-offcanvas-right #search-sidebar-xs {
|
||||
&.row-offcanvas-right #search-sidebar-sm {
|
||||
right: -100%;
|
||||
}
|
||||
|
||||
&.row-offcanvas-left #search-sidebar-xs {
|
||||
&.row-offcanvas-left #search-sidebar-sm {
|
||||
left: -100%;
|
||||
}
|
||||
|
||||
#search-sidebar-xs {
|
||||
#search-sidebar-sm {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
@@ -43,15 +39,16 @@
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-sm-fixed {
|
||||
@include media-breakpoint-up(sm) {
|
||||
position: absolute;
|
||||
margin-top: -$content-spacing;
|
||||
padding-top: $content-spacing;
|
||||
&.stick {
|
||||
@include media-breakpoint-up(md) {
|
||||
.sidebar-md-sticky {
|
||||
position: sticky;
|
||||
position: -webkit-sticky;
|
||||
top: 0;
|
||||
margin-top: 0px;
|
||||
position: fixed;
|
||||
}
|
||||
z-index: $zindex-sticky;
|
||||
padding-top: $content-spacing;
|
||||
margin-top: -$content-spacing;
|
||||
align-self: flex-start;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -28,8 +28,14 @@ describe('SearchPageComponent', () => {
|
||||
/* tslint:enable:no-empty */
|
||||
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 searchServiceStub = {
|
||||
searchOptions:{ pagination: pagination, sort: sort },
|
||||
search: () => mockResults
|
||||
};
|
||||
const queryParam = 'test query';
|
||||
@@ -151,7 +157,7 @@ describe('SearchPageComponent', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -179,7 +185,7 @@ describe('SearchPageComponent', () => {
|
||||
let menu: HTMLElement;
|
||||
|
||||
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);
|
||||
fixture.detectChanges();
|
||||
});
|
||||
@@ -194,7 +200,7 @@ describe('SearchPageComponent', () => {
|
||||
let menu: HTMLElement;
|
||||
|
||||
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);
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
@@ -1,17 +1,15 @@
|
||||
import { ChangeDetectionStrategy, Component, OnDestroy, OnInit } from '@angular/core';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { Observable } from 'rxjs/Observable';
|
||||
import { SortOptions } from '../core/cache/models/sort-options.model';
|
||||
import { CommunityDataService } from '../core/data/community-data.service';
|
||||
import { RemoteData } from '../core/data/remote-data';
|
||||
import { Community } from '../core/shared/community.model';
|
||||
import { DSpaceObject } from '../core/shared/dspace-object.model';
|
||||
import { isNotEmpty } from '../shared/empty.util';
|
||||
import { PaginationComponentOptions } from '../shared/pagination/pagination-component-options.model';
|
||||
import { SearchOptions } from './search-options.model';
|
||||
import { SearchResult } from './search-result.model';
|
||||
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 { SearchSidebarService } from './search-sidebar/search-sidebar.service';
|
||||
|
||||
@@ -26,7 +24,7 @@ import { SearchSidebarService } from './search-sidebar/search-sidebar.service';
|
||||
styleUrls: ['./search-page.component.scss'],
|
||||
templateUrl: './search-page.component.html',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
animations: [slideInOut]
|
||||
animations: [pushInOut]
|
||||
})
|
||||
export class SearchPageComponent implements OnInit, OnDestroy {
|
||||
|
||||
@@ -46,15 +44,14 @@ export class SearchPageComponent implements OnInit, OnDestroy {
|
||||
private communityService: CommunityDataService,
|
||||
private sidebarService: SearchSidebarService,
|
||||
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();
|
||||
// Initial pagination config
|
||||
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 = this.service.searchOptions;
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
@@ -90,7 +87,6 @@ export class SearchPageComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
private updateSearchResults(searchOptions) {
|
||||
// Resolve search results
|
||||
this.resultsRDObs = this.service.search(this.query, this.scope, searchOptions);
|
||||
}
|
||||
|
||||
|
@@ -11,7 +11,12 @@ import { SearchService } from './search-service/search.service';
|
||||
import { SearchSidebarComponent } from './search-sidebar/search-sidebar.component';
|
||||
import { SearchSidebarService } from './search-sidebar/search-sidebar.service';
|
||||
import { SearchSidebarEffects } from './search-sidebar/search-sidebar.effects';
|
||||
import { SearchSettingsComponent } from './search-settings/search-settings.component';
|
||||
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 = [
|
||||
SearchSidebarEffects
|
||||
@@ -28,13 +33,18 @@ const effects = [
|
||||
SearchPageComponent,
|
||||
SearchResultsComponent,
|
||||
SearchSidebarComponent,
|
||||
SearchSettingsComponent,
|
||||
ItemSearchResultListElementComponent,
|
||||
CollectionSearchResultListElementComponent,
|
||||
CommunitySearchResultListElementComponent
|
||||
CommunitySearchResultListElementComponent,
|
||||
SearchFiltersComponent,
|
||||
SearchFilterComponent,
|
||||
SearchFacetFilterComponent
|
||||
],
|
||||
providers: [
|
||||
SearchService,
|
||||
SearchSidebarService
|
||||
SearchSidebarService,
|
||||
SearchFilterService
|
||||
],
|
||||
entryComponents: [
|
||||
ItemSearchResultListElementComponent,
|
||||
|
@@ -1,11 +1,10 @@
|
||||
<div *ngIf="searchResults?.hasSucceeded" @fadeIn>
|
||||
<h2 *ngIf="searchResults?.payload?.length > 0">{{ 'search.results.title' | translate }}</h2>
|
||||
<ds-object-list
|
||||
<h2 *ngIf="searchResults?.payload ?.length > 0">{{ 'search.results.head' | translate }}</h2>
|
||||
<ds-object-list
|
||||
[config]="searchConfig.pagination"
|
||||
[sortConfig]="searchConfig.sort"
|
||||
[objects]="searchResults"
|
||||
[hideGear]="false">
|
||||
</ds-object-list>
|
||||
</div>
|
||||
[hideGear]="true">
|
||||
</ds-object-list></div>
|
||||
<ds-loading *ngIf="searchResults?.isLoading" message="{{'loading.search-results' | translate}}"></ds-loading>
|
||||
<ds-error *ngIf="searchResults?.hasFailed" message="{{'error.search-results' | translate}}"></ds-error>
|
||||
|
@@ -5,6 +5,7 @@ export class SearchFilterConfig {
|
||||
name: string;
|
||||
type: FilterType;
|
||||
hasFacets: boolean;
|
||||
pageSize = 5;
|
||||
isOpenByDefault: boolean;
|
||||
/**
|
||||
* Name of this configuration that can be used in a url
|
||||
|
@@ -3,11 +3,11 @@ import { RouterTestingModule } from '@angular/router/testing';
|
||||
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
|
||||
import { SearchService } from './search.service';
|
||||
import { ItemDataService } from './../../core/data/item-data.service';
|
||||
import { ViewMode } from '../../+search-page/search-options.model';
|
||||
import { RouteService } from '../../shared/route.service';
|
||||
|
||||
@Component({ template: '' })
|
||||
class DummyComponent { }
|
||||
@@ -28,6 +28,7 @@ describe('SearchService', () => {
|
||||
],
|
||||
providers: [
|
||||
{ provide: ItemDataService, useValue: {} },
|
||||
{ provide: RouteService, useValue: {} },
|
||||
SearchService
|
||||
],
|
||||
});
|
||||
|
@@ -1,4 +1,4 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Injectable, OnDestroy } from '@angular/core';
|
||||
import { RemoteData } from '../../core/data/remote-data';
|
||||
import { Observable } from 'rxjs/Observable';
|
||||
import { SearchResult } from '../search-result.model';
|
||||
@@ -15,6 +15,9 @@ import { FilterType } from './filter-type.model';
|
||||
import { FacetValue } from './facet-value.model';
|
||||
import { ViewMode } from '../../+search-page/search-options.model';
|
||||
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[]) {
|
||||
let i = 0;
|
||||
@@ -31,7 +34,7 @@ function shuffle(array: any[]) {
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SearchService {
|
||||
export class SearchService implements OnDestroy {
|
||||
|
||||
totalPages = 5;
|
||||
mockedHighlights: string[] = new Array(
|
||||
@@ -46,6 +49,8 @@ export class SearchService {
|
||||
'<em>This was blank in the actual item, no abstract</em>',
|
||||
'<em>The QSAR DataBank (QsarDB) repository</em>',
|
||||
);
|
||||
private sub;
|
||||
searchLink = '/search';
|
||||
|
||||
config: SearchFilterConfig[] = [
|
||||
Object.assign(new SearchFilterConfig(),
|
||||
@@ -77,15 +82,25 @@ export class SearchService {
|
||||
isOpenByDefault: false
|
||||
})
|
||||
];
|
||||
// searchOptions: BehaviorSubject<SearchOptions>;
|
||||
searchOptions: SearchOptions;
|
||||
|
||||
constructor(
|
||||
private itemDataService: ItemDataService,
|
||||
constructor(private itemDataService: ItemDataService,
|
||||
private routeService: RouteService,
|
||||
private route: ActivatedRoute,
|
||||
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>>>> {
|
||||
this.searchOptions = this.searchOptions;
|
||||
let self = `https://dspace7.4science.it/dspace-spring-rest/api/search?query=${query}`;
|
||||
if (hasValue(scopeId)) {
|
||||
self += `&scope=${scopeId}`;
|
||||
@@ -181,22 +196,27 @@ export class SearchService {
|
||||
}
|
||||
|
||||
getFacetValuesFor(searchFilterConfigName: string): Observable<RemoteData<FacetValue[]>> {
|
||||
const filterConfig = this.config.find((config: SearchFilterConfig) => config.name === searchFilterConfigName);
|
||||
return this.routeService.getQueryParameterValues(filterConfig.paramName).map((selectedValues: string[]) => {
|
||||
const values: FacetValue[] = [];
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const totalFilters = 13;
|
||||
for (let i = 0; i < totalFilters; i++) {
|
||||
const value = searchFilterConfigName + ' ' + (i + 1);
|
||||
if (!selectedValues.includes(value)) {
|
||||
values.push({
|
||||
value: value,
|
||||
count: Math.floor(Math.random() * 20) + 20 * (5 - i), // make sure first results have the highest (random) count
|
||||
search: 'https://dspace7.4science.it/dspace-spring-rest/api/search?f.' + searchFilterConfigName + '=' + encodeURI(value)
|
||||
count: Math.floor(Math.random() * 20) + 20 * (totalFilters - i), // make sure first results have the highest (random) count
|
||||
search: decodeURI(this.router.url) + (this.router.url.includes('?') ? '&' : '?') + filterConfig.paramName + '=' + value
|
||||
});
|
||||
}
|
||||
}
|
||||
const requestPending = false;
|
||||
const responsePending = false;
|
||||
const isSuccessful = true;
|
||||
const errorMessage = undefined;
|
||||
const statusCode = '200';
|
||||
const returningPageInfo = new PageInfo();
|
||||
return Observable.of(new RemoteData(
|
||||
return new RemoteData(
|
||||
'https://dspace7.4science.it/dspace-spring-rest/api/search',
|
||||
requestPending,
|
||||
responsePending,
|
||||
@@ -205,7 +225,9 @@ export class SearchService {
|
||||
statusCode,
|
||||
returningPageInfo,
|
||||
values
|
||||
));
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
getViewMode(): Observable<ViewMode> {
|
||||
@@ -220,10 +242,34 @@ export class SearchService {
|
||||
|
||||
setViewMode(viewMode: ViewMode) {
|
||||
const navigationExtras: NavigationExtras = {
|
||||
queryParams: {view: viewMode},
|
||||
queryParams: { view: viewMode },
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -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>
|
@@ -0,0 +1,5 @@
|
||||
@import '../../../styles/variables.scss';
|
||||
|
||||
.setting-option {
|
||||
border: 1px solid map-get($theme-colors, light);
|
||||
}
|
@@ -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();
|
||||
});
|
||||
|
||||
});
|
@@ -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);
|
||||
}
|
||||
}
|
@@ -1,13 +1,17 @@
|
||||
<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>
|
||||
<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}}
|
||||
</button>
|
||||
</div>
|
||||
<div id="search-sidebar-content">
|
||||
<ds-view-mode-switch class="d-none d-sm-block"></ds-view-mode-switch>
|
||||
Filters
|
||||
<ds-view-mode-switch class="d-none d-md-block"></ds-view-mode-switch>
|
||||
<div class="sidebar-content">
|
||||
<ds-search-filters></ds-search-filters>
|
||||
<ds-search-settings></ds-search-settings>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
@@ -5,4 +5,11 @@
|
||||
.results {
|
||||
line-height: $button-height;
|
||||
}
|
||||
ds-view-mode-switch {
|
||||
margin-bottom: $spacer;
|
||||
}
|
||||
.sidebar-content > *:not(:last-child) {
|
||||
margin-bottom: 4*$spacer;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
@@ -9,6 +9,7 @@ import { SearchSidebarEffects } from './search-sidebar.effects';
|
||||
describe('SearchSidebarEffects', () => {
|
||||
let sidebarEffects: SearchSidebarEffects;
|
||||
let actions: Observable<any>;
|
||||
const dummyURL = 'http://f4fb15e2-1bd3-4e63-8d0d-486ad8bc714a';
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
@@ -24,13 +25,12 @@ describe('SearchSidebarEffects', () => {
|
||||
|
||||
describe('routeChange$', () => {
|
||||
|
||||
it('should return a COLLAPSE action in response to an UPDATE_LOCATION action', () => {
|
||||
actions = hot('--a-', { a: { type: fromRouter.ROUTER_NAVIGATION } });
|
||||
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, payload: {routerState: {url: dummyURL}} } });
|
||||
|
||||
const expected = cold('--b-', { b: new SearchSidebarCollapseAction() });
|
||||
|
||||
expect(sidebarEffects.routeChange$).toBeObservable(expected);
|
||||
});
|
||||
|
||||
});
|
||||
});
|
||||
|
@@ -2,18 +2,27 @@ import { Injectable } from '@angular/core';
|
||||
import { Effect, Actions } from '@ngrx/effects'
|
||||
import * as fromRouter from '@ngrx/router-store';
|
||||
|
||||
import { HostWindowActionTypes } from '../../shared/host-window.actions';
|
||||
import { SearchSidebarCollapseAction } from './search-sidebar.actions';
|
||||
import { URLBaser } from '../../core/url-baser/url-baser';
|
||||
|
||||
@Injectable()
|
||||
export class SearchSidebarEffects {
|
||||
|
||||
private previousPath: string;
|
||||
@Effect() routeChange$ = this.actions$
|
||||
.ofType(fromRouter.ROUTER_NAVIGATION)
|
||||
.filter((action) => this.previousPath !== this.getBaseUrl(action))
|
||||
.do((action) => {this.previousPath = this.getBaseUrl(action)})
|
||||
.map(() => new SearchSidebarCollapseAction());
|
||||
|
||||
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 */
|
||||
}
|
||||
|
||||
}
|
||||
|
@@ -15,7 +15,12 @@ export class SearchSidebarService {
|
||||
private isCollapsdeInStored: Observable<boolean>;
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
|
@@ -15,6 +15,7 @@ import { PageNotFoundComponent } from './pagenotfound/pagenotfound.component';
|
||||
{ path: '**', pathMatch: 'full', component: PageNotFoundComponent },
|
||||
])
|
||||
],
|
||||
exports: [RouterModule]
|
||||
})
|
||||
export class AppRoutingModule {
|
||||
|
||||
|
@@ -1,7 +1,6 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import { CommonModule, APP_BASE_HREF } from '@angular/common';
|
||||
import { HttpModule } from '@angular/http';
|
||||
import { RouterModule } from '@angular/router';
|
||||
|
||||
import { EffectsModule } from '@ngrx/effects';
|
||||
import { StoreModule, MetaReducer, META_REDUCERS } from '@ngrx/store';
|
||||
@@ -46,14 +45,13 @@ export function getMetaReducers(config: GlobalConfig): Array<MetaReducer<AppStat
|
||||
const DEV_MODULES: any[] = [];
|
||||
|
||||
if (!ENV_CONFIG.production) {
|
||||
DEV_MODULES.push(StoreDevtoolsModule.instrument({ maxAge: 50 }));
|
||||
DEV_MODULES.push(StoreDevtoolsModule.instrument({ maxAge: 500 }));
|
||||
}
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
CommonModule,
|
||||
HttpModule,
|
||||
RouterModule,
|
||||
AppRoutingModule,
|
||||
CoreModule.forRoot(),
|
||||
NgbModule.forRoot(),
|
||||
|
@@ -7,12 +7,17 @@ import {
|
||||
SearchSidebarState,
|
||||
sidebarReducer
|
||||
} from './+search-page/search-sidebar/search-sidebar.reducer';
|
||||
import {
|
||||
filterReducer,
|
||||
SearchFiltersState
|
||||
} from './+search-page/search-filters/search-filter/search-filter.reducer';
|
||||
|
||||
export interface AppState {
|
||||
router: fromRouter.RouterReducerState;
|
||||
hostWindow: HostWindowState;
|
||||
header: HeaderState;
|
||||
searchSidebar: SearchSidebarState;
|
||||
searchFilter: SearchFiltersState;
|
||||
}
|
||||
|
||||
export const appReducers: ActionReducerMap<AppState> = {
|
||||
@@ -20,4 +25,5 @@ export const appReducers: ActionReducerMap<AppState> = {
|
||||
hostWindow: hostWindowReducer,
|
||||
header: headerReducer,
|
||||
searchSidebar: sidebarReducer,
|
||||
searchFilter: filterReducer
|
||||
};
|
||||
|
@@ -32,8 +32,10 @@ import { ServerResponseService } from '../shared/server-response.service';
|
||||
import { NativeWindowFactory, NativeWindowService } from '../shared/window.service';
|
||||
import { BrowseService } from './browse/browse.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 { RouteService } from '../shared/route.service';
|
||||
import { SubmissionDefinitionsConfigService } from './config/submission-definitions-config.service';
|
||||
import { SubmissionFormsConfigService } from './config/submission-forms-config.service';
|
||||
import { SubmissionSectionsConfigService } from './config/submission-sections-config.service';
|
||||
|
||||
@@ -69,7 +71,9 @@ const PROVIDERS = [
|
||||
ServerResponseService,
|
||||
BrowseResponseParsingService,
|
||||
BrowseService,
|
||||
|
||||
ConfigResponseParsingService,
|
||||
RouteService,
|
||||
SubmissionDefinitionsConfigService,
|
||||
SubmissionFormsConfigService,
|
||||
SubmissionSectionsConfigService,
|
||||
|
39
src/app/core/url-baser/url-baser.ts
Normal file
39
src/app/core/url-baser/url-baser.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
16
src/app/shared/animations/push.ts
Normal file
16
src/app/shared/animations/push.ts
Normal 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)),
|
||||
]);
|
@@ -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({ right: '100%' }));
|
||||
state('expanded', style({ height: '*' })),
|
||||
|
||||
state('collapsed', style({ right: 0 }));
|
||||
*/
|
||||
state('collapsed', style({ height: 0 })),
|
||||
|
||||
state('expanded', style({ left: '100%' })),
|
||||
|
||||
state('collapsed', style({ left: 0 })),
|
||||
|
||||
transition('expanded <=> collapsed', animate(250)),
|
||||
transition('expanded <=> collapsed', animate(250))
|
||||
]);
|
||||
|
@@ -1,12 +1,12 @@
|
||||
<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="col pagination-info">
|
||||
<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>
|
||||
</div>
|
||||
<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>
|
||||
<div id="paginationControlsDropdownMenu" aria-labelledby="paginationControls" ngbDropdownMenu>
|
||||
<h6 class="dropdown-header">{{ 'pagination.results-per-page' | translate}}</h6>
|
||||
|
118
src/app/shared/route.service.spec.ts
Normal file
118
src/app/shared/route.service.spec.ts
Normal 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({});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
});
|
55
src/app/shared/route.service.ts
Normal file
55
src/app/shared/route.service.ts
Normal 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);
|
||||
});
|
||||
|
||||
}
|
||||
}
|
@@ -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 { ViewModeSwitchComponent } from './view-mode-switch/view-mode-switch.component';
|
||||
import { VarDirective } from './utils/var.directive';
|
||||
import { ScrollAndStickDirective } from './utils/scroll-and-stick.directive';
|
||||
|
||||
const MODULES = [
|
||||
// Do NOT include UniversalModule, HttpModule, or JsonpModule here
|
||||
@@ -68,7 +67,6 @@ const COMPONENTS = [
|
||||
ViewModeSwitchComponent
|
||||
];
|
||||
|
||||
|
||||
const ENTRY_COMPONENTS = [
|
||||
// put shared entry components (components that are created dynamically) here
|
||||
CollectionListElementComponent,
|
||||
@@ -78,8 +76,7 @@ const ENTRY_COMPONENTS = [
|
||||
];
|
||||
|
||||
const DIRECTIVES = [
|
||||
VarDirective,
|
||||
ScrollAndStickDirective,
|
||||
VarDirective
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
|
@@ -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');
|
||||
|
||||
}
|
||||
}
|
||||
}
|
@@ -1,4 +1,3 @@
|
||||
import { DebugElement } from '@angular/core';
|
||||
import { TranslateModule, TranslateLoader } from '@ngx-translate/core';
|
||||
import { async, ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
|
||||
import { By } from '@angular/platform-browser';
|
||||
@@ -6,12 +5,12 @@ import { MockTranslateLoader } from '../mocks/mock-translate-loader';
|
||||
import { RouterTestingModule } from '@angular/router/testing';
|
||||
|
||||
import { Component } from '@angular/core';
|
||||
import { Observable } from 'rxjs/Observable';
|
||||
|
||||
import { SearchService } from '../../+search-page/search-service/search.service';
|
||||
import { ItemDataService } from './../../core/data/item-data.service';
|
||||
import { ViewModeSwitchComponent } from './view-mode-switch.component';
|
||||
import { ViewMode } from '../../+search-page/search-options.model';
|
||||
import { RouteService } from '../route.service';
|
||||
|
||||
@Component({ template: '' })
|
||||
class DummyComponent { }
|
||||
@@ -42,6 +41,7 @@ describe('ViewModeSwitchComponent', () => {
|
||||
],
|
||||
providers: [
|
||||
{ provide: ItemDataService, useValue: {} },
|
||||
{ provide: RouteService, useValue: {} },
|
||||
SearchService
|
||||
],
|
||||
}).compileComponents();
|
||||
|
Reference in New Issue
Block a user