diff --git a/package.json b/package.json
index 7ded007e83..0936b27ea4 100644
--- a/package.json
+++ b/package.json
@@ -90,11 +90,12 @@
"@ngx-translate/http-loader": "2.0.1",
"@nicky-lenaers/ngx-scroll-to": "^0.6.0",
"angular-idle-preload": "2.0.4",
+ "angular2-moment": "^1.9.0",
"angular-sortablejs": "^2.5.0",
"angular2-text-mask": "8.0.4",
"angulartics2": "^5.2.0",
"body-parser": "1.18.2",
- "bootstrap": "^4.0.0",
+ "bootstrap": "4.1.1",
"cerialize": "0.1.18",
"compression": "1.7.1",
"cookie-parser": "1.4.3",
@@ -109,10 +110,13 @@
"jsonschema": "1.2.2",
"jwt-decode": "^2.2.0",
"methods": "1.1.2",
+ "moment": "^2.22.1",
"morgan": "1.9.0",
+ "ng2-nouislider": "^1.7.11",
"ng2-file-upload": "1.2.1",
"ngx-infinite-scroll": "0.8.2",
"ngx-pagination": "3.0.3",
+ "nouislider": "^11.0.0",
"pem": "1.12.3",
"reflect-metadata": "0.1.12",
"rxjs": "5.5.6",
diff --git a/resources/i18n/en.json b/resources/i18n/en.json
index ba70b87e12..2566f4e7ab 100644
--- a/resources/i18n/en.json
+++ b/resources/i18n/en.json
@@ -59,8 +59,13 @@
}
},
"sorting": {
- "ASC": "Ascending",
- "DESC": "Descending"
+ "score": {
+ "DESC": "Relevance"
+ },
+ "dc.title": {
+ "ASC": "Title Ascending",
+ "DESC": "Title Descending"
+ }
},
"title": "DSpace",
"404": {
@@ -93,13 +98,13 @@
"close": "Back to results",
"open": "Search Tools",
"results": "results",
- "filters":{
- "title":"Filters"
+ "filters": {
+ "title": "Filters"
},
- "settings":{
- "title":"Settings",
- "sort-by":"Sort By",
- "rpp":"Results per page"
+ "settings": {
+ "title": "Settings",
+ "sort-by": "Sort By",
+ "rpp": "Results per page"
}
},
"view-switch": {
@@ -109,6 +114,13 @@
"filters": {
"head": "Filters",
"reset": "Reset filters",
+ "applied": {
+ "f.author": "Author",
+ "f.dateIssued.min": "Start date",
+ "f.dateIssued.max": "End date",
+ "f.subject": "Subject",
+ "f.has_content_in_original_bundle": "Has files"
+ },
"filter": {
"show-more": "Show more",
"show-less": "Collapse",
@@ -125,11 +137,15 @@
"head": "Subject"
},
"dateIssued": {
- "placeholder": "Date",
+ "max": {
+ "placeholder": "Minimum Date"
+ },
+ "min": {
+ "placeholder": "Maximum Date"
+ },
"head": "Date"
},
"has_content_in_original_bundle": {
- "placeholder": "Has files",
"head": "Has files"
}
}
diff --git a/src/app/+home-page/top-level-community-list/top-level-community-list.component.ts b/src/app/+home-page/top-level-community-list/top-level-community-list.component.ts
index 8fca66ea79..1915a8ce64 100644
--- a/src/app/+home-page/top-level-community-list/top-level-community-list.component.ts
+++ b/src/app/+home-page/top-level-community-list/top-level-community-list.component.ts
@@ -38,7 +38,7 @@ export class TopLevelCommunityListComponent {
}
updatePage(data) {
- this.communitiesRDObs = this.cds.findAll({
+ this.communitiesRDObs = this.cds.findTop({
currentPage: data.page,
elementsPerPage: data.pageSize,
sort: { field: data.sortField, direction: data.sortDirection }
diff --git a/src/app/+search-page/normalized-search-result.model.ts b/src/app/+search-page/normalized-search-result.model.ts
index 2e9a4a8b8e..0683c74aed 100644
--- a/src/app/+search-page/normalized-search-result.model.ts
+++ b/src/app/+search-page/normalized-search-result.model.ts
@@ -2,11 +2,19 @@ import { autoserialize } from 'cerialize';
import { Metadatum } from '../core/shared/metadatum.model';
import { ListableObject } from '../shared/object-collection/shared/listable-object.model';
+/**
+ * Represents a normalized version of a search result object of a certain DSpaceObject
+ */
export class NormalizedSearchResult implements ListableObject {
-
+ /**
+ * The UUID of the DSpaceObject that was found
+ */
@autoserialize
dspaceObject: string;
+ /**
+ * The metadata that was used to find this item, hithighlighted
+ */
@autoserialize
hitHighlights: Metadatum[];
diff --git a/src/app/+search-page/paginated-search-options.model.spec.ts b/src/app/+search-page/paginated-search-options.model.spec.ts
new file mode 100644
index 0000000000..312e170f1b
--- /dev/null
+++ b/src/app/+search-page/paginated-search-options.model.spec.ts
@@ -0,0 +1,40 @@
+import 'rxjs/add/observable/of';
+import { SortDirection, SortOptions } from '../core/cache/models/sort-options.model';
+import { PaginationComponentOptions } from '../shared/pagination/pagination-component-options.model';
+import { PaginatedSearchOptions } from './paginated-search-options.model';
+
+describe('PaginatedSearchOptions', () => {
+ let options: PaginatedSearchOptions;
+ const sortOptions = new SortOptions('test.field', SortDirection.DESC);
+ const pageOptions = Object.assign(new PaginationComponentOptions(), { pageSize: 40, page: 1 });
+ const filters = { 'f.test': ['value'], 'f.example': ['another value', 'second value'] };
+ const query = 'search query';
+ const scope = '0fde1ecb-82cc-425a-b600-ac3576d76b47';
+ const baseUrl = 'www.rest.com';
+ beforeEach(() => {
+ options = new PaginatedSearchOptions();
+ options.sort = sortOptions;
+ options.pagination = pageOptions;
+ options.filters = filters;
+ options.query = query;
+ options.scope = scope;
+ });
+
+ describe('when toRestUrl is called', () => {
+
+ it('should generate a string with all parameters that are present', () => {
+ const outcome = options.toRestUrl(baseUrl);
+ expect(outcome).toEqual('www.rest.com?' +
+ 'sort=test.field,DESC&' +
+ 'page=0&' +
+ 'size=40&' +
+ 'query=search query&' +
+ 'scope=0fde1ecb-82cc-425a-b600-ac3576d76b47&' +
+ 'f.test=value,query&' +
+ 'f.example=another value,query&' +
+ 'f.example=second value,query'
+ );
+ });
+
+ });
+});
diff --git a/src/app/+search-page/paginated-search-options.model.ts b/src/app/+search-page/paginated-search-options.model.ts
index 4f04480391..a1d147fb9d 100644
--- a/src/app/+search-page/paginated-search-options.model.ts
+++ b/src/app/+search-page/paginated-search-options.model.ts
@@ -3,9 +3,19 @@ import { PaginationComponentOptions } from '../shared/pagination/pagination-comp
import { isNotEmpty } from '../shared/empty.util';
import { SearchOptions } from './search-options.model';
+/**
+ * This model class represents all parameters needed to request information about a certain page of a search request, in a certain order
+ */
export class PaginatedSearchOptions extends SearchOptions {
pagination?: PaginationComponentOptions;
sort?: SortOptions;
+
+ /**
+ * Method to generate the URL that can be used to request a certain page with specific sort options
+ * @param {string} url The URL to the REST endpoint
+ * @param {string[]} args A list of query arguments that should be included in the URL
+ * @returns {string} URL with all paginated search options and passed arguments as query parameters
+ */
toRestUrl(url: string, args: string[] = []): string {
if (isNotEmpty(this.sort)) {
args.push(`sort=${this.sort.field},${this.sort.direction}`);
diff --git a/src/app/+search-page/search-filters/search-filter/search-boolean-filter/search-boolean-filter.component.html b/src/app/+search-page/search-filters/search-filter/search-boolean-filter/search-boolean-filter.component.html
new file mode 100644
index 0000000000..32d9ea6e77
--- /dev/null
+++ b/src/app/+search-page/search-filters/search-filter/search-boolean-filter/search-boolean-filter.component.html
@@ -0,0 +1,34 @@
+
diff --git a/src/app/+search-page/search-filters/search-filter/search-boolean-filter/search-boolean-filter.component.scss b/src/app/+search-page/search-filters/search-filter/search-boolean-filter/search-boolean-filter.component.scss
new file mode 100644
index 0000000000..030184640e
--- /dev/null
+++ b/src/app/+search-page/search-filters/search-filter/search-boolean-filter/search-boolean-filter.component.scss
@@ -0,0 +1,25 @@
+@import '../../../../../styles/variables.scss';
+@import '../../../../../styles/mixins.scss';
+
+.filters {
+ a {
+ color: $body-color;
+ &:hover, &focus {
+ text-decoration: none;
+ }
+ span.badge {
+ vertical-align: text-top;
+ }
+ }
+ .toggle-more-filters a {
+ color: $link-color;
+ text-decoration: underline;
+ cursor: pointer;
+ }
+}
+::ng-deep em {
+ font-weight: bold;
+ font-style: normal;
+}
+
+
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..5deaa34d29
--- /dev/null
+++ b/src/app/+search-page/search-filters/search-filter/search-boolean-filter/search-boolean-filter.component.ts
@@ -0,0 +1,21 @@
+import { Component, OnInit } from '@angular/core';
+import { FilterType } from '../../../search-service/filter-type.model';
+import { renderFacetFor } from '../search-filter-type-decorator';
+import {
+ facetLoad,
+ SearchFacetFilterComponent
+} from '../search-facet-filter/search-facet-filter.component';
+
+@Component({
+ selector: 'ds-search-boolean-filter',
+ styleUrls: ['./search-boolean-filter.component.scss'],
+ templateUrl: './search-boolean-filter.component.html',
+ animations: [facetLoad]
+})
+
+/**
+ * Component that represents a boolean facet for a specific filter configuration
+ */
+@renderFacetFor(FilterType.boolean)
+export class SearchBooleanFilterComponent extends SearchFacetFilterComponent implements OnInit {
+}
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..bc088777fa
--- /dev/null
+++ b/src/app/+search-page/search-filters/search-filter/search-facet-filter-wrapper/search-facet-filter-wrapper.component.ts
@@ -0,0 +1,48 @@
+import { Component, Injector, Input, OnInit } from '@angular/core';
+import { renderFilterType } from '../search-filter-type-decorator';
+import { FilterType } from '../../../search-service/filter-type.model';
+import { SearchFilterConfig } from '../../../search-service/search-filter-config.model';
+import { FILTER_CONFIG } from '../search-filter.service';
+
+@Component({
+ selector: 'ds-search-facet-filter-wrapper',
+ templateUrl: './search-facet-filter-wrapper.component.html'
+})
+
+/**
+ * Wrapper component that renders a specific facet filter based on the filter config's type
+ */
+export class SearchFacetFilterWrapperComponent implements OnInit {
+ /**
+ * Configuration for the filter of this wrapper component
+ */
+ @Input() filterConfig: SearchFilterConfig;
+
+ /**
+ * Injector to inject a child component with the @Input parameters
+ */
+ objectInjector: Injector;
+
+ constructor(private injector: Injector) {
+ }
+
+ /**
+ * Initialize and add the filter config to the injector
+ */
+ ngOnInit(): void {
+ this.objectInjector = Injector.create({
+ providers: [
+ { provide: FILTER_CONFIG, useFactory: () => (this.filterConfig), deps: [] }
+ ],
+ parent: this.injector
+ });
+ }
+
+ /**
+ * Find the correct component based on the filter config's type
+ */
+ getSearchFilter() {
+ const type: FilterType = this.filterConfig.type;
+ return renderFilterType(type);
+ }
+}
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
deleted file mode 100644
index 074c5700d7..0000000000
--- a/src/app/+search-page/search-filters/search-filter/search-facet-filter/search-facet-filter.component.html
+++ /dev/null
@@ -1,38 +0,0 @@
-
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 03b760318f..49141c2b68 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
@@ -1,10 +1,8 @@
import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core';
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
-import { RouterTestingModule } from '@angular/router/testing';
import { TranslateModule } from '@ngx-translate/core';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
-import { SearchFacetFilterComponent } from './search-facet-filter.component';
-import { SearchFilterService } from '../search-filter.service';
+import { FILTER_CONFIG, SearchFilterService } from '../search-filter.service';
import { SearchFilterConfig } from '../../../search-service/search-filter-config.model';
import { FilterType } from '../../../search-service/filter-type.model';
import { FacetValue } from '../../../search-service/facet-value.model';
@@ -14,11 +12,12 @@ import { SearchService } from '../../../search-service/search.service';
import { SearchServiceStub } from '../../../../shared/testing/search-service-stub';
import { RemoteData } from '../../../../core/data/remote-data';
import { PaginatedList } from '../../../../core/data/paginated-list';
-import { SearchOptions } from '../../../search-options.model';
import { RouterStub } from '../../../../shared/testing/router-stub';
import { Router } from '@angular/router';
-import { BehaviorSubject } from 'rxjs/BehaviorSubject';
import { PageInfo } from '../../../../core/shared/page-info.model';
+import { SearchFacetFilterComponent } from './search-facet-filter.component';
+import { RemoteDataBuildService } from '../../../../core/cache/builders/remote-data-build.service';
+import { SearchConfigurationService } from '../../../search-service/search-configuration.service';
describe('SearchFacetFilterComponent', () => {
let comp: SearchFacetFilterComponent;
@@ -65,18 +64,21 @@ describe('SearchFacetFilterComponent', () => {
providers: [
{ provide: SearchService, useValue: new SearchServiceStub(searchLink) },
{ provide: Router, useValue: new RouterStub() },
+ { provide: FILTER_CONFIG, useValue: new SearchFilterConfig() },
+ { provide: RemoteDataBuildService, useValue: {aggregate: () => Observable.of({})} },
+ { provide: SearchConfigurationService, useValue: {searchOptions: Observable.of({})} },
{
provide: SearchFilterService, useValue: {
- isFilterActiveWithValue: (paramName: string, filterValue: string) => true,
- getPage: (paramName: string) => page,
- /* tslint:disable:no-empty */
- incrementPage: (filterName: string) => {
- },
- resetPage: (filterName: string) => {
- },
- getSearchOptions: () => Observable.of({}),
- /* tslint:enable:no-empty */
- }
+ getSelectedValuesForFilter: () => Observable.of(selectedValues),
+ isFilterActiveWithValue: (paramName: string, filterValue: string) => true,
+ getPage: (paramName: string) => page,
+ /* tslint:disable:no-empty */
+ incrementPage: (filterName: string) => {
+ },
+ resetPage: (filterName: string) => {
+ }
+ /* tslint:enable:no-empty */
+ }
}
],
schemas: [NO_ERRORS_SCHEMA]
@@ -89,9 +91,6 @@ describe('SearchFacetFilterComponent', () => {
fixture = TestBed.createComponent(SearchFacetFilterComponent);
comp = fixture.componentInstance; // SearchPageComponent test instance
comp.filterConfig = mockFilterConfig;
- comp.filterValues = [mockValues];
- comp.filterValues$ = new BehaviorSubject(comp.filterValues);
- comp.selectedValues = selectedValues;
filterService = (comp as any).filterService;
searchService = (comp as any).searchService;
spyOn(searchService, 'getFacetValuesFor').and.returnValue(mockValues);
@@ -124,14 +123,14 @@ describe('SearchFacetFilterComponent', () => {
describe('when the getAddParams method is called wih a value', () => {
it('should return the selectedValue list with the new parameter value', () => {
const result = comp.getAddParams(value3);
- expect(result[mockFilterConfig.paramName]).toEqual([value1, value2, value3]);
+ result.subscribe((r) => expect(r[mockFilterConfig.paramName]).toEqual([value1, value2, value3]));
});
});
describe('when the getRemoveParams method is called wih a value', () => {
it('should return the selectedValue list with the parameter value left out', () => {
const result = comp.getRemoveParams(value1);
- expect(result[mockFilterConfig.paramName]).toEqual([value2]);
+ result.subscribe((r) => expect(r[mockFilterConfig.paramName]).toEqual([value2]));
});
});
@@ -169,7 +168,7 @@ describe('SearchFacetFilterComponent', () => {
});
describe('when the getCurrentUrl method is called', () => {
- const url = 'test.url/test'
+ const url = 'test.url/test';
beforeEach(() => {
router.navigateByUrl(url);
});
@@ -182,7 +181,7 @@ describe('SearchFacetFilterComponent', () => {
describe('when the onSubmit method is called with data', () => {
const searchUrl = '/search/path';
const testValue = 'test';
- const data = { [mockFilterConfig.paramName]: testValue };
+ const data = testValue;
beforeEach(() => {
spyOn(comp, 'getSearchLink').and.returnValue(searchUrl);
comp.onSubmit(data);
@@ -197,46 +196,26 @@ describe('SearchFacetFilterComponent', () => {
});
describe('when updateFilterValueList is called', () => {
- const cPage = 10;
- const searchOptions = new SearchOptions();
beforeEach(() => {
- // spyOn(searchService, 'getFacetValuesFor'); Already spied upon
- comp.currentPage = Observable.of(cPage);
- comp.updateFilterValueList(searchOptions);
+ spyOn(comp, 'showFirstPageOnly');
+ comp.updateFilterValueList()
});
- it('should call getFacetValuesFor on the searchService with the correct parameters', () => {
- expect(searchService.getFacetValuesFor).toHaveBeenCalledWith(mockFilterConfig, cPage, searchOptions);
+ it('should call showFirstPageOnly and empty the filter', () => {
+ expect(comp.animationState).toEqual('loading');
+ expect((comp as any).collapseNextUpdate).toBeTruthy();
+ expect(comp.filter).toEqual('');
});
});
- describe('when updateFilterValueList is called and pageChange is set to true', () => {
- const searchOptions = new SearchOptions();
+ describe('when findSuggestions is called with query \'test\'', () => {
+ const query = 'test';
beforeEach(() => {
- comp.pageChange = true;
- spyOn(comp, 'showFirstPageOnly');
- comp.updateFilterValueList(searchOptions);
+ comp.findSuggestions(query);
});
- it('should not call showFirstPageOnly on the component', () => {
- expect(comp.showFirstPageOnly).not.toHaveBeenCalled();
- });
-
- it('should set pageChange to false', () => {
- expect(comp.pageChange).toBeFalsy();
- });
- });
-
- describe('when updateFilterValueList is called and pageChange is set to false', () => {
- const searchOptions = new SearchOptions();
- beforeEach(() => {
- comp.pageChange = false;
- spyOn(comp, 'showFirstPageOnly');
- comp.updateFilterValueList(searchOptions);
- });
-
- it('should call showFirstPageOnly on the component', () => {
- expect(comp.showFirstPageOnly).toHaveBeenCalled();
+ it('should call getFacetValuesFor on the component\'s SearchService with the right query', () => {
+ expect((comp as any).searchService.getFacetValuesFor).toHaveBeenCalledWith(comp.filterConfig, 1, {}, query);
});
});
});
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 5f8111c87b..c87f96ffba 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,125 +1,283 @@
-import { Component, Input, OnDestroy, OnInit } from '@angular/core';
+import { animate, state, style, transition, trigger } from '@angular/animations';
+import { Component, Inject, OnDestroy, OnInit } from '@angular/core';
+import { Router } from '@angular/router';
+import { BehaviorSubject } from 'rxjs/BehaviorSubject';
+import { Observable } from 'rxjs/Observable';
+import { Subject } from 'rxjs/Subject';
+import { Subscription } from 'rxjs/Subscription';
+import { RemoteDataBuildService } from '../../../../core/cache/builders/remote-data-build.service';
+import { PaginatedList } from '../../../../core/data/paginated-list';
+import { RemoteData } from '../../../../core/data/remote-data';
+import { hasNoValue, hasValue, isNotEmpty } from '../../../../shared/empty.util';
+import { EmphasizePipe } from '../../../../shared/utils/emphasize.pipe';
+import { SearchOptions } from '../../../search-options.model';
import { FacetValue } from '../../../search-service/facet-value.model';
import { SearchFilterConfig } from '../../../search-service/search-filter-config.model';
-import { Router } from '@angular/router';
-import { Observable } from 'rxjs/Observable';
-import { SearchFilterService } from '../search-filter.service';
-import { hasNoValue, hasValue, isNotEmpty } from '../../../../shared/empty.util';
-import { RemoteData } from '../../../../core/data/remote-data';
-import { PaginatedList } from '../../../../core/data/paginated-list';
import { SearchService } from '../../../search-service/search.service';
-import { SearchOptions } from '../../../search-options.model';
-import { BehaviorSubject } from 'rxjs/BehaviorSubject';
-import { Subscription } from 'rxjs/Subscription';
-
-/**
- * This component renders a simple item page.
- * The route parameter 'id' is used to request the item it represents.
- * All fields of the item that should be displayed, are defined in its template.
- */
+import { FILTER_CONFIG, SearchFilterService } from '../search-filter.service';
+import { SearchConfigurationService } from '../../../search-service/search-configuration.service';
@Component({
selector: 'ds-search-facet-filter',
- styleUrls: ['./search-facet-filter.component.scss'],
- templateUrl: './search-facet-filter.component.html'
+ template: ``,
})
+/**
+ * Super class for all different representations of facets
+ */
export class SearchFacetFilterComponent implements OnInit, OnDestroy {
- @Input() filterConfig: SearchFilterConfig;
- @Input() selectedValues: string[];
- filterValues: Array>>> = [];
- filterValues$: BehaviorSubject = new BehaviorSubject(this.filterValues);
+ /**
+ * Emits an array of pages with values found for this facet
+ */
+ filterValues$: Subject>>>;
+
+ /**
+ * Emits the current last shown page of this facet's values
+ */
currentPage: Observable;
+
+ /**
+ * Emits true if the current page is also the last page available
+ */
isLastPage$: BehaviorSubject = new BehaviorSubject(false);
+
+ /**
+ * The value of the input field that is used to query for possible values for this filter
+ */
filter: string;
- pageChange = false;
- sub: Subscription;
- constructor(private searchService: SearchService, private filterService: SearchFilterService, private router: Router) {
+ /**
+ * List of subscriptions to unsubscribe from
+ */
+ private subs: Subscription[] = [];
+
+ /**
+ * Emits the result values for this filter found by the current filter query
+ */
+ filterSearchResults: Observable = Observable.of([]);
+
+ /**
+ * Emits the active values for this filter
+ */
+ selectedValues: Observable;
+ private collapseNextUpdate = true;
+
+ /**
+ * State of the requested facets used to time the animation
+ */
+ animationState = 'loading';
+
+ constructor(protected searchService: SearchService,
+ protected filterService: SearchFilterService,
+ protected searchConfigService: SearchConfigurationService,
+ protected rdbs: RemoteDataBuildService,
+ protected router: Router,
+ @Inject(FILTER_CONFIG) public filterConfig: SearchFilterConfig) {
}
+ /**
+ * Initializes all observable instance variables and starts listening to them
+ */
ngOnInit(): void {
- this.currentPage = this.getCurrentPage();
- this.currentPage.distinctUntilChanged().subscribe((page) => this.pageChange = true);
- this.filterService.getSearchOptions().distinctUntilChanged().subscribe((options) => this.updateFilterValueList(options));
- }
-
- updateFilterValueList(options: SearchOptions) {
- if (!this.pageChange) {
- this.showFirstPageOnly();
- }
- this.pageChange = false;
-
- this.unsubscribe();
- this.sub = this.currentPage.distinctUntilChanged().map((page) => {
- return this.searchService.getFacetValuesFor(this.filterConfig, page, options);
- }).subscribe((newValues$) => {
- this.filterValues = [...this.filterValues, newValues$];
- this.filterValues$.next(this.filterValues);
- newValues$.first().subscribe((rd) => this.isLastPage$.next(hasNoValue(rd.payload.next)));
+ this.filterValues$ = new BehaviorSubject(new RemoteData(true, false, undefined, undefined, undefined));
+ this.currentPage = this.getCurrentPage().distinctUntilChanged();
+ this.selectedValues = this.filterService.getSelectedValuesForFilter(this.filterConfig);
+ const searchOptions = this.searchConfigService.searchOptions;
+ this.subs.push(this.searchConfigService.searchOptions.subscribe(() => this.updateFilterValueList()));
+ const facetValues = Observable.combineLatest(searchOptions, this.currentPage, (options, page) => {
+ return { options, page }
+ }).switchMap(({ options, page }) => {
+ return this.searchService.getFacetValuesFor(this.filterConfig, page, options)
+ .first((RD) => !RD.isLoading).map((results) => {
+ return {
+ values: Observable.of(results),
+ page: page
+ };
+ }
+ );
});
+ let filterValues = [];
+ this.subs.push(facetValues.subscribe((facetOutcome) => {
+ const newValues$ = facetOutcome.values;
+
+ if (this.collapseNextUpdate) {
+ this.showFirstPageOnly();
+ facetOutcome.page = 1;
+ this.collapseNextUpdate = false;
+ }
+ if (facetOutcome.page === 1) {
+ filterValues = [];
+ }
+
+ filterValues = [...filterValues, newValues$];
+
+ this.subs.push(this.rdbs.aggregate(filterValues).subscribe((rd: RemoteData>>) => {
+ this.animationState = 'ready';
+ this.filterValues$.next(rd);
+ }));
+ this.subs.push(newValues$.first().subscribe((rd) => {
+ this.isLastPage$.next(hasNoValue(rd.payload.next))
+ }));
+ }));
+
}
+ /**
+ * Prepare for refreshing the values of this filter
+ */
+ updateFilterValueList() {
+ this.animationState = 'loading';
+ this.collapseNextUpdate = true;
+ this.filter = '';
+ }
+
+ /**
+ * Checks if a value for this filter is currently active
+ */
isChecked(value: FacetValue): Observable {
return this.filterService.isFilterActiveWithValue(this.filterConfig.paramName, value.value);
}
+ /**
+ * @returns {string} The base path to the search page
+ */
getSearchLink() {
return this.searchService.getSearchLink();
}
+ /**
+ * Show the next page as well
+ */
showMore() {
this.filterService.incrementPage(this.filterConfig.name);
}
+ /**
+ * Make sure only the first page is shown
+ */
showFirstPageOnly() {
- this.filterValues = [];
this.filterService.resetPage(this.filterConfig.name);
}
+ /**
+ * @returns {Observable} The current page of this filter
+ */
getCurrentPage(): Observable {
return this.filterService.getPage(this.filterConfig.name);
}
+ /**
+ * @returns {string} the current URL
+ */
getCurrentUrl() {
return this.router.url;
}
+ /**
+ * Submits a new active custom value to the filter from the input field
+ * @param data The string from the input field
+ */
onSubmit(data: any) {
- if (isNotEmpty(data)) {
- this.router.navigate([this.getSearchLink()], {
- queryParams:
- { [this.filterConfig.paramName]: [...this.selectedValues, data[this.filterConfig.paramName]] },
- queryParamsHandling: 'merge'
- });
- this.filter = '';
- }
+ this.selectedValues.first().subscribe((selectedValues) => {
+ if (isNotEmpty(data)) {
+ this.router.navigate([this.getSearchLink()], {
+ queryParams:
+ { [this.filterConfig.paramName]: [...selectedValues, data] },
+ queryParamsHandling: 'merge'
+ });
+ this.filter = '';
+ }
+ this.filterSearchResults = Observable.of([]);
+ }
+ )
}
+ onClick(data: any) {
+ this.filter = data;
+ }
+
+ /**
+ * For usage of the hasValue function in the template
+ */
hasValue(o: any): boolean {
return hasValue(o);
}
- getRemoveParams(value: string) {
- return {
- [this.filterConfig.paramName]: this.selectedValues.filter((v) => v !== value),
- page: 1
- };
+
+ /**
+ * Calculates the parameters that should change if a given value for this filter would be removed from the active filters
+ * @param {string} value The value that is removed for this filter
+ * @returns {Observable} The changed filter parameters
+ */
+ getRemoveParams(value: string): Observable {
+ return this.selectedValues.map((selectedValues) => {
+ return {
+ [this.filterConfig.paramName]: selectedValues.filter((v) => v !== value),
+ page: 1
+ };
+ });
}
- getAddParams(value: string) {
- return {
- [this.filterConfig.paramName]: [...this.selectedValues, value],
- page: 1
- };
+ /**
+ * Calculates the parameters that should change if a given value for this filter would be added to the active filters
+ * @param {string} value The value that is added for this filter
+ * @returns {Observable} The changed filter parameters
+ */
+ getAddParams(value: string): Observable {
+ return this.selectedValues.map((selectedValues) => {
+ return {
+ [this.filterConfig.paramName]: [...selectedValues, value],
+ page: 1
+ };
+ });
}
+ /**
+ * Unsubscribe from all subscriptions
+ */
ngOnDestroy(): void {
- this.unsubscribe();
+ this.subs
+ .filter((sub) => hasValue(sub))
+ .forEach((sub) => sub.unsubscribe());
}
- unsubscribe(): void {
- if (hasValue(this.sub)) {
- this.sub.unsubscribe();
+ /**
+ * Updates the found facet value suggestions for a given query
+ * Transforms the found values into display values
+ * @param data The query for which is being searched
+ */
+ findSuggestions(data): void {
+ if (isNotEmpty(data)) {
+ this.searchConfigService.searchOptions.first().subscribe(
+ (options) => {
+ this.filterSearchResults = this.searchService.getFacetValuesFor(this.filterConfig, 1, options, data.toLowerCase())
+ .first()
+ .map(
+ (rd: RemoteData>) => {
+ return rd.payload.page.map((facet) => {
+ return { displayValue: this.getDisplayValue(facet, data), value: facet.value }
+ })
+ }
+ );
+ }
+ )
+ } else {
+ this.filterSearchResults = Observable.of([]);
}
}
+
+ /**
+ * Transforms the facet value string, so if the query matches part of the value, it's emphasized in the value
+ * @param {FacetValue} facet The value of the facet as returned by the server
+ * @param {string} query The query that was used to search facet values
+ * @returns {string} The facet value with the query part emphasized
+ */
+ getDisplayValue(facet: FacetValue, query: string): string {
+ return new EmphasizePipe().transform(facet.value, query) + ' (' + facet.count + ')';
+ }
}
+
+export const facetLoad = trigger('facetLoad', [
+ state('ready', style({ opacity: 1 })),
+ state('loading', style({ opacity: 0 })),
+ transition('loading <=> ready', animate(100)),
+]);
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..bcc22be82c
--- /dev/null
+++ b/src/app/+search-page/search-filters/search-filter/search-filter-type-decorator.ts
@@ -0,0 +1,30 @@
+
+import { FilterType } from '../../search-service/filter-type.model';
+
+/**
+ * Contains the mapping between a facet component and a FilterType
+ */
+const filterTypeMap = new Map();
+
+/**
+ * Sets the mapping for a facet component in relation to a filter type
+ * @param {FilterType} type The type for which the matching component is mapped
+ * @returns Decorator function that performs the actual mapping on initialization of the facet component
+ */
+export function renderFacetFor(type: FilterType) {
+ return function decorator(objectElement: any) {
+ if (!objectElement) {
+ return;
+ }
+ filterTypeMap.set(type, objectElement);
+ };
+}
+
+/**
+ * Requests the matching facet component based on a given filter type
+ * @param {FilterType} type The filter type for which the facet component is requested
+ * @returns The facet component's constructor that matches the given filter type
+ */
+export function renderFilterType(type: FilterType) {
+ return filterTypeMap.get(type);
+}
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
index 5c9803c7a9..2e556b32d6 100644
--- 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
@@ -22,41 +22,78 @@ export const SearchFilterActionTypes = {
};
export class SearchFilterAction implements Action {
+ /**
+ * Name of the filter the action is performed on, used to identify the filter
+ */
filterName: string;
+
+ /**
+ * Type of action that will be performed
+ */
type;
+
+ /**
+ * Initialize with the filter's name
+ * @param {string} name of the filter
+ */
constructor(name: string) {
this.filterName = name;
}
}
/* tslint:disable:max-classes-per-file */
+/**
+ * Used to collapse a filter
+ */
export class SearchFilterCollapseAction extends SearchFilterAction {
type = SearchFilterActionTypes.COLLAPSE;
}
+/**
+ * Used to expand a filter
+ */
export class SearchFilterExpandAction extends SearchFilterAction {
type = SearchFilterActionTypes.EXPAND;
}
+/**
+ * Used to collapse a filter when it's expanded and expand it when it's collapsed
+ */
export class SearchFilterToggleAction extends SearchFilterAction {
type = SearchFilterActionTypes.TOGGLE;
}
+/**
+ * Used to set the initial state of a filter to collapsed
+ */
export class SearchFilterInitialCollapseAction extends SearchFilterAction {
type = SearchFilterActionTypes.INITIAL_COLLAPSE;
}
+/**
+ * Used to set the initial state of a filter to expanded
+ */
export class SearchFilterInitialExpandAction extends SearchFilterAction {
type = SearchFilterActionTypes.INITIAL_EXPAND;
}
+
+/**
+ * Used to set the state of a filter to the previous page
+ */
export class SearchFilterDecrementPageAction extends SearchFilterAction {
type = SearchFilterActionTypes.DECREMENT_PAGE;
}
+/**
+ * Used to set the state of a filter to the next page
+ */
export class SearchFilterIncrementPageAction extends SearchFilterAction {
type = SearchFilterActionTypes.INCREMENT_PAGE;
}
+/**
+ * Used to set the state of a filter to the first page
+ */
export class SearchFilterResetPageAction extends SearchFilterAction {
type = SearchFilterActionTypes.RESET_PAGE;
}
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 6cf9df9b05..f5dc5fff38 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.component.scss b/src/app/+search-page/search-filters/search-filter/search-filter.component.scss
index f694e9e167..6e49172a48 100644
--- 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
@@ -3,7 +3,7 @@
:host {
border: 1px solid map-get($theme-colors, light);
- .search-filter-wrapper {
+ .search-filter-wrapper.closed {
overflow: hidden;
}
.filter-toggle {
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
index be26075d25..bd3c9f7a0c 100644
--- 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
@@ -1,18 +1,9 @@
import { Component, Input, OnInit } from '@angular/core';
import { SearchFilterConfig } from '../../search-service/search-filter-config.model';
-import { SearchService } from '../../search-service/search.service';
-import { RemoteData } from '../../../core/data/remote-data';
-import { FacetValue } from '../../search-service/facet-value.model';
import { SearchFilterService } from './search-filter.service';
import { Observable } from 'rxjs/Observable';
import { slide } from '../../../shared/animations/slide';
-import { PaginatedList } from '../../../core/data/paginated-list';
-
-/**
- * This component renders a simple item page.
- * The route parameter 'id' is used to request the item it represents.
- * All fields of the item that should be displayed, are defined in its template.
- */
+import { isNotEmpty } from '../../../shared/empty.util';
@Component({
selector: 'ds-search-filter',
@@ -21,15 +12,31 @@ import { PaginatedList } from '../../../core/data/paginated-list';
animations: [slide],
})
+/**
+ * Represents a part of the filter section for a single type of filter
+ */
export class SearchFilterComponent implements OnInit {
+ /**
+ * The filter config for this component
+ */
@Input() filter: SearchFilterConfig;
+ /**
+ * True when the filter is 100% collapsed in the UI
+ */
+ collapsed;
+
constructor(private filterService: SearchFilterService) {
}
+ /**
+ * Requests the current set values for this filter
+ * If the filter config is open by default OR the filter has at least one value, the filter should be initially expanded
+ * Else, the filter should initially be collapsed
+ */
ngOnInit() {
- this.filterService.isFilterActive(this.filter.paramName).first().subscribe((isActive) => {
- if (this.filter.isOpenByDefault || isActive) {
+ this.getSelectedValues().first().subscribe((isActive) => {
+ if (this.filter.isOpenByDefault || isNotEmpty(isActive)) {
this.initialExpand();
} else {
this.initialCollapse();
@@ -37,23 +44,61 @@ export class SearchFilterComponent implements OnInit {
});
}
+ /**
+ * Changes the state for this filter to collapsed when it's expanded and to expanded it when it's collapsed
+ */
toggle() {
this.filterService.toggle(this.filter.name);
}
+ /**
+ * Checks if the filter is currently collapsed
+ * @returns {Observable
} Emits true when the current state of the filter is collapsed, false when it's expanded
+ */
isCollapsed(): Observable {
return this.filterService.isCollapsed(this.filter.name);
}
+ /**
+ * Changes the initial state to collapsed
+ */
initialCollapse() {
this.filterService.initialCollapse(this.filter.name);
+ this.collapsed = true;
}
+ /**
+ * Changes the initial state to expanded
+ */
initialExpand() {
this.filterService.initialExpand(this.filter.name);
+ this.collapsed = false;
}
+ /**
+ * @returns {Observable} Emits a list of all values that are currently active for this filter
+ */
getSelectedValues(): Observable {
return this.filterService.getSelectedValuesForFilter(this.filter);
}
+
+ /**
+ * Method to change this.collapsed to false when the slide animation ends and is sliding open
+ * @param event The animation event
+ */
+ finishSlide(event: any): void {
+ if (event.fromState === 'collapsed') {
+ this.collapsed = false;
+ }
+ }
+
+ /**
+ * Method to change this.collapsed to true when the slide animation starts and is sliding closed
+ * @param event The animation event
+ */
+ startSlide(event: any): void {
+ if (event.toState === 'collapsed') {
+ this.collapsed = true;
+ }
+ }
}
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
index 9b1a084462..f7e064fcc7 100644
--- 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
@@ -1,17 +1,29 @@
import { SearchFilterAction, SearchFilterActionTypes } from './search-filter.actions';
import { isEmpty } from '../../../shared/empty.util';
+/**
+ * Interface that represents the state for a single filters
+ */
export interface SearchFilterState {
filterCollapsed: boolean,
page: number
}
+/**
+ * Interface that represents the state for all available filters
+ */
export interface SearchFiltersState {
[name: string]: SearchFilterState
}
const initialState: SearchFiltersState = Object.create(null);
+/**
+ * Performs a search filter action on the current state
+ * @param {SearchFiltersState} state The state before the action is performed
+ * @param {SearchFilterAction} action The action that should be performed
+ * @returns {SearchFiltersState} The state after the action is performed
+ */
export function filterReducer(state = initialState, action: SearchFilterAction): SearchFiltersState {
switch (action.type) {
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
index 26eb961c53..6d250f6869 100644
--- 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
@@ -10,6 +10,7 @@ import {
import { SearchFiltersState } from './search-filter.reducer';
import { SearchFilterConfig } from '../../search-service/search-filter-config.model';
import { FilterType } from '../../search-service/filter-type.model';
+import { ActivatedRouteStub } from '../../../shared/testing/active-router-stub';
describe('SearchFilterService', () => {
let service: SearchFilterService;
@@ -41,10 +42,14 @@ describe('SearchFilterService', () => {
addQueryParameterValue: (param: string, value: string) => {
},
getQueryParameterValues: (param: string) => {
+ return Observable.of({});
+ },
+ getQueryParamsWithPrefix: (param: string) => {
+ return Observable.of({});
}
/* tslint:enable:no-empty */
};
-
+ const activatedRoute: any = new ActivatedRouteStub();
const searchServiceStub: any = {
uiSearchRoute: '/search'
};
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..3b7c7b8e86 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,128 +1,82 @@
-import { Injectable } from '@angular/core';
-import { debounceTime, distinctUntilChanged, map } from 'rxjs/operators';
+import { Injectable, InjectionToken } from '@angular/core';
+import { distinctUntilChanged, map } from 'rxjs/operators';
import { SearchFiltersState, SearchFilterState } from './search-filter.reducer';
import { createSelector, MemoizedSelector, Store } from '@ngrx/store';
import { Observable } from 'rxjs/Observable';
import {
SearchFilterCollapseAction,
- SearchFilterDecrementPageAction, SearchFilterExpandAction,
+ SearchFilterDecrementPageAction,
+ SearchFilterExpandAction,
SearchFilterIncrementPageAction,
SearchFilterInitialCollapseAction,
- SearchFilterInitialExpandAction, SearchFilterResetPageAction,
+ SearchFilterInitialExpandAction,
+ SearchFilterResetPageAction,
SearchFilterToggleAction
} from './search-filter.actions';
import { hasValue, isEmpty, isNotEmpty, } from '../../../shared/empty.util';
import { SearchFilterConfig } from '../../search-service/search-filter-config.model';
-import { SearchService } from '../../search-service/search.service';
import { RouteService } from '../../../shared/services/route.service';
-import ObjectExpression from 'rollup/dist/typings/ast/nodes/ObjectExpression';
import { SortDirection, SortOptions } from '../../../core/cache/models/sort-options.model';
import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model';
import { SearchOptions } from '../../search-options.model';
import { PaginatedSearchOptions } from '../../paginated-search-options.model';
+import { ActivatedRoute, Params } from '@angular/router';
const filterStateSelector = (state: SearchFiltersState) => state.searchFilter;
+export const FILTER_CONFIG: InjectionToken = new InjectionToken('filterConfig');
+
+/**
+ * Service that performs all actions that have to do with search filters and facets
+ */
@Injectable()
export class SearchFilterService {
constructor(private store: Store,
- private routeService: RouteService) {
+ private routeService: RouteService
+ ) {
}
+ /**
+ * Checks if a given filter is active with a given value
+ * @param {string} paramName The parameter name of the filter's configuration for which to search
+ * @param {string} filterValue The value for which to search
+ * @returns {Observable} Emit true when the filter is active with the given value
+ */
isFilterActiveWithValue(paramName: string, filterValue: string): Observable {
return this.routeService.hasQueryParamWithValue(paramName, filterValue);
}
+ /**
+ * Checks if a given filter is active with any value
+ * @param {string} paramName The parameter name of the filter's configuration for which to search
+ * @returns {Observable} Emit true when the filter is active with any value
+ */
isFilterActive(paramName: string): Observable {
return this.routeService.hasQueryParam(paramName);
}
- getCurrentScope() {
- return this.routeService.getQueryParameterValue('scope');
- }
-
- getCurrentQuery() {
- return this.routeService.getQueryParameterValue('query');
- }
-
- getCurrentPagination(pagination: any = {}): Observable {
- const page$ = this.routeService.getQueryParameterValue('page');
- const size$ = this.routeService.getQueryParameterValue('pageSize');
- return Observable.combineLatest(page$, size$, (page, size) => {
- return Object.assign(new PaginationComponentOptions(), pagination, {
- currentPage: page || 1,
- pageSize: size || pagination.pageSize
- });
- });
- }
-
- getCurrentSort(defaultSort: SortOptions): Observable {
- const sortDirection$ = this.routeService.getQueryParameterValue('sortDirection');
- const sortField$ = this.routeService.getQueryParameterValue('sortField');
- return Observable.combineLatest(sortDirection$, sortField$, (sortDirection, sortField) => {
- const field = sortField || defaultSort.field;
- const direction = SortDirection[sortDirection] || defaultSort.direction;
- return new SortOptions(field, direction)
- }
- );
- }
-
- getCurrentFilters() {
- return this.routeService.getQueryParamsWithPrefix('f.');
- }
-
- getCurrentView() {
- return this.routeService.getQueryParameterValue('view');
- }
-
- getPaginatedSearchOptions(defaults: any = {}): Observable {
- return Observable.combineLatest(
- this.getCurrentPagination(defaults.pagination),
- this.getCurrentSort(defaults.sort),
- this.getCurrentView(),
- this.getCurrentScope(),
- this.getCurrentQuery(),
- this.getCurrentFilters()).pipe(
- distinctUntilChanged((a, b) => JSON.stringify(a) === JSON.stringify(b)),
- map(([pagination, sort, view, scope, query, filters]) => {
- return Object.assign(new PaginatedSearchOptions(),
- defaults,
- {
- pagination: pagination,
- sort: sort,
- view: view,
- scope: scope || defaults.scope,
- query: query,
- filters: filters
- })
- })
- )
- }
-
- getSearchOptions(defaults: any = {}): Observable {
- return Observable.combineLatest(
- this.getCurrentView(),
- this.getCurrentScope(),
- this.getCurrentQuery(),
- this.getCurrentFilters(),
- (view, scope, query, filters) => {
- return Object.assign(new SearchOptions(),
- defaults,
- {
- view: view,
- scope: scope || defaults.scope,
- query: query,
- filters: filters
- })
- }
- )
- }
-
+ /**
+ * Requests the active filter values set for a given filter
+ * @param {SearchFilterConfig} filterConfig The configuration for which the filters are active
+ * @returns {Observable} Emits the active filters for the given filter configuration
+ */
getSelectedValuesForFilter(filterConfig: SearchFilterConfig): Observable {
- return this.routeService.getQueryParameterValues(filterConfig.paramName);
+ const values$ = this.routeService.getQueryParameterValues(filterConfig.paramName);
+ const prefixValues$ = this.routeService.getQueryParamsWithPrefix(filterConfig.paramName + '.').map((params: Params) => [].concat(...Object.values(params)));
+ return Observable.combineLatest(values$, prefixValues$, (values, prefixValues) => {
+ if (isNotEmpty(values)) {
+ return values;
+ }
+ return prefixValues;
+ })
}
+ /**
+ * Checks if the state of a given filter is currently collapsed or not
+ * @param {string} filterName The filtername for which the collapsed state is checked
+ * @returns {Observable} Emits the current collapsed state of the given filter, if it's unavailable, return false
+ */
isCollapsed(filterName: string): Observable {
return this.store.select(filterByNameSelector(filterName))
.map((object: SearchFilterState) => {
@@ -134,6 +88,11 @@ export class SearchFilterService {
});
}
+ /**
+ * Request the current page of a given filter
+ * @param {string} filterName The filtername for which the page state is checked
+ * @returns {Observable} Emits the current page state of the given filter, if it's unavailable, return 1
+ */
getPage(filterName: string): Observable {
return this.store.select(filterByNameSelector(filterName))
.map((object: SearchFilterState) => {
@@ -145,34 +104,65 @@ export class SearchFilterService {
});
}
+ /**
+ * Dispatches a collapse action to the store for a given filter
+ * @param {string} filterName The filter for which the action is dispatched
+ */
public collapse(filterName: string): void {
this.store.dispatch(new SearchFilterCollapseAction(filterName));
}
+ /**
+ * Dispatches an expand action to the store for a given filter
+ * @param {string} filterName The filter for which the action is dispatched
+ */
public expand(filterName: string): void {
this.store.dispatch(new SearchFilterExpandAction(filterName));
}
+ /**
+ * Dispatches a toggle action to the store for a given filter
+ * @param {string} filterName The filter for which the action is dispatched
+ */
public toggle(filterName: string): void {
this.store.dispatch(new SearchFilterToggleAction(filterName));
}
+ /**
+ * Dispatches an initial collapse action to the store for a given filter
+ * @param {string} filterName The filter for which the action is dispatched
+ */
public initialCollapse(filterName: string): void {
this.store.dispatch(new SearchFilterInitialCollapseAction(filterName));
}
+ /**
+ * Dispatches an initial expand action to the store for a given filter
+ * @param {string} filterName The filter for which the action is dispatched
+ */
public initialExpand(filterName: string): void {
this.store.dispatch(new SearchFilterInitialExpandAction(filterName));
}
+ /**
+ * Dispatches a decrement action to the store for a given filter
+ * @param {string} filterName The filter for which the action is dispatched
+ */
public decrementPage(filterName: string): void {
this.store.dispatch(new SearchFilterDecrementPageAction(filterName));
}
+ /**
+ * Dispatches an increment page action to the store for a given filter
+ * @param {string} filterName The filter for which the action is dispatched
+ */
public incrementPage(filterName: string): void {
this.store.dispatch(new SearchFilterIncrementPageAction(filterName));
}
-
+ /**
+ * Dispatches a reset page action to the store for a given filter
+ * @param {string} filterName The filter for which the action is dispatched
+ */
public resetPage(filterName: string): void {
this.store.dispatch(new SearchFilterResetPageAction(filterName));
}
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..812f543716
--- /dev/null
+++ b/src/app/+search-page/search-filters/search-filter/search-hierarchy-filter/search-hierarchy-filter.component.html
@@ -0,0 +1,43 @@
+
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..9ec0f61541
--- /dev/null
+++ b/src/app/+search-page/search-filters/search-filter/search-hierarchy-filter/search-hierarchy-filter.component.scss
@@ -0,0 +1,23 @@
+@import '../../../../../styles/variables.scss';
+@import '../../../../../styles/mixins.scss';
+
+.filters {
+ a {
+ color: $body-color;
+ &:hover, &focus {
+ text-decoration: none;
+ }
+ span.badge {
+ vertical-align: text-top;
+ }
+ }
+ .toggle-more-filters a {
+ color: $link-color;
+ text-decoration: underline;
+ cursor: pointer;
+ }
+}
+::ng-deep em {
+ font-weight: bold;
+ font-style: normal;
+}
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..b048a9ccd0
--- /dev/null
+++ b/src/app/+search-page/search-filters/search-filter/search-hierarchy-filter/search-hierarchy-filter.component.ts
@@ -0,0 +1,21 @@
+import { Component, OnInit } from '@angular/core';
+import { FilterType } from '../../../search-service/filter-type.model';
+import { renderFacetFor } from '../search-filter-type-decorator';
+import {
+ facetLoad,
+ SearchFacetFilterComponent
+} from '../search-facet-filter/search-facet-filter.component';
+
+@Component({
+ selector: 'ds-search-hierarchy-filter',
+ styleUrls: ['./search-hierarchy-filter.component.scss'],
+ templateUrl: './search-hierarchy-filter.component.html',
+ animations: [facetLoad]
+})
+
+/**
+ * Component that represents a hierarchy facet for a specific filter configuration
+ */
+@renderFacetFor(FilterType.hierarchy)
+export class SearchHierarchyFilterComponent extends SearchFacetFilterComponent implements OnInit {
+}
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..352c1710c0
--- /dev/null
+++ b/src/app/+search-page/search-filters/search-filter/search-range-filter/search-range-filter.component.html
@@ -0,0 +1,40 @@
+
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..c45302b162
--- /dev/null
+++ b/src/app/+search-page/search-filters/search-filter/search-range-filter/search-range-filter.component.scss
@@ -0,0 +1,42 @@
+@import '../../../../../styles/variables.scss';
+@import '../../../../../styles/mixins.scss';
+
+
+.filters {
+ a {
+ color: $link-color;
+ &:hover {
+ text-decoration: underline;
+ color: $link-hover-color;
+
+ }
+ span.badge {
+ vertical-align: text-top;
+ }
+ }
+ .toggle-more-filters a {
+ color: $link-color;
+ text-decoration: underline;
+ cursor: pointer;
+ }
+ }
+
+$slider-handle-width: 18px;
+::ng-deep
+{
+ html:not([dir=rtl]) .noUi-horizontal .noUi-handle {
+ right: -$slider-handle-width/2;
+ }
+ .noUi-horizontal .noUi-handle {
+ width: $slider-handle-width;
+ &:before {
+ left: ($slider-handle-width - 2)/2 - 2;
+ }
+ &:after {
+ left: ($slider-handle-width - 2)/2 + 2;
+ }
+ &:focus {
+ outline: none;
+ }
+ }
+}
\ 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..4e555459d6
--- /dev/null
+++ b/src/app/+search-page/search-filters/search-filter/search-range-filter/search-range-filter.component.spec.ts
@@ -0,0 +1,138 @@
+import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core';
+import { async, ComponentFixture, TestBed } from '@angular/core/testing';
+import { TranslateModule } from '@ngx-translate/core';
+import { NoopAnimationsModule } from '@angular/platform-browser/animations';
+import { FILTER_CONFIG, SearchFilterService } from '../search-filter.service';
+import { SearchFilterConfig } from '../../../search-service/search-filter-config.model';
+import { FilterType } from '../../../search-service/filter-type.model';
+import { FacetValue } from '../../../search-service/facet-value.model';
+import { FormsModule } from '@angular/forms';
+import { Observable } from 'rxjs/Observable';
+import { SearchService } from '../../../search-service/search.service';
+import { SearchServiceStub } from '../../../../shared/testing/search-service-stub';
+import { RemoteData } from '../../../../core/data/remote-data';
+import { PaginatedList } from '../../../../core/data/paginated-list';
+import { RouterStub } from '../../../../shared/testing/router-stub';
+import { Router } from '@angular/router';
+import { PageInfo } from '../../../../core/shared/page-info.model';
+import { SearchRangeFilterComponent } from './search-range-filter.component';
+import { RouteService } from '../../../../shared/services/route.service';
+import { RemoteDataBuildService } from '../../../../core/cache/builders/remote-data-build.service';
+import { SearchConfigurationService } from '../../../search-service/search-configuration.service';
+
+describe('SearchRangeFilterComponent', () => {
+ let comp: SearchRangeFilterComponent;
+ let fixture: ComponentFixture;
+ const minSuffix = '.min';
+ const maxSuffix = '.max';
+ const dateFormats = ['YYYY', 'YYYY-MM', 'YYYY-MM-DD'];
+ const filterName1 = 'test name';
+ const value1 = '2000 - 2012';
+ const value2 = '1992 - 2000';
+ const value3 = '1990 - 1992';
+ const mockFilterConfig: SearchFilterConfig = Object.assign(new SearchFilterConfig(), {
+ name: filterName1,
+ type: FilterType.range,
+ hasFacets: false,
+ isOpenByDefault: false,
+ pageSize: 2,
+ minValue: 200,
+ maxValue: 3000,
+ });
+ const values: FacetValue[] = [
+ {
+ value: value1,
+ count: 52,
+ search: ''
+ }, {
+ value: value2,
+ count: 20,
+ search: ''
+ }, {
+ value: value3,
+ count: 5,
+ search: ''
+ }
+ ];
+
+ const searchLink = '/search';
+ const selectedValues = Observable.of([value1]);
+ let filterService;
+ let searchService;
+ let router;
+ const page = Observable.of(0);
+
+ const mockValues = Observable.of(new RemoteData(false, false, true, null, new PaginatedList(new PageInfo(), values)));
+ beforeEach(async(() => {
+ TestBed.configureTestingModule({
+ imports: [TranslateModule.forRoot(), NoopAnimationsModule, FormsModule],
+ declarations: [SearchRangeFilterComponent],
+ providers: [
+ { provide: SearchService, useValue: new SearchServiceStub(searchLink) },
+ { provide: Router, useValue: new RouterStub() },
+ { provide: FILTER_CONFIG, useValue: mockFilterConfig },
+ { provide: RemoteDataBuildService, useValue: {aggregate: () => Observable.of({})} },
+ { provide: RouteService, useValue: {getQueryParameterValue: () => Observable.of({})} },
+ { provide: SearchConfigurationService, useValue: {
+ searchOptions: Observable.of({}) }
+ },
+ {
+ provide: SearchFilterService, useValue: {
+ getSelectedValuesForFilter: () => selectedValues,
+ isFilterActiveWithValue: (paramName: string, filterValue: string) => true,
+ getPage: (paramName: string) => page,
+ /* tslint:disable:no-empty */
+ incrementPage: (filterName: string) => {
+ },
+ resetPage: (filterName: string) => {
+ }
+ /* tslint:enable:no-empty */
+ }
+ }
+ ],
+ schemas: [NO_ERRORS_SCHEMA]
+ }).overrideComponent(SearchRangeFilterComponent, {
+ set: { changeDetection: ChangeDetectionStrategy.Default }
+ }).compileComponents();
+ }));
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(SearchRangeFilterComponent);
+ comp = fixture.componentInstance; // SearchPageComponent test instance
+ filterService = (comp as any).filterService;
+ searchService = (comp as any).searchService;
+ spyOn(searchService, 'getFacetValuesFor').and.returnValue(mockValues);
+ router = (comp as any).router;
+ fixture.detectChanges();
+ });
+
+ describe('when the getChangeParams method is called wih a value', () => {
+ it('should return the selectedValue list with the new parameter value', () => {
+ const result$ = comp.getChangeParams(value3);
+ result$.subscribe((result) => {
+ expect(result[mockFilterConfig.paramName + minSuffix]).toEqual(['1990']);
+ expect(result[mockFilterConfig.paramName + maxSuffix]).toEqual(['1992']);
+ });
+ });
+ });
+
+ describe('when the onSubmit method is called with data', () => {
+ const searchUrl = '/search/path';
+ // const data = { [mockFilterConfig.paramName + minSuffix]: '1900', [mockFilterConfig.paramName + maxSuffix]: '1950' };
+ beforeEach(() => {
+ comp.range = [1900, 1950];
+ spyOn(comp, 'getSearchLink').and.returnValue(searchUrl);
+ comp.onSubmit();
+ });
+
+ it('should call navigate on the router with the right searchlink and parameters', () => {
+ expect(router.navigate).toHaveBeenCalledWith([searchUrl], {
+ queryParams: {
+ [mockFilterConfig.paramName + minSuffix]: [1900],
+ [mockFilterConfig.paramName + maxSuffix]: [1950]
+ },
+ queryParamsHandling: 'merge'
+ });
+ });
+ });
+});
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..61e07b9b53
--- /dev/null
+++ b/src/app/+search-page/search-filters/search-filter/search-range-filter/search-range-filter.component.ts
@@ -0,0 +1,148 @@
+import { isPlatformBrowser } from '@angular/common';
+import { Component, Inject, OnDestroy, OnInit, PLATFORM_ID } from '@angular/core';
+import { RemoteDataBuildService } from '../../../../core/cache/builders/remote-data-build.service';
+import { FilterType } from '../../../search-service/filter-type.model';
+import { renderFacetFor } from '../search-filter-type-decorator';
+import {
+ facetLoad,
+ SearchFacetFilterComponent
+} from '../search-facet-filter/search-facet-filter.component';
+import { SearchFilterConfig } from '../../../search-service/search-filter-config.model';
+import { FILTER_CONFIG, SearchFilterService } from '../search-filter.service';
+import { SearchService } from '../../../search-service/search.service';
+import { Router } from '@angular/router';
+import * as moment from 'moment';
+import { Observable } from 'rxjs/Observable';
+import { RouteService } from '../../../../shared/services/route.service';
+import { hasValue } from '../../../../shared/empty.util';
+import { Subscription } from 'rxjs/Subscription';
+import { SearchConfigurationService } from '../../../search-service/search-configuration.service';
+
+/**
+ * This component renders a simple item page.
+ * The route parameter 'id' is used to request the item it represents.
+ * All fields of the item that should be displayed, are defined in its template.
+ */
+const minSuffix = '.min';
+const maxSuffix = '.max';
+const dateFormats = ['YYYY', 'YYYY-MM', 'YYYY-MM-DD'];
+const rangeDelimiter = '-';
+
+@Component({
+ selector: 'ds-search-range-filter',
+ styleUrls: ['./search-range-filter.component.scss'],
+ templateUrl: './search-range-filter.component.html',
+ animations: [facetLoad]
+})
+
+/**
+ * Component that represents a range facet for a specific filter configuration
+ */
+@renderFacetFor(FilterType.range)
+export class SearchRangeFilterComponent extends SearchFacetFilterComponent implements OnInit, OnDestroy {
+ /**
+ * Fallback minimum for the range
+ */
+ min = 1950;
+
+ /**
+ * Fallback maximum for the range
+ */
+ max = 2018;
+
+ /**
+ * The current range of the filter
+ */
+ range;
+
+ /**
+ * Subscription to unsubscribe from
+ */
+ sub: Subscription;
+
+ constructor(protected searchService: SearchService,
+ protected filterService: SearchFilterService,
+ protected searchConfigService: SearchConfigurationService,
+ protected router: Router,
+ protected rdbs: RemoteDataBuildService,
+ @Inject(FILTER_CONFIG) public filterConfig: SearchFilterConfig,
+ @Inject(PLATFORM_ID) private platformId: any,
+ private route: RouteService) {
+ super(searchService, filterService, searchConfigService, rdbs, router, filterConfig);
+
+ }
+
+ /**
+ * Initialize with the min and max values as configured in the filter configuration
+ * Set the initial values of the range
+ */
+ ngOnInit(): void {
+ super.ngOnInit();
+ this.min = moment(this.filterConfig.minValue, dateFormats).year() || this.min;
+ this.max = moment(this.filterConfig.maxValue, dateFormats).year() || this.max;
+ const iniMin = this.route.getQueryParameterValue(this.filterConfig.paramName + minSuffix).startWith(undefined);
+ const iniMax = this.route.getQueryParameterValue(this.filterConfig.paramName + maxSuffix).startWith(undefined);
+ this.sub = Observable.combineLatest(iniMin, iniMax, (min, max) => {
+ const minimum = hasValue(min) ? min : this.min;
+ const maximum = hasValue(max) ? max : this.max;
+ return [minimum, maximum]
+ }).subscribe((minmax) => this.range = minmax);
+ }
+
+ /**
+ * Calculates the parameters that should change if a given values for this range filter would be changed
+ * @param {string} value The values that are changed for this filter
+ * @returns {Observable} The changed filter parameters
+ */
+ getChangeParams(value: string) {
+ const parts = value.split(rangeDelimiter);
+ const min = parts.length > 1 ? parts[0].trim() : value;
+ const max = parts.length > 1 ? parts[1].trim() : value;
+ return Observable.of(
+ {
+ [this.filterConfig.paramName + minSuffix]: [min],
+ [this.filterConfig.paramName + maxSuffix]: [max],
+ page: 1
+ });
+ }
+
+ /**
+ * Submits new custom range values to the range filter from the widget
+ */
+ onSubmit() {
+ const newMin = this.range[0] !== this.min ? [this.range[0]] : null;
+ const newMax = this.range[1] !== this.max ? [this.range[1]] : null;
+ this.router.navigate([this.getSearchLink()], {
+ queryParams:
+ {
+ [this.filterConfig.paramName + minSuffix]: newMin,
+ [this.filterConfig.paramName + maxSuffix]: newMax
+ },
+ queryParamsHandling: 'merge'
+ });
+ this.filter = '';
+ }
+
+ /**
+ * TODO when upgrading nouislider, verify that this check is still needed.
+ * Prevents AoT bug
+ * @returns {boolean} True if the platformId is a platform browser
+ */
+ shouldShowSlider(): boolean {
+ return isPlatformBrowser(this.platformId);
+ }
+
+ /**
+ * Unsubscribe from all subscriptions
+ */
+ ngOnDestroy() {
+ super.ngOnDestroy();
+ if (hasValue(this.sub)) {
+ this.sub.unsubscribe();
+ }
+ }
+
+ out(call) {
+ console.log(call);
+ }
+}
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..fcc2393b93
--- /dev/null
+++ b/src/app/+search-page/search-filters/search-filter/search-text-filter/search-text-filter.component.html
@@ -0,0 +1,45 @@
+
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-text-filter/search-text-filter.component.scss
similarity index 66%
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-text-filter/search-text-filter.component.scss
index 595b2aefb8..33e354f2d8 100644
--- 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-text-filter/search-text-filter.component.scss
@@ -2,17 +2,22 @@
@import '../../../../../styles/mixins.scss';
.filters {
- margin-top: $spacer/2;
- margin-bottom: $spacer/2;
a {
color: $body-color;
- &:hover {
+ &:hover, &focus {
text-decoration: none;
}
+ span.badge {
+ vertical-align: text-top;
+ }
}
.toggle-more-filters a {
color: $link-color;
text-decoration: underline;
cursor: pointer;
}
-}
\ No newline at end of file
+}
+::ng-deep em {
+ font-weight: bold;
+ font-style: normal;
+}
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..9e603184e8
--- /dev/null
+++ b/src/app/+search-page/search-filters/search-filter/search-text-filter/search-text-filter.component.ts
@@ -0,0 +1,29 @@
+import { animate, state, style, transition, trigger } from '@angular/animations';
+import { Component, HostBinding, OnInit } from '@angular/core';
+import { Observable } from 'rxjs/Observable';
+import { FilterType } from '../../../search-service/filter-type.model';
+import {
+ facetLoad,
+ SearchFacetFilterComponent
+} from '../search-facet-filter/search-facet-filter.component';
+import { renderFacetFor } from '../search-filter-type-decorator';
+
+/**
+ * This component renders a simple item page.
+ * The route parameter 'id' is used to request the item it represents.
+ * All fields of the item that should be displayed, are defined in its template.
+ */
+
+@Component({
+ selector: 'ds-search-text-filter',
+ styleUrls: ['./search-text-filter.component.scss'],
+ templateUrl: './search-text-filter.component.html',
+ animations: [facetLoad]
+})
+
+/**
+ * Component that represents a text facet for a specific filter configuration
+ */
+@renderFacetFor(FilterType.text)
+export class SearchTextFilterComponent extends SearchFacetFilterComponent implements OnInit {
+}
diff --git a/src/app/+search-page/search-filters/search-filters.component.html b/src/app/+search-page/search-filters/search-filters.component.html
index 566450b7f5..0522c1fba0 100644
--- a/src/app/+search-page/search-filters/search-filters.component.html
+++ b/src/app/+search-page/search-filters/search-filters.component.html
@@ -1,7 +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.spec.ts b/src/app/+search-page/search-filters/search-filters.component.spec.ts
index 64c2ea5332..7f0d4ad748 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
@@ -8,6 +8,7 @@ import { SearchFilterService } from './search-filter/search-filter.service';
import { SearchFiltersComponent } from './search-filters.component';
import { SearchService } from '../search-service/search.service';
import { Observable } from 'rxjs/Observable';
+import { SearchConfigurationService } from '../search-service/search-configuration.service';
describe('SearchFiltersComponent', () => {
let comp: SearchFiltersComponent;
@@ -23,8 +24,14 @@ describe('SearchFiltersComponent', () => {
}
/* tslint:enable:no-empty */
};
- const searchFilterServiceStub = jasmine.createSpyObj('SearchFilterService', {
- getCurrentFilters: Observable.of({})
+
+ const searchFiltersStub = {
+ getSelectedValuesForFilter: (filter) =>
+ []
+ };
+
+ const searchConfigServiceStub = jasmine.createSpyObj('SearchConfigurationService', {
+ getCurrentFrontendFilters: Observable.of({})
});
beforeEach(async(() => {
@@ -33,7 +40,8 @@ describe('SearchFiltersComponent', () => {
declarations: [SearchFiltersComponent],
providers: [
{ provide: SearchService, useValue: searchServiceStub },
- { provide: SearchFilterService, useValue: searchFilterServiceStub },
+ { provide: SearchConfigurationService, useValue: searchConfigServiceStub },
+ { provide: SearchFilterService, useValue: searchFiltersStub },
],
schemas: [NO_ERRORS_SCHEMA]
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..684f4d94fe 100644
--- a/src/app/+search-page/search-filters/search-filters.component.ts
+++ b/src/app/+search-page/search-filters/search-filters.component.ts
@@ -3,29 +3,74 @@ import { SearchService } from '../search-service/search.service';
import { RemoteData } from '../../core/data/remote-data';
import { SearchFilterConfig } from '../search-service/search-filter-config.model';
import { Observable } from 'rxjs/Observable';
+import { SearchConfigurationService } from '../search-service/search-configuration.service';
+import { isNotEmpty } from '../../shared/empty.util';
import { SearchFilterService } from './search-filter/search-filter.service';
-/**
- * This component renders a simple item page.
- * The route parameter 'id' is used to request the item it represents.
- * All fields of the item that should be displayed, are defined in its template.
- */
-
@Component({
selector: 'ds-search-filters',
styleUrls: ['./search-filters.component.scss'],
templateUrl: './search-filters.component.html',
})
+/**
+ * This component represents the part of the search sidebar that contains filters.
+ */
export class SearchFiltersComponent {
+ /**
+ * An observable containing configuration about which filters are shown and how they are shown
+ */
filters: Observable
>;
+
+ /**
+ * List of all filters that are currently active with their value set to null.
+ * Used to reset all filters at once
+ */
clearParams;
- constructor(private searchService: SearchService, private filterService: SearchFilterService) {
- this.filters = searchService.getConfig();
- this.clearParams = filterService.getCurrentFilters().map((filters) => {Object.keys(filters).forEach((f) => filters[f] = null); return filters;});
+
+ /**
+ * Initialize instance variables
+ * @param {SearchService} searchService
+ * @param {SearchConfigurationService} searchConfigService
+ * @param {SearchFilterService} filterService
+ */
+ constructor(private searchService: SearchService, private searchConfigService: SearchConfigurationService, private filterService: SearchFilterService) {
+ this.filters = searchService.getConfig().first((RD) => !RD.isLoading);
+ this.clearParams = searchConfigService.getCurrentFrontendFilters().map((filters) => {
+ Object.keys(filters).forEach((f) => filters[f] = null);
+ return filters;
+ });
}
+ /**
+ * @returns {string} The base path to the search page
+ */
getSearchLink() {
return this.searchService.getSearchLink();
}
+
+ /**
+ * Check if a given filter is supposed to be shown or not
+ * @param {SearchFilterConfig} filter The filter to check for
+ * @returns {Observable} Emits true whenever a given filter config should be shown
+ */
+ isActive(filter: SearchFilterConfig): Observable {
+ // console.log(filter.name);
+ return this.filterService.getSelectedValuesForFilter(filter)
+ .flatMap((isActive) => {
+ if (isNotEmpty(isActive)) {
+ return Observable.of(true);
+ } else {
+ return this.searchConfigService.searchOptions
+ .switchMap((options) => {
+ return this.searchService.getFacetValuesFor(filter, 1, options)
+ .filter((RD) => !RD.isLoading)
+ .map((valuesRD) => {
+ return valuesRD.payload.totalElements > 0
+ })
+ }
+ )
+ }
+ }).startWith(true);
+ }
}
diff --git a/src/app/+search-page/search-labels/search-labels.component.html b/src/app/+search-page/search-labels/search-labels.component.html
new file mode 100644
index 0000000000..61a5618dad
--- /dev/null
+++ b/src/app/+search-page/search-labels/search-labels.component.html
@@ -0,0 +1,13 @@
+
diff --git a/src/app/+search-page/search-labels/search-labels.component.scss b/src/app/+search-page/search-labels/search-labels.component.scss
new file mode 100644
index 0000000000..c48cd57304
--- /dev/null
+++ b/src/app/+search-page/search-labels/search-labels.component.scss
@@ -0,0 +1,3 @@
+:host {
+ line-height: 1;
+}
\ No newline at end of file
diff --git a/src/app/+search-page/search-labels/search-labels.component.spec.ts b/src/app/+search-page/search-labels/search-labels.component.spec.ts
new file mode 100644
index 0000000000..bf512ed5db
--- /dev/null
+++ b/src/app/+search-page/search-labels/search-labels.component.spec.ts
@@ -0,0 +1,68 @@
+import { SearchLabelsComponent } from './search-labels.component';
+import { async, ComponentFixture, TestBed } from '@angular/core/testing';
+import { NoopAnimationsModule } from '@angular/platform-browser/animations';
+import { TranslateModule } from '@ngx-translate/core';
+import { SearchService } from '../search-service/search.service';
+import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core';
+import { FormsModule } from '@angular/forms';
+import { SearchServiceStub } from '../../shared/testing/search-service-stub';
+import { Observable } from 'rxjs/Observable';
+import { Params } from '@angular/router';
+import { ObjectKeysPipe } from '../../shared/utils/object-keys-pipe';
+import { SearchConfigurationService } from '../search-service/search-configuration.service';
+
+describe('SearchLabelsComponent', () => {
+ let comp: SearchLabelsComponent;
+ let fixture: ComponentFixture;
+
+ const searchLink = '/search';
+ let searchService;
+
+ const field1 = 'author';
+ const field2 = 'subject';
+ const value1 = 'TestAuthor';
+ const value2 = 'TestSubject';
+ const filter1 = [field1, value1];
+ const filter2 = [field2, value2];
+ const mockFilters = [
+ filter1,
+ filter2
+ ];
+
+ beforeEach(async(() => {
+ TestBed.configureTestingModule({
+ imports: [TranslateModule.forRoot(), NoopAnimationsModule, FormsModule],
+ declarations: [SearchLabelsComponent, ObjectKeysPipe],
+ providers: [
+ { provide: SearchService, useValue: new SearchServiceStub(searchLink) },
+ { provide: SearchConfigurationService, useValue: {getCurrentFrontendFilters : () => Observable.of({})} }
+ ],
+ schemas: [NO_ERRORS_SCHEMA]
+ }).overrideComponent(SearchLabelsComponent, {
+ set: { changeDetection: ChangeDetectionStrategy.Default }
+ }).compileComponents();
+ }));
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(SearchLabelsComponent);
+ comp = fixture.componentInstance;
+ searchService = (comp as any).searchService;
+ (comp as any).appliedFilters = Observable.of(mockFilters);
+ fixture.detectChanges();
+ });
+
+ describe('when getRemoveParams is called', () => {
+ let obs: Observable;
+
+ beforeEach(() => {
+ obs = comp.getRemoveParams(filter1[0], filter1[1]);
+ });
+
+ it('should return all params but the provided filter', () => {
+ obs.subscribe((params) => {
+ // Should contain only filter2 and page: length == 2
+ expect(Object.keys(params).length).toBe(2);
+ });
+ })
+ });
+});
diff --git a/src/app/+search-page/search-labels/search-labels.component.ts b/src/app/+search-page/search-labels/search-labels.component.ts
new file mode 100644
index 0000000000..61482f8d8a
--- /dev/null
+++ b/src/app/+search-page/search-labels/search-labels.component.ts
@@ -0,0 +1,56 @@
+import { Component } from '@angular/core';
+import { SearchService } from '../search-service/search.service';
+import { Observable } from 'rxjs/Observable';
+import { Params } from '@angular/router';
+import { map } from 'rxjs/operators';
+import { hasValue, isNotEmpty } from '../../shared/empty.util';
+import { SearchConfigurationService } from '../search-service/search-configuration.service';
+
+@Component({
+ selector: 'ds-search-labels',
+ styleUrls: ['./search-labels.component.scss'],
+ templateUrl: './search-labels.component.html',
+})
+
+/**
+ * Component that represents the labels containing the currently active filters
+ */
+export class SearchLabelsComponent {
+ /**
+ * Emits the currently active filters
+ */
+ appliedFilters: Observable;
+
+ /**
+ * Initialize the instance variable
+ */
+ constructor(private searchService: SearchService, private searchConfigService: SearchConfigurationService) {
+ this.appliedFilters = this.searchConfigService.getCurrentFrontendFilters();
+ }
+
+ /**
+ * Calculates the parameters that should change if a given value for the given filter would be removed from the active filters
+ * @param {string} filterField The filter field parameter name from which the value should be removed
+ * @param {string} filterValue The value that is removed for this given filter field
+ * @returns {Observable} The changed filter parameters
+ */
+ getRemoveParams(filterField: string, filterValue: string): Observable {
+ return this.appliedFilters.pipe(
+ map((filters) => {
+ const field: string = Object.keys(filters).find((f) => f === filterField);
+ const newValues = hasValue(filters[field]) ? filters[field].filter((v) => v !== filterValue) : null;
+ return {
+ [field]: isNotEmpty(newValues) ? newValues : null,
+ page: 1
+ };
+ })
+ )
+ }
+
+ /**
+ * @returns {string} The base path to the search page
+ */
+ getSearchLink() {
+ return this.searchService.getSearchLink();
+ }
+}
diff --git a/src/app/+search-page/search-options.model.spec.ts b/src/app/+search-page/search-options.model.spec.ts
new file mode 100644
index 0000000000..fc4c9278d8
--- /dev/null
+++ b/src/app/+search-page/search-options.model.spec.ts
@@ -0,0 +1,32 @@
+import 'rxjs/add/observable/of';
+import { PaginatedSearchOptions } from './paginated-search-options.model';
+import { SearchOptions } from './search-options.model';
+
+describe('SearchOptions', () => {
+ let options: PaginatedSearchOptions;
+ const filters = { 'f.test': ['value'], 'f.example': ['another value', 'second value'] };
+ const query = 'search query';
+ const scope = '0fde1ecb-82cc-425a-b600-ac3576d76b47';
+ const baseUrl = 'www.rest.com';
+ beforeEach(() => {
+ options = new SearchOptions();
+ options.filters = filters;
+ options.query = query;
+ options.scope = scope;
+ });
+
+ describe('when toRestUrl is called', () => {
+
+ it('should generate a string with all parameters that are present', () => {
+ const outcome = options.toRestUrl(baseUrl);
+ expect(outcome).toEqual('www.rest.com?' +
+ 'query=search query&' +
+ 'scope=0fde1ecb-82cc-425a-b600-ac3576d76b47&' +
+ 'f.test=value,query&' +
+ 'f.example=another value,query&' +
+ 'f.example=second value,query'
+ );
+ });
+
+ });
+});
diff --git a/src/app/+search-page/search-options.model.ts b/src/app/+search-page/search-options.model.ts
index 4b4987a096..6ac606f5cc 100644
--- a/src/app/+search-page/search-options.model.ts
+++ b/src/app/+search-page/search-options.model.ts
@@ -2,17 +2,20 @@ import { isNotEmpty } from '../shared/empty.util';
import { URLCombiner } from '../core/url-combiner/url-combiner';
import 'core-js/library/fn/object/entries';
-export enum ViewMode {
- List = 'list',
- Grid = 'grid'
-}
-
+/**
+ * This model class represents all parameters needed to request information about a certain search request
+ */
export class SearchOptions {
- view?: ViewMode = ViewMode.List;
scope?: string;
query?: string;
filters?: any;
+ /**
+ * Method to generate the URL that can be used request information about a search request
+ * @param {string} url The URL to the REST endpoint
+ * @param {string[]} args A list of query arguments that should be included in the URL
+ * @returns {string} URL with all search options and passed arguments as query parameters
+ */
toRestUrl(url: string, args: string[] = []): string {
if (isNotEmpty(this.query)) {
@@ -24,7 +27,7 @@ export class SearchOptions {
}
if (isNotEmpty(this.filters)) {
Object.entries(this.filters).forEach(([key, values]) => {
- values.forEach((value) => args.push(`${key}=${value},equals`));
+ values.forEach((value) => args.push(`${key}=${value},query`));
});
}
if (isNotEmpty(args)) {
diff --git a/src/app/+search-page/search-page.component.html b/src/app/+search-page/search-page.component.html
index e8dee94139..653f5e8cd4 100644
--- a/src/app/+search-page/search-page.component.html
+++ b/src/app/+search-page/search-page.component.html
@@ -1,39 +1,40 @@
-
+
+ [resultCount]="(resultsRD$ | async)?.payload.totalElements">
diff --git a/src/app/+search-page/search-page.component.spec.ts b/src/app/+search-page/search-page.component.spec.ts
index 96820e8a0b..57b8caaddc 100644
--- a/src/app/+search-page/search-page.component.spec.ts
+++ b/src/app/+search-page/search-page.component.spec.ts
@@ -19,6 +19,8 @@ import { By } from '@angular/platform-browser';
import { NgbCollapseModule } from '@ng-bootstrap/ng-bootstrap';
import { SearchSidebarService } from './search-sidebar/search-sidebar.service';
import { SearchFilterService } from './search-filters/search-filter/search-filter.service';
+import { SearchConfigurationService } from './search-service/search-configuration.service';
+import { RemoteData } from '../core/data/remote-data';
describe('SearchPageComponent', () => {
let comp: SearchPageComponent;
@@ -35,19 +37,20 @@ describe('SearchPageComponent', () => {
pagination.currentPage = 1;
pagination.pageSize = 10;
const sort: SortOptions = new SortOptions('score', SortDirection.DESC);
- const mockResults = Observable.of(['test', 'data']);
+ const mockResults = Observable.of(new RemoteData(false, false, true, null,['test', 'data']));
const searchServiceStub = jasmine.createSpyObj('SearchService', {
search: mockResults,
- getSearchLink: '/search'
+ getSearchLink: '/search',
+ getScopes: Observable.of(['test-scope'])
});
const queryParam = 'test query';
const scopeParam = '7669c72a-3f2a-451f-a3b9-9210e7a4c02f';
const paginatedSearchOptions = {
- query: queryParam,
- scope: scopeParam,
- pagination,
- sort
- };
+ query: queryParam,
+ scope: scopeParam,
+ pagination,
+ sort
+ };
const activatedRouteStub = {
queryParams: Observable.of({
query: queryParam,
@@ -76,11 +79,11 @@ describe('SearchPageComponent', () => {
},
{
provide: HostWindowService, useValue: jasmine.createSpyObj('hostWindowService',
- {
- isXs: Observable.of(true),
- isSm: Observable.of(false),
- isXsOrSm: Observable.of(true)
- })
+ {
+ isXs: Observable.of(true),
+ isSm: Observable.of(false),
+ isXsOrSm: Observable.of(true)
+ })
},
{
provide: SearchSidebarService,
@@ -88,16 +91,20 @@ describe('SearchPageComponent', () => {
},
{
provide: SearchFilterService,
- useValue: jasmine.createSpyObj('SearchFilterService', {
- getPaginatedSearchOptions: hot('a', {
+ useValue: {}
+ }, {
+ provide: SearchConfigurationService,
+ useValue: {
+ paginatedSearchOptions: hot('a', {
a: paginatedSearchOptions
- })
- })
+ }),
+ getCurrentScope: (a) => Observable.of('test-id')
+ }
},
],
schemas: [NO_ERRORS_SCHEMA]
}).overrideComponent(SearchPageComponent, {
- set: { changeDetection: ChangeDetectionStrategy.Default }
+ set: { changeDetection: ChangeDetectionStrategy.Default }
}).compileComponents();
}));
@@ -171,4 +178,5 @@ describe('SearchPageComponent', () => {
});
});
-});
+})
+;
diff --git a/src/app/+search-page/search-page.component.ts b/src/app/+search-page/search-page.component.ts
index 63e72960d8..893395719a 100644
--- a/src/app/+search-page/search-page.component.ts
+++ b/src/app/+search-page/search-page.component.ts
@@ -1,11 +1,8 @@
-import { ChangeDetectionStrategy, Component, OnDestroy, OnInit } from '@angular/core';
+import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
import { Observable } from 'rxjs/Observable';
-import { flatMap, } from 'rxjs/operators';
-import { SortDirection, SortOptions } from '../core/cache/models/sort-options.model';
-import { CommunityDataService } from '../core/data/community-data.service';
+import { flatMap, switchMap, } from 'rxjs/operators';
import { PaginatedList } from '../core/data/paginated-list';
import { RemoteData } from '../core/data/remote-data';
-import { Community } from '../core/shared/community.model';
import { DSpaceObject } from '../core/shared/dspace-object.model';
import { pushInOut } from '../shared/animations/push';
import { HostWindowService } from '../shared/host-window.service';
@@ -14,6 +11,10 @@ import { SearchFilterService } from './search-filters/search-filter/search-filte
import { SearchResult } from './search-result.model';
import { SearchService } from './search-service/search.service';
import { SearchSidebarService } from './search-sidebar/search-sidebar.service';
+import { Subscription } from 'rxjs/Subscription';
+import { hasValue } from '../shared/empty.util';
+import { BehaviorSubject } from 'rxjs/BehaviorSubject';
+import { SearchConfigurationService } from './search-service/search-configuration.service';
/**
* This component renders a simple item page.
@@ -28,54 +29,99 @@ import { SearchSidebarService } from './search-sidebar/search-sidebar.service';
changeDetection: ChangeDetectionStrategy.OnPush,
animations: [pushInOut]
})
+
+/**
+ * This component represents the whole search page
+ */
export class SearchPageComponent implements OnInit {
- resultsRD$: Observable
>>>;
+ /**
+ * The current search results
+ */
+ resultsRD$: BehaviorSubject>>> = new BehaviorSubject(null);
+
+ /**
+ * The current paginated search options
+ */
searchOptions$: Observable;
- sortConfig: SortOptions;
- scopeListRD$: Observable>>;
+
+ /**
+ * The current relevant scopes
+ */
+ scopeListRD$: Observable;
+
+ /**
+ * Emits true if were on a small screen
+ */
isXsOrSm$: Observable;
- pageSize;
- pageSizeOptions;
- defaults = {
- pagination: {
- id: 'search-results-pagination',
- pageSize: 10
- },
- sort: new SortOptions('score', SortDirection.DESC),
- query: '',
- scope: ''
- };
+
+ /**
+ * Subscription to unsubscribe from
+ */
+ sub: Subscription;
constructor(private service: SearchService,
- private communityService: CommunityDataService,
private sidebarService: SearchSidebarService,
private windowService: HostWindowService,
- private filterService: SearchFilterService) {
+ private filterService: SearchFilterService,
+ private searchConfigService: SearchConfigurationService) {
this.isXsOrSm$ = this.windowService.isXsOrSm();
- this.scopeListRD$ = communityService.findAll();
}
+ /**
+ * Listening to changes in the paginated search options
+ * If something changes, update the search results
+ *
+ * Listen to changes in the scope
+ * If something changes, update the list of scopes for the dropdown
+ */
ngOnInit(): void {
- this.searchOptions$ = this.filterService.getPaginatedSearchOptions(this.defaults);
- this.resultsRD$ = this.searchOptions$.pipe(
- flatMap((searchOptions) => this.service.search(searchOptions))
+ this.searchOptions$ = this.searchConfigService.paginatedSearchOptions;
+ this.sub = this.searchOptions$
+ .switchMap((options) => this.service.search(options).filter((rd) => !rd.isLoading).first())
+ .subscribe((results) => {
+ this.resultsRD$.next(results);
+ });
+ this.scopeListRD$ = this.searchConfigService.getCurrentScope('').pipe(
+ switchMap((scopeId) => this.service.getScopes(scopeId))
);
}
+ /**
+ * Set the sidebar to a collapsed state
+ */
public closeSidebar(): void {
this.sidebarService.collapse()
}
+ /**
+ * Set the sidebar to an expanded state
+ */
public openSidebar(): void {
this.sidebarService.expand();
}
+ /**
+ * Check if the sidebar is collapsed
+ * @returns {Observable} emits true if the sidebar is currently collapsed, false if it is expanded
+ */
public isSidebarCollapsed(): Observable {
return this.sidebarService.isCollapsed;
}
+ /**
+ * @returns {string} The base path to the search page
+ */
public getSearchLink(): string {
return this.service.getSearchLink();
}
+
+ /**
+ * Unsubscribe from the subscription
+ */
+ ngOnDestroy(): void {
+ if (hasValue(this.sub)) {
+ this.sub.unsubscribe();
+ }
+ }
}
diff --git a/src/app/+search-page/search-page.module.ts b/src/app/+search-page/search-page.module.ts
index 1468fe532e..0c8a4ee306 100644
--- a/src/app/+search-page/search-page.module.ts
+++ b/src/app/+search-page/search-page.module.ts
@@ -21,6 +21,13 @@ import { SearchFiltersComponent } from './search-filters/search-filters.componen
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';
+import { SearchLabelsComponent } from './search-labels/search-labels.component';
+import { SearchRangeFilterComponent } from './search-filters/search-filter/search-range-filter/search-range-filter.component';
+import { SearchTextFilterComponent } from './search-filters/search-filter/search-text-filter/search-text-filter.component';
+import { SearchFacetFilterWrapperComponent } from './search-filters/search-filter/search-facet-filter-wrapper/search-facet-filter-wrapper.component';
+import { SearchBooleanFilterComponent } from './search-filters/search-filter/search-boolean-filter/search-boolean-filter.component';
+import { SearchHierarchyFilterComponent } from './search-filters/search-filter/search-hierarchy-filter/search-hierarchy-filter.component';
+import { SearchConfigurationService } from './search-service/search-configuration.service';
const effects = [
SearchSidebarEffects
@@ -48,12 +55,20 @@ const effects = [
CommunitySearchResultListElementComponent,
SearchFiltersComponent,
SearchFilterComponent,
- SearchFacetFilterComponent
+ SearchFacetFilterComponent,
+ SearchLabelsComponent,
+ SearchFacetFilterComponent,
+ SearchFacetFilterWrapperComponent,
+ SearchRangeFilterComponent,
+ SearchTextFilterComponent,
+ SearchHierarchyFilterComponent,
+ SearchBooleanFilterComponent,
],
providers: [
SearchService,
SearchSidebarService,
- SearchFilterService
+ SearchFilterService,
+ SearchConfigurationService
],
entryComponents: [
ItemSearchResultListElementComponent,
@@ -62,7 +77,16 @@ const effects = [
ItemSearchResultGridElementComponent,
CollectionSearchResultGridElementComponent,
CommunitySearchResultGridElementComponent,
+ SearchFacetFilterComponent,
+ SearchRangeFilterComponent,
+ SearchTextFilterComponent,
+ SearchHierarchyFilterComponent,
+ SearchBooleanFilterComponent,
]
})
+
+/**
+ * This module handles all components and pipes that are necessary for the search page
+ */
export class SearchPageModule {
}
diff --git a/src/app/+search-page/search-result.model.ts b/src/app/+search-page/search-result.model.ts
index cc2bd8cd58..00b1c62a99 100644
--- a/src/app/+search-page/search-result.model.ts
+++ b/src/app/+search-page/search-result.model.ts
@@ -2,9 +2,18 @@ import { DSpaceObject } from '../core/shared/dspace-object.model';
import { Metadatum } from '../core/shared/metadatum.model';
import { ListableObject } from '../shared/object-collection/shared/listable-object.model';
+/**
+ * Represents a search result object of a certain () DSpaceObject
+ */
export class SearchResult implements ListableObject {
-
+ /**
+ * The DSpaceObject that was found
+ */
dspaceObject: T;
+
+ /**
+ * The metadata that was used to find this item, hithighlighted
+ */
hitHighlights: Metadatum[];
}
diff --git a/src/app/+search-page/search-results/search-results.component.ts b/src/app/+search-page/search-results/search-results.component.ts
index 14ccb5d541..6399243f92 100644
--- a/src/app/+search-page/search-results/search-results.component.ts
+++ b/src/app/+search-page/search-results/search-results.component.ts
@@ -2,16 +2,11 @@ import { Component, Input } from '@angular/core';
import { RemoteData } from '../../core/data/remote-data';
import { DSpaceObject } from '../../core/shared/dspace-object.model';
import { fadeIn, fadeInOut } from '../../shared/animations/fade';
-import { SearchOptions, ViewMode } from '../search-options.model';
-import { SortOptions } from '../../core/cache/models/sort-options.model';
+import { SearchOptions } from '../search-options.model';
import { SearchResult } from '../search-result.model';
import { PaginatedList } from '../../core/data/paginated-list';
+import { ViewMode } from '../../core/shared/view-mode.model';
-/**
- * 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-results',
templateUrl: './search-results.component.html',
@@ -20,9 +15,24 @@ import { PaginatedList } from '../../core/data/paginated-list';
fadeInOut
]
})
+
+/**
+ * Component that represents all results from a search
+ */
export class SearchResultsComponent {
+ /**
+ * The actual search result objects
+ */
@Input() searchResults: RemoteData>>;
+
+ /**
+ * The current configuration of the search
+ */
@Input() searchConfig: SearchOptions;
- @Input() sortConfig: SortOptions;
+
+ /**
+ * The current view mode for the search results
+ */
@Input() viewMode: ViewMode;
+
}
diff --git a/src/app/+search-page/search-service/facet-value.model.ts b/src/app/+search-page/search-service/facet-value.model.ts
index 06eb50bc6b..a597528d50 100644
--- a/src/app/+search-page/search-service/facet-value.model.ts
+++ b/src/app/+search-page/search-service/facet-value.model.ts
@@ -1,13 +1,25 @@
import { autoserialize, autoserializeAs } from 'cerialize';
+/**
+ * Class representing possible values for a certain filter
+ */
export class FacetValue {
+ /**
+ * The display value of the facet value
+ */
@autoserializeAs(String, 'label')
value: string;
+ /**
+ * The number of results this facet value would have if selected
+ */
@autoserialize
count: number;
+ /**
+ * The REST url to add this filter value
+ */
@autoserialize
search: string;
}
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..d9b9629347 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,24 @@
+/**
+ * Enumeration containing all possible types for filters
+ */
export enum FilterType {
- text,
- date,
- hierarchical,
- standard
+ /**
+ * Represents simple text facets
+ */
+ text = 'text',
+
+ /**
+ * Represents date facets
+ */
+ range = 'date',
+
+ /**
+ * Represents hierarchically structured facets
+ */
+ hierarchy = 'hierarchical',
+
+ /**
+ * Represents binary facets
+ */
+ boolean = 'standard'
}
diff --git a/src/app/+search-page/search-service/search-configuration.service.spec.ts b/src/app/+search-page/search-service/search-configuration.service.spec.ts
new file mode 100644
index 0000000000..af1b19f06a
--- /dev/null
+++ b/src/app/+search-page/search-service/search-configuration.service.spec.ts
@@ -0,0 +1,133 @@
+import { SearchConfigurationService } from './search-configuration.service';
+import { ActivatedRouteStub } from '../../shared/testing/active-router-stub';
+import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model';
+import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model';
+import { PaginatedSearchOptions } from '../paginated-search-options.model';
+import { Observable } from 'rxjs/Observable';
+
+describe('SearchConfigurationService', () => {
+ let service: SearchConfigurationService;
+ const value1 = 'random value';
+ const value2 = 'another value';
+ const prefixFilter = {
+ 'f.author': ['another value'],
+ 'f.date.min': ['2013'],
+ 'f.date.max': ['2018']
+ };
+ const defaults = Object.assign(new PaginatedSearchOptions(), {
+ pagination: Object.assign(new PaginationComponentOptions(), { currentPage: 1, pageSize: 20 }),
+ sort: new SortOptions('score', SortDirection.DESC),
+ query: '',
+ scope: ''
+ });
+ const backendFilters = { 'f.author': ['another value'], 'f.date': ['[2013 TO 2018]'] };
+
+ const spy = jasmine.createSpyObj('RouteService', {
+ getQueryParameterValue: Observable.of([value1, value2]),
+ getQueryParamsWithPrefix: Observable.of(prefixFilter)
+ });
+
+ const activatedRoute: any = new ActivatedRouteStub();
+
+ beforeEach(() => {
+ service = new SearchConfigurationService(spy, activatedRoute);
+ });
+
+ describe('when the scope is called', () => {
+ beforeEach(() => {
+ service.getCurrentScope('');
+ });
+ it('should call getQueryParameterValue on the routeService with parameter name \'scope\'', () => {
+ expect((service as any).routeService.getQueryParameterValue).toHaveBeenCalledWith('scope');
+ });
+ });
+
+ describe('when getCurrentQuery is called', () => {
+ beforeEach(() => {
+ service.getCurrentQuery('');
+ });
+ it('should call getQueryParameterValue on the routeService with parameter name \'query\'', () => {
+ expect((service as any).routeService.getQueryParameterValue).toHaveBeenCalledWith('query');
+ });
+ });
+
+ describe('when getCurrentFrontendFilters is called', () => {
+ beforeEach(() => {
+ service.getCurrentFrontendFilters();
+ });
+ it('should call getQueryParamsWithPrefix on the routeService with parameter prefix \'f.\'', () => {
+ expect((service as any).routeService.getQueryParamsWithPrefix).toHaveBeenCalledWith('f.');
+ });
+ });
+
+ describe('when getCurrentFilters is called', () => {
+ let parsedValues$;
+ beforeEach(() => {
+ parsedValues$ = service.getCurrentFilters();
+ });
+ it('should call getQueryParamsWithPrefix on the routeService with parameter prefix \'f.\'', () => {
+ expect((service as any).routeService.getQueryParamsWithPrefix).toHaveBeenCalledWith('f.');
+ parsedValues$.subscribe((values) => {
+ expect(values).toEqual(backendFilters);
+ });
+ });
+ });
+
+ describe('when getCurrentSort is called', () => {
+ beforeEach(() => {
+ service.getCurrentSort({} as any);
+ });
+ it('should call getQueryParameterValue on the routeService with parameter name \'sortDirection\'', () => {
+ expect((service as any).routeService.getQueryParameterValue).toHaveBeenCalledWith('sortDirection');
+ });
+ it('should call getQueryParameterValue on the routeService with parameter name \'sortField\'', () => {
+ expect((service as any).routeService.getQueryParameterValue).toHaveBeenCalledWith('sortField');
+ });
+ });
+ describe('when getCurrentPagination is called', () => {
+ beforeEach(() => {
+ service.getCurrentPagination({ currentPage: 1, pageSize: 10 } as any);
+ });
+ it('should call getQueryParameterValue on the routeService with parameter name \'page\'', () => {
+ expect((service as any).routeService.getQueryParameterValue).toHaveBeenCalledWith('page');
+ });
+ it('should call getQueryParameterValue on the routeService with parameter name \'pageSize\'', () => {
+ expect((service as any).routeService.getQueryParameterValue).toHaveBeenCalledWith('pageSize');
+ });
+ });
+ describe('when subscribeToSearchOptions or subscribeToPaginatedSearchOptions is called', () => {
+ beforeEach(() => {
+ spyOn(service, 'getCurrentPagination').and.callThrough();
+ spyOn(service, 'getCurrentSort').and.callThrough();
+ spyOn(service, 'getCurrentScope').and.callThrough();
+ spyOn(service, 'getCurrentQuery').and.callThrough();
+ spyOn(service, 'getCurrentFilters').and.callThrough();
+ });
+
+ describe('when subscribeToSearchOptions is called', () => {
+ beforeEach(() => {
+ service.subscribeToSearchOptions(defaults)
+ });
+ it('should call all getters it needs, but not call any others', () => {
+ expect(service.getCurrentPagination).not.toHaveBeenCalled();
+ expect(service.getCurrentSort).not.toHaveBeenCalled();
+ expect(service.getCurrentScope).toHaveBeenCalled();
+ expect(service.getCurrentQuery).toHaveBeenCalled();
+ expect(service.getCurrentFilters).toHaveBeenCalled();
+ });
+ });
+
+ describe('when subscribeToPaginatedSearchOptions is called', () => {
+ beforeEach(() => {
+ service.subscribeToPaginatedSearchOptions(defaults);
+ });
+ it('should call all getters it needs', () => {
+ expect(service.getCurrentPagination).toHaveBeenCalled();
+ expect(service.getCurrentSort).toHaveBeenCalled();
+ expect(service.getCurrentScope).toHaveBeenCalled();
+ expect(service.getCurrentQuery).toHaveBeenCalled();
+ expect(service.getCurrentFilters).toHaveBeenCalled();
+ });
+ });
+ });
+});
diff --git a/src/app/+search-page/search-service/search-configuration.service.ts b/src/app/+search-page/search-service/search-configuration.service.ts
new file mode 100644
index 0000000000..8ad0b684ad
--- /dev/null
+++ b/src/app/+search-page/search-service/search-configuration.service.ts
@@ -0,0 +1,267 @@
+import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model';
+import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model';
+import { SearchOptions } from '../search-options.model';
+import { Observable } from 'rxjs/Observable';
+import { ActivatedRoute, Params } from '@angular/router';
+import { PaginatedSearchOptions } from '../paginated-search-options.model';
+import { Injectable, OnDestroy } from '@angular/core';
+import { RouteService } from '../../shared/services/route.service';
+import { hasNoValue, isEmpty, isNotEmpty } from '../../shared/empty.util';
+import { RemoteData } from '../../core/data/remote-data';
+import { BehaviorSubject } from 'rxjs/BehaviorSubject';
+import { Subscription } from 'rxjs/Subscription';
+
+/**
+ * Service that performs all actions that have to do with the current search configuration
+ */
+@Injectable()
+export class SearchConfigurationService implements OnDestroy {
+ /**
+ * Default pagination settings
+ */
+ private defaultPagination = Object.assign(new PaginationComponentOptions(), {
+ id: 'search-page-configuration',
+ pageSize: 10,
+ currentPage: 1
+ });
+
+ /**
+ * Default sort settings
+ */
+ private defaultSort = new SortOptions('score', SortDirection.DESC);
+
+ /**
+ * Default scope setting
+ */
+ private defaultScope = '';
+
+ /**
+ * Default query setting
+ */
+ private defaultQuery = '';
+
+ /**
+ * Emits the current default values
+ */
+ private _defaults: Observable>;
+
+ /**
+ * Emits the current search options
+ */
+ public searchOptions: BehaviorSubject;
+
+ /**
+ * Emits the current search options including pagination and sort
+ */
+ public paginatedSearchOptions: BehaviorSubject;
+
+ /**
+ * List of subscriptions to unsubscribe from on destroy
+ */
+ private subs: Subscription[] = new Array();
+
+ /**
+ * Initialize the search options
+ * @param {RouteService} routeService
+ * @param {ActivatedRoute} route
+ */
+ constructor(private routeService: RouteService,
+ private route: ActivatedRoute) {
+ this.defaults.first().subscribe((defRD) => {
+ const defs = defRD.payload;
+ this.paginatedSearchOptions = new BehaviorSubject(defs);
+ this.searchOptions = new BehaviorSubject(defs);
+
+ this.subs.push(this.subscribeToSearchOptions(defs));
+ this.subs.push(this.subscribeToPaginatedSearchOptions(defs));
+ }
+ )
+ }
+
+ /**
+ * @returns {Observable} Emits the current scope's identifier
+ */
+ getCurrentScope(defaultScope: string) {
+ return this.routeService.getQueryParameterValue('scope').map((scope) => {
+ return scope || defaultScope;
+ });
+ }
+
+ /**
+ * @returns {Observable} Emits the current query string
+ */
+ getCurrentQuery(defaultQuery: string) {
+ return this.routeService.getQueryParameterValue('query').map((query) => {
+ return query || defaultQuery;
+ });
+ }
+
+ /**
+ * @returns {Observable} Emits the current pagination settings
+ */
+ getCurrentPagination(defaultPagination: PaginationComponentOptions): Observable {
+ const page$ = this.routeService.getQueryParameterValue('page');
+ const size$ = this.routeService.getQueryParameterValue('pageSize');
+ return Observable.combineLatest(page$, size$, (page, size) => {
+ return Object.assign(new PaginationComponentOptions(), defaultPagination, {
+ currentPage: page || defaultPagination.currentPage,
+ pageSize: size || defaultPagination.pageSize
+ });
+ });
+ }
+
+ /**
+ * @returns {Observable} Emits the current sorting settings
+ */
+ getCurrentSort(defaultSort: SortOptions): Observable {
+ const sortDirection$ = this.routeService.getQueryParameterValue('sortDirection');
+ const sortField$ = this.routeService.getQueryParameterValue('sortField');
+ return Observable.combineLatest(sortDirection$, sortField$, (sortDirection, sortField) => {
+ // Dirty fix because sometimes the observable value is null somehow
+ sortField = this.route.snapshot.queryParamMap.get('sortField');
+
+ const field = sortField || defaultSort.field;
+ const direction = SortDirection[sortDirection] || defaultSort.direction;
+ return new SortOptions(field, direction)
+ }
+ )
+ }
+
+ /**
+ * @returns {Observable} Emits the current active filters with their values as they are sent to the backend
+ */
+ 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'] ? filterParams[realKey + '.min'][0] : '*';
+ const max = filterParams[realKey + '.max'] ? filterParams[realKey + '.max'][0] : '*';
+ params[realKey] = ['[' + min + ' TO ' + max + ']'];
+ }
+ } else {
+ params[key] = filterParams[key];
+ }
+ });
+ return params;
+ }
+ return filterParams;
+ });
+ }
+
+ /**
+ * @returns {Observable} Emits the current active filters with their values as they are displayed in the frontend URL
+ */
+ getCurrentFrontendFilters(): Observable {
+ return this.routeService.getQueryParamsWithPrefix('f.');
+ }
+
+ /**
+ * Sets up a subscription to all necessary parameters to make sure the searchOptions emits a new value every time they update
+ * @param {SearchOptions} defaults Default values for when no parameters are available
+ * @returns {Subscription} The subscription to unsubscribe from
+ */
+ subscribeToSearchOptions(defaults: SearchOptions): Subscription {
+ return Observable.merge(
+ this.getScopePart(defaults.scope),
+ this.getQueryPart(defaults.query),
+ this.getFiltersPart()
+ ).subscribe((update) => {
+ const currentValue: SearchOptions = this.searchOptions.getValue();
+ const updatedValue: SearchOptions = Object.assign(new SearchOptions(), currentValue, update);
+ this.searchOptions.next(updatedValue);
+ });
+ }
+
+ /**
+ * Sets up a subscription to all necessary parameters to make sure the paginatedSearchOptions emits a new value every time they update
+ * @param {PaginatedSearchOptions} defaults Default values for when no parameters are available
+ * @returns {Subscription} The subscription to unsubscribe from
+ */
+ subscribeToPaginatedSearchOptions(defaults: PaginatedSearchOptions): Subscription {
+ return Observable.merge(
+ this.getPaginationPart(defaults.pagination),
+ this.getSortPart(defaults.sort),
+ this.getScopePart(defaults.scope),
+ this.getQueryPart(defaults.query),
+ this.getFiltersPart()
+ ).subscribe((update) => {
+ const currentValue: PaginatedSearchOptions = this.paginatedSearchOptions.getValue();
+ const updatedValue: PaginatedSearchOptions = Object.assign(new PaginatedSearchOptions(), currentValue, update);
+ this.paginatedSearchOptions.next(updatedValue);
+ });
+ }
+
+ /**
+ * Default values for the Search Options
+ */
+ get defaults(): Observable> {
+ if (hasNoValue(this._defaults)) {
+ const options = Object.assign(new PaginatedSearchOptions(), {
+ pagination: this.defaultPagination,
+ sort: this.defaultSort,
+ scope: this.defaultScope,
+ query: this.defaultQuery
+ });
+ this._defaults = Observable.of(new RemoteData(false, false, true, null, options));
+ }
+ return this._defaults;
+ }
+
+ /**
+ * Make sure to unsubscribe from all existing subscription to prevent memory leaks
+ */
+ ngOnDestroy(): void {
+ this.subs.forEach((sub) => {
+ sub.unsubscribe();
+ });
+ }
+
+ /**
+ * @returns {Observable} Emits the current scope's identifier
+ */
+ private getScopePart(defaultScope: string): Observable {
+ return this.getCurrentScope(defaultScope).map((scope) => {
+ return { scope }
+ });
+ }
+
+ /**
+ * @returns {Observable} Emits the current query string as a partial SearchOptions object
+ */
+ private getQueryPart(defaultQuery: string): Observable {
+ return this.getCurrentQuery(defaultQuery).map((query) => {
+ return { query }
+ });
+ }
+
+ /**
+ * @returns {Observable} Emits the current pagination settings as a partial SearchOptions object
+ */
+ private getPaginationPart(defaultPagination: PaginationComponentOptions): Observable {
+ return this.getCurrentPagination(defaultPagination).map((pagination) => {
+ return { pagination }
+ });
+ }
+
+ /**
+ * @returns {Observable} Emits the current sorting settings as a partial SearchOptions object
+ */
+ private getSortPart(defaultSort: SortOptions): Observable {
+ return this.getCurrentSort(defaultSort).map((sort) => {
+ return { sort }
+ });
+ }
+
+ /**
+ * @returns {Observable} Emits the current active filters as a partial SearchOptions object
+ */
+ private getFiltersPart(): Observable {
+ return this.getCurrentFilters().map((filters) => {
+ return { filters }
+ });
+ }
+}
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..dc6fef1f87 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
@@ -1,22 +1,53 @@
import { FilterType } from './filter-type.model';
import { autoserialize, autoserializeAs } from 'cerialize';
+ /**
+ * The configuration for a search filter
+ */
export class SearchFilterConfig {
+ /**
+ * The name of this filter
+ */
@autoserialize
name: string;
+ /**
+ * The FilterType of this filter
+ */
@autoserializeAs(String, 'facetType')
type: FilterType;
+ /**
+ * True if the filter has facets
+ */
@autoserialize
hasFacets: boolean;
- // @autoserializeAs(String, 'facetLimit') - uncomment when fixed in rest
+ /**
+ * @type {number} The page size used for this facet
+ */
+ @autoserializeAs(String, 'facetLimit')
pageSize = 5;
+ /**
+ * Defines if the item facet is collapsed by default or not on the search page
+ */
@autoserialize
isOpenByDefault: boolean;
+
+ /**
+ * Minimum value possible for this facet in the repository
+ */
+ @autoserialize
+ maxValue: string;
+
+ /**
+ * Maximum value possible for this facet in the repository
+ */
+ @autoserialize
+ minValue: string;
+
/**
* Name of this configuration that can be used in a url
* @returns Parameter name
diff --git a/src/app/+search-page/search-service/search-query-response.model.ts b/src/app/+search-page/search-service/search-query-response.model.ts
index b8948d963f..ac1d8b7df3 100644
--- a/src/app/+search-page/search-service/search-query-response.model.ts
+++ b/src/app/+search-page/search-service/search-query-response.model.ts
@@ -2,46 +2,88 @@ import { autoserialize, autoserializeAs } from 'cerialize';
import { PageInfo } from '../../core/shared/page-info.model';
import { NormalizedSearchResult } from '../normalized-search-result.model';
+/**
+ * Class representing the response returned by the server when performing a search request
+ */
export class SearchQueryResponse {
+ /**
+ * The scope used in the search request represented by the UUID of a DSpaceObject
+ */
@autoserialize
scope: string;
+ /**
+ * The search query used in the search request
+ */
@autoserialize
query: string;
+ /**
+ * The currently active filters used in the search request
+ */
@autoserialize
appliedFilters: any[]; // TODO
+ /**
+ * The sort parameters used in the search request
+ */
@autoserialize
sort: any; // TODO
+ /**
+ * The sort parameters used in the search request
+ */
@autoserialize
configurationName: string;
+ /**
+ * The sort parameters used in the search request
+ */
@autoserialize
public type: string;
+ /**
+ * Pagination configuration for this response
+ */
@autoserialize
page: PageInfo;
+ /**
+ * The results for this query
+ */
@autoserializeAs(NormalizedSearchResult)
objects: NormalizedSearchResult[];
@autoserialize
facets: any; // TODO
+ /**
+ * The REST url to retrieve the current response
+ */
@autoserialize
self: string;
+ /**
+ * The REST url to retrieve the next response
+ */
@autoserialize
next: string;
+ /**
+ * The REST url to retrieve the previous response
+ */
@autoserialize
previous: string;
+ /**
+ * The REST url to retrieve the first response
+ */
@autoserialize
first: string;
+ /**
+ * The REST url to retrieve the last response
+ */
@autoserialize
last: string;
}
diff --git a/src/app/+search-page/search-service/search-result-element-decorator.ts b/src/app/+search-page/search-service/search-result-element-decorator.ts
index 545d1b20eb..348cf7f592 100644
--- a/src/app/+search-page/search-service/search-result-element-decorator.ts
+++ b/src/app/+search-page/search-service/search-result-element-decorator.ts
@@ -1,8 +1,16 @@
import { GenericConstructor } from '../../core/shared/generic-constructor';
import { ListableObject } from '../../shared/object-collection/shared/listable-object.model';
+/**
+ * Contains the mapping between a search result component and a DSpaceObject
+ */
const searchResultMap = new Map();
+/**
+ * Used to map Search Result components to their matching DSpaceObject
+ * @param {GenericConstructor} domainConstructor The constructor of the DSpaceObject
+ * @returns Decorator function that performs the actual mapping on initialization of the component
+ */
export function searchResultFor(domainConstructor: GenericConstructor) {
return function decorator(searchResult: any) {
if (!searchResult) {
@@ -12,6 +20,11 @@ export function searchResultFor(domainConstructor: GenericConstructor} domainConstructor The DSpaceObject's constructor for which the search result component is requested
+ * @returns The component's constructor that matches the given DSpaceObject
+ */
export function getSearchResultFor(domainConstructor: GenericConstructor) {
return searchResultMap.get(domainConstructor);
}
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 5cc71d9bbd..488996a3da 100644
--- a/src/app/+search-page/search-service/search.service.spec.ts
+++ b/src/app/+search-page/search-service/search.service.spec.ts
@@ -1,14 +1,10 @@
-import { TestBed, fakeAsync, tick } from '@angular/core/testing';
+import { TestBed } from '@angular/core/testing';
import { RouterTestingModule } from '@angular/router/testing';
import { CommonModule } from '@angular/common';
import { Component } from '@angular/core';
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/services/route.service';
-import { GLOBAL_CONFIG } from '../../../config';
import { RemoteDataBuildService } from '../../core/cache/builders/remote-data-build.service';
import { ActivatedRoute, Router, UrlTree } from '@angular/router';
import { RequestService } from '../../core/data/request.service';
@@ -19,19 +15,19 @@ import { HALEndpointService } from '../../core/shared/hal-endpoint.service';
import { Observable } from 'rxjs/Observable';
import { PaginatedSearchOptions } from '../paginated-search-options.model';
import { RemoteData } from '../../core/data/remote-data';
-import { PaginatedList } from '../../core/data/paginated-list';
-import { SearchResult } from '../search-result.model';
-import { DSpaceObject } from '../../core/shared/dspace-object.model';
import { ResponseCacheEntry } from '../../core/cache/response-cache.reducer';
import { RequestEntry } from '../../core/data/request.reducer';
import { getMockRequestService } from '../../shared/mocks/mock-request.service';
import { getMockResponseCacheService } from '../../shared/mocks/mock-response-cache.service';
import {
- FacetConfigSuccessResponse, RestResponse,
+ FacetConfigSuccessResponse,
SearchSuccessResponse
} from '../../core/cache/response-cache.models';
import { SearchQueryResponse } from './search-query-response.model';
import { SearchFilterConfig } from './search-filter-config.model';
+import { CommunityDataService } from '../../core/data/community-data.service';
+import { ViewMode } from '../../core/shared/view-mode.model';
+import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service';
@Component({ template: '' })
class DummyComponent {
@@ -60,6 +56,8 @@ describe('SearchService', () => {
{ provide: RequestService, useValue: getMockRequestService() },
{ provide: RemoteDataBuildService, useValue: {} },
{ provide: HALEndpointService, useValue: {} },
+ { provide: CommunityDataService, useValue: {}},
+ { provide: DSpaceObjectDataService, useValue: {}},
SearchService
],
});
@@ -115,6 +113,8 @@ describe('SearchService', () => {
{ provide: RequestService, useValue: getMockRequestService() },
{ provide: RemoteDataBuildService, useValue: remoteDataBuildService },
{ provide: HALEndpointService, useValue: halService },
+ { provide: CommunityDataService, useValue: {}},
+ { provide: DSpaceObjectDataService, 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 b51a9c834d..ac5f7a6169 100644
--- a/src/app/+search-page/search-service/search.service.ts
+++ b/src/app/+search-page/search-service/search.service.ts
@@ -1,13 +1,14 @@
import { Injectable, OnDestroy } from '@angular/core';
import {
- ActivatedRoute, NavigationExtras, PRIMARY_OUTLET, Router,
+ ActivatedRoute,
+ NavigationExtras,
+ PRIMARY_OUTLET,
+ Router,
UrlSegmentGroup
} from '@angular/router';
import { Observable } from 'rxjs/Observable';
-import { flatMap, map, tap } from 'rxjs/operators';
-import { ViewMode } from '../../+search-page/search-options.model';
+import { flatMap, map, switchMap } from 'rxjs/operators';
import { RemoteDataBuildService } from '../../core/cache/builders/remote-data-build.service';
-import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model';
import {
FacetConfigSuccessResponse,
FacetValueSuccessResponse,
@@ -23,10 +24,9 @@ import { RequestService } from '../../core/data/request.service';
import { DSpaceObject } from '../../core/shared/dspace-object.model';
import { GenericConstructor } from '../../core/shared/generic-constructor';
import { HALEndpointService } from '../../core/shared/hal-endpoint.service';
-import { configureRequest } from '../../core/shared/operators';
+import { configureRequest, getSucceededRemoteData } from '../../core/shared/operators';
import { URLCombiner } from '../../core/url-combiner/url-combiner';
import { hasValue, isEmpty, isNotEmpty } from '../../shared/empty.util';
-import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model';
import { NormalizedSearchResult } from '../normalized-search-result.model';
import { SearchOptions } from '../search-options.model';
import { SearchResult } from '../search-result.model';
@@ -40,32 +40,48 @@ import { ListableObject } from '../../shared/object-collection/shared/listable-o
import { FacetValueResponseParsingService } from '../../core/data/facet-value-response-parsing.service';
import { FacetConfigResponseParsingService } from '../../core/data/facet-config-response-parsing.service';
import { PaginatedSearchOptions } from '../paginated-search-options.model';
-import { observable } from 'rxjs/symbol/observable';
+import { Community } from '../../core/shared/community.model';
+import { CommunityDataService } from '../../core/data/community-data.service';
+import { ViewMode } from '../../core/shared/view-mode.model';
+import { ResourceType } from '../../core/shared/resource-type';
+import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service';
+/**
+ * Service that performs all general actions that have to do with the search page
+ */
@Injectable()
export class SearchService implements OnDestroy {
+ /**
+ * Endpoint link path for retrieving general search results
+ */
private searchLinkPath = 'discover/search/objects';
- private facetValueLinkPathPrefix = 'discover/facets/';
- private facetConfigLinkPath = 'discover/facets';
+ /**
+ * Endpoint link path for retrieving facet config incl values
+ */
+ private facetLinkPathPrefix = 'discover/facets/';
+
+ /**
+ * Subscription to unsubscribe from
+ */
private sub;
- searchOptions: SearchOptions;
-
constructor(private router: Router,
private route: ActivatedRoute,
protected responseCache: ResponseCacheService,
protected requestService: RequestService,
private rdb: RemoteDataBuildService,
- private halService: HALEndpointService) {
- const pagination: PaginationComponentOptions = new PaginationComponentOptions();
- pagination.id = 'search-results-pagination';
- pagination.currentPage = 1;
- pagination.pageSize = 10;
- const sort: SortOptions = new SortOptions('score', SortDirection.DESC);
- this.searchOptions = Object.assign(new SearchOptions(), { pagination: pagination, sort: sort });
+ private halService: HALEndpointService,
+ private communityService: CommunityDataService,
+ private dspaceObjectService: DSpaceObjectDataService
+ ) {
}
+ /**
+ * Method to retrieve a paginated list of search results from the server
+ * @param {PaginatedSearchOptions} searchOptions The configuration necessary to perform this search
+ * @returns {Observable>>>} Emits a paginated list with all search results found
+ */
search(searchOptions?: PaginatedSearchOptions): Observable>>> {
const requestObs = this.halService.getEndpoint(this.searchLinkPath).pipe(
map((url: string) => {
@@ -134,8 +150,13 @@ export class SearchService implements OnDestroy {
return this.rdb.toRemoteDataObservable(requestEntryObs, responseCacheObs, payloadObs);
}
+ /**
+ * Request the filter configuration for a given scope or the whole repository
+ * @param {string} scope UUID of the object for which config the filter config is requested, when no scope is provided the configuration for the whole repository is loaded
+ * @returns {Observable>} The found filter configuration
+ */
getConfig(scope?: string): Observable> {
- const requestObs = this.halService.getEndpoint(this.facetConfigLinkPath).pipe(
+ const requestObs = this.halService.getEndpoint(this.facetLinkPathPrefix).pipe(
map((url: string) => {
const args: string[] = [];
@@ -175,13 +196,25 @@ export class SearchService implements OnDestroy {
return this.rdb.toRemoteDataObservable(requestEntryObs, responseCacheObs, facetConfigObs);
}
- getFacetValuesFor(filterConfig: SearchFilterConfig, valuePage: number, searchOptions?: SearchOptions): Observable>> {
- const requestObs = this.halService.getEndpoint(this.facetValueLinkPathPrefix + filterConfig.name).pipe(
+ /**
+ * Method to request a single page of filter values for a given value
+ * @param {SearchFilterConfig} filterConfig The filter config for which we want to request filter values
+ * @param {number} valuePage The page number of the filter values
+ * @param {SearchOptions} searchOptions The search configuration for the current search
+ * @param {string} filterQuery The optional query used to filter out filter values
+ * @returns {Observable>>} Emits the given page of facet values
+ */
+ getFacetValuesFor(filterConfig: SearchFilterConfig, valuePage: number, searchOptions?: SearchOptions, filterQuery?: string): Observable>> {
+ const requestObs = this.halService.getEndpoint(this.facetLinkPathPrefix + filterConfig.name).pipe(
map((url: string) => {
const args: string[] = [`page=${valuePage - 1}`, `size=${filterConfig.pageSize}`];
+ if (hasValue(filterQuery)) {
+ args.push(`prefix=${filterQuery}`);
+ }
if (hasValue(searchOptions)) {
url = searchOptions.toRestUrl(url, args);
}
+
const request = new GetRequest(this.requestService.generateRequestId(), url);
return Object.assign(request, {
getResponseParser(): GenericConstructor {
@@ -218,6 +251,45 @@ export class SearchService implements OnDestroy {
return this.rdb.toRemoteDataObservable(requestEntryObs, responseCacheObs, payloadObs);
}
+ /**
+ * Request a list of DSpaceObjects that can be used as a scope, based on the current scope
+ * @param {string} scopeId UUID of the current scope, if the scope is empty, the repository wide scopes will be returned
+ * @returns {Observable} Emits a list of DSpaceObjects which represent possible scopes
+ */
+ getScopes(scopeId?: string): Observable {
+
+ if (isEmpty(scopeId)) {
+ const top: Observable = this.communityService.findTop({ elementsPerPage: 9999 }).pipe(
+ map(
+ (communities: RemoteData>) => communities.payload.page
+ )
+ );
+ return top;
+ }
+
+ const scopeObject: Observable> = this.dspaceObjectService.findById(scopeId).pipe(getSucceededRemoteData());
+ const scopeList: Observable = scopeObject.pipe(
+ switchMap((dsoRD: RemoteData) => {
+ if (dsoRD.payload.type === ResourceType.Community) {
+ const community: Community = dsoRD.payload as Community;
+ return Observable.combineLatest(community.subcommunities, community.collections, (subCommunities, collections) => {
+ /*if this is a community, we also need to show the direct children*/
+ return [community, ...subCommunities.payload.page, ...collections.payload.page]
+ })
+ } else {
+ return Observable.of([dsoRD.payload]);
+ }
+ }
+ ));
+
+ return scopeList;
+
+ }
+
+ /**
+ * Requests the current view mode based on the current URL
+ * @returns {Observable} The current view mode
+ */
getViewMode(): Observable {
return this.route.queryParams.map((params) => {
if (isNotEmpty(params.view) && hasValue(params.view)) {
@@ -228,6 +300,10 @@ export class SearchService implements OnDestroy {
});
}
+ /**
+ * Changes the current view mode in the current URL
+ * @param {ViewMode} viewMode Mode to switch to
+ */
setViewMode(viewMode: ViewMode) {
const navigationExtras: NavigationExtras = {
queryParams: { view: viewMode },
@@ -237,12 +313,18 @@ export class SearchService implements OnDestroy {
this.router.navigate([this.getSearchLink()], navigationExtras);
}
+ /**
+ * @returns {string} The base path to the search page
+ */
getSearchLink(): string {
const urlTree = this.router.parseUrl(this.router.url);
const g: UrlSegmentGroup = urlTree.root.children[PRIMARY_OUTLET];
return '/' + g.toString();
}
+ /**
+ * Unsubscribe from the subscription
+ */
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
index 18fd45caed..d693196dae 100644
--- a/src/app/+search-page/search-settings/search-settings.component.html
+++ b/src/app/+search-page/search-settings/search-settings.component.html
@@ -1,22 +1,24 @@
-{{ 'search.sidebar.settings.title' | translate}}
-
+
+ {{ 'search.sidebar.settings.title' | translate}}
+
{{ 'search.sidebar.settings.sort-by' | translate}}
-
- {{'sorting.' + sortDirection.key | translate}}
+
+ {{'sorting.' + sortOption.field + '.' + sortOption.direction | translate}}
-
+
-
-
{{ 'search.sidebar.settings.rpp' | translate}}
-
-
-
- {{pageSizeOption}}
-
-
-
+
+
{{ 'search.sidebar.settings.rpp' | translate}}
+
+
+ {{pageSizeOption}}
+
+
+
+
\ No newline at end of file
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
index 2330b62669..5e6dc9b369 100644
--- a/src/app/+search-page/search-settings/search-settings.component.spec.ts
+++ b/src/app/+search-page/search-settings/search-settings.component.spec.ts
@@ -11,6 +11,10 @@ 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';
+import { SearchFilterService } from '../search-filters/search-filter/search-filter.service';
+import { hot } from 'jasmine-marbles';
+import { VarDirective } from '../../shared/utils/var.directive';
+import { SearchConfigurationService } from '../search-service/search-configuration.service';
describe('SearchSettingsComponent', () => {
@@ -23,13 +27,21 @@ describe('SearchSettingsComponent', () => {
pagination.currentPage = 1;
pagination.pageSize = 10;
const sort: SortOptions = new SortOptions('score', SortDirection.DESC);
- const mockResults = [ 'test', 'data' ];
+ 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 paginatedSearchOptions = {
+ query: queryParam,
+ scope: scopeParam,
+ pagination,
+ sort
+ };
+
const activatedRouteStub = {
queryParams: Observable.of({
query: queryParam,
@@ -41,12 +53,12 @@ describe('SearchSettingsComponent', () => {
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 ],
+ imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([])],
+ declarations: [SearchSettingsComponent, EnumKeysPipe, VarDirective],
providers: [
{ provide: SearchService, useValue: searchServiceStub },
@@ -55,8 +67,23 @@ describe('SearchSettingsComponent', () => {
provide: SearchSidebarService,
useValue: sidebarService
},
+ {
+ provide: SearchFilterService,
+ useValue: {}
+ },
+ {
+ provide: SearchConfigurationService,
+ useValue: {
+ paginatedSearchOptions: hot('a', {
+ a: paginatedSearchOptions
+ }),
+ getCurrentScope: hot('a', {
+ a: 'test-id'
+ }),
+ }
+ },
],
- schemas: [ NO_ERRORS_SCHEMA ]
+ schemas: [NO_ERRORS_SCHEMA]
}).compileComponents();
}));
@@ -74,30 +101,42 @@ describe('SearchSettingsComponent', () => {
});
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);
-
+ (comp as any).searchOptions$.first().subscribe((options) => {
+ fixture.detectChanges();
+ 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(comp.searchOptionPossibilities.length);
+ });
});
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);
+ (comp as any).searchOptions$.first().subscribe((options) => {
+ fixture.detectChanges();
+ 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(options.pagination.pageSizeOptions.length);
+ }
+ )
});
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();
+ (comp as any).searchOptions$.first().subscribe((options) => {
+ fixture.detectChanges();
+ 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();
+ (comp as any).searchOptions$.first().subscribe((options) => {
+ fixture.detectChanges();
+ 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
index 145b58e27b..81e2366e39 100644
--- a/src/app/+search-page/search-settings/search-settings.component.ts
+++ b/src/app/+search-page/search-settings/search-settings.component.ts
@@ -1,77 +1,75 @@
-import { Component, Input, OnInit, ChangeDetectionStrategy } from '@angular/core';
+import { Component, OnInit } from '@angular/core';
import { SearchService } from '../search-service/search.service';
-import { SearchOptions, ViewMode } from '../search-options.model';
-import { SortDirection } from '../../core/cache/models/sort-options.model';
+import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model';
import { ActivatedRoute, NavigationExtras, Router } from '@angular/router';
import { PaginatedSearchOptions } from '../paginated-search-options.model';
+import { SearchFilterService } from '../search-filters/search-filter/search-filter.service';
+import { Observable } from 'rxjs/Observable';
+import { SearchConfigurationService } from '../search-service/search-configuration.service';
@Component({
selector: 'ds-search-settings',
styleUrls: ['./search-settings.component.scss'],
templateUrl: './search-settings.component.html'
})
+
+/**
+ * This component represents the part of the search sidebar that contains the general search settings.
+ */
export class SearchSettingsComponent implements OnInit {
- @Input() searchOptions: PaginatedSearchOptions;
/**
- * Declare SortDirection enumeration to use it in the template
+ * The configuration for the current paginated search results
*/
- public sortDirections = SortDirection;
- /**
- * Number of items per page.
- */
- public pageSize;
- @Input() public pageSizeOptions;
+ searchOptions$: Observable;
- private sub;
- private scope: string;
- query: string;
- page: number;
- direction: SortDirection;
- currentParams = {};
+ /**
+ * All sort options that are shown in the settings
+ */
+ searchOptionPossibilities = [new SortOptions('score', SortDirection.DESC), new SortOptions('dc.title', SortDirection.ASC), new SortOptions('dc.title', SortDirection.DESC)];
constructor(private service: SearchService,
private route: ActivatedRoute,
- private router: Router) {
+ private router: Router,
+ private searchConfigurationService: SearchConfigurationService) {
}
+ /**
+ * Initialize paginated search options
+ */
ngOnInit(): void {
- this.searchOptions = this.service.searchOptions;
- this.pageSize = this.searchOptions.pagination.pageSize;
- this.pageSizeOptions = this.searchOptions.pagination.pageSizeOptions;
- 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;
- if (params.view === ViewMode.Grid) {
- this.pageSizeOptions = this.pageSizeOptions;
- } else {
- this.pageSizeOptions = this.pageSizeOptions;
- }
- });
+ this.searchOptions$ = this.searchConfigurationService.paginatedSearchOptions;
}
+ /**
+ * Method to change the current page size (results per page)
+ * @param {Event} event Change event containing the new page size value
+ */
reloadRPP(event: Event) {
const value = (event.target as HTMLInputElement).value;
const navigationExtras: NavigationExtras = {
- queryParams: Object.assign({}, this.currentParams, {
- pageSize: value
- })
+ queryParams: {
+ pageSize: value,
+ page: 1
+ },
+ queryParamsHandling: 'merge'
};
this.router.navigate([ '/search' ], navigationExtras);
}
+ /**
+ * Method to change the current sort field and direction
+ * @param {Event} event Change event containing the sort direction and sort field
+ */
reloadOrder(event: Event) {
- const value = (event.target as HTMLInputElement).value;
+ const values = (event.target as HTMLInputElement).value.split(',');
const navigationExtras: NavigationExtras = {
- queryParams: Object.assign({}, this.currentParams, {
- sortDirection: value
- })
+ queryParams: {
+ sortDirection: values[1],
+ sortField: values[0],
+ page: 1
+ },
+ queryParamsHandling: 'merge'
};
this.router.navigate([ '/search' ], navigationExtras);
}
diff --git a/src/app/+search-page/search-sidebar/search-sidebar.actions.ts b/src/app/+search-page/search-sidebar/search-sidebar.actions.ts
index f393bc10b3..84a34b2790 100644
--- a/src/app/+search-page/search-sidebar/search-sidebar.actions.ts
+++ b/src/app/+search-page/search-sidebar/search-sidebar.actions.ts
@@ -17,14 +17,23 @@ export const SearchSidebarActionTypes = {
};
/* tslint:disable:max-classes-per-file */
+/**
+ * Used to collapse the sidebar
+ */
export class SearchSidebarCollapseAction implements Action {
type = SearchSidebarActionTypes.COLLAPSE;
}
+/**
+ * Used to expand the sidebar
+ */
export class SearchSidebarExpandAction implements Action {
type = SearchSidebarActionTypes.EXPAND;
}
+/**
+ * Used to collapse the sidebar when it's expanded and expand it when it's collapsed
+ */
export class SearchSidebarToggleAction implements Action {
type = SearchSidebarActionTypes.TOGGLE;
}
diff --git a/src/app/+search-page/search-sidebar/search-sidebar.component.ts b/src/app/+search-page/search-sidebar/search-sidebar.component.ts
index 946048462a..8b68cda793 100644
--- a/src/app/+search-page/search-sidebar/search-sidebar.component.ts
+++ b/src/app/+search-page/search-sidebar/search-sidebar.component.ts
@@ -12,7 +12,18 @@ import { Component, EventEmitter, Input, Output } from '@angular/core';
templateUrl: './search-sidebar.component.html',
})
+/**
+ * Component representing the sidebar on the search page
+ */
export class SearchSidebarComponent {
+
+ /**
+ * The total amount of results
+ */
@Input() resultCount;
+
+ /**
+ * Emits event when the user clicks a button to open or close the sidebar
+ */
@Output() toggleSidebar = new EventEmitter();
}
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 b65010b6e0..758ef2320b 100644
--- a/src/app/+search-page/search-sidebar/search-sidebar.effects.ts
+++ b/src/app/+search-page/search-sidebar/search-sidebar.effects.ts
@@ -5,6 +5,9 @@ import * as fromRouter from '@ngrx/router-store';
import { SearchSidebarCollapseAction } from './search-sidebar.actions';
import { URLBaser } from '../../core/url-baser/url-baser';
+/**
+ * Makes sure that if the user navigates to another route, the sidebar is collapsed
+ */
@Injectable()
export class SearchSidebarEffects {
private previousPath: string;
diff --git a/src/app/+search-page/search-sidebar/search-sidebar.reducer.ts b/src/app/+search-page/search-sidebar/search-sidebar.reducer.ts
index 81d9069238..a01f0ff6d6 100644
--- a/src/app/+search-page/search-sidebar/search-sidebar.reducer.ts
+++ b/src/app/+search-page/search-sidebar/search-sidebar.reducer.ts
@@ -1,5 +1,8 @@
import { SearchSidebarAction, SearchSidebarActionTypes } from './search-sidebar.actions';
+/**
+ * Interface that represents the state of the sidebar
+ */
export interface SearchSidebarState {
sidebarCollapsed: boolean;
}
@@ -8,6 +11,12 @@ const initialState: SearchSidebarState = {
sidebarCollapsed: true
};
+/**
+ * Performs a search sidebar action on the current state
+ * @param {SearchSidebarState} state The state before the action is performed
+ * @param {SearchSidebarAction} action The action that should be performed
+ * @returns {SearchSidebarState} The state after the action is performed
+ */
export function sidebarReducer(state = initialState, action: SearchSidebarAction): SearchSidebarState {
switch (action.type) {
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 3a17dc87ab..8cf9339c5c 100644
--- a/src/app/+search-page/search-sidebar/search-sidebar.service.ts
+++ b/src/app/+search-page/search-sidebar/search-sidebar.service.ts
@@ -9,27 +9,47 @@ import { HostWindowService } from '../../shared/host-window.service';
const sidebarStateSelector = (state: AppState) => state.searchSidebar;
const sidebarCollapsedSelector = createSelector(sidebarStateSelector, (sidebar: SearchSidebarState) => sidebar.sidebarCollapsed);
+/**
+ * Service that performs all actions that have to do with the search sidebar
+ */
@Injectable()
export class SearchSidebarService {
+ /**
+ * Emits true is the current screen size is mobile
+ */
private isXsOrSm$: Observable;
- private isCollapsdeInStored: Observable;
+
+ /**
+ * Emits true is the sidebar's state in the store is currently collapsed
+ */
+ private isCollapsedInStore: Observable;
constructor(private store: Store, private windowService: HostWindowService) {
this.isXsOrSm$ = this.windowService.isXsOrSm();
- this.isCollapsdeInStored = this.store.select(sidebarCollapsedSelector);
+ this.isCollapsedInStore = this.store.select(sidebarCollapsedSelector);
}
+ /**
+ * Checks if the sidebar should currently be collapsed
+ * @returns {Observable} Emits true if the user's screen size is mobile or when the state in the store is currently collapsed
+ */
get isCollapsed(): Observable {
return Observable.combineLatest(
this.isXsOrSm$,
- this.isCollapsdeInStored,
+ this.isCollapsedInStore,
(mobile, store) => mobile ? store : true);
}
+ /**
+ * Dispatches a collapse action to the store
+ */
public collapse(): void {
this.store.dispatch(new SearchSidebarCollapseAction());
}
+ /**
+ * Dispatches an expand action to the store
+ */
public expand(): void {
this.store.dispatch(new SearchSidebarExpandAction());
}
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/cache/models/normalized-community.model.ts b/src/app/core/cache/models/normalized-community.model.ts
index b1c2fe3cdd..4ab2408a53 100644
--- a/src/app/core/cache/models/normalized-community.model.ts
+++ b/src/app/core/cache/models/normalized-community.model.ts
@@ -46,4 +46,8 @@ export class NormalizedCommunity extends NormalizedDSpaceObject {
@relationship(ResourceType.Collection, true)
collections: string[];
+ @autoserialize
+ @relationship(ResourceType.Community, true)
+ subcommunities: string[];
+
}
diff --git a/src/app/core/core.module.ts b/src/app/core/core.module.ts
index 8536169688..dabdfba0ab 100644
--- a/src/app/core/core.module.ts
+++ b/src/app/core/core.module.ts
@@ -62,6 +62,7 @@ import { RegistryMetadatafieldsResponseParsingService } from './data/registry-me
import { RegistryBitstreamformatsResponseParsingService } from './data/registry-bitstreamformats-response-parsing.service';
import { NotificationsService } from '../shared/notifications/notifications.service';
import { UploaderService } from '../shared/uploader/uploader.service';
+import { DSpaceObjectDataService } from './data/dspace-object-data.service';
const IMPORTS = [
CommonModule,
@@ -124,6 +125,7 @@ const PROVIDERS = [
IntegrationResponseParsingService,
UploaderService,
UUIDService,
+ DSpaceObjectDataService,
// register AuthInterceptor as HttpInterceptor
{
provide: HTTP_INTERCEPTORS,
diff --git a/src/app/core/data/community-data.service.ts b/src/app/core/data/community-data.service.ts
index 88ad3a5287..3e31c62d25 100644
--- a/src/app/core/data/community-data.service.ts
+++ b/src/app/core/data/community-data.service.ts
@@ -1,7 +1,6 @@
-import { Inject, Injectable } from '@angular/core';
+import { Injectable } from '@angular/core';
import { Store } from '@ngrx/store';
-import { GLOBAL_CONFIG, GlobalConfig } from '../../../config';
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
import { NormalizedCommunity } from '../cache/models/normalized-community.model';
import { ObjectCacheService } from '../cache/object-cache.service';
@@ -11,10 +10,16 @@ import { Community } from '../shared/community.model';
import { ComColDataService } from './comcol-data.service';
import { RequestService } from './request.service';
import { HALEndpointService } from '../shared/hal-endpoint.service';
+import { FindAllOptions, FindAllRequest } from './request.models';
+import { RemoteData } from './remote-data';
+import { hasValue, isNotEmpty } from '../../shared/empty.util';
+import { Observable } from 'rxjs/Observable';
+import { PaginatedList } from './paginated-list';
@Injectable()
export class CommunityDataService extends ComColDataService {
protected linkPath = 'communities';
+ protected topLinkPath = 'communities/search/top';
protected cds = this;
constructor(
@@ -31,4 +36,19 @@ export class CommunityDataService extends ComColDataService>> {
+ const hrefObs = this.halService.getEndpoint(this.topLinkPath).filter((href: string) => isNotEmpty(href))
+ .flatMap((endpoint: string) => this.getFindAllHref(endpoint, options));
+
+ hrefObs
+ .filter((href: string) => hasValue(href))
+ .take(1)
+ .subscribe((href: string) => {
+ const request = new FindAllRequest(this.requestService.generateRequestId(), href, options);
+ this.requestService.configure(request);
+ });
+
+ return this.rdbService.buildList(hrefObs) as Observable>>;
+ }
}
diff --git a/src/app/core/data/data.service.ts b/src/app/core/data/data.service.ts
index 06e7f25926..c7588a5231 100644
--- a/src/app/core/data/data.service.ts
+++ b/src/app/core/data/data.service.ts
@@ -80,8 +80,7 @@ export abstract class DataService
.map((endpoint: string) => this.getFindByIDHref(endpoint, id));
hrefObs
- .filter((href: string) => hasValue(href))
- .take(1)
+ .first((href: string) => hasValue(href))
.subscribe((href: string) => {
const request = new FindByIDRequest(this.requestService.generateRequestId(), href, id);
this.requestService.configure(request);
diff --git a/src/app/core/data/dspace-object-data.service.spec.ts b/src/app/core/data/dspace-object-data.service.spec.ts
new file mode 100644
index 0000000000..bb2bdc675d
--- /dev/null
+++ b/src/app/core/data/dspace-object-data.service.spec.ts
@@ -0,0 +1,73 @@
+import { cold, getTestScheduler } from 'jasmine-marbles';
+import { TestScheduler } from '../../../../node_modules/rxjs';
+import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
+import { DSpaceObject } from '../shared/dspace-object.model';
+import { HALEndpointService } from '../shared/hal-endpoint.service';
+import { FindByIDRequest } from './request.models';
+import { RequestService } from './request.service';
+import { DSpaceObjectDataService } from './dspace-object-data.service';
+
+describe('DSpaceObjectDataService', () => {
+ let scheduler: TestScheduler;
+ let service: DSpaceObjectDataService;
+ let halService: HALEndpointService;
+ let requestService: RequestService;
+ let rdbService: RemoteDataBuildService;
+ const testObject = {
+ uuid: '9b4f22f4-164a-49db-8817-3316b6ee5746'
+ } as DSpaceObject;
+ const dsoLink = 'https://rest.api/rest/api/dso/find{?uuid}';
+ const requestURL = `https://rest.api/rest/api/dso/find?uuid=${testObject.uuid}`;
+ const requestUUID = '34cfed7c-f597-49ef-9cbe-ea351f0023c2';
+
+ beforeEach(() => {
+ scheduler = getTestScheduler();
+
+ halService = jasmine.createSpyObj('halService', {
+ getEndpoint: cold('a', { a: dsoLink })
+ });
+ requestService = jasmine.createSpyObj('requestService', {
+ generateRequestId: requestUUID,
+ configure: true
+ });
+ rdbService = jasmine.createSpyObj('rdbService', {
+ buildSingle: cold('a', {
+ a: {
+ payload: testObject
+ }
+ })
+ });
+
+ service = new DSpaceObjectDataService(
+ requestService,
+ rdbService,
+ halService
+ )
+ });
+
+ describe('findById', () => {
+ it('should call HALEndpointService with the path to the dso endpoint', () => {
+ scheduler.schedule(() => service.findById(testObject.uuid));
+ scheduler.flush();
+
+ expect(halService.getEndpoint).toHaveBeenCalledWith('dso');
+ });
+
+ it('should configure the proper FindByIDRequest', () => {
+ scheduler.schedule(() => service.findById(testObject.uuid));
+ scheduler.flush();
+
+ expect(requestService.configure).toHaveBeenCalledWith(new FindByIDRequest(requestUUID, requestURL, testObject.uuid));
+ });
+
+ it('should return a RemoteData for the object with the given ID', () => {
+ const result = service.findById(testObject.uuid);
+ const expected = cold('a', {
+ a: {
+ payload: testObject
+ }
+ });
+ expect(result).toBeObservable(expected);
+ });
+ });
+});
diff --git a/src/app/core/data/dspace-object-data.service.ts b/src/app/core/data/dspace-object-data.service.ts
new file mode 100644
index 0000000000..39feea4c30
--- /dev/null
+++ b/src/app/core/data/dspace-object-data.service.ts
@@ -0,0 +1,51 @@
+import { Injectable } from '@angular/core';
+import { Store } from '@ngrx/store';
+import { Observable } from 'rxjs/Observable';
+import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
+import { NormalizedDSpaceObject } from '../cache/models/normalized-dspace-object.model';
+import { ResponseCacheService } from '../cache/response-cache.service';
+import { CoreState } from '../core.reducers';
+import { DSpaceObject } from '../shared/dspace-object.model';
+import { HALEndpointService } from '../shared/hal-endpoint.service';
+import { DataService } from './data.service';
+import { RemoteData } from './remote-data';
+import { RequestService } from './request.service';
+
+/* tslint:disable:max-classes-per-file */
+class DataServiceImpl extends DataService {
+ protected linkPath = 'dso';
+
+ constructor(
+ protected responseCache: ResponseCacheService,
+ protected requestService: RequestService,
+ protected rdbService: RemoteDataBuildService,
+ protected store: Store,
+ protected halService: HALEndpointService) {
+ super();
+ }
+
+ getScopedEndpoint(scope: string): Observable {
+ return undefined;
+ }
+
+ getFindByIDHref(endpoint, resourceID): string {
+ return endpoint.replace(/\{\?uuid\}/,`?uuid=${resourceID}`);
+ }
+}
+
+@Injectable()
+export class DSpaceObjectDataService {
+ protected linkPath = 'dso';
+ private dataService: DataServiceImpl;
+
+ constructor(
+ protected requestService: RequestService,
+ protected rdbService: RemoteDataBuildService,
+ protected halService: HALEndpointService) {
+ this.dataService = new DataServiceImpl(null, requestService, rdbService, null, halService);
+ }
+
+ findById(uuid: string): Observable> {
+ return this.dataService.findById(uuid);
+ }
+}
diff --git a/src/app/core/data/paginated-list.ts b/src/app/core/data/paginated-list.ts
index 21cc13f3fa..07d53739d0 100644
--- a/src/app/core/data/paginated-list.ts
+++ b/src/app/core/data/paginated-list.ts
@@ -8,7 +8,7 @@ export class PaginatedList {
}
get elementsPerPage(): number {
- if (hasValue(this.pageInfo)) {
+ if (hasValue(this.pageInfo) && hasValue(this.pageInfo.elementsPerPage)) {
return this.pageInfo.elementsPerPage;
}
return this.page.length;
@@ -19,7 +19,7 @@ export class PaginatedList {
}
get totalElements(): number {
- if (hasValue(this.pageInfo)) {
+ if (hasValue(this.pageInfo) && hasValue(this.pageInfo.totalElements)) {
return this.pageInfo.totalElements;
}
return this.page.length;
@@ -30,7 +30,7 @@ export class PaginatedList {
}
get totalPages(): number {
- if (hasValue(this.pageInfo)) {
+ if (hasValue(this.pageInfo) && hasValue(this.pageInfo.totalPages)) {
return this.pageInfo.totalPages;
}
return 1;
@@ -41,7 +41,7 @@ export class PaginatedList {
}
get currentPage(): number {
- if (hasValue(this.pageInfo)) {
+ if (hasValue(this.pageInfo) && hasValue(this.pageInfo.currentPage)) {
return this.pageInfo.currentPage;
}
return 1;
diff --git a/src/app/core/data/registry-metadatafields-response-parsing.service.ts b/src/app/core/data/registry-metadatafields-response-parsing.service.ts
index 2620916070..1fe8b1e15f 100644
--- a/src/app/core/data/registry-metadatafields-response-parsing.service.ts
+++ b/src/app/core/data/registry-metadatafields-response-parsing.service.ts
@@ -1,15 +1,13 @@
import {
- RegistryMetadatafieldsSuccessResponse, RegistryMetadataschemasSuccessResponse,
+ RegistryMetadatafieldsSuccessResponse,
RestResponse
} from '../cache/response-cache.models';
import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model';
import { RestRequest } from './request.models';
import { ResponseParsingService } from './parsing.service';
-import { RegistryMetadataschemasResponse } from '../registry/registry-metadataschemas-response.model';
import { DSpaceRESTv2Serializer } from '../dspace-rest-v2/dspace-rest-v2.serializer';
import { DSOResponseParsingService } from './dso-response-parsing.service';
import { Injectable } from '@angular/core';
-import { forEach } from '@angular/router/src/utils/collection';
import { RegistryMetadatafieldsResponse } from '../registry/registry-metadatafields-response.model';
@Injectable()
diff --git a/src/app/core/data/search-response-parsing.service.ts b/src/app/core/data/search-response-parsing.service.ts
index c7456aa2f9..4039b8f761 100644
--- a/src/app/core/data/search-response-parsing.service.ts
+++ b/src/app/core/data/search-response-parsing.service.ts
@@ -5,8 +5,7 @@ import { ResponseParsingService } from './parsing.service';
import { RestRequest } from './request.models';
import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model';
import { DSpaceRESTv2Serializer } from '../dspace-rest-v2/dspace-rest-v2.serializer';
-import { PageInfo } from '../shared/page-info.model';
-import { hasValue, isNotEmpty } from '../../shared/empty.util';
+import { hasValue } from '../../shared/empty.util';
import { SearchQueryResponse } from '../../+search-page/search-service/search-query-response.model';
import { Metadatum } from '../shared/metadatum.model';
@@ -16,7 +15,7 @@ export class SearchResponseParsingService implements ResponseParsingService {
}
parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse {
- const payload = data.payload;
+ const payload = data.payload._embedded.searchResult;
const hitHighlights = payload._embedded.objects
.map((object) => object.hitHighlights)
.map((hhObject) => {
@@ -56,6 +55,6 @@ export class SearchResponseParsingService implements ResponseParsingService {
}));
payload.objects = objects;
const deserialized = new DSpaceRESTv2Serializer(SearchQueryResponse).deserialize(payload);
- return new SearchSuccessResponse(deserialized, data.statusCode, this.dsoParser.processPageInfo(data.payload));
+ return new SearchSuccessResponse(deserialized, data.statusCode, this.dsoParser.processPageInfo(payload));
}
}
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/core/shared/community.model.ts b/src/app/core/shared/community.model.ts
index 8fd55d312f..20bd50f4a9 100644
--- a/src/app/core/shared/community.model.ts
+++ b/src/app/core/shared/community.model.ts
@@ -61,4 +61,6 @@ export class Community extends DSpaceObject {
collections: Observable>>;
+ subcommunities: Observable>>;
+
}
diff --git a/src/app/core/shared/operators.ts b/src/app/core/shared/operators.ts
index c0b9be3fbf..af41c9c56b 100644
--- a/src/app/core/shared/operators.ts
+++ b/src/app/core/shared/operators.ts
@@ -1,5 +1,5 @@
import { Observable } from 'rxjs/Observable';
-import { filter, flatMap, map, tap } from 'rxjs/operators';
+import { filter, first, flatMap, map, tap } from 'rxjs/operators';
import { hasValueOperator } from '../../shared/empty.util';
import { DSOSuccessResponse } from '../cache/response-cache.models';
import { ResponseCacheEntry } from '../cache/response-cache.reducer';
@@ -45,3 +45,7 @@ export const configureRequest = (requestService: RequestService) =>
export const getRemoteDataPayload = () =>
(source: Observable>): Observable =>
source.pipe(map((remoteData: RemoteData) => remoteData.payload));
+
+export const getSucceededRemoteData = () =>
+ (source: Observable>): Observable> =>
+ source.pipe(first((rd: RemoteData) => rd.hasSucceeded));
diff --git a/src/app/core/shared/view-mode.model.ts b/src/app/core/shared/view-mode.model.ts
new file mode 100644
index 0000000000..b026d68431
--- /dev/null
+++ b/src/app/core/shared/view-mode.model.ts
@@ -0,0 +1,8 @@
+/**
+ * This enumeration represents all possible ways of representing a group of objects in the UI
+ */
+
+export enum ViewMode {
+ List = 'list',
+ Grid = 'grid'
+}
diff --git a/src/app/shared/input-suggestions/input-suggestions.component.html b/src/app/shared/input-suggestions/input-suggestions.component.html
new file mode 100644
index 0000000000..bbe090dac0
--- /dev/null
+++ b/src/app/shared/input-suggestions/input-suggestions.component.html
@@ -0,0 +1,21 @@
+
\ No newline at end of file
diff --git a/src/app/shared/input-suggestions/input-suggestions.component.scss b/src/app/shared/input-suggestions/input-suggestions.component.scss
new file mode 100644
index 0000000000..bea74cf7af
--- /dev/null
+++ b/src/app/shared/input-suggestions/input-suggestions.component.scss
@@ -0,0 +1,17 @@
+.autocomplete {
+ width: 100%;
+ .dropdown-item {
+ white-space: normal;
+ word-break: break-word;
+ &:focus {
+ outline: none;
+ }
+ }
+}
+
+form {
+ position: relative;
+ .dropdown-menu {
+ position: absolute;
+ }
+}
\ No newline at end of file
diff --git a/src/app/shared/input-suggestions/input-suggestions.component.spec.ts b/src/app/shared/input-suggestions/input-suggestions.component.spec.ts
new file mode 100644
index 0000000000..8b6cdd2aa5
--- /dev/null
+++ b/src/app/shared/input-suggestions/input-suggestions.component.spec.ts
@@ -0,0 +1,306 @@
+import { ChangeDetectionStrategy, DebugElement, NO_ERRORS_SCHEMA } from '@angular/core';
+
+import { TranslateModule } from '@ngx-translate/core';
+import { InputSuggestionsComponent } from './input-suggestions.component';
+import { By } from '@angular/platform-browser';
+import { async, ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing';
+import { FormsModule } from '@angular/forms';
+import { NoopAnimationsModule } from '@angular/platform-browser/animations';
+import { RouterTestingModule } from '@angular/router/testing';
+
+describe('InputSuggestionsComponent', () => {
+
+ let comp: InputSuggestionsComponent;
+ let fixture: ComponentFixture;
+ let de: DebugElement;
+ let el: HTMLElement;
+ const suggestions = [{displayValue: 'suggestion uno', value: 'suggestion uno'}, {displayValue: 'suggestion dos', value: 'suggestion dos'}, {displayValue: 'suggestion tres', value: 'suggestion tres'}];
+
+ beforeEach(async(() => {
+ TestBed.configureTestingModule({
+ imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([]), NoopAnimationsModule, FormsModule],
+ declarations: [InputSuggestionsComponent],
+ providers: [],
+ schemas: [NO_ERRORS_SCHEMA]
+ }).overrideComponent(InputSuggestionsComponent, {
+ set: { changeDetection: ChangeDetectionStrategy.Default }
+ }).compileComponents();
+ }));
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(InputSuggestionsComponent);
+
+ comp = fixture.componentInstance; // LoadingComponent test instance
+ comp.suggestions = suggestions;
+ // query for the message by CSS element selector
+ de = fixture.debugElement;
+ el = de.nativeElement;
+ comp.show.next(true);
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(comp).toBeTruthy();
+ });
+
+ describe('when the input field is in focus', () => {
+
+ beforeEach(() => {
+ const inputElement = de.query(By.css('.suggestion_input'));
+ inputElement.nativeElement.focus();
+ fixture.detectChanges();
+
+ });
+
+ it('should not have any element in focus', () => {
+ const activeElement = el.ownerDocument.activeElement;
+ expect(activeElement.nodeName.toLowerCase()).not.toEqual('a');
+ });
+
+ describe('when key up is pressed', () => {
+ beforeEach(() => {
+ spyOn(comp, 'shiftFocusUp');
+ const form = de.query(By.css('form'));
+ form.triggerEventHandler('keydown.arrowup', {});
+ fixture.detectChanges();
+ });
+
+ it('should call shiftFocusUp()', () => {
+ expect(comp.shiftFocusUp).toHaveBeenCalled();
+ });
+ });
+
+ describe('when shiftFocusUp() is triggered', () => {
+ beforeEach(() => {
+ comp.shiftFocusUp(new KeyboardEvent('keydown.arrowup'));
+ fixture.detectChanges();
+ });
+
+ it('should put the focus on the last element ', () => {
+ const lastLink = de.query(By.css('.list-unstyled > li:last-child a'));
+ const activeElement = el.ownerDocument.activeElement;
+ expect(activeElement).toEqual(lastLink.nativeElement);
+ });
+ });
+
+ describe('when key down is pressed', () => {
+ beforeEach(() => {
+ spyOn(comp, 'shiftFocusDown');
+ const form = de.query(By.css('form'));
+ form.triggerEventHandler('keydown.arrowdown', {});
+ fixture.detectChanges();
+ });
+
+ it('should call shiftFocusDown()', () => {
+ expect(comp.shiftFocusDown).toHaveBeenCalled();
+ });
+ });
+
+ describe('when shiftFocusDown() is triggered', () => {
+ beforeEach(() => {
+ comp.shiftFocusDown(new KeyboardEvent('keydown.arrowdown'));
+ fixture.detectChanges();
+ });
+
+ it('should put the focus on the first element ', () => {
+ const firstLink = de.query(By.css('.list-unstyled > li:first-child a'));
+ const activeElement = el.ownerDocument.activeElement;
+ expect(activeElement).toEqual(firstLink.nativeElement);
+ });
+ });
+
+ describe('when changeFocus() is triggered when selectedIndex is 1', () => {
+ beforeEach(() => {
+ comp.selectedIndex = 1;
+ comp.changeFocus();
+ fixture.detectChanges();
+ });
+
+ it('should put the focus on the second element', () => {
+ const secondLink = de.query(By.css('.list-unstyled > li:nth-child(2) a'));
+ const activeElement = el.ownerDocument.activeElement;
+ expect(activeElement).toEqual(secondLink.nativeElement);
+ });
+ });
+ });
+
+ describe('when the first element is in focus', () => {
+ beforeEach(() => {
+ const firstLink = de.query(By.css('.list-unstyled > li:first-child a'));
+ firstLink.nativeElement.focus();
+ comp.selectedIndex = 0;
+ fixture.detectChanges();
+
+ });
+
+ describe('when shiftFocusUp() is triggered', () => {
+ beforeEach(() => {
+ comp.shiftFocusUp(new KeyboardEvent('keydown.arrowup'));
+ fixture.detectChanges();
+ });
+
+ it('should put the focus on the last element ', () => {
+ const lastLink = de.query(By.css('.list-unstyled > li:last-child a'));
+ const activeElement = el.ownerDocument.activeElement;
+ expect(activeElement).toEqual(lastLink.nativeElement);
+ });
+ });
+
+ describe('when shiftFocusDown() is triggered', () => {
+ beforeEach(() => {
+ comp.shiftFocusDown(new KeyboardEvent('keydown.arrowdown'));
+ fixture.detectChanges();
+ });
+
+ it('should put the focus on the second element ', () => {
+ const secondLink = de.query(By.css('.list-unstyled > li:nth-child(2) a'));
+ const activeElement = el.ownerDocument.activeElement;
+ expect(activeElement).toEqual(secondLink.nativeElement);
+ });
+ });
+ });
+
+ describe('when the last element is in focus', () => {
+ beforeEach(() => {
+ const lastLink = de.query(By.css('.list-unstyled > li:last-child a'));
+ lastLink.nativeElement.focus();
+ comp.selectedIndex = suggestions.length - 1;
+ fixture.detectChanges();
+
+ });
+
+ describe('when shiftFocusUp() is triggered', () => {
+ beforeEach(() => {
+ comp.shiftFocusUp(new KeyboardEvent('keydown.arrowup'));
+ fixture.detectChanges();
+ });
+
+ it('should put the focus on the second last element ', () => {
+ const secondLastLink = de.query(By.css('.list-unstyled > li:nth-last-child(2) a'));
+ const activeElement = el.ownerDocument.activeElement;
+ expect(activeElement).toEqual(secondLastLink.nativeElement);
+ });
+ });
+
+ describe('when shiftFocusDown() is triggered', () => {
+ beforeEach(() => {
+ comp.shiftFocusDown(new KeyboardEvent('keydown.arrowdown'));
+ fixture.detectChanges();
+ });
+
+ it('should put the focus on the first element ', () => {
+ const firstLink = de.query(By.css('.list-unstyled > li:first-child a'));
+ const activeElement = el.ownerDocument.activeElement;
+ expect(activeElement).toEqual(firstLink.nativeElement);
+ });
+ });
+
+ describe('when any key but is pressed in the form', () => {
+ beforeEach(() => {
+ spyOn(comp, 'onKeydown');
+ const form = de.query(By.css('form'));
+ form.triggerEventHandler('keydown', { key: 'Shift' });
+ fixture.detectChanges();
+ });
+
+ it('should call onKeydown', () => {
+ expect(comp.onKeydown).toHaveBeenCalled();
+ fixture.detectChanges();
+ })
+ });
+ describe('when onKeydown is triggered with the Enter key', () => {
+ beforeEach(() => {
+ spyOn(comp.queryInput.nativeElement, 'focus');
+ comp.onKeydown(new KeyboardEvent('keydown', {key: 'Enter'}));
+ fixture.detectChanges();
+ });
+
+ it('should not change the focus', () => {
+ expect(comp.queryInput.nativeElement.focus).not.toHaveBeenCalled();
+ });
+
+ });
+
+ describe('when onKeydown is triggered with the any other (not-Enter) key', () => {
+ beforeEach(() => {
+ spyOn(comp.queryInput.nativeElement, 'focus');
+ comp.onKeydown(new KeyboardEvent('keydown', {key: 'Shift'}));
+ fixture.detectChanges();
+ });
+
+ it('should change the focus', () => {
+ expect(comp.queryInput.nativeElement.focus).toHaveBeenCalled();
+ });
+
+ });
+ });
+
+ describe('when the suggestions list is not empty and show is true', () => {
+ beforeEach(() => {
+ comp.show.next(true);
+ fixture.detectChanges();
+ });
+ it('should contain an .autocomplete list with a \'show\' class', () => {
+ const autocomplete = de.query(By.css('div.autocomplete'));
+ expect(autocomplete.nativeElement.classList).toContain('show');
+ });
+ });
+
+ describe('when the suggestions list is not empty and show is false', () => {
+ beforeEach(() => {
+ comp.show.next(false);
+ fixture.detectChanges();
+ });
+ it('should contain an .autocomplete list without a \'show\' class', () => {
+ const autocomplete = de.query(By.css('div.autocomplete'));
+ expect(autocomplete.nativeElement.classList).not.toContain('show');
+ });
+ });
+
+ describe('when the suggestions list is empty and show is false', () => {
+ beforeEach(() => {
+ comp.suggestions = [];
+ comp.show.next(false);
+ fixture.detectChanges();
+ });
+ it('should contain an .autocomplete list without a \'show\' class', () => {
+ const autocomplete = de.query(By.css('div.autocomplete'));
+ expect(autocomplete.nativeElement.classList).not.toContain('show');
+ });
+ });
+ describe('when the suggestions list is empty and show is true', () => {
+ beforeEach(() => {
+ comp.suggestions = [];
+ comp.show.next(true);
+ fixture.detectChanges();
+ });
+ it('should contain an .autocomplete list without a \'show\' class', () => {
+ const autocomplete = de.query(By.css('div.autocomplete'));
+ expect(autocomplete.nativeElement.classList).not.toContain('show');
+ });
+ });
+ describe('when the variable \'show\' is set to true and close() is called', () => {
+ beforeEach(() => {
+ comp.show.next(true);
+ comp.close();
+ fixture.detectChanges();
+ });
+ it('should set \'show\' to false', () => {
+ expect(comp.show.getValue()).toBeFalsy();
+ });
+ });
+
+ describe('when an element is clicked', () => {
+ const clickedIndex = 0;
+ beforeEach(() => {
+ spyOn(comp, 'onClickSuggestion');
+ const clickedLink = de.query(By.css('.list-unstyled > li:nth-child(' + (clickedIndex + 1) + ') a'));
+ clickedLink.triggerEventHandler('click', {} );
+ fixture.detectChanges();
+ });
+ it('should call onClickSuggestion() with the suggestion as a parameter', () => {
+ expect(comp.onClickSuggestion).toHaveBeenCalledWith(suggestions[clickedIndex].value);
+ });
+ });
+
+});
diff --git a/src/app/shared/input-suggestions/input-suggestions.component.ts b/src/app/shared/input-suggestions/input-suggestions.component.ts
new file mode 100644
index 0000000000..eb28583eaa
--- /dev/null
+++ b/src/app/shared/input-suggestions/input-suggestions.component.ts
@@ -0,0 +1,191 @@
+import {
+ Component,
+ ElementRef, EventEmitter,
+ Input,
+ Output,
+ QueryList, SimpleChanges,
+ ViewChild,
+ ViewChildren
+} from '@angular/core';
+import { BehaviorSubject } from 'rxjs/BehaviorSubject';
+import { hasValue, isNotEmpty } from '../empty.util';
+
+@Component({
+ selector: 'ds-input-suggestions',
+ styleUrls: ['./input-suggestions.component.scss'],
+ templateUrl: './input-suggestions.component.html'
+})
+
+/**
+ * Component representing a form with a autocomplete functionality
+ */
+export class InputSuggestionsComponent {
+ /**
+ * The suggestions that should be shown
+ */
+ @Input() suggestions: any[] = [];
+
+ /**
+ * The time waited to detect if any other input will follow before requesting the suggestions
+ */
+ @Input() debounceTime = 500;
+
+ /**
+ * Placeholder attribute for the input field
+ */
+ @Input() placeholder = '';
+
+ /**
+ * Action attribute for the form
+ */
+ @Input() action;
+
+ /**
+ * Name attribute for the input field
+ */
+ @Input() name;
+
+ /**
+ * Value of the input field
+ */
+ @Input() ngModel;
+
+ /**
+ * Output for when the input field's value changes
+ */
+ @Output() ngModelChange = new EventEmitter();
+
+ /**
+ * Output for when the form is submitted
+ */
+ @Output() submitSuggestion = new EventEmitter();
+
+ /**
+ * Output for when a suggestion is clicked
+ */
+ @Output() clickSuggestion = new EventEmitter();
+
+ /**
+ * Output for when new suggestions should be requested
+ */
+ @Output() findSuggestions = new EventEmitter();
+
+ /**
+ * Emits true when the list of suggestions should be shown
+ */
+ show = new BehaviorSubject(false);
+
+ /**
+ * Index of the currently selected suggestion
+ */
+ selectedIndex = -1;
+
+ /**
+ * True when the dropdown should not reopen
+ */
+ blockReopen = false;
+
+ /**
+ * Reference to the input field component
+ */
+ @ViewChild('inputField') queryInput: ElementRef;
+ /**
+ * Reference to the suggestion components
+ */
+ @ViewChildren('suggestion') resultViews: QueryList;
+
+ /**
+ * When any of the inputs change, check if we should still show the suggestions
+ */
+ ngOnChanges(changes: SimpleChanges) {
+ if (hasValue(changes.suggestions)) {
+ this.show.next(isNotEmpty(changes.suggestions.currentValue));
+ }
+ }
+
+ /**
+ * Move the focus on one of the suggestions up to the previous suggestion
+ * When no suggestion is currently in focus OR the first suggestion is in focus: shift to the last suggestion
+ */
+ shiftFocusUp(event: KeyboardEvent) {
+ event.preventDefault();
+ if (this.selectedIndex > 0) {
+ this.selectedIndex--;
+ this.selectedIndex = (this.selectedIndex + this.resultViews.length) % this.resultViews.length; // Prevent negative modulo outcome
+ } else {
+ this.selectedIndex = this.resultViews.length - 1;
+ }
+ this.changeFocus();
+ }
+
+ /**
+ * Move the focus on one of the suggestions up to the next suggestion
+ * When no suggestion is currently in focus OR the last suggestion is in focus: shift to the first suggestion
+ */
+ shiftFocusDown(event: KeyboardEvent) {
+ event.preventDefault();
+ if (this.selectedIndex >= 0) {
+ this.selectedIndex++;
+ this.selectedIndex %= this.resultViews.length;
+ } else {
+ this.selectedIndex = 0;
+ }
+ this.changeFocus();
+ }
+
+ /**
+ * Perform the change of focus to the current selectedIndex
+ */
+ changeFocus() {
+ if (this.resultViews.length > 0) {
+ this.resultViews.toArray()[this.selectedIndex].nativeElement.focus();
+ }
+ }
+
+ /**
+ * When any key is pressed (except for the Enter button) the query input should move to the input field
+ * @param {KeyboardEvent} event The keyboard event
+ */
+ onKeydown(event: KeyboardEvent) {
+ if (event.key !== 'Enter') {
+ this.queryInput.nativeElement.focus();
+ }
+ }
+
+ /**
+ * Changes the show variable so the suggestion dropdown closes
+ */
+ close() {
+ this.show.next(false);
+ }
+
+ /**
+ * For usage of the isNotEmpty function in the template
+ */
+ isNotEmpty(data) {
+ return isNotEmpty(data);
+ }
+
+ /**
+ * Make sure that if a suggestion is clicked, the suggestions dropdown closes, does not reopen and the focus moves to the input field
+ */
+ onClickSuggestion(data) {
+ this.clickSuggestion.emit(data);
+ this.close();
+ this.blockReopen = true;
+ this.queryInput.nativeElement.focus();
+ return false;
+ }
+
+ /**
+ * Finds new suggestions when necessary
+ * @param data The query value to emit
+ */
+ find(data) {
+ if (!this.blockReopen) {
+ this.findSuggestions.emit(data);
+ }
+ this.blockReopen = false;
+ }
+
+}
diff --git a/src/app/shared/loading/loading.component.html b/src/app/shared/loading/loading.component.html
index 4f6b1a34fa..c628f75f7b 100644
--- a/src/app/shared/loading/loading.component.html
+++ b/src/app/shared/loading/loading.component.html
@@ -1,5 +1,5 @@
-
{{ message }}
+
{{ message }}
diff --git a/src/app/shared/loading/loading.component.spec.ts b/src/app/shared/loading/loading.component.spec.ts
index aca9673282..3c58fa3e9e 100644
--- a/src/app/shared/loading/loading.component.spec.ts
+++ b/src/app/shared/loading/loading.component.spec.ts
@@ -34,7 +34,8 @@ describe('LoadingComponent (inline template)', () => {
fixture = TestBed.createComponent(LoadingComponent);
comp = fixture.componentInstance; // LoadingComponent test instance
-
+ comp.message = 'test message';
+ fixture.detectChanges();
// query for the message
by CSS element selector
de = fixture.debugElement.query(By.css('label'));
el = de.nativeElement;
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/notifications/notification/notification.component.html b/src/app/shared/notifications/notification/notification.component.html
index 561e10263c..b35d6dee59 100644
--- a/src/app/shared/notifications/notification/notification.component.html
+++ b/src/app/shared/notifications/notification/notification.component.html
@@ -1,4 +1,4 @@
-
diff --git a/src/app/shared/notifications/notification/notification.component.scss b/src/app/shared/notifications/notification/notification.component.scss
index c433cd1e4d..1c8f0ae17c 100644
--- a/src/app/shared/notifications/notification/notification.component.scss
+++ b/src/app/shared/notifications/notification/notification.component.scss
@@ -1,5 +1,11 @@
@import '../../../../styles/variables.scss';
+.alert {
+ display: inline-block;
+ min-width: $modal-sm;
+ text-align: left;
+}
+
.close {
outline: none !important
}
diff --git a/src/app/shared/notifications/notifications-board/notifications-board.component.html b/src/app/shared/notifications/notifications-board/notifications-board.component.html
index d660444632..15f5044bc4 100644
--- a/src/app/shared/notifications/notifications-board/notifications-board.component.html
+++ b/src/app/shared/notifications/notifications-board/notifications-board.component.html
@@ -1,5 +1,6 @@
diff --git a/src/app/shared/notifications/notifications-board/notifications-board.component.scss b/src/app/shared/notifications/notifications-board/notifications-board.component.scss
index 0dd1584c4e..47b1dba0c3 100644
--- a/src/app/shared/notifications/notifications-board/notifications-board.component.scss
+++ b/src/app/shared/notifications/notifications-board/notifications-board.component.scss
@@ -2,24 +2,28 @@
@import '../../../../styles/mixins';
.notifications-wrapper {
- width: 300px;
- z-index: 1051;
+ z-index: $zindex-popover;
+ text-align: right;
+ @include word-wrap;
+ .notification {
+ display: block;
+ }
}
.notifications-wrapper.left {
- left: 20px;
+ left: 0;
}
.notifications-wrapper.top {
- top: 20px;
+ top:0;
}
.notifications-wrapper.right {
- right: 20px;
+ right: 0;
}
.notifications-wrapper.bottom {
- bottom: 20px;
+ bottom: 0;
}
.notifications-wrapper.center {
@@ -39,7 +43,7 @@
@media screen and (max-width: map-get($grid-breakpoints, sm)) {
.notifications-wrapper {
width: auto;
- left: 20px;
- right: 20px;
+ left: 0;
+ right: 0;
}
}
diff --git a/src/app/shared/object-collection/object-collection.component.spec.ts b/src/app/shared/object-collection/object-collection.component.spec.ts
index b27a342eac..0548efc967 100644
--- a/src/app/shared/object-collection/object-collection.component.spec.ts
+++ b/src/app/shared/object-collection/object-collection.component.spec.ts
@@ -1,13 +1,11 @@
import { ObjectCollectionComponent } from './object-collection.component';
-import { ViewMode } from '../../+search-page/search-options.model';
-import { element } from 'protractor';
import { By } from '@angular/platform-browser';
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
-import { Config } from '../../../config/config.interface';
-import { DebugElement, NO_ERRORS_SCHEMA } from '@angular/core';
+import { NO_ERRORS_SCHEMA } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { Observable } from 'rxjs/Observable';
import { RouterStub } from '../testing/router-stub';
+import { ViewMode } from '../../core/shared/view-mode.model';
describe('ObjectCollectionComponent', () => {
let fixture: ComponentFixture
;
diff --git a/src/app/shared/object-collection/object-collection.component.ts b/src/app/shared/object-collection/object-collection.component.ts
index 3af92443c1..b4a436c5de 100644
--- a/src/app/shared/object-collection/object-collection.component.ts
+++ b/src/app/shared/object-collection/object-collection.component.ts
@@ -14,8 +14,8 @@ import { PaginationComponentOptions } from '../pagination/pagination-component-o
import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model';
import { ListableObject } from './shared/listable-object.model';
-import { ViewMode } from '../../+search-page/search-options.model';
import { hasValue, isNotEmpty } from '../empty.util';
+import { ViewMode } from '../../core/shared/view-mode.model';
@Component({
selector: 'ds-viewable-collection',
diff --git a/src/app/shared/object-collection/shared/dso-element-decorator.spec.ts b/src/app/shared/object-collection/shared/dso-element-decorator.spec.ts
index 9f54bebed9..9243d13b8e 100644
--- a/src/app/shared/object-collection/shared/dso-element-decorator.spec.ts
+++ b/src/app/shared/object-collection/shared/dso-element-decorator.spec.ts
@@ -1,6 +1,6 @@
-import { ViewMode } from '../../../+search-page/search-options.model';
import { renderElementsFor } from './dso-element-decorator';
import { Item } from '../../../core/shared/item.model';
+import { ViewMode } from '../../../core/shared/view-mode.model';
describe('ElementDecorator', () => {
const gridDecorator = renderElementsFor(Item, ViewMode.Grid);
diff --git a/src/app/shared/object-collection/shared/dso-element-decorator.ts b/src/app/shared/object-collection/shared/dso-element-decorator.ts
index 51aa57bc50..98650fd25b 100644
--- a/src/app/shared/object-collection/shared/dso-element-decorator.ts
+++ b/src/app/shared/object-collection/shared/dso-element-decorator.ts
@@ -1,6 +1,6 @@
import { GenericConstructor } from '../../../core/shared/generic-constructor';
import { ListableObject } from './listable-object.model';
-import { ViewMode } from '../../../+search-page/search-options.model';
+import { ViewMode } from '../../../core/shared/view-mode.model';
const dsoElementMap = new Map();
export function renderElementsFor(listable: GenericConstructor, viewMode: ViewMode) {
diff --git a/src/app/shared/object-grid/collection-grid-element/collection-grid-element.component.ts b/src/app/shared/object-grid/collection-grid-element/collection-grid-element.component.ts
index f383367e3f..a690037619 100644
--- a/src/app/shared/object-grid/collection-grid-element/collection-grid-element.component.ts
+++ b/src/app/shared/object-grid/collection-grid-element/collection-grid-element.component.ts
@@ -2,8 +2,8 @@ import { Component, Inject } from '@angular/core';
import { Collection } from '../../../core/shared/collection.model';
import { renderElementsFor} from '../../object-collection/shared/dso-element-decorator';
-import { ViewMode } from '../../../+search-page/search-options.model';
import { AbstractListableElementComponent } from '../../object-collection/shared/object-collection-element/abstract-listable-element.component';
+import { ViewMode } from '../../../core/shared/view-mode.model';
@Component({
selector: 'ds-collection-grid-element',
diff --git a/src/app/shared/object-grid/community-grid-element/community-grid-element.component.ts b/src/app/shared/object-grid/community-grid-element/community-grid-element.component.ts
index 21d73ff0fa..4df9ab9555 100644
--- a/src/app/shared/object-grid/community-grid-element/community-grid-element.component.ts
+++ b/src/app/shared/object-grid/community-grid-element/community-grid-element.component.ts
@@ -3,7 +3,7 @@ import { Component, Input, Inject } from '@angular/core';
import { Community } from '../../../core/shared/community.model';
import { AbstractListableElementComponent } from '../../object-collection/shared/object-collection-element/abstract-listable-element.component';
import { renderElementsFor} from '../../object-collection/shared/dso-element-decorator';
-import { ViewMode } from '../../../+search-page/search-options.model';
+import { ViewMode } from '../../../core/shared/view-mode.model';
@Component({
selector: 'ds-community-grid-element',
diff --git a/src/app/shared/object-grid/item-grid-element/item-grid-element.component.ts b/src/app/shared/object-grid/item-grid-element/item-grid-element.component.ts
index 5a8d46f1f2..7a679fef25 100644
--- a/src/app/shared/object-grid/item-grid-element/item-grid-element.component.ts
+++ b/src/app/shared/object-grid/item-grid-element/item-grid-element.component.ts
@@ -3,7 +3,7 @@ import { Component, Input, Inject } from '@angular/core';
import { Item } from '../../../core/shared/item.model';
import { renderElementsFor} from '../../object-collection/shared/dso-element-decorator';
import { AbstractListableElementComponent } from '../../object-collection/shared/object-collection-element/abstract-listable-element.component';
-import { ViewMode } from '../../../+search-page/search-options.model';
+import { ViewMode } from '../../../core/shared/view-mode.model';
@Component({
selector: 'ds-item-grid-element',
diff --git a/src/app/shared/object-grid/search-result-grid-element/collection-search-result/collection-search-result-grid-element.component.ts b/src/app/shared/object-grid/search-result-grid-element/collection-search-result/collection-search-result-grid-element.component.ts
index 7f8a3bb9fd..c1f27bee4f 100644
--- a/src/app/shared/object-grid/search-result-grid-element/collection-search-result/collection-search-result-grid-element.component.ts
+++ b/src/app/shared/object-grid/search-result-grid-element/collection-search-result/collection-search-result-grid-element.component.ts
@@ -3,8 +3,8 @@ import { Component } from '@angular/core';
import { renderElementsFor} from '../../../object-collection/shared/dso-element-decorator';
import { SearchResultGridElementComponent } from '../search-result-grid-element.component';
import { Collection } from '../../../../core/shared/collection.model';
-import { ViewMode } from '../../../../+search-page/search-options.model';
import { CollectionSearchResult } from '../../../object-collection/shared/collection-search-result.model';
+import { ViewMode } from '../../../../core/shared/view-mode.model';
@Component({
selector: 'ds-collection-search-result-grid-element',
diff --git a/src/app/shared/object-grid/search-result-grid-element/community-search-result/community-search-result-grid-element.component.ts b/src/app/shared/object-grid/search-result-grid-element/community-search-result/community-search-result-grid-element.component.ts
index 7c34207f5e..8fac11a6a4 100644
--- a/src/app/shared/object-grid/search-result-grid-element/community-search-result/community-search-result-grid-element.component.ts
+++ b/src/app/shared/object-grid/search-result-grid-element/community-search-result/community-search-result-grid-element.component.ts
@@ -2,8 +2,8 @@ import { Component } from '@angular/core';
import { Community } from '../../../../core/shared/community.model';
import { renderElementsFor } from '../../../object-collection/shared/dso-element-decorator';
import { SearchResultGridElementComponent } from '../search-result-grid-element.component';
-import { ViewMode } from '../../../../+search-page/search-options.model';
import { CommunitySearchResult } from '../../../object-collection/shared/community-search-result.model';
+import { ViewMode } from '../../../../core/shared/view-mode.model';
@Component({
selector: 'ds-community-search-result-grid-element',
diff --git a/src/app/shared/object-grid/search-result-grid-element/item-search-result/item-search-result-grid-element.component.ts b/src/app/shared/object-grid/search-result-grid-element/item-search-result/item-search-result-grid-element.component.ts
index 518fc23a44..232c161779 100644
--- a/src/app/shared/object-grid/search-result-grid-element/item-search-result/item-search-result-grid-element.component.ts
+++ b/src/app/shared/object-grid/search-result-grid-element/item-search-result/item-search-result-grid-element.component.ts
@@ -4,8 +4,8 @@ import { renderElementsFor } from '../../../object-collection/shared/dso-element
import { SearchResultGridElementComponent } from '../search-result-grid-element.component';
import { Item } from '../../../../core/shared/item.model';
import { ItemSearchResult } from '../../../object-collection/shared/item-search-result.model';
-import { ViewMode } from '../../../../+search-page/search-options.model';
import { focusShadow } from '../../../../shared/animations/focus';
+import { ViewMode } from '../../../../core/shared/view-mode.model';
@Component({
selector: 'ds-item-search-result-grid-element',
diff --git a/src/app/shared/object-grid/wrapper-grid-element/wrapper-grid-element.component.ts b/src/app/shared/object-grid/wrapper-grid-element/wrapper-grid-element.component.ts
index f807542e76..e16aac6799 100644
--- a/src/app/shared/object-grid/wrapper-grid-element/wrapper-grid-element.component.ts
+++ b/src/app/shared/object-grid/wrapper-grid-element/wrapper-grid-element.component.ts
@@ -1,8 +1,8 @@
import { Component, Injector, Input, OnInit } from '@angular/core';
-import { ViewMode } from '../../../+search-page/search-options.model';
import { GenericConstructor } from '../../../core/shared/generic-constructor';
import { rendersDSOType } from '../../object-collection/shared/dso-element-decorator';
import { ListableObject } from '../../object-collection/shared/listable-object.model';
+import { ViewMode } from '../../../core/shared/view-mode.model';
@Component({
selector: 'ds-wrapper-grid-element',
diff --git a/src/app/shared/object-list/collection-list-element/collection-list-element.component.ts b/src/app/shared/object-list/collection-list-element/collection-list-element.component.ts
index f95c087d5c..b4e9de16b7 100644
--- a/src/app/shared/object-list/collection-list-element/collection-list-element.component.ts
+++ b/src/app/shared/object-list/collection-list-element/collection-list-element.component.ts
@@ -2,8 +2,8 @@ import { Component, Inject } from '@angular/core';
import { Collection } from '../../../core/shared/collection.model';
import { renderElementsFor } from '../../object-collection/shared/dso-element-decorator';
-import { ViewMode } from '../../../+search-page/search-options.model';
import { AbstractListableElementComponent } from '../../object-collection/shared/object-collection-element/abstract-listable-element.component';
+import { ViewMode } from '../../../core/shared/view-mode.model';
@Component({
selector: 'ds-collection-list-element',
diff --git a/src/app/shared/object-list/community-list-element/community-list-element.component.ts b/src/app/shared/object-list/community-list-element/community-list-element.component.ts
index e92c080a31..8f6870db7e 100644
--- a/src/app/shared/object-list/community-list-element/community-list-element.component.ts
+++ b/src/app/shared/object-list/community-list-element/community-list-element.component.ts
@@ -1,9 +1,9 @@
-import { Component, Input, Inject } from '@angular/core';
+import { Component } from '@angular/core';
import { Community } from '../../../core/shared/community.model';
import { AbstractListableElementComponent } from '../../object-collection/shared/object-collection-element/abstract-listable-element.component';
import { renderElementsFor } from '../../object-collection/shared/dso-element-decorator';
-import { ViewMode } from '../../../+search-page/search-options.model';
+import { ViewMode } from '../../../core/shared/view-mode.model';
@Component({
selector: 'ds-community-list-element',
diff --git a/src/app/shared/object-list/item-list-element/item-list-element.component.ts b/src/app/shared/object-list/item-list-element/item-list-element.component.ts
index e4efbd6b08..bc77b513fb 100644
--- a/src/app/shared/object-list/item-list-element/item-list-element.component.ts
+++ b/src/app/shared/object-list/item-list-element/item-list-element.component.ts
@@ -1,9 +1,9 @@
-import { Component, Input, Inject } from '@angular/core';
+import { Component } from '@angular/core';
import { Item } from '../../../core/shared/item.model';
import { AbstractListableElementComponent } from '../../object-collection/shared/object-collection-element/abstract-listable-element.component';
import { renderElementsFor } from '../../object-collection/shared/dso-element-decorator';
-import { ViewMode } from '../../../+search-page/search-options.model';
+import { ViewMode } from '../../../core/shared/view-mode.model';
@Component({
selector: 'ds-item-list-element',
diff --git a/src/app/shared/object-list/search-result-list-element/collection-search-result/collection-search-result-list-element.component.ts b/src/app/shared/object-list/search-result-list-element/collection-search-result/collection-search-result-list-element.component.ts
index 746e700f05..264f9ce1a9 100644
--- a/src/app/shared/object-list/search-result-list-element/collection-search-result/collection-search-result-list-element.component.ts
+++ b/src/app/shared/object-list/search-result-list-element/collection-search-result/collection-search-result-list-element.component.ts
@@ -4,8 +4,8 @@ import { renderElementsFor } from '../../../object-collection/shared/dso-element
import { SearchResultListElementComponent } from '../search-result-list-element.component';
import { Collection } from '../../../../core/shared/collection.model';
-import { ViewMode } from '../../../../+search-page/search-options.model';
import { CollectionSearchResult } from '../../../object-collection/shared/collection-search-result.model';
+import { ViewMode } from '../../../../core/shared/view-mode.model';
@Component({
selector: 'ds-collection-search-result-list-element',
diff --git a/src/app/shared/object-list/search-result-list-element/community-search-result/community-search-result-list-element.component.ts b/src/app/shared/object-list/search-result-list-element/community-search-result/community-search-result-list-element.component.ts
index 2ca89fc9c5..227ff9a45d 100644
--- a/src/app/shared/object-list/search-result-list-element/community-search-result/community-search-result-list-element.component.ts
+++ b/src/app/shared/object-list/search-result-list-element/community-search-result/community-search-result-list-element.component.ts
@@ -4,8 +4,8 @@ import { renderElementsFor } from '../../../object-collection/shared/dso-element
import { SearchResultListElementComponent } from '../search-result-list-element.component';
import { Community } from '../../../../core/shared/community.model';
-import { ViewMode } from '../../../../+search-page/search-options.model';
import { CommunitySearchResult } from '../../../object-collection/shared/community-search-result.model';
+import { ViewMode } from '../../../../core/shared/view-mode.model';
@Component({
selector: 'ds-community-search-result-list-element',
diff --git a/src/app/shared/object-list/search-result-list-element/item-search-result/item-search-result-list-element.component.html b/src/app/shared/object-list/search-result-list-element/item-search-result/item-search-result-list-element.component.html
index e1f559dd66..584d476e73 100644
--- a/src/app/shared/object-list/search-result-list-element/item-search-result/item-search-result-list-element.component.html
+++ b/src/app/shared/object-list/search-result-list-element/item-search-result/item-search-result-list-element.component.html
@@ -5,7 +5,7 @@
(, )
0"
diff --git a/src/app/shared/object-list/search-result-list-element/item-search-result/item-search-result-list-element.component.ts b/src/app/shared/object-list/search-result-list-element/item-search-result/item-search-result-list-element.component.ts
index b776abc214..a0cbc2469c 100644
--- a/src/app/shared/object-list/search-result-list-element/item-search-result/item-search-result-list-element.component.ts
+++ b/src/app/shared/object-list/search-result-list-element/item-search-result/item-search-result-list-element.component.ts
@@ -1,12 +1,11 @@
-import { ChangeDetectorRef, Component, Inject, OnInit } from '@angular/core';
+import { Component } from '@angular/core';
import { renderElementsFor } from '../../../object-collection/shared/dso-element-decorator';
import { SearchResultListElementComponent } from '../search-result-list-element.component';
import { Item } from '../../../../core/shared/item.model';
import { ItemSearchResult } from '../../../object-collection/shared/item-search-result.model';
-import { ViewMode } from '../../../../+search-page/search-options.model';
-import { ListableObject } from '../../../object-collection/shared/listable-object.model';
import { focusBackground } from '../../../animations/focus';
+import { ViewMode } from '../../../../core/shared/view-mode.model';
@Component({
selector: 'ds-item-search-result-list-element',
diff --git a/src/app/shared/object-list/wrapper-list-element/wrapper-list-element.component.ts b/src/app/shared/object-list/wrapper-list-element/wrapper-list-element.component.ts
index 217c1776c5..e95def7cf7 100644
--- a/src/app/shared/object-list/wrapper-list-element/wrapper-list-element.component.ts
+++ b/src/app/shared/object-list/wrapper-list-element/wrapper-list-element.component.ts
@@ -1,8 +1,8 @@
import { Component, Injector, Input, OnInit } from '@angular/core';
-import { ViewMode } from '../../../+search-page/search-options.model';
import { GenericConstructor } from '../../../core/shared/generic-constructor';
import { rendersDSOType } from '../../object-collection/shared/dso-element-decorator'
import { ListableObject } from '../../object-collection/shared/listable-object.model';
+import { ViewMode } from '../../../core/shared/view-mode.model';
@Component({
selector: 'ds-wrapper-list-element',
diff --git a/src/app/shared/search-form/search-form.component.html b/src/app/shared/search-form/search-form.component.html
index 8bca5aabeb..19c6f8f1ac 100644
--- a/src/app/shared/search-form/search-form.component.html
+++ b/src/app/shared/search-form/search-form.component.html
@@ -1,6 +1,6 @@