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 @@ +
+
+ + + {{value}} + + +
+ + + + {{value.value}} + + {{value.count}} + + + +
+
+ +
+ +
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 @@ -
-
- - - {{value}} - - - - - - {{value.value}} - - {{value.count}} - - - - - -
-
- - -
-
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}}
-
+
-
-
{{ 'search.sidebar.settings.rpp' | translate}}
- - -
+
+
{{ 'search.sidebar.settings.rpp' | translate}}
+ +
+ \ 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