diff --git a/package.json b/package.json index 76c1f07a63..442c9fd1aa 100644 --- a/package.json +++ b/package.json @@ -87,6 +87,7 @@ "@ngx-translate/core": "9.1.1", "@ngx-translate/http-loader": "2.0.1", "angular-idle-preload": "2.0.4", + "angular2-moment": "^1.9.0", "angulartics2": "^5.2.0", "body-parser": "1.18.2", "bootstrap": "^4.0.0", @@ -104,8 +105,11 @@ "jsonschema": "1.2.2", "jwt-decode": "^2.2.0", "methods": "1.1.2", + "moment": "^2.22.1", "morgan": "1.9.0", + "ng2-nouislider": "^1.7.11", "ngx-pagination": "3.0.3", + "nouislider": "^11.0.0", "pem": "1.12.3", "reflect-metadata": "0.1.12", "rxjs": "5.5.6", diff --git a/resources/i18n/en.json b/resources/i18n/en.json index 3169dcbae9..ce0cdf687e 100644 --- a/resources/i18n/en.json +++ b/resources/i18n/en.json @@ -125,7 +125,12 @@ "head": "Subject" }, "dateIssued": { - "placeholder": "Date", + "max": { + "placeholder": "Minimum Date" + }, + "min": { + "placeholder": "Maximum Date" + }, "head": "Date" }, "has_content_in_original_bundle": { 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-boolean-filter/search-boolean-filter.component.html similarity index 100% rename from src/app/+search-page/search-filters/search-filter/search-facet-filter/search-facet-filter.component.html rename to src/app/+search-page/search-filters/search-filter/search-boolean-filter/search-boolean-filter.component.html 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-boolean-filter/search-boolean-filter.component.scss similarity index 100% rename from src/app/+search-page/search-filters/search-filter/search-facet-filter/search-facet-filter.component.scss rename to src/app/+search-page/search-filters/search-filter/search-boolean-filter/search-boolean-filter.component.scss diff --git a/src/app/+search-page/search-filters/search-filter/search-boolean-filter/search-boolean-filter.component.ts b/src/app/+search-page/search-filters/search-filter/search-boolean-filter/search-boolean-filter.component.ts new file mode 100644 index 0000000000..c80e7ce86a --- /dev/null +++ b/src/app/+search-page/search-filters/search-filter/search-boolean-filter/search-boolean-filter.component.ts @@ -0,0 +1,22 @@ +import { Component, OnInit } from '@angular/core'; +import { Observable } from 'rxjs/Observable'; +import { FilterType } from '../../../search-service/filter-type.model'; +import { renderFacetFor } from '../search-filter-type-decorator'; +import { SearchFacetFilterComponent } from '../search-facet-filter/search-facet-filter.component'; + +/** + * 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-boolean-filter', + styleUrls: ['./search-boolean-filter.component.scss'], + templateUrl: './search-boolean-filter.component.html', +}) + +@renderFacetFor(FilterType.boolean) +export class SearchBooleanFilterComponent extends SearchFacetFilterComponent implements OnInit { + currentPage: Observable; +} diff --git a/src/app/+search-page/search-filters/search-filter/search-facet-filter-wrapper/search-facet-filter-wrapper.component.html b/src/app/+search-page/search-filters/search-filter/search-facet-filter-wrapper/search-facet-filter-wrapper.component.html new file mode 100644 index 0000000000..b7e03af473 --- /dev/null +++ b/src/app/+search-page/search-filters/search-filter/search-facet-filter-wrapper/search-facet-filter-wrapper.component.html @@ -0,0 +1 @@ + diff --git a/src/app/+search-page/search-filters/search-filter/search-facet-filter-wrapper/search-facet-filter-wrapper.component.ts b/src/app/+search-page/search-filters/search-filter/search-facet-filter-wrapper/search-facet-filter-wrapper.component.ts new file mode 100644 index 0000000000..d1f7eeab5c --- /dev/null +++ b/src/app/+search-page/search-filters/search-filter/search-facet-filter-wrapper/search-facet-filter-wrapper.component.ts @@ -0,0 +1,36 @@ +import { Component, Injector, Input, OnInit } from '@angular/core'; +import { renderFilterType } from '../search-filter-type-decorator'; +import { FilterType } from '../../../search-service/filter-type.model'; +import { SearchFilterConfig } from '../../../search-service/search-filter-config.model'; +import { FILTER_CONFIG, SELECTED_VALUES } from '../search-filter.service'; +import { Observable } from 'rxjs/Observable'; + +@Component({ + selector: 'ds-search-facet-filter-wrapper', + templateUrl: './search-facet-filter-wrapper.component.html' +}) +export class SearchFacetFilterWrapperComponent implements OnInit { + @Input() filterConfig: SearchFilterConfig; + @Input() selectedValues: Observable; + objectInjector: Injector; + + constructor(private injector: Injector) { + } + + ngOnInit(): void { + this.selectedValues.subscribe((values) => { + this.objectInjector = Injector.create({ + providers: [ + { provide: FILTER_CONFIG, useFactory: () => (this.filterConfig), deps: [] }, + { provide: SELECTED_VALUES, useFactory: () => (values), deps: [] }], + + parent: this.injector + }); + }); + } + + getSearchFilter(): string { + const type: FilterType = this.filterConfig.type; + return renderFilterType(type); + } +} 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 index b05851f7c7..88da2a89cb 100644 --- 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 @@ -2,8 +2,7 @@ import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; import { async, ComponentFixture, TestBed } from '@angular/core/testing'; import { TranslateModule } from '@ngx-translate/core'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; -import { SearchFacetFilterComponent } from './search-facet-filter.component'; -import { SearchFilterService } from '../search-filter.service'; +import { FILTER_CONFIG, SearchFilterService, SELECTED_VALUES } 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'; @@ -18,6 +17,7 @@ import { RouterStub } from '../../../../shared/testing/router-stub'; import { Router } from '@angular/router'; import { BehaviorSubject } from 'rxjs/BehaviorSubject'; import { PageInfo } from '../../../../core/shared/page-info.model'; +import { SearchFacetFilterComponent } from './search-facet-filter.component'; describe('SearchFacetFilterComponent', () => { let comp: SearchFacetFilterComponent; @@ -64,6 +64,8 @@ describe('SearchFacetFilterComponent', () => { providers: [ { provide: SearchService, useValue: new SearchServiceStub(searchLink) }, { provide: Router, useValue: new RouterStub() }, + { provide: FILTER_CONFIG, useValue: new SearchFilterConfig()}, + { provide: SELECTED_VALUES, useValue: {} }, { provide: SearchFilterService, useValue: { isFilterActiveWithValue: (paramName: string, filterValue: string) => true, 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 index 74e040a019..65f748b7ab 100644 --- 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 @@ -1,7 +1,7 @@ import { Component, ElementRef, - Input, + Inject, OnDestroy, OnInit, QueryList, ViewChild, @@ -11,7 +11,7 @@ import { FacetValue } from '../../../search-service/facet-value.model'; import { SearchFilterConfig } from '../../../search-service/search-filter-config.model'; import { Router } from '@angular/router'; import { Observable } from 'rxjs/Observable'; -import { SearchFilterService } from '../search-filter.service'; +import { FILTER_CONFIG, SearchFilterService, SELECTED_VALUES } from '../search-filter.service'; import { hasNoValue, hasValue, isNotEmpty } from '../../../../shared/empty.util'; import { RemoteData } from '../../../../core/data/remote-data'; import { PaginatedList } from '../../../../core/data/paginated-list'; @@ -29,13 +29,10 @@ import { EmphasizePipe } from '../../../../shared/utils/emphasize.pipe'; @Component({ selector: 'ds-search-facet-filter', - styleUrls: ['./search-facet-filter.component.scss'], - templateUrl: './search-facet-filter.component.html' + template: ``, }) export class SearchFacetFilterComponent implements OnInit, OnDestroy { - @Input() filterConfig: SearchFilterConfig; - @Input() selectedValues: string[]; filterValues: Array>>> = []; filterValues$: BehaviorSubject = new BehaviorSubject(this.filterValues); currentPage: Observable; @@ -45,7 +42,11 @@ export class SearchFacetFilterComponent implements OnInit, OnDestroy { sub: Subscription; filterSearchResults: Observable = Observable.of([]); - constructor(private searchService: SearchService, private filterService: SearchFilterService, private router: Router) { + constructor(protected searchService: SearchService, + protected filterService: SearchFilterService, + protected router: Router, + @Inject(FILTER_CONFIG) public filterConfig: SearchFilterConfig, + @Inject(SELECTED_VALUES) public selectedValues: string[]) { } ngOnInit(): void { diff --git a/src/app/+search-page/search-filters/search-filter/search-filter-type-decorator.ts b/src/app/+search-page/search-filters/search-filter/search-filter-type-decorator.ts new file mode 100644 index 0000000000..3169709466 --- /dev/null +++ b/src/app/+search-page/search-filters/search-filter/search-filter-type-decorator.ts @@ -0,0 +1,17 @@ + +import { FilterType } from '../../search-service/filter-type.model'; + +const filterTypeMap = new Map(); + +export function renderFacetFor(type: FilterType) { + return function decorator(objectElement: any) { + if (!objectElement) { + return; + } + filterTypeMap.set(type, objectElement); + }; +} + +export function renderFilterType(type: FilterType) { + return filterTypeMap.get(type); +} 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 index d6fb1d4267..4196ff8a7b 100644 --- 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 @@ -1,7 +1,7 @@
{{'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.service.ts b/src/app/+search-page/search-filters/search-filter/search-filter.service.ts index 695e0204f2..f13fdef2e5 100644 --- 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 @@ -1,4 +1,4 @@ -import { Injectable } from '@angular/core'; +import { Injectable, InjectionToken } from '@angular/core'; import { debounceTime, distinctUntilChanged, map } from 'rxjs/operators'; import { SearchFiltersState, SearchFilterState } from './search-filter.reducer'; import { createSelector, MemoizedSelector, Store } from '@ngrx/store'; @@ -23,6 +23,9 @@ import { PaginatedSearchOptions } from '../../paginated-search-options.model'; const filterStateSelector = (state: SearchFiltersState) => state.searchFilter; +export const FILTER_CONFIG: InjectionToken = new InjectionToken('filterConfig'); +export const SELECTED_VALUES: InjectionToken = new InjectionToken('selectedValues'); + @Injectable() export class SearchFilterService { @@ -68,10 +71,31 @@ export class SearchFilterService { ); } - getCurrentFilters() { - return this.routeService.getQueryParamsWithPrefix('f.'); + getCurrentFilters(): Observable { + return this.routeService.getQueryParamsWithPrefix('f.').map((filterParams) => { + if (isNotEmpty(filterParams)) { + const params = {}; + Object.keys(filterParams).forEach((key) => { + if (key.endsWith('.min') || key.endsWith('.max')) { + const realKey = key.slice(0, -4); + if (isEmpty(params[realKey])) { + const min = filterParams[realKey + '.min'][0] || '*'; + const max = filterParams[realKey + '.max'][0] || '*'; + params[realKey] = ['[' + min + ' TO ' + max + ']']; + } + } else { + params[key] = filterParams[key]; + } + }); + return params; + } + return filterParams; + }); } + getCurrentFrontendFilters(): Observable { + return this.routeService.getQueryParamsWithPrefix('f.'); + } getCurrentView() { return this.routeService.getQueryParameterValue('view'); } diff --git a/src/app/+search-page/search-filters/search-filter/search-hierarchy-filter/search-hierarchy-filter.component.html b/src/app/+search-page/search-filters/search-filter/search-hierarchy-filter/search-hierarchy-filter.component.html new file mode 100644 index 0000000000..074c5700d7 --- /dev/null +++ b/src/app/+search-page/search-filters/search-filter/search-hierarchy-filter/search-hierarchy-filter.component.html @@ -0,0 +1,38 @@ + diff --git a/src/app/+search-page/search-filters/search-filter/search-hierarchy-filter/search-hierarchy-filter.component.scss b/src/app/+search-page/search-filters/search-filter/search-hierarchy-filter/search-hierarchy-filter.component.scss new file mode 100644 index 0000000000..1297f1ae3a --- /dev/null +++ b/src/app/+search-page/search-filters/search-filter/search-hierarchy-filter/search-hierarchy-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-hierarchy-filter/search-hierarchy-filter.component.ts b/src/app/+search-page/search-filters/search-filter/search-hierarchy-filter/search-hierarchy-filter.component.ts new file mode 100644 index 0000000000..5e8966cdb4 --- /dev/null +++ b/src/app/+search-page/search-filters/search-filter/search-hierarchy-filter/search-hierarchy-filter.component.ts @@ -0,0 +1,23 @@ +import { Component, OnInit } from '@angular/core'; +import { FacetValue } from '../../../search-service/facet-value.model'; +import { Observable } from 'rxjs/Observable'; +import { FilterType } from '../../../search-service/filter-type.model'; +import { renderFacetFor } from '../search-filter-type-decorator'; +import { SearchFacetFilterComponent } from '../search-facet-filter/search-facet-filter.component'; + +/** + * 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-hierarchy-filter', + styleUrls: ['./search-hierarchy-filter.component.scss'], + templateUrl: './search-hierarchy-filter.component.html', +}) + +@renderFacetFor(FilterType.hierarchy) +export class SearchHierarchyFilterComponent extends SearchFacetFilterComponent implements OnInit { + currentPage: Observable; +} diff --git a/src/app/+search-page/search-filters/search-filter/search-range-filter/search-range-filter.component.html b/src/app/+search-page/search-filters/search-filter/search-range-filter/search-range-filter.component.html new file mode 100644 index 0000000000..c20cc750b7 --- /dev/null +++ b/src/app/+search-page/search-filters/search-filter/search-range-filter/search-range-filter.component.html @@ -0,0 +1,38 @@ +
+
+
+
+ +
+
+ +
+ +
+ + + + + + + + + {{value.value}} + + {{value.count}} + + + + +
+
diff --git a/src/app/+search-page/search-filters/search-filter/search-range-filter/search-range-filter.component.scss b/src/app/+search-page/search-filters/search-filter/search-range-filter/search-range-filter.component.scss new file mode 100644 index 0000000000..4e8c56b5ff --- /dev/null +++ b/src/app/+search-page/search-filters/search-filter/search-range-filter/search-range-filter.component.scss @@ -0,0 +1,41 @@ +@import '../../../../../styles/variables.scss'; +@import '../../../../../styles/mixins.scss'; + + +.filters { + margin-top: $spacer/2; + margin-bottom: $spacer/2; + a { + color: $link-color; + &:hover { + text-decoration: underline; + color: $link-hover-color; + + } + } + .toggle-more-filters a { + color: $link-color; + text-decoration: underline; + cursor: pointer; + } + } + +$slider-handle-width: 18px; +::ng-deep +{ + html:not([dir=rtl]) .noUi-horizontal .noUi-handle { + right: -$slider-handle-width/2; + } + .noUi-horizontal .noUi-handle { + width: $slider-handle-width; + &:before { + left: ($slider-handle-width - 2)/2 - 2; + } + &:after { + left: ($slider-handle-width - 2)/2 + 2; + } + &:focus { + outline: none; + } + } +} \ No newline at end of file diff --git a/src/app/+search-page/search-filters/search-filter/search-range-filter/search-range-filter.component.spec.ts b/src/app/+search-page/search-filters/search-filter/search-range-filter/search-range-filter.component.spec.ts new file mode 100644 index 0000000000..e4aa887caa --- /dev/null +++ b/src/app/+search-page/search-filters/search-filter/search-range-filter/search-range-filter.component.spec.ts @@ -0,0 +1,139 @@ +import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { FILTER_CONFIG, SearchFilterService, SELECTED_VALUES } from '../search-filter.service'; +import { SearchFilterConfig } from '../../../search-service/search-filter-config.model'; +import { FilterType } from '../../../search-service/filter-type.model'; +import { FacetValue } from '../../../search-service/facet-value.model'; +import { FormsModule } from '@angular/forms'; +import { Observable } from 'rxjs/Observable'; +import { SearchService } from '../../../search-service/search.service'; +import { SearchServiceStub } from '../../../../shared/testing/search-service-stub'; +import { RemoteData } from '../../../../core/data/remote-data'; +import { PaginatedList } from '../../../../core/data/paginated-list'; +import { RouterStub } from '../../../../shared/testing/router-stub'; +import { ActivatedRoute, Router } from '@angular/router'; +import { BehaviorSubject } from 'rxjs/BehaviorSubject'; +import { PageInfo } from '../../../../core/shared/page-info.model'; +import { SearchRangeFilterComponent } from './search-range-filter.component'; +import { MockActivatedRoute } from '../../../../shared/mocks/mock-active-router'; + +describe('SearchFacetFilterComponent', () => { + let comp: SearchRangeFilterComponent; + let fixture: ComponentFixture; + const minSuffix = '.min'; + const maxSuffix = '.max'; + const dateFormats = ['YYYY', 'YYYY-MM', 'YYYY-MM-DD']; + const filterName1 = 'test name'; + const value1 = '2000 - 2012'; + const value2 = '1992 - 2000'; + const value3 = '1990 - 1992'; + const mockFilterConfig: SearchFilterConfig = Object.assign(new SearchFilterConfig(), { + name: filterName1, + type: FilterType.range, + hasFacets: false, + isOpenByDefault: false, + pageSize: 2, + minValue: 200, + maxValue: 3000, + }); + const values: FacetValue[] = [ + { + value: value1, + count: 52, + search: '' + }, { + value: value2, + count: 20, + search: '' + }, { + value: value3, + count: 5, + search: '' + } + ]; + + const searchLink = '/search'; + const selectedValues = [value1]; + let filterService; + let searchService; + let router; + const page = Observable.of(0); + const activatedRouteStub = new MockActivatedRoute(); + + const mockValues = Observable.of(new RemoteData(false, false, true, null, new PaginatedList(new PageInfo(), values))); + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot(), NoopAnimationsModule, FormsModule], + declarations: [SearchRangeFilterComponent], + providers: [ + { provide: SearchService, useValue: new SearchServiceStub(searchLink) }, + { provide: Router, useValue: new RouterStub() }, + { provide: FILTER_CONFIG, useValue: mockFilterConfig}, + { provide: SELECTED_VALUES, useValue: selectedValues }, + { provide: ActivatedRoute, useValue: activatedRouteStub }, + { + provide: SearchFilterService, useValue: { + isFilterActiveWithValue: (paramName: string, filterValue: string) => true, + getPage: (paramName: string) => page, + /* tslint:disable:no-empty */ + incrementPage: (filterName: string) => { + }, + resetPage: (filterName: string) => { + }, + getSearchOptions: () => Observable.of({}), + /* tslint:enable:no-empty */ + } + } + ], + schemas: [NO_ERRORS_SCHEMA] + }).overrideComponent(SearchRangeFilterComponent, { + set: { changeDetection: ChangeDetectionStrategy.Default } + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(SearchRangeFilterComponent); + comp = fixture.componentInstance; // SearchPageComponent test instance + comp.filterValues = [mockValues]; + comp.filterValues$ = new BehaviorSubject(comp.filterValues); + filterService = (comp as any).filterService; + searchService = (comp as any).searchService; + spyOn(searchService, 'getFacetValuesFor').and.returnValue(mockValues); + router = (comp as any).router; + fixture.detectChanges(); + }); + + describe('when the getAddParams method is called wih a value', () => { + it('should return the selectedValue list with the new parameter value', () => { + const result = comp.getAddParams(value3); + expect(result[mockFilterConfig.paramName + minSuffix]).toEqual(['1990']); + expect(result[mockFilterConfig.paramName + maxSuffix]).toEqual(['1992']); + }); + }); + + describe('when the getRemoveParams method is called wih a value', () => { + it('should return the selectedValue list with the parameter value left out', () => { + const result = comp.getRemoveParams(value1); + expect(result[mockFilterConfig.paramName + minSuffix]).toBeNull(); + expect(result[mockFilterConfig.paramName + maxSuffix]).toBeNull(); + }); + }); + + describe('when the onSubmit method is called with data', () => { + const searchUrl = '/search/path'; + const data = { [mockFilterConfig.paramName + minSuffix]: '1900', [mockFilterConfig.paramName + maxSuffix]: '1950' }; + beforeEach(() => { + spyOn(comp, 'getSearchLink').and.returnValue(searchUrl); + comp.onSubmit(data); + }); + + it('should call navigate on the router with the right searchlink and parameters', () => { + expect(router.navigate).toHaveBeenCalledWith([searchUrl], { + queryParams: { [mockFilterConfig.paramName + minSuffix]: ['1900'], [mockFilterConfig.paramName + maxSuffix]: ['1950']}, + queryParamsHandling: 'merge' + }); + }); + }); +}); diff --git a/src/app/+search-page/search-filters/search-filter/search-range-filter/search-range-filter.component.ts b/src/app/+search-page/search-filters/search-filter/search-range-filter/search-range-filter.component.ts new file mode 100644 index 0000000000..c8ea8c7587 --- /dev/null +++ b/src/app/+search-page/search-filters/search-filter/search-range-filter/search-range-filter.component.ts @@ -0,0 +1,95 @@ +import { isPlatformBrowser } from '@angular/common'; +import { Component, Inject, OnInit, PLATFORM_ID } from '@angular/core'; +import { FilterType } from '../../../search-service/filter-type.model'; +import { renderFacetFor } from '../search-filter-type-decorator'; +import { SearchFacetFilterComponent } from '../search-facet-filter/search-facet-filter.component'; +import { isNotEmpty } from '../../../../shared/empty.util'; +import { SearchFilterConfig } from '../../../search-service/search-filter-config.model'; +import { FILTER_CONFIG, SearchFilterService, SELECTED_VALUES } from '../search-filter.service'; +import { SearchService } from '../../../search-service/search.service'; +import { ActivatedRoute, Router } from '@angular/router'; +import * as moment from 'moment'; + +/** + * This component renders a simple item page. + * The route parameter 'id' is used to request the item it represents. + * All fields of the item that should be displayed, are defined in its template. + */ +const minSuffix = '.min'; +const maxSuffix = '.max'; +const dateFormats = ['YYYY', 'YYYY-MM', 'YYYY-MM-DD']; +const rangeDelimiter = '-'; + +@Component({ + selector: 'ds-search-range-filter', + styleUrls: ['./search-range-filter.component.scss'], + templateUrl: './search-range-filter.component.html', +}) + +@renderFacetFor(FilterType.range) +export class SearchRangeFilterComponent extends SearchFacetFilterComponent implements OnInit { + min = 1950; + max = 2018; + range; + + constructor(protected searchService: SearchService, + protected filterService: SearchFilterService, + protected router: Router, + @Inject(FILTER_CONFIG) public filterConfig: SearchFilterConfig, + @Inject(SELECTED_VALUES) public selectedValues: string[], + @Inject(PLATFORM_ID) private platformId: any, + private route: ActivatedRoute) { + super(searchService, filterService, router, filterConfig, selectedValues); + } + + ngOnInit(): void { + super.ngOnInit(); + this.min = moment(this.filterConfig.minValue, dateFormats).year() || this.min; + this.max = moment(this.filterConfig.maxValue, dateFormats).year() || this.max; + const iniMin = this.route.snapshot.queryParams[this.filterConfig.paramName + minSuffix] || this.min; + const iniMax = this.route.snapshot.queryParams[this.filterConfig.paramName + maxSuffix] || this.max; + this.range = [iniMin, iniMax]; + + } + + getAddParams(value: string) { + const parts = value.split(rangeDelimiter); + const min = parts.length > 1 ? parts[0].trim() : value; + const max = parts.length > 1 ? parts[1].trim() : value; + return { + [this.filterConfig.paramName + minSuffix]: [min], + [this.filterConfig.paramName + maxSuffix]: [max], + page: 1 + }; + } + + getRemoveParams(value: string) { + return { + [this.filterConfig.paramName + minSuffix]: null, + [this.filterConfig.paramName + maxSuffix]: null, + page: 1 + }; + } + + onSubmit(data: any) { + if (isNotEmpty(data)) { + this.router.navigate([this.getSearchLink()], { + queryParams: + { + [this.filterConfig.paramName + minSuffix]: [data[this.filterConfig.paramName + minSuffix]], + [this.filterConfig.paramName + maxSuffix]: [data[this.filterConfig.paramName + maxSuffix]] + }, + queryParamsHandling: 'merge' + }); + this.filter = ''; + } + } + + /** + * TODO when upgrading nouislider, verify that this check is still needed. + */ + shouldShowSlider(): boolean { + return isPlatformBrowser(this.platformId); + } + +} diff --git a/src/app/+search-page/search-filters/search-filter/search-text-filter/search-text-filter.component.html b/src/app/+search-page/search-filters/search-filter/search-text-filter/search-text-filter.component.html new file mode 100644 index 0000000000..074c5700d7 --- /dev/null +++ b/src/app/+search-page/search-filters/search-filter/search-text-filter/search-text-filter.component.html @@ -0,0 +1,38 @@ + diff --git a/src/app/+search-page/search-filters/search-filter/search-text-filter/search-text-filter.component.scss b/src/app/+search-page/search-filters/search-filter/search-text-filter/search-text-filter.component.scss new file mode 100644 index 0000000000..595b2aefb8 --- /dev/null +++ b/src/app/+search-page/search-filters/search-filter/search-text-filter/search-text-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-text-filter/search-text-filter.component.ts b/src/app/+search-page/search-filters/search-filter/search-text-filter/search-text-filter.component.ts new file mode 100644 index 0000000000..9d61696612 --- /dev/null +++ b/src/app/+search-page/search-filters/search-filter/search-text-filter/search-text-filter.component.ts @@ -0,0 +1,23 @@ +import { Component, OnInit } from '@angular/core'; +import { FacetValue } from '../../../search-service/facet-value.model'; +import { Observable } from 'rxjs/Observable'; +import { FilterType } from '../../../search-service/filter-type.model'; +import { renderFacetFor } from '../search-filter-type-decorator'; +import { SearchFacetFilterComponent } from '../search-facet-filter/search-facet-filter.component'; + +/** + * This component renders a simple item page. + * The route parameter 'id' is used to request the item it represents. + * All fields of the item that should be displayed, are defined in its template. + */ + +@Component({ + selector: 'ds-search-text-filter', + styleUrls: ['./search-text-filter.component.scss'], + templateUrl: './search-text-filter.component.html', +}) + +@renderFacetFor(FilterType.text) +export class SearchTextFilterComponent extends SearchFacetFilterComponent implements OnInit { + currentPage: Observable; +} 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 index 64c2ea5332..6557c5e55f 100644 --- a/src/app/+search-page/search-filters/search-filters.component.spec.ts +++ b/src/app/+search-page/search-filters/search-filters.component.spec.ts @@ -24,7 +24,7 @@ describe('SearchFiltersComponent', () => { /* tslint:enable:no-empty */ }; const searchFilterServiceStub = jasmine.createSpyObj('SearchFilterService', { - getCurrentFilters: Observable.of({}) + getCurrentFrontendFilters: Observable.of({}) }); beforeEach(async(() => { diff --git a/src/app/+search-page/search-filters/search-filters.component.ts b/src/app/+search-page/search-filters/search-filters.component.ts index 517b2e1e59..b0c69ee5ce 100644 --- a/src/app/+search-page/search-filters/search-filters.component.ts +++ b/src/app/+search-page/search-filters/search-filters.component.ts @@ -22,7 +22,7 @@ export class SearchFiltersComponent { clearParams; constructor(private searchService: SearchService, private filterService: SearchFilterService) { this.filters = searchService.getConfig(); - this.clearParams = filterService.getCurrentFilters().map((filters) => {Object.keys(filters).forEach((f) => filters[f] = null); return filters;}); + this.clearParams = filterService.getCurrentFrontendFilters().map((filters) => {Object.keys(filters).forEach((f) => filters[f] = null); return filters;}); } getSearchLink() { diff --git a/src/app/+search-page/search-page.module.ts b/src/app/+search-page/search-page.module.ts index 8d658eb5c8..4611d09bd9 100644 --- a/src/app/+search-page/search-page.module.ts +++ b/src/app/+search-page/search-page.module.ts @@ -22,6 +22,11 @@ import { SearchFilterComponent } from './search-filters/search-filter/search-fil import { SearchFacetFilterComponent } from './search-filters/search-filter/search-facet-filter/search-facet-filter.component'; import { SearchFilterService } from './search-filters/search-filter/search-filter.service'; import { SearchLabelsComponent } from './search-labels/search-labels.component'; +import { SearchRangeFilterComponent } from './search-filters/search-filter/search-range-filter/search-range-filter.component'; +import { SearchTextFilterComponent } from './search-filters/search-filter/search-text-filter/search-text-filter.component'; +import { SearchFacetFilterWrapperComponent } from './search-filters/search-filter/search-facet-filter-wrapper/search-facet-filter-wrapper.component'; +import { SearchBooleanFilterComponent } from './search-filters/search-filter/search-boolean-filter/search-boolean-filter.component'; +import { SearchHierarchyFilterComponent } from './search-filters/search-filter/search-hierarchy-filter/search-hierarchy-filter.component'; const effects = [ SearchSidebarEffects @@ -50,7 +55,13 @@ const effects = [ SearchFiltersComponent, SearchFilterComponent, SearchFacetFilterComponent, - SearchLabelsComponent + SearchLabelsComponent, + SearchFacetFilterComponent, + SearchFacetFilterWrapperComponent, + SearchRangeFilterComponent, + SearchTextFilterComponent, + SearchHierarchyFilterComponent, + SearchBooleanFilterComponent, ], providers: [ SearchService, @@ -64,6 +75,11 @@ const effects = [ ItemSearchResultGridElementComponent, CollectionSearchResultGridElementComponent, CommunitySearchResultGridElementComponent, + SearchFacetFilterComponent, + SearchRangeFilterComponent, + SearchTextFilterComponent, + SearchHierarchyFilterComponent, + SearchBooleanFilterComponent, ] }) export class SearchPageModule { diff --git a/src/app/+search-page/search-service/filter-type.model.ts b/src/app/+search-page/search-service/filter-type.model.ts index 354ca87f98..3c2966a778 100644 --- a/src/app/+search-page/search-service/filter-type.model.ts +++ b/src/app/+search-page/search-service/filter-type.model.ts @@ -1,6 +1,6 @@ export enum FilterType { - text, - date, - hierarchical, - standard + text = 'text', + range = 'date', + hierarchy = 'hierarchical', + boolean = 'standard' } 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 2b77ef6768..ced1f7a8eb 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 @@ -17,6 +17,12 @@ @autoserialize isOpenByDefault: boolean; + + @autoserialize + maxValue: string; + + @autoserialize + minValue: string; /** * Name of this configuration that can be used in a url * @returns Parameter name diff --git a/src/app/app.component.scss b/src/app/app.component.scss index 00a3e56121..e4c51ae37b 100644 --- a/src/app/app.component.scss +++ b/src/app/app.component.scss @@ -1,5 +1,6 @@ @import '../styles/variables.scss'; @import '../../node_modules/bootstrap/scss/bootstrap.scss'; +@import '../../node_modules/nouislider/distribute/nouislider.min.css'; @import "../../node_modules/font-awesome/scss/font-awesome.scss"; html { diff --git a/src/app/core/metadata/metadata.service.ts b/src/app/core/metadata/metadata.service.ts index 39fb454ac5..cf597195e9 100644 --- a/src/app/core/metadata/metadata.service.ts +++ b/src/app/core/metadata/metadata.service.ts @@ -26,7 +26,7 @@ import { Metadatum } from '../shared/metadatum.model'; import { GLOBAL_CONFIG, GlobalConfig } from '../../../config'; import { BitstreamFormat } from '../shared/bitstream-format.model'; -import { hasValue } from '../../shared/empty.util'; +import { hasValue, isNotEmpty } from '../../shared/empty.util'; @Injectable() export class MetadataService { @@ -269,11 +269,9 @@ export class MetadataService { private setCitationPdfUrlTag(): void { if (this.currentObject.value instanceof Item) { const item = this.currentObject.value as Item; - // NOTE: Observable resolves many times with same data - // taking only two, fist one is empty array - item.getFiles().take(2).subscribe((bitstreams: Bitstream[]) => { + item.getFiles().filter((files) => isNotEmpty(files)).first().subscribe((bitstreams: Bitstream[]) => { for (const bitstream of bitstreams) { - bitstream.format.take(1) + bitstream.format.first() .map((rd: RemoteData) => rd.payload) .filter((format: BitstreamFormat) => hasValue(format)) .subscribe((format: BitstreamFormat) => { diff --git a/src/app/shared/mocks/mock-active-router.ts b/src/app/shared/mocks/mock-active-router.ts index 391b9c3426..183d6c13d0 100644 --- a/src/app/shared/mocks/mock-active-router.ts +++ b/src/app/shared/mocks/mock-active-router.ts @@ -29,6 +29,6 @@ export class MockActivatedRoute { // ActivatedRoute.snapshot.params get snapshot() { - return { params: this.testParams }; + return { params: this.testParams, queryParams: this.testParams }; } } diff --git a/src/app/shared/shared.module.ts b/src/app/shared/shared.module.ts index 4ddfd8017c..df311cfc70 100644 --- a/src/app/shared/shared.module.ts +++ b/src/app/shared/shared.module.ts @@ -2,7 +2,7 @@ import { NgModule } from '@angular/core'; import { CommonModule } from '@angular/common'; import { RouterModule } from '@angular/router'; import { FormsModule, ReactiveFormsModule } from '@angular/forms'; - +import { NouisliderModule } from 'ng2-nouislider'; import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; import { TranslateModule } from '@ngx-translate/core'; @@ -54,6 +54,7 @@ import { ClickOutsideDirective } from './utils/click-outside.directive'; import { EmphasizePipe } from './utils/emphasize.pipe'; import { InputSuggestionsComponent } from './input-suggestions/input-suggestions.component'; import { CapitalizePipe } from './utils/capitalize.pipe'; +import { MomentModule } from 'angular2-moment'; const MODULES = [ // Do NOT include UniversalModule, HttpModule, or JsonpModule here @@ -63,7 +64,9 @@ const MODULES = [ NgxPaginationModule, ReactiveFormsModule, RouterModule, - TranslateModule + TranslateModule, + NouisliderModule, + MomentModule ]; const PIPES = [ diff --git a/yarn.lock b/yarn.lock index 28091288cb..fbdc5d7b6c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -425,6 +425,12 @@ angular-idle-preload@2.0.4: version "2.0.4" resolved "https://registry.yarnpkg.com/angular-idle-preload/-/angular-idle-preload-2.0.4.tgz#7b177c0f52918c090e5c345480b922297cd59a0d" +angular2-moment@^1.9.0: + version "1.9.0" + resolved "https://registry.yarnpkg.com/angular2-moment/-/angular2-moment-1.9.0.tgz#d198a4d9bc825f61de19106ac7ea07a78569f5a1" + dependencies: + moment "^2.19.3" + angular2-template-loader@0.6.2: version "0.6.2" resolved "https://registry.yarnpkg.com/angular2-template-loader/-/angular2-template-loader-0.6.2.tgz#c0d44e90fff0fac95e8b23f043acda7fd1c51d7c" @@ -5503,6 +5509,10 @@ module-deps@^4.0.8: through2 "^2.0.0" xtend "^4.0.0" +moment@^2.19.3, moment@^2.22.1: + version "2.22.1" + resolved "https://registry.yarnpkg.com/moment/-/moment-2.22.1.tgz#529a2e9bf973f259c9643d237fda84de3a26e8ad" + morgan@1.9.0: version "1.9.0" resolved "https://registry.yarnpkg.com/morgan/-/morgan-1.9.0.tgz#d01fa6c65859b76fcf31b3cb53a3821a311d8051" @@ -5599,6 +5609,10 @@ netmask@~1.0.4: version "1.0.6" resolved "https://registry.yarnpkg.com/netmask/-/netmask-1.0.6.tgz#20297e89d86f6f6400f250d9f4f6b4c1945fcd35" +ng2-nouislider@^1.7.11: + version "1.7.11" + resolved "https://registry.yarnpkg.com/ng2-nouislider/-/ng2-nouislider-1.7.11.tgz#b8ba5e3d2ffc23e1e32dfe54dd1726e2b4be316b" + ngrx-store-freeze@^0.2.1: version "0.2.1" resolved "https://registry.yarnpkg.com/ngrx-store-freeze/-/ngrx-store-freeze-0.2.1.tgz#04fb29db33cafda0f2d6ea32adeaac4891b1b27b" @@ -5818,6 +5832,10 @@ normalize-url@^1.4.0: query-string "^4.1.0" sort-keys "^1.0.0" +nouislider@^11.0.0: + version "11.1.0" + resolved "https://registry.yarnpkg.com/nouislider/-/nouislider-11.1.0.tgz#1768eb5b854917325d41b96f2dc4eb3757d73381" + npm-run-all@4.1.2: version "4.1.2" resolved "https://registry.yarnpkg.com/npm-run-all/-/npm-run-all-4.1.2.tgz#90d62d078792d20669139e718621186656cea056"