diff --git a/e2e/search-page/search-page.po.ts b/e2e/search-page/search-page.po.ts index 5ea9a0019b..fde3e68bf8 100644 --- a/e2e/search-page/search-page.po.ts +++ b/e2e/search-page/search-page.po.ts @@ -17,13 +17,13 @@ export class ProtractorPage { } getCurrentScope(): promise.Promise { - 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 { - return element(by.tagName('input')).getAttribute('value'); + return element(by.css('#search-form input')).getAttribute('value'); } setCurrentScope(scope: string) { diff --git a/resources/i18n/en.json b/resources/i18n/en.json index b949967bdb..7b3d31c3e0 100644 --- a/resources/i18n/en.json +++ b/resources/i18n/en.json @@ -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": { diff --git a/src/app/+search-page/search-filters/search-filter/search-facet-filter/search-facet-filter.component.html b/src/app/+search-page/search-filters/search-filter/search-facet-filter/search-facet-filter.component.html new file mode 100644 index 0000000000..114837ce65 --- /dev/null +++ b/src/app/+search-page/search-filters/search-filter/search-facet-filter/search-facet-filter.component.html @@ -0,0 +1,35 @@ +
+ +
+ + +
+
\ No newline at end of file diff --git a/src/app/+search-page/search-filters/search-filter/search-facet-filter/search-facet-filter.component.scss b/src/app/+search-page/search-filters/search-filter/search-facet-filter/search-facet-filter.component.scss new file mode 100644 index 0000000000..595b2aefb8 --- /dev/null +++ b/src/app/+search-page/search-filters/search-filter/search-facet-filter/search-facet-filter.component.scss @@ -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; + } +} \ No newline at end of file diff --git a/src/app/+search-page/search-filters/search-filter/search-facet-filter/search-facet-filter.component.spec.ts b/src/app/+search-page/search-filters/search-filter/search-facet-filter/search-facet-filter.component.spec.ts new file mode 100644 index 0000000000..08ee0fd840 --- /dev/null +++ b/src/app/+search-page/search-filters/search-filter/search-facet-filter/search-facet-filter.component.spec.ts @@ -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; + 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; + 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; + 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) + }); + }); +}); \ No newline at end of file diff --git a/src/app/+search-page/search-filters/search-filter/search-facet-filter/search-facet-filter.component.ts b/src/app/+search-page/search-filters/search-filter/search-facet-filter/search-facet-filter.component.ts new file mode 100644 index 0000000000..99501f346a --- /dev/null +++ b/src/app/+search-page/search-filters/search-filter/search-facet-filter/search-facet-filter.component.ts @@ -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; + filter: string; + + constructor(private filterService: SearchFilterService, private router: Router) { + } + + ngOnInit(): void { + this.currentPage = this.filterService.getPage(this.filterConfig.name); + } + + isChecked(value: FacetValue): Observable { + return this.filterService.isFilterActiveWithValue(this.filterConfig.paramName, value.value); + } + + getSearchLink() { + return this.filterService.searchLink; + } + + getQueryParamsWith(value: string): Observable { + return this.filterService.getQueryParamsWith(this.filterConfig, value); + } + + getQueryParamsWithout(value: string): Observable { + return this.filterService.getQueryParamsWithout(this.filterConfig, value); + } + + get facetCount(): Observable { + 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 { + 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(); + } + } +} diff --git a/src/app/+search-page/search-filters/search-filter/search-filter.actions.ts b/src/app/+search-page/search-filters/search-filter/search-filter.actions.ts new file mode 100644 index 0000000000..5c9803c7a9 --- /dev/null +++ b/src/app/+search-page/search-filters/search-filter/search-filter.actions.ts @@ -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 */ diff --git a/src/app/+search-page/search-filters/search-filter/search-filter.component.html b/src/app/+search-page/search-filters/search-filter/search-filter.component.html new file mode 100644 index 0000000000..f5acb42b6d --- /dev/null +++ b/src/app/+search-page/search-filters/search-filter/search-filter.component.html @@ -0,0 +1,8 @@ +
+
{{'search.filters.filter.' + filter.name + '.head'| translate}}
+
+ +
+
\ No newline at end of file diff --git a/src/app/+search-page/search-filters/search-filter/search-filter.component.scss b/src/app/+search-page/search-filters/search-filter/search-filter.component.scss new file mode 100644 index 0000000000..f694e9e167 --- /dev/null +++ b/src/app/+search-page/search-filters/search-filter/search-filter.component.scss @@ -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; + } +} \ No newline at end of file diff --git a/src/app/+search-page/search-filters/search-filter/search-filter.component.spec.ts b/src/app/+search-page/search-filters/search-filter/search-filter.component.spec.ts new file mode 100644 index 0000000000..62cfb23970 --- /dev/null +++ b/src/app/+search-page/search-filters/search-filter/search-filter.component.spec.ts @@ -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; + 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; + 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; + 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; + 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(); + }); + }); + + +}); diff --git a/src/app/+search-page/search-filters/search-filter/search-filter.component.ts b/src/app/+search-page/search-filters/search-filter/search-filter.component.ts new file mode 100644 index 0000000000..08d72da984 --- /dev/null +++ b/src/app/+search-page/search-filters/search-filter/search-filter.component.ts @@ -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>; + + 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 { + return this.filterService.isCollapsed(this.filter.name); + } + + initialCollapse() { + this.filterService.initialCollapse(this.filter.name); + } + + initialExpand() { + this.filterService.initialExpand(this.filter.name); + } + + getSelectedValues(): Observable { + return this.filterService.getSelectedValuesForFilter(this.filter); + } +} diff --git a/src/app/+search-page/search-filters/search-filter/search-filter.reducer.spec.ts b/src/app/+search-page/search-filters/search-filter/search-filter.reducer.spec.ts new file mode 100644 index 0000000000..8fbfbf2e65 --- /dev/null +++ b/src/app/+search-page/search-filters/search-filter/search-filter.reducer.spec.ts @@ -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); + }); +}); diff --git a/src/app/+search-page/search-filters/search-filter/search-filter.reducer.ts b/src/app/+search-page/search-filters/search-filter/search-filter.reducer.ts new file mode 100644 index 0000000000..9b1a084462 --- /dev/null +++ b/src/app/+search-page/search-filters/search-filter/search-filter.reducer.ts @@ -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; + } + } +} diff --git a/src/app/+search-page/search-filters/search-filter/search-filter.service.spec.ts b/src/app/+search-page/search-filters/search-filter/search-filter.service.spec.ts new file mode 100644 index 0000000000..7371e55ee8 --- /dev/null +++ b/src/app/+search-page/search-filters/search-filter/search-filter.service.spec.ts @@ -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 = 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); + }); + }); +}); diff --git a/src/app/+search-page/search-filters/search-filter/search-filter.service.ts b/src/app/+search-page/search-filters/search-filter/search-filter.service.ts new file mode 100644 index 0000000000..f3efc19b86 --- /dev/null +++ b/src/app/+search-page/search-filters/search-filter/search-filter.service.ts @@ -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, + private routeService: RouteService, + private searchService: SearchService) { + } + + isFilterActiveWithValue(paramName: string, filterValue: string): Observable { + return this.routeService.hasQueryParamWithValue(paramName, filterValue); + } + + isFilterActive(paramName: string): Observable { + 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 { + return this.routeService.getQueryParameterValues(filterConfig.paramName); + } + + get searchLink() { + return this.searchService.searchLink; + } + + isCollapsed(filterName: string): Observable { + return this.store.select(filterByNameSelector(filterName)) + .map((object: SearchFilterState) => { + if (object) { + return object.filterCollapsed; + } else { + return false; + } + }); + } + + getPage(filterName: string): Observable { + 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 { + return keySelector(name); +} + +export function keySelector(key: string): MemoizedSelector { + return createSelector(filterStateSelector, (state: SearchFilterState) => { + if (hasValue(state)) { + return state[key]; + } else { + return undefined; + } + }); +} diff --git a/src/app/+search-page/search-filters/search-filters.component.html b/src/app/+search-page/search-filters/search-filters.component.html new file mode 100644 index 0000000000..7f375b1238 --- /dev/null +++ b/src/app/+search-page/search-filters/search-filters.component.html @@ -0,0 +1,7 @@ +

{{"search.filters.head" | translate}}

+
+
+ +
+
+{{"search.filters.reset" | translate}} \ No newline at end of file diff --git a/src/app/+search-page/search-filters/search-filters.component.scss b/src/app/+search-page/search-filters/search-filters.component.scss new file mode 100644 index 0000000000..628c8ed46f --- /dev/null +++ b/src/app/+search-page/search-filters/search-filters.component.scss @@ -0,0 +1,2 @@ +@import '../../../styles/variables.scss'; +@import '../../../styles/mixins.scss'; \ No newline at end of file diff --git a/src/app/+search-page/search-filters/search-filters.component.spec.ts b/src/app/+search-page/search-filters/search-filters.component.spec.ts new file mode 100644 index 0000000000..0bdee94634 --- /dev/null +++ b/src/app/+search-page/search-filters/search-filters.component.spec.ts @@ -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; + 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() + }); + }); + +}); diff --git a/src/app/+search-page/search-filters/search-filters.component.ts b/src/app/+search-page/search-filters/search-filters.component.ts new file mode 100644 index 0000000000..808ce3be67 --- /dev/null +++ b/src/app/+search-page/search-filters/search-filters.component.ts @@ -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>; + constructor(private searchService: SearchService) { + this.filters = searchService.getConfig(); + } + + getClearFiltersQueryParams(): any { + return this.searchService.getClearFiltersQueryParams(); + } + + getSearchLink() { + return this.searchService.getSearchLink(); + } +} diff --git a/src/app/+search-page/search-page.component.html b/src/app/+search-page/search-page.component.html index 4c0a28114b..c4d679f72b 100644 --- a/src/app/+search-page/search-page.component.html +++ b/src/app/+search-page/search-page.component.html @@ -1,37 +1,41 @@
-
- -
- - -
-
-
- -
-
- - - -
- -
+
+ +
+ + +
+
+ + +
+
+ +
+ +
+
+
+ + diff --git a/src/app/+search-page/search-page.component.scss b/src/app/+search-page/search-page.component.scss index 60a696a52a..26011dd57e 100644 --- a/src/app/+search-page/search-page.component.scss +++ b/src/app/+search-page/search-page.component.scss @@ -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; +@include media-breakpoint-up(md) { + .sidebar-md-sticky { + position: sticky; + position: -webkit-sticky; + top: 0; + z-index: $zindex-sticky; padding-top: $content-spacing; - &.stick { - top: 0; - margin-top: 0px; - position: fixed; - } + margin-top: -$content-spacing; + align-self: flex-start; + display: block; } -} \ No newline at end of file +} + diff --git a/src/app/+search-page/search-page.component.spec.ts b/src/app/+search-page/search-page.component.spec.ts index eab50dc124..31878ceb21 100644 --- a/src/app/+search-page/search-page.component.spec.ts +++ b/src/app/+search-page/search-page.component.spec.ts @@ -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(); }); diff --git a/src/app/+search-page/search-page.component.ts b/src/app/+search-page/search-page.component.ts index 30f38aa45c..153402d11f 100644 --- a/src/app/+search-page/search-page.component.ts +++ b/src/app/+search-page/search-page.component.ts @@ -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); } diff --git a/src/app/+search-page/search-page.module.ts b/src/app/+search-page/search-page.module.ts index 1f295bc75f..6519d1e92a 100644 --- a/src/app/+search-page/search-page.module.ts +++ b/src/app/+search-page/search-page.module.ts @@ -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, diff --git a/src/app/+search-page/search-results/search-results.component.html b/src/app/+search-page/search-results/search-results.component.html index 808a952d8b..70e315671b 100644 --- a/src/app/+search-page/search-results/search-results.component.html +++ b/src/app/+search-page/search-results/search-results.component.html @@ -1,11 +1,10 @@
-

{{ 'search.results.title' | translate }}

- - -
+

{{ 'search.results.head' | translate }}

+ +
diff --git a/src/app/+search-page/search-service/search-filter-config.model.ts b/src/app/+search-page/search-service/search-filter-config.model.ts index 2335e1bb25..1464300daa 100644 --- a/src/app/+search-page/search-service/search-filter-config.model.ts +++ b/src/app/+search-page/search-service/search-filter-config.model.ts @@ -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 diff --git a/src/app/+search-page/search-service/search.service.spec.ts b/src/app/+search-page/search-service/search.service.spec.ts index 489ac76763..65af3231f9 100644 --- a/src/app/+search-page/search-service/search.service.spec.ts +++ b/src/app/+search-page/search-service/search.service.spec.ts @@ -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 ], }); diff --git a/src/app/+search-page/search-service/search.service.ts b/src/app/+search-page/search-service/search.service.ts index cb76f9f251..4b5ba7b702 100644 --- a/src/app/+search-page/search-service/search.service.ts +++ b/src/app/+search-page/search-service/search.service.ts @@ -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,46 +49,58 @@ export class SearchService { 'This was blank in the actual item, no abstract', 'The QSAR DataBank (QsarDB) repository', ); + private sub; + searchLink = '/search'; config: SearchFilterConfig[] = [ Object.assign(new SearchFilterConfig(), { - name: 'scope', - type: FilterType.hierarchy, - hasFacets: true, - isOpenByDefault: true - }), + name: 'scope', + type: FilterType.hierarchy, + hasFacets: true, + isOpenByDefault: true + }), Object.assign(new SearchFilterConfig(), - { - name: 'author', - type: FilterType.text, - hasFacets: true, - isOpenByDefault: false - }), + { + name: 'author', + type: FilterType.text, + hasFacets: true, + isOpenByDefault: false + }), Object.assign(new SearchFilterConfig(), - { - name: 'date', - type: FilterType.range, - hasFacets: true, - isOpenByDefault: false - }), + { + name: 'date', + type: FilterType.range, + hasFacets: true, + isOpenByDefault: false + }), Object.assign(new SearchFilterConfig(), - { - name: 'subject', - type: FilterType.text, - hasFacets: false, - isOpenByDefault: false - }) + { + name: 'subject', + type: FilterType.text, + hasFacets: false, + isOpenByDefault: false + }) ]; + // searchOptions: BehaviorSubject; + searchOptions: SearchOptions; - constructor( - private itemDataService: ItemDataService, - private route: ActivatedRoute, - private router: Router) { + 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); } search(query: string, scopeId?: string, searchOptions?: SearchOptions): Observable>>> { + this.searchOptions = this.searchOptions; let self = `https://dspace7.4science.it/dspace-spring-rest/api/search?query=${query}`; if (hasValue(scopeId)) { self += `&scope=${scopeId}`; @@ -125,31 +140,31 @@ export class SearchService { .filter((rd: RemoteData) => rd.hasSucceeded) .map((rd: RemoteData) => { - const totalElements = rd.pageInfo.totalElements > 20 ? 20 : rd.pageInfo.totalElements; - const pageInfo = Object.assign({}, rd.pageInfo, { totalElements: totalElements }); + const totalElements = rd.pageInfo.totalElements > 20 ? 20 : rd.pageInfo.totalElements; + const pageInfo = Object.assign({}, rd.pageInfo, { totalElements: totalElements }); - const payload = shuffle(rd.payload) - .map((item: Item, index: number) => { - const mockResult: SearchResult = new ItemSearchResult(); - mockResult.dspaceObject = item; - const highlight = new Metadatum(); - highlight.key = 'dc.description.abstract'; - highlight.value = this.mockedHighlights[index % this.mockedHighlights.length]; - mockResult.hitHighlights = new Array(highlight); - return mockResult; - }); + const payload = shuffle(rd.payload) + .map((item: Item, index: number) => { + const mockResult: SearchResult = new ItemSearchResult(); + mockResult.dspaceObject = item; + const highlight = new Metadatum(); + highlight.key = 'dc.description.abstract'; + highlight.value = this.mockedHighlights[index % this.mockedHighlights.length]; + mockResult.hitHighlights = new Array(highlight); + return mockResult; + }); - return new RemoteData( - self, - rd.isRequestPending, - rd.isResponsePending, - rd.hasSucceeded, - errorMessage, - statusCode, - pageInfo, - payload - ) - }).startWith(new RemoteData( + return new RemoteData( + self, + rd.isRequestPending, + rd.isResponsePending, + rd.hasSucceeded, + errorMessage, + statusCode, + pageInfo, + payload + ) + }).startWith(new RemoteData( '', true, false, @@ -181,31 +196,38 @@ export class SearchService { } getFacetValuesFor(searchFilterConfigName: string): Observable> { - const values: FacetValue[] = []; - for (let i = 0; i < 5; i++) { - const value = searchFilterConfigName + ' ' + (i + 1); - 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) - }); - } - const requestPending = false; - const responsePending = false; - const isSuccessful = true; - const errorMessage = undefined; - const statusCode = '200'; - const returningPageInfo = new PageInfo(); - return Observable.of(new RemoteData( - 'https://dspace7.4science.it/dspace-spring-rest/api/search', - requestPending, - responsePending, - isSuccessful, - errorMessage, - statusCode, - returningPageInfo, - values - )); + const filterConfig = this.config.find((config: SearchFilterConfig) => config.name === searchFilterConfigName); + return this.routeService.getQueryParameterValues(filterConfig.paramName).map((selectedValues: string[]) => { + const values: FacetValue[] = []; + 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 * (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 new RemoteData( + 'https://dspace7.4science.it/dspace-spring-rest/api/search', + requestPending, + responsePending, + isSuccessful, + errorMessage, + statusCode, + returningPageInfo, + values + ) + } + ) } getViewMode(): Observable { @@ -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(); + } } } diff --git a/src/app/+search-page/search-settings/search-settings.component.html b/src/app/+search-page/search-settings/search-settings.component.html new file mode 100644 index 0000000000..d5a4d412ff --- /dev/null +++ b/src/app/+search-page/search-settings/search-settings.component.html @@ -0,0 +1,22 @@ +

{{ 'search.sidebar.settings.title' | translate}}

+
+
{{ 'search.sidebar.settings.sort-by' | translate}}
+ +
+ +
+
{{ 'search.sidebar.settings.rpp' | translate}}
+ + +
diff --git a/src/app/+search-page/search-settings/search-settings.component.scss b/src/app/+search-page/search-settings/search-settings.component.scss new file mode 100644 index 0000000000..0a3824f815 --- /dev/null +++ b/src/app/+search-page/search-settings/search-settings.component.scss @@ -0,0 +1,5 @@ +@import '../../../styles/variables.scss'; + +.setting-option { + border: 1px solid map-get($theme-colors, light); +} diff --git a/src/app/+search-page/search-settings/search-settings.component.spec.ts b/src/app/+search-page/search-settings/search-settings.component.spec.ts new file mode 100644 index 0000000000..504bfbc2bf --- /dev/null +++ b/src/app/+search-page/search-settings/search-settings.component.spec.ts @@ -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; + 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(); + }); + +}); diff --git a/src/app/+search-page/search-settings/search-settings.component.ts b/src/app/+search-page/search-settings/search-settings.component.ts new file mode 100644 index 0000000000..7b8bb8eb6e --- /dev/null +++ b/src/app/+search-page/search-settings/search-settings.component.ts @@ -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); + } +} diff --git a/src/app/+search-page/search-sidebar/search-sidebar.component.html b/src/app/+search-page/search-sidebar/search-sidebar.component.html index 9a34e05dbc..71959b558b 100644 --- a/src/app/+search-page/search-sidebar/search-sidebar.component.html +++ b/src/app/+search-page/search-sidebar/search-sidebar.component.html @@ -1,13 +1,17 @@
- \ No newline at end of file +
diff --git a/src/app/+search-page/search-sidebar/search-sidebar.component.scss b/src/app/+search-page/search-sidebar/search-sidebar.component.scss index c8c0a60225..b5bd6dd30d 100644 --- a/src/app/+search-page/search-sidebar/search-sidebar.component.scss +++ b/src/app/+search-page/search-sidebar/search-sidebar.component.scss @@ -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; + } } diff --git a/src/app/+search-page/search-sidebar/search-sidebar.effects.spec.ts b/src/app/+search-page/search-sidebar/search-sidebar.effects.spec.ts index 693ba0be5d..146b1fdcdb 100644 --- a/src/app/+search-page/search-sidebar/search-sidebar.effects.spec.ts +++ b/src/app/+search-page/search-sidebar/search-sidebar.effects.spec.ts @@ -9,6 +9,7 @@ import { SearchSidebarEffects } from './search-sidebar.effects'; describe('SearchSidebarEffects', () => { let sidebarEffects: SearchSidebarEffects; let actions: Observable; + 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); }); - }); }); diff --git a/src/app/+search-page/search-sidebar/search-sidebar.effects.ts b/src/app/+search-page/search-sidebar/search-sidebar.effects.ts index 71d928bd5f..b65010b6e0 100644 --- a/src/app/+search-page/search-sidebar/search-sidebar.effects.ts +++ b/src/app/+search-page/search-sidebar/search-sidebar.effects.ts @@ -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 */ + } + } diff --git a/src/app/+search-page/search-sidebar/search-sidebar.service.ts b/src/app/+search-page/search-sidebar/search-sidebar.service.ts index c3e10c8ddb..e2ad5e0960 100644 --- a/src/app/+search-page/search-sidebar/search-sidebar.service.ts +++ b/src/app/+search-page/search-sidebar/search-sidebar.service.ts @@ -15,7 +15,12 @@ export class SearchSidebarService { private isCollapsdeInStored: Observable; constructor(private store: Store, 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); } diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts index b3b8eacfce..dc442cd485 100644 --- a/src/app/app-routing.module.ts +++ b/src/app/app-routing.module.ts @@ -15,6 +15,7 @@ import { PageNotFoundComponent } from './pagenotfound/pagenotfound.component'; { path: '**', pathMatch: 'full', component: PageNotFoundComponent }, ]) ], + exports: [RouterModule] }) export class AppRoutingModule { diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 14719ed266..ef1e098f02 100755 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -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 = { @@ -20,4 +25,5 @@ export const appReducers: ActionReducerMap = { hostWindow: hostWindowReducer, header: headerReducer, searchSidebar: sidebarReducer, + searchFilter: filterReducer }; diff --git a/src/app/core/core.module.ts b/src/app/core/core.module.ts index 0313b1b4ca..e001d83bd7 100644 --- a/src/app/core/core.module.ts +++ b/src/app/core/core.module.ts @@ -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, diff --git a/src/app/core/url-baser/url-baser.ts b/src/app/core/url-baser/url-baser.ts new file mode 100644 index 0000000000..290f91fe83 --- /dev/null +++ b/src/app/core/url-baser/url-baser.ts @@ -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); + } + } + } + +} diff --git a/src/app/shared/animations/push.ts b/src/app/shared/animations/push.ts new file mode 100644 index 0000000000..124e3289e7 --- /dev/null +++ b/src/app/shared/animations/push.ts @@ -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)), +]); diff --git a/src/app/shared/animations/slide.ts b/src/app/shared/animations/slide.ts index e4a1fcbb59..fa4a451863 100644 --- a/src/app/shared/animations/slide.ts +++ b/src/app/shared/animations/slide.ts @@ -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)) ]); diff --git a/src/app/shared/pagination/pagination.component.html b/src/app/shared/pagination/pagination.component.html index 25412b3195..062209f4bb 100644 --- a/src/app/shared/pagination/pagination.component.html +++ b/src/app/shared/pagination/pagination.component.html @@ -1,12 +1,12 @@
-
+
{{ 'pagination.showing.label' | translate }} {{ 'pagination.showing.detail' | translate:getShowingDetails(collectionSize)}}
-
+
diff --git a/src/app/shared/route.service.spec.ts b/src/app/shared/route.service.spec.ts new file mode 100644 index 0000000000..10bd147e1d --- /dev/null +++ b/src/app/shared/route.service.spec.ts @@ -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({}); + }); + }); + }); + +}); diff --git a/src/app/shared/route.service.ts b/src/app/shared/route.service.ts new file mode 100644 index 0000000000..b11da41211 --- /dev/null +++ b/src/app/shared/route.service.ts @@ -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 { + return this.route.queryParamMap.map((map) => map.getAll(paramName)); + } + + getQueryParameterValue(paramName: string): Observable { + return this.route.queryParamMap.map((map) => map.get(paramName)); + } + + hasQueryParam(paramName: string): Observable { + return this.route.queryParamMap.map((map) => {return map.has(paramName);}); + } + + hasQueryParamWithValue(paramName: string, paramValue: string): Observable { + return this.route.queryParamMap.map((map) => map.getAll(paramName).indexOf(paramValue) > -1); + } + + addQueryParameterValue(paramName: string, paramValue: string): Observable { + 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 { + 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 { + return this.route.queryParams.map((currentParams) => { + const newParam = {}; + newParam[paramName] = {}; + return Object.assign({}, currentParams, newParam); + }); + + } +} diff --git a/src/app/shared/shared.module.ts b/src/app/shared/shared.module.ts index 5742909e49..245d45ea4e 100644 --- a/src/app/shared/shared.module.ts +++ b/src/app/shared/shared.module.ts @@ -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({ diff --git a/src/app/shared/utils/scroll-and-stick.directive.ts b/src/app/shared/utils/scroll-and-stick.directive.ts deleted file mode 100644 index 4ec99d8db6..0000000000 --- a/src/app/shared/utils/scroll-and-stick.directive.ts +++ /dev/null @@ -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'); - - } - } -} diff --git a/src/app/shared/view-mode-switch/view-mode-switch.component.spec.ts b/src/app/shared/view-mode-switch/view-mode-switch.component.spec.ts index a8486d011d..541b1ed4c3 100644 --- a/src/app/shared/view-mode-switch/view-mode-switch.component.spec.ts +++ b/src/app/shared/view-mode-switch/view-mode-switch.component.spec.ts @@ -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();