Merge branch 'master' into browse-by-features

Conflicts:
	src/app/core/core.module.ts
	src/app/core/shared/operators.ts
	src/app/shared/object-grid/item-grid-element/item-grid-element.component.html
	src/app/shared/object-list/item-list-element/item-list-element.component.html
	src/app/shared/shared.module.ts
This commit is contained in:
Kristof De Langhe
2018-08-29 14:24:45 +02:00
171 changed files with 4696 additions and 963 deletions

21
.dockerignore Normal file
View File

@@ -0,0 +1,21 @@
.git
node-modules
__build__
__server_build__
typings
tsd_typings
npm-debug.log
dist
coverage
.idea
*.iml
*.ngfactory.ts
*.css.shim.ts
*.scss.shim.ts
.DS_Store
webpack.records.json
npm-debug.log.*
morgan.log
yarn-error.log
*.css
package-lock.json

10
Dockerfile Normal file
View File

@@ -0,0 +1,10 @@
# This image will be published as dspace/dspace-angular
# See https://dspace-labs.github.io/DSpace-Docker-Images/ for usage details
FROM node:8-alpine
WORKDIR /app
ADD . /app/
EXPOSE 3000
RUN yarn install
CMD yarn run watch

View File

@@ -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",

View File

@@ -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"
}
}

View File

@@ -2,7 +2,7 @@
<div *ngIf="subCollectionsRD?.hasSucceeded" @fadeIn>
<h2>{{'community.sub-collection-list.head' | translate}}</h2>
<ul>
<li *ngFor="let collection of subCollectionsRD?.payload">
<li *ngFor="let collection of subCollectionsRD?.payload.page">
<p>
<span class="lead"><a [routerLink]="['/collections', collection.id]">{{collection.name}}</a></span><br>
<span class="text-muted">{{collection.shortDescription}}</span>

View File

@@ -6,6 +6,7 @@ import { Collection } from '../../core/shared/collection.model';
import { Community } from '../../core/shared/community.model';
import { fadeIn } from '../../shared/animations/fade';
import { PaginatedList } from '../../core/data/paginated-list';
@Component({
selector: 'ds-community-page-sub-collection-list',
@@ -15,7 +16,7 @@ import { fadeIn } from '../../shared/animations/fade';
})
export class CommunityPageSubCollectionListComponent implements OnInit {
@Input() community: Community;
subCollectionsRDObs: Observable<RemoteData<Collection[]>>;
subCollectionsRDObs: Observable<RemoteData<PaginatedList<Collection>>>;
ngOnInit(): void {
this.subCollectionsRDObs = this.community.collections;

View File

@@ -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 }

View File

@@ -1,4 +1,4 @@
<ds-metadata-field-wrapper [label]="label | translate">
<ds-metadata-field-wrapper *ngIf="hasSucceeded() | async" [label]="label | translate">
<div class="collections">
<a *ngFor="let collection of (collections | async); let last=last;" [routerLink]="['/collections', collection.id]">
<span>{{collection?.name}}</span><span *ngIf="!last" [innerHTML]="separator"></span>

View File

@@ -0,0 +1,74 @@
import { CollectionsComponent } from './collections.component';
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core';
import { By } from '@angular/platform-browser';
import { Collection } from '../../../core/shared/collection.model';
import { RemoteDataBuildService } from '../../../core/cache/builders/remote-data-build.service';
import { getMockRemoteDataBuildService } from '../../../shared/mocks/mock-remote-data-build.service';
import { Item } from '../../../core/shared/item.model';
import { Observable } from 'rxjs/Observable';
import { RemoteData } from '../../../core/data/remote-data';
import { TranslateModule } from '@ngx-translate/core';
let collectionsComponent: CollectionsComponent;
let fixture: ComponentFixture<CollectionsComponent>;
const mockCollection1: Collection = Object.assign(new Collection(), {
metadata: [
{
key: 'dc.description.abstract',
language: 'en_US',
value: 'Short description'
}]
});
const succeededMockItem: Item = Object.assign(new Item(), {owningCollection: Observable.of(new RemoteData(false, false, true, null, mockCollection1))});
const failedMockItem: Item = Object.assign(new Item(), {owningCollection: Observable.of(new RemoteData(false, false, false, null, mockCollection1))});
describe('CollectionsComponent', () => {
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot()],
declarations: [ CollectionsComponent ],
providers: [
{ provide: RemoteDataBuildService, useValue: getMockRemoteDataBuildService()}
],
schemas: [ NO_ERRORS_SCHEMA ]
}).overrideComponent(CollectionsComponent, {
set: { changeDetection: ChangeDetectionStrategy.Default }
}).compileComponents();
}));
beforeEach(async(() => {
fixture = TestBed.createComponent(CollectionsComponent);
collectionsComponent = fixture.componentInstance;
collectionsComponent.label = 'test.test';
collectionsComponent.separator = '<br/>';
}));
describe('When the requested item request has succeeded', () => {
beforeEach(() => {
collectionsComponent.item = succeededMockItem;
fixture.detectChanges();
});
it('should show the collection', () => {
const collectionField = fixture.debugElement.query(By.css('ds-metadata-field-wrapper div.collections'));
expect(collectionField).not.toBeNull();
});
});
describe('When the requested item request has failed', () => {
beforeEach(() => {
collectionsComponent.item = failedMockItem;
fixture.detectChanges();
});
it('should not show the collection', () => {
const collectionField = fixture.debugElement.query(By.css('ds-metadata-field-wrapper div.collections'));
expect(collectionField).toBeNull();
});
});
});

View File

@@ -38,4 +38,8 @@ export class CollectionsComponent implements OnInit {
this.collections = this.item.owner.map((rd: RemoteData<Collection>) => [rd.payload]);
}
hasSucceeded() {
return this.item.owner.map((rd: RemoteData<Collection>) => rd.hasSucceeded);
}
}

View File

@@ -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[];

View File

@@ -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'
);
});
});
});

View File

@@ -1,12 +1,21 @@
import { SortOptions } from '../core/cache/models/sort-options.model';
import { PaginationComponentOptions } from '../shared/pagination/pagination-component-options.model';
import { isNotEmpty } from '../shared/empty.util';
import { URLCombiner } from '../core/url-combiner/url-combiner';
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}`);

View File

@@ -0,0 +1,34 @@
<div>
<div class="filters py-2">
<a *ngFor="let value of (selectedValues | async)" class="d-flex flex-row"
[routerLink]="[getSearchLink()]"
[queryParams]="getRemoveParams(value) | async" queryParamsHandling="merge">
<input type="checkbox" [checked]="true" class="my-1 align-self-stretch"/>
<span class="filter-value pl-1">{{value}}</span>
</a>
<ng-container *ngFor="let page of (filterValues$ | async)?.payload">
<div [@facetLoad]="animationState">
<ng-container *ngFor="let value of page.page; let i=index">
<a *ngIf="!(selectedValues | async).includes(value.value)" class="d-flex flex-row"
[routerLink]="[getSearchLink()]"
[queryParams]="getAddParams(value.value) | async" queryParamsHandling="merge">
<input type="checkbox" [checked]="false" class="my-1 align-self-stretch"/>
<span class="filter-value px-1">{{value.value}}</span>
<span class="float-right filter-value-count ml-auto">
<span class="badge badge-secondary badge-pill">{{value.count}}</span>
</span>
</a>
</ng-container>
</div>
</ng-container>
<div class="clearfix toggle-more-filters">
<a class="float-left" *ngIf="!(isLastPage$ | async)"
(click)="showMore()">{{"search.filters.filter.show-more"
| translate}}</a>
<a class="float-right" *ngIf="(currentPage | async) > 1"
(click)="showFirstPageOnly()">{{"search.filters.filter.show-less"
| translate}}</a>
</div>
</div>
</div>

View File

@@ -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;
}

View File

@@ -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 {
}

View File

@@ -0,0 +1 @@
<ng-container *ngComponentOutlet="getSearchFilter(); injector: objectInjector;"></ng-container>

View File

@@ -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);
}
}

View File

@@ -1,38 +0,0 @@
<div>
<div class="filters">
<a *ngFor="let value of selectedValues" class="d-block"
[routerLink]="[getSearchLink()]"
[queryParams]="getRemoveParams(value)" queryParamsHandling="merge">
<input type="checkbox" [checked]="true"/>
<span class="filter-value">{{value}}</span>
</a>
<ng-container *ngFor="let page of (filterValues$ | async)">
<ng-container *ngFor="let value of (page | async)?.payload.page; let i=index">
<a *ngIf="!selectedValues.includes(value.value)" class="d-block clearfix"
[routerLink]="[getSearchLink()]"
[queryParams]="getAddParams(value.value)" queryParamsHandling="merge" >
<input type="checkbox" [checked]="false"/>
<span class="filter-value">{{value.value}}</span>
<span class="float-right filter-value-count">
<span class="badge badge-secondary badge-pill">{{value.count}}</span>
</span>
</a>
</ng-container>
</ng-container>
<div class="clearfix toggle-more-filters">
<a class="float-left" *ngIf="!(isLastPage$ | async)"
(click)="showMore()">{{"search.filters.filter.show-more"
| translate}}</a>
<a class="float-right" *ngIf="(currentPage | async) > 1"
(click)="showFirstPageOnly()">{{"search.filters.filter.show-less"
| translate}}</a>
</div>
</div>
<form #form="ngForm" (ngSubmit)="onSubmit(form.value)" class="add-filter"
[action]="getCurrentUrl()">
<input type="text" [(ngModel)]="filter" [name]="filterConfig.paramName" class="form-control"
aria-label="New filter input"
[placeholder]="'search.filters.filter.' + filterConfig.name + '.placeholder'| translate" [ngModelOptions]="{standalone: true}"/>
<input type="submit" class="d-none"/>
</form>
</div>

View File

@@ -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,16 +64,19 @@ 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: {
getSelectedValuesForFilter: () => Observable.of(selectedValues),
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 */
}
}
@@ -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);
});
it('should call getFacetValuesFor on the searchService with the correct parameters', () => {
expect(searchService.getFacetValuesFor).toHaveBeenCalledWith(mockFilterConfig, cPage, searchOptions);
});
});
describe('when updateFilterValueList is called and pageChange is set to true', () => {
const searchOptions = new SearchOptions();
beforeEach(() => {
comp.pageChange = true;
spyOn(comp, 'showFirstPageOnly');
comp.updateFilterValueList(searchOptions);
comp.updateFilterValueList()
});
it('should not call showFirstPageOnly on the component', () => {
expect(comp.showFirstPageOnly).not.toHaveBeenCalled();
});
it('should set pageChange to false', () => {
expect(comp.pageChange).toBeFalsy();
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 false', () => {
const searchOptions = new SearchOptions();
describe('when findSuggestions is called with query \'test\'', () => {
const query = 'test';
beforeEach(() => {
comp.pageChange = false;
spyOn(comp, 'showFirstPageOnly');
comp.updateFilterValueList(searchOptions);
comp.findSuggestions(query);
});
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);
});
});
});

View File

@@ -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<Observable<RemoteData<PaginatedList<FacetValue>>>> = [];
filterValues$: BehaviorSubject<any> = new BehaviorSubject(this.filterValues);
/**
* Emits an array of pages with values found for this facet
*/
filterValues$: Subject<RemoteData<Array<PaginatedList<FacetValue>>>>;
/**
* Emits the current last shown page of this facet's values
*/
currentPage: Observable<number>;
/**
* Emits true if the current page is also the last page available
*/
isLastPage$: BehaviorSubject<boolean> = 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<any[]> = Observable.of([]);
/**
* Emits the active values for this filter
*/
selectedValues: Observable<string[]>;
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));
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
};
}
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)));
);
});
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<Array<PaginatedList<FacetValue>>>) => {
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<boolean> {
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<number>} The current page of this filter
*/
getCurrentPage(): Observable<number> {
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) {
this.selectedValues.first().subscribe((selectedValues) => {
if (isNotEmpty(data)) {
this.router.navigate([this.getSearchLink()], {
queryParams:
{ [this.filterConfig.paramName]: [...this.selectedValues, data[this.filterConfig.paramName]] },
{ [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) {
/**
* 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<any>} The changed filter parameters
*/
getRemoveParams(value: string): Observable<any> {
return this.selectedValues.map((selectedValues) => {
return {
[this.filterConfig.paramName]: this.selectedValues.filter((v) => v !== value),
[this.filterConfig.paramName]: selectedValues.filter((v) => v !== value),
page: 1
};
});
}
getAddParams(value: string) {
/**
* 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<any>} The changed filter parameters
*/
getAddParams(value: string): Observable<any> {
return this.selectedValues.map((selectedValues) => {
return {
[this.filterConfig.paramName]: [...this.selectedValues, value],
[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<PaginatedList<FacetValue>>) => {
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)),
]);

View File

@@ -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);
}

View File

@@ -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;
}

View File

@@ -1,7 +1,7 @@
<div>
<div (click)="toggle()" class="filter-name"><h5 class="d-inline-block mb-0">{{'search.filters.filter.' + filter.name + '.head'| translate}}</h5> <span class="filter-toggle fa float-right"
[ngClass]="(isCollapsed() | async) ? 'fa-plus' : 'fa-minus'"></span></div>
<div [@slide]="(isCollapsed() | async) ? 'collapsed' : 'expanded'" class="search-filter-wrapper">
<ds-search-facet-filter [filterConfig]="filter" [selectedValues]="getSelectedValues() | async"></ds-search-facet-filter>
<div [@slide]="(isCollapsed() | async) ? 'collapsed' : 'expanded'" (@slide.start)="startSlide($event)" (@slide.done)="finishSlide($event)" class="search-filter-wrapper" [ngClass]="{'closed' : collapsed}">
<ds-search-facet-filter-wrapper [filterConfig]="filter"></ds-search-facet-filter-wrapper>
</div>
</div>

View File

@@ -3,7 +3,7 @@
:host {
border: 1px solid map-get($theme-colors, light);
.search-filter-wrapper {
.search-filter-wrapper.closed {
overflow: hidden;
}
.filter-toggle {

View File

@@ -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<boolean>} Emits true when the current state of the filter is collapsed, false when it's expanded
*/
isCollapsed(): Observable<boolean> {
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<string[]>} Emits a list of all values that are currently active for this filter
*/
getSelectedValues(): Observable<string[]> {
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;
}
}
}

View File

@@ -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) {

View File

@@ -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'
};

View File

@@ -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<SearchFilterConfig> = new InjectionToken<SearchFilterConfig>('filterConfig');
/**
* Service that performs all actions that have to do with search filters and facets
*/
@Injectable()
export class SearchFilterService {
constructor(private store: Store<SearchFiltersState>,
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<boolean>} Emit true when the filter is active with the given value
*/
isFilterActiveWithValue(paramName: string, filterValue: string): Observable<boolean> {
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<boolean>} Emit true when the filter is active with any value
*/
isFilterActive(paramName: string): Observable<boolean> {
return this.routeService.hasQueryParam(paramName);
}
getCurrentScope() {
return this.routeService.getQueryParameterValue('scope');
}
getCurrentQuery() {
return this.routeService.getQueryParameterValue('query');
}
getCurrentPagination(pagination: any = {}): Observable<PaginationComponentOptions> {
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<SortOptions> {
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<PaginatedSearchOptions> {
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<SearchOptions> {
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<string[]>} Emits the active filters for the given filter configuration
*/
getSelectedValuesForFilter(filterConfig: SearchFilterConfig): Observable<string[]> {
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<boolean>} Emits the current collapsed state of the given filter, if it's unavailable, return false
*/
isCollapsed(filterName: string): Observable<boolean> {
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<boolean>} Emits the current page state of the given filter, if it's unavailable, return 1
*/
getPage(filterName: string): Observable<number> {
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));
}

View File

@@ -0,0 +1,43 @@
<div>
<div class="filters py-2">
<a *ngFor="let value of (selectedValues | async)" class="d-flex flex-row"
[routerLink]="[getSearchLink()]"
[queryParams]="getRemoveParams(value) | async" queryParamsHandling="merge">
<input type="checkbox" [checked]="true" class="my-1 align-self-stretch"/>
<span class="filter-value pl-1">{{value}}</span>
</a>
<ng-container *ngFor="let page of (filterValues$ | async)?.payload">
<div [@facetLoad]="animationState">
<ng-container *ngFor="let value of page.page; let i=index">
<a *ngIf="!(selectedValues | async).includes(value.value)" class="d-flex flex-row"
[routerLink]="[getSearchLink()]"
[queryParams]="getAddParams(value.value) | async" queryParamsHandling="merge" >
<input type="checkbox" [checked]="false" class="my-1 align-self-stretch"/>
<span class="filter-value px-1">{{value.value}}</span>
<span class="float-right filter-value-count ml-auto">
<span class="badge badge-secondary badge-pill">{{value.count}}</span>
</span>
</a>
</ng-container>
</div>
</ng-container>
<div class="clearfix toggle-more-filters">
<a class="float-left" *ngIf="!(isLastPage$ | async)"
(click)="showMore()">{{"search.filters.filter.show-more"
| translate}}</a>
<a class="float-right" *ngIf="(currentPage | async) > 1"
(click)="showFirstPageOnly()">{{"search.filters.filter.show-less"
| translate}}</a>
</div>
</div>
<ds-input-suggestions [suggestions]="(filterSearchResults | async)"
[placeholder]="'search.filters.filter.' + filterConfig.name + '.placeholder'| translate"
[action]="getCurrentUrl()"
[name]="filterConfig.paramName"
[(ngModel)]="filter"
(submitSuggestion)="onSubmit($event)"
(clickSuggestion)="onClick($event)"
(findSuggestions)="findSuggestions($event)"
ngDefaultControl
></ds-input-suggestions>
</div>

View File

@@ -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;
}

View File

@@ -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 {
}

View File

@@ -0,0 +1,40 @@
<div>
<div class="filters py-2">
<form #form="ngForm" (ngSubmit)="onSubmit()" class="add-filter row"
[action]="getCurrentUrl()">
<div class="col-6">
<input type="text" [(ngModel)]="range[0]" [name]="filterConfig.paramName + '.min'"
class="form-control" (blur)="onSubmit()"
aria-label="Mininum value"
[placeholder]="'search.filters.filter.' + filterConfig.name + '.min.placeholder'| translate"/>
</div>
<div class="col-6">
<input type="text" [(ngModel)]="range[1]" [name]="filterConfig.paramName + '.max'"
class="form-control" (blur)="onSubmit()"
aria-label="Maximum value"
[placeholder]="'search.filters.filter.' + filterConfig.name + '.max.placeholder'| translate"/>
</div>
<input type="submit" class="d-none"/>
</form>
<ng-container *ngIf="shouldShowSlider()">
<nouislider [connect]="true" [min]="min" [max]="max" [step]="1"
[(ngModel)]="range" (change)="onSubmit()" ngDefaultControl></nouislider>
</ng-container>
<ng-container *ngFor="let page of (filterValues$ | async)?.payload">
<div [@facetLoad]="animationState">
<ng-container *ngFor="let value of page.page; let i=index">
<a *ngIf="!(selectedValues | async).includes(value.value)" class="d-flex flex-row"
[routerLink]="[getSearchLink()]"
[queryParams]="getChangeParams(value.value) | async" queryParamsHandling="merge">
<span class="filter-value px-1">{{value.value}}</span>
<span class="float-right filter-value-count ml-auto">
<span class="badge badge-secondary badge-pill">{{value.count}}</span>
</span>
</a>
</ng-container>
</div>
</ng-container>
</div>
</div>

View File

@@ -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;
}
}
}

View File

@@ -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<SearchRangeFilterComponent>;
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'
});
});
});
});

View File

@@ -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<any>} 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);
}
}

View File

@@ -0,0 +1,45 @@
<div>
<div class="filters py-2">
<a *ngFor="let value of (selectedValues | async)" class="d-flex flex-row"
[routerLink]="[getSearchLink()]"
[queryParams]="getRemoveParams(value) | async" queryParamsHandling="merge">
<input type="checkbox" [checked]="true" class="my-1 align-self-stretch"/>
<span class="filter-value pl-1">{{value}}</span>
</a>
<ng-container *ngVar="(filterValues$ | async) as filterValuesRD">
<div [@facetLoad]="animationState">
<ng-container *ngFor="let page of filterValuesRD?.payload">
<ng-container *ngFor="let value of page.page; let i=index">
<a *ngIf="!(selectedValues | async).includes(value.value)" class="d-flex flex-row"
[routerLink]="[getSearchLink()]"
[queryParams]="getAddParams(value.value) | async" queryParamsHandling="merge" >
<input type="checkbox" [checked]="false" class="my-1 align-self-stretch"/>
<span class="filter-value px-1">{{value.value}}</span>
<span class="float-right filter-value-count ml-auto">
<span class="badge badge-secondary badge-pill">{{value.count}}</span>
</span>
</a>
</ng-container>
</ng-container>
</div>
</ng-container>
<div class="clearfix toggle-more-filters">
<a class="float-left" *ngIf="!(isLastPage$ | async)"
(click)="showMore()">{{"search.filters.filter.show-more"
| translate}}</a>
<a class="float-right" *ngIf="(currentPage | async) > 1"
(click)="showFirstPageOnly()">{{"search.filters.filter.show-less"
| translate}}</a>
</div>
</div>
<ds-input-suggestions [suggestions]="(filterSearchResults | async)"
[placeholder]="'search.filters.filter.' + filterConfig.name + '.placeholder'| translate"
[action]="getCurrentUrl()"
[name]="filterConfig.paramName"
[(ngModel)]="filter"
(submitSuggestion)="onSubmit($event)"
(clickSuggestion)="onClick($event)"
(findSuggestions)="findSuggestions($event)"
ngDefaultControl
></ds-input-suggestions>
</div>

View File

@@ -2,13 +2,14 @@
@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;
@@ -16,3 +17,7 @@
cursor: pointer;
}
}
::ng-deep em {
font-weight: bold;
font-style: normal;
}

View File

@@ -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 {
}

View File

@@ -1,7 +1,7 @@
<h3>{{"search.filters.head" | translate}}</h3>
<div *ngIf="(filters | async)?.hasSucceeded">
<div *ngFor="let filter of (filters | async).payload">
<ds-search-filter class="d-block mb-3 p-3" [filter]="filter"></ds-search-filter>
<div *ngFor="let filter of (filters | async)?.payload">
<ds-search-filter *ngIf="isActive(filter) | async" class="d-block mb-3 p-3" [filter]="filter"></ds-search-filter>
</div>
</div>
<a class="btn btn-primary" [routerLink]="[getSearchLink()]" [queryParams]="clearParams | async" queryParamsHandling="merge" role="button">{{"search.filters.reset" | translate}}</a>

View File

@@ -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]

View File

@@ -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<RemoteData<SearchFilterConfig[]>>;
/**
* 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<boolean>} Emits true whenever a given filter config should be shown
*/
isActive(filter: SearchFilterConfig): Observable<boolean> {
// 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);
}
}

View File

@@ -0,0 +1,13 @@
<div class="row mb-3 mb-md-1">
<div class="labels col-sm-9 offset-sm-3">
<ng-container *ngFor="let key of ((appliedFilters | async) | dsObjectKeys)"><!--Do not remove this to prevent uneven spacing
--><a *ngFor="let values of (appliedFilters | async)[key]"
class="badge badge-primary mr-1 mb-1"
[routerLink]="getSearchLink()"
[queryParams]="(getRemoveParams(key, values) | async)" queryParamsHandling="merge">
{{('search.filters.applied.' + key) | translate}}: {{values}}
<span> ×</span>
</a><!--Do not remove this to prevent uneven spacing
--></ng-container>
</div>
</div>

View File

@@ -0,0 +1,3 @@
:host {
line-height: 1;
}

View File

@@ -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<SearchLabelsComponent>;
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<Params>;
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);
});
})
});
});

View File

@@ -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<Params>;
/**
* 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<Params>} The changed filter parameters
*/
getRemoveParams(filterField: string, filterValue: string): Observable<Params> {
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();
}
}

View File

@@ -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'
);
});
});
});

View File

@@ -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)) {

View File

@@ -2,21 +2,22 @@
<div class="search-page row">
<ds-search-sidebar *ngIf="!(isXsOrSm$ | async)" class="col-3 sidebar-md-sticky"
id="search-sidebar"
[resultCount]="(resultsRD$ | async)?.pageInfo?.totalElements"></ds-search-sidebar>
[resultCount]="(resultsRD$ | async)?.payload.totalElements"></ds-search-sidebar>
<div class="col-12 col-md-9">
<ds-search-form id="search-form"
[query]="(searchOptions$ | async)?.query"
[scope]="(searchOptions$ | async)?.scope"
[currentUrl]="getSearchLink()"
[scopes]="(scopeListRD$ | async)?.payload?.page">
[scopes]="(scopeListRD$ | async)">
</ds-search-form>
<ds-search-labels></ds-search-labels>
<div class="row">
<div id="search-body"
class="row-offcanvas row-offcanvas-left"
[@pushInOut]="(isSidebarCollapsed() | async) ? 'collapsed' : 'expanded'">
<ds-search-sidebar *ngIf="(isXsOrSm$ | async)" class="col-12"
id="search-sidebar-sm"
[resultCount]="(resultsRD$ | async)?.pageInfo?.totalElements"
[resultCount]="(resultsRD$ | async)?.payload.totalElements"
(toggleSidebar)="closeSidebar()"
[ngClass]="{'active': !(isSidebarCollapsed() | async)}">
</ds-search-sidebar>
@@ -30,7 +31,7 @@
</button>
</div>
<ds-search-results [searchResults]="resultsRD$ | async"
[searchConfig]="searchOptions$ | async" [sortConfig]="sortConfig"></ds-search-results>
[searchConfig]="searchOptions$ | async"></ds-search-results>
</div>
</div>
</div>

View File

@@ -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,10 +37,11 @@ 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';
@@ -88,11 +91,15 @@ 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]
@@ -171,4 +178,5 @@ describe('SearchPageComponent', () => {
});
});
});
})
;

View File

@@ -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<RemoteData<PaginatedList<SearchResult<DSpaceObject>>>>;
/**
* The current search results
*/
resultsRD$: BehaviorSubject<RemoteData<PaginatedList<SearchResult<DSpaceObject>>>> = new BehaviorSubject(null);
/**
* The current paginated search options
*/
searchOptions$: Observable<PaginatedSearchOptions>;
sortConfig: SortOptions;
scopeListRD$: Observable<RemoteData<PaginatedList<Community>>>;
/**
* The current relevant scopes
*/
scopeListRD$: Observable<DSpaceObject[]>;
/**
* Emits true if were on a small screen
*/
isXsOrSm$: Observable<boolean>;
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<boolean>} emits true if the sidebar is currently collapsed, false if it is expanded
*/
public isSidebarCollapsed(): Observable<boolean> {
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();
}
}
}

View File

@@ -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 {
}

View File

@@ -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 (<T>) DSpaceObject
*/
export class SearchResult<T extends DSpaceObject> implements ListableObject {
/**
* The DSpaceObject that was found
*/
dspaceObject: T;
/**
* The metadata that was used to find this item, hithighlighted
*/
hitHighlights: Metadatum[];
}

View File

@@ -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<PaginatedList<SearchResult<DSpaceObject>>>;
/**
* The current configuration of the search
*/
@Input() searchConfig: SearchOptions;
@Input() sortConfig: SortOptions;
/**
* The current view mode for the search results
*/
@Input() viewMode: ViewMode;
}

View File

@@ -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;
}

View File

@@ -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'
}

View File

@@ -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();
});
});
});
});

View File

@@ -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<RemoteData<PaginatedSearchOptions>>;
/**
* Emits the current search options
*/
public searchOptions: BehaviorSubject<SearchOptions>;
/**
* Emits the current search options including pagination and sort
*/
public paginatedSearchOptions: BehaviorSubject<PaginatedSearchOptions>;
/**
* 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<SearchOptions>(defs);
this.searchOptions = new BehaviorSubject<PaginatedSearchOptions>(defs);
this.subs.push(this.subscribeToSearchOptions(defs));
this.subs.push(this.subscribeToPaginatedSearchOptions(defs));
}
)
}
/**
* @returns {Observable<string>} Emits the current scope's identifier
*/
getCurrentScope(defaultScope: string) {
return this.routeService.getQueryParameterValue('scope').map((scope) => {
return scope || defaultScope;
});
}
/**
* @returns {Observable<string>} Emits the current query string
*/
getCurrentQuery(defaultQuery: string) {
return this.routeService.getQueryParameterValue('query').map((query) => {
return query || defaultQuery;
});
}
/**
* @returns {Observable<string>} Emits the current pagination settings
*/
getCurrentPagination(defaultPagination: PaginationComponentOptions): Observable<PaginationComponentOptions> {
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<string>} Emits the current sorting settings
*/
getCurrentSort(defaultSort: SortOptions): Observable<SortOptions> {
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<Params>} Emits the current active filters with their values as they are sent to the backend
*/
getCurrentFilters(): Observable<Params> {
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<Params>} Emits the current active filters with their values as they are displayed in the frontend URL
*/
getCurrentFrontendFilters(): Observable<Params> {
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<RemoteData<PaginatedSearchOptions>> {
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<string>} Emits the current scope's identifier
*/
private getScopePart(defaultScope: string): Observable<any> {
return this.getCurrentScope(defaultScope).map((scope) => {
return { scope }
});
}
/**
* @returns {Observable<string>} Emits the current query string as a partial SearchOptions object
*/
private getQueryPart(defaultQuery: string): Observable<any> {
return this.getCurrentQuery(defaultQuery).map((query) => {
return { query }
});
}
/**
* @returns {Observable<string>} Emits the current pagination settings as a partial SearchOptions object
*/
private getPaginationPart(defaultPagination: PaginationComponentOptions): Observable<any> {
return this.getCurrentPagination(defaultPagination).map((pagination) => {
return { pagination }
});
}
/**
* @returns {Observable<string>} Emits the current sorting settings as a partial SearchOptions object
*/
private getSortPart(defaultSort: SortOptions): Observable<any> {
return this.getCurrentSort(defaultSort).map((sort) => {
return { sort }
});
}
/**
* @returns {Observable<Params>} Emits the current active filters as a partial SearchOptions object
*/
private getFiltersPart(): Observable<any> {
return this.getCurrentFilters().map((filters) => {
return { filters }
});
}
}

View File

@@ -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

View File

@@ -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;
}

View File

@@ -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<ListableObject>} domainConstructor The constructor of the DSpaceObject
* @returns Decorator function that performs the actual mapping on initialization of the component
*/
export function searchResultFor(domainConstructor: GenericConstructor<ListableObject>) {
return function decorator(searchResult: any) {
if (!searchResult) {
@@ -12,6 +20,11 @@ export function searchResultFor(domainConstructor: GenericConstructor<ListableOb
};
}
/**
* Requests the matching component based on a given DSpaceObject's constructor
* @param {GenericConstructor<ListableObject>} 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<ListableObject>) {
return searchResultMap.get(domainConstructor);
}

View File

@@ -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
],
});

View File

@@ -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<RemoteData<PaginatedList<SearchResult<DSpaceObject>>>>} Emits a paginated list with all search results found
*/
search(searchOptions?: PaginatedSearchOptions): Observable<RemoteData<PaginatedList<SearchResult<DSpaceObject>>>> {
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<RemoteData<SearchFilterConfig[]>>} The found filter configuration
*/
getConfig(scope?: string): Observable<RemoteData<SearchFilterConfig[]>> {
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<RemoteData<PaginatedList<FacetValue>>> {
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<RemoteData<PaginatedList<FacetValue>>>} Emits the given page of facet values
*/
getFacetValuesFor(filterConfig: SearchFilterConfig, valuePage: number, searchOptions?: SearchOptions, filterQuery?: string): Observable<RemoteData<PaginatedList<FacetValue>>> {
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<ResponseParsingService> {
@@ -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<DSpaceObject[]>} Emits a list of DSpaceObjects which represent possible scopes
*/
getScopes(scopeId?: string): Observable<DSpaceObject[]> {
if (isEmpty(scopeId)) {
const top: Observable<Community[]> = this.communityService.findTop({ elementsPerPage: 9999 }).pipe(
map(
(communities: RemoteData<PaginatedList<Community>>) => communities.payload.page
)
);
return top;
}
const scopeObject: Observable<RemoteData<DSpaceObject>> = this.dspaceObjectService.findById(scopeId).pipe(getSucceededRemoteData());
const scopeList: Observable<DSpaceObject[]> = scopeObject.pipe(
switchMap((dsoRD: RemoteData<DSpaceObject>) => {
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<ViewMode>} The current view mode
*/
getViewMode(): Observable<ViewMode> {
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();

View File

@@ -1,22 +1,24 @@
<h3>{{ 'search.sidebar.settings.title' | translate}}</h3>
<div *ngIf="[searchOptions].sort" class="setting-option result-order-settings mb-3 p-3">
<ng-container *ngVar="(searchOptions$ | async) as config">
<h3>{{ 'search.sidebar.settings.title' | translate}}</h3>
<div *ngIf="config?.sort" class="setting-option result-order-settings mb-3 p-3">
<h5>{{ 'search.sidebar.settings.sort-by' | translate}}</h5>
<select class="form-control" (change)="reloadOrder($event)">
<option *ngFor="let sortDirection of (sortDirections | dsKeys)"
[value]="sortDirection.value"
[selected]="sortDirection.value === direction? 'selected': null">
{{'sorting.' + sortDirection.key | translate}}
<option *ngFor="let sortOption of searchOptionPossibilities"
[value]="sortOption.field + ',' + sortOption.direction.toString()"
[selected]="sortOption.field === config?.sort.field && sortOption.direction === (config?.sort.direction)? 'selected': null">
{{'sorting.' + sortOption.field + '.' + sortOption.direction | translate}}
</option>
</select>
</div>
</div>
<div class="setting-option page-size-settings mb-3 p-3">
<div class="setting-option page-size-settings mb-3 p-3">
<h5>{{ 'search.sidebar.settings.rpp' | translate}}</h5>
<select class="form-control" (change)="reloadRPP($event)">
<option *ngFor="let pageSizeOption of pageSizeOptions" [value]="pageSizeOption"
[selected]="pageSizeOption === pageSize ? 'selected': null">
<option *ngFor="let pageSizeOption of config?.pagination.pageSizeOptions"
[value]="pageSizeOption"
[selected]="pageSizeOption === +config?.pagination.pageSize ? 'selected': null">
{{pageSizeOption}}
</option>
</select>
</div>
</div>
</ng-container>

View File

@@ -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', () => {
(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(2);
expect(childElements.length).toEqual(comp.searchOptionPossibilities.length);
});
});
it('it should show the size settings with the respective selectable options', () => {
(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(7);
expect(childElements.length).toEqual(options.pagination.pageSizeOptions.length);
}
)
});
it('should have the proper order value selected by default', () => {
(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"]'))
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', () => {
(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"]'))
const childElementToBeSelected = pageSizeSetting.query(By.css('.form-control option[value="10"][selected="selected"]'));
expect(childElementToBeSelected).toBeDefined();
});
});
});

View File

@@ -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<PaginatedSearchOptions>;
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);
}

View File

@@ -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;
}

View File

@@ -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<boolean>();
}

View File

@@ -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;

View File

@@ -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) {

View File

@@ -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<boolean>;
private isCollapsdeInStored: Observable<boolean>;
/**
* Emits true is the sidebar's state in the store is currently collapsed
*/
private isCollapsedInStore: Observable<boolean>;
constructor(private store: Store<AppState>, 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<boolean>} Emits true if the user's screen size is mobile or when the state in the store is currently collapsed
*/
get isCollapsed(): Observable<boolean> {
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());
}

View File

@@ -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 {

View File

@@ -8,12 +8,14 @@ import { RemoteDataError } from '../../data/remote-data-error';
import { GetRequest } from '../../data/request.models';
import { RequestEntry } from '../../data/request.reducer';
import { RequestService } from '../../data/request.service';
import { NormalizedObject } from '../models/normalized-object.model';
import { ObjectCacheService } from '../object-cache.service';
import { DSOSuccessResponse, ErrorResponse } from '../response-cache.models';
import { ResponseCacheEntry } from '../response-cache.reducer';
import { ResponseCacheService } from '../response-cache.service';
import { getMapsTo, getRelationMetadata, getRelationships } from './build-decorators';
import { PageInfo } from '../../shared/page-info.model';
import {
getRequestFromSelflink,
getResourceLinksFromResponse,
@@ -96,7 +98,6 @@ export class RemoteDataBuildService {
error = new RemoteDataError(resEntry.response.statusCode, errorMessage);
}
}
return new RemoteData(
requestPending,
responsePending,
@@ -107,7 +108,7 @@ export class RemoteDataBuildService {
});
}
buildList<TNormalized extends NormalizedObject, TDomain>(href$: string | Observable<string>): Observable<RemoteData<TDomain[] | PaginatedList<TDomain>>> {
buildList<TNormalized extends NormalizedObject, TDomain>(href$: string | Observable<string>): Observable<RemoteData<PaginatedList<TDomain>>> {
if (typeof href$ === 'string') {
href$ = Observable.of(href$);
}
@@ -144,11 +145,7 @@ export class RemoteDataBuildService {
);
const payload$ = Observable.combineLatest(tDomainList$, pageInfo$, (tDomainList, pageInfo) => {
if (hasValue(pageInfo)) {
return new PaginatedList(pageInfo, tDomainList);
} else {
return tDomainList;
}
});
return this.toRemoteDataObservable(requestEntry$, responseCache$, payload$);
@@ -160,35 +157,43 @@ export class RemoteDataBuildService {
const relationships = getRelationships(normalized.constructor) || [];
relationships.forEach((relationship: string) => {
let result;
if (hasValue(normalized[relationship])) {
const { resourceType, isList } = getRelationMetadata(normalized, relationship);
if (Array.isArray(normalized[relationship])) {
normalized[relationship].forEach((href: string) => {
const objectList = normalized[relationship].page || normalized[relationship];
if (typeof objectList !== 'string') {
objectList.forEach((href: string) => {
this.requestService.configure(new GetRequest(this.requestService.generateRequestId(), href))
});
const rdArr = [];
normalized[relationship].forEach((href: string) => {
objectList.forEach((href: string) => {
rdArr.push(this.buildSingle(href));
});
if (isList) {
links[relationship] = this.aggregate(rdArr);
result = this.aggregate(rdArr);
} else if (rdArr.length === 1) {
links[relationship] = rdArr[0];
result = rdArr[0];
}
} else {
this.requestService.configure(new GetRequest(this.requestService.generateRequestId(), normalized[relationship]));
this.requestService.configure(new GetRequest(this.requestService.generateRequestId(), objectList));
// The rest API can return a single URL to represent a list of resources (e.g. /items/:id/bitstreams)
// in that case only 1 href will be stored in the normalized obj (so the isArray above fails),
// but it should still be built as a list
if (isList) {
links[relationship] = this.buildList(normalized[relationship]);
result = this.buildList(objectList);
} else {
links[relationship] = this.buildSingle(normalized[relationship]);
result = this.buildSingle(objectList);
}
}
if (hasValue(normalized[relationship].page)) {
links[relationship] = this.aggregatePaginatedList(result, normalized[relationship].pageInfo);
} else {
links[relationship] = result;
}
}
});
@@ -249,4 +254,8 @@ export class RemoteDataBuildService {
})
}
aggregatePaginatedList<T>(input: Observable<RemoteData<T[]>>, pageInfo: PageInfo): Observable<RemoteData<PaginatedList<T>>> {
return input.map((rd) => Object.assign(rd, {payload: new PaginatedList(pageInfo, rd.payload)}));
}
}

View File

@@ -0,0 +1,34 @@
import { IDToUUIDSerializer } from './id-to-uuid-serializer';
describe('IDToUUIDSerializer', () => {
let serializer: IDToUUIDSerializer;
const prefix = 'test-prefix';
beforeEach(() => {
serializer = new IDToUUIDSerializer(prefix);
});
describe('Serialize', () => {
it('should return undefined', () => {
expect(serializer.Serialize('some-uuid')).toBeUndefined()
});
});
describe('Deserialize', () => {
describe('when ID is defined', () => {
it('should prepend the prefix to the ID', () => {
const id = 'some-id';
expect(serializer.Deserialize(id)).toBe(`${prefix}-${id}`);
});
});
describe('when ID is null or undefined', () => {
it('should return null or undefined', () => {
expect(serializer.Deserialize(null)).toBeNull();
expect(serializer.Deserialize(undefined)).toBeUndefined();
});
});
});
});

View File

@@ -0,0 +1,35 @@
import { hasValue } from '../../shared/empty.util';
/**
* Serializer to create unique fake UUID's from id's that might otherwise be the same across multiple object types
*/
export class IDToUUIDSerializer {
/**
* @param {string} prefix To prepend the original ID with
*/
constructor(private prefix: string) {
}
/**
* Method to serialize a UUID
* @param {string} uuid
* @returns {any} undefined Fake UUID's should not be sent back to the server, but only be used in the UI
*/
Serialize(uuid: string): any {
return undefined;
}
/**
* Method to deserialize a UUID
* @param {string} id Identifier to transform in to a UUID
* @returns {string} UUID based on the prefix and the given id
*/
Deserialize(id: string): string {
if (hasValue(id)) {
return `${this.prefix}-${id}`;
} else {
return id;
}
}
}

View File

@@ -0,0 +1,64 @@
/**
* Enum representing the Action Type of a Resource Policy
*/
export enum ActionType {
/**
* Action of reading, viewing or downloading something
*/
READ = 0,
/**
* Action of modifying something
*/
WRITE = 1,
/**
* Action of deleting something
*/
DELETE = 2,
/**
* Action of adding something to a container
*/
ADD = 3,
/**
* Action of removing something from a container
*/
REMOVE = 4,
/**
* Action of performing workflow step 1
*/
WORKFLOW_STEP_1 = 5,
/**
* Action of performing workflow step 2
*/
WORKFLOW_STEP_2 = 6,
/**
* Action of performing workflow step 3
*/
WORKFLOW_STEP_3 = 7,
/**
* Action of performing a workflow abort
*/
WORKFLOW_ABORT = 8,
/**
* Default Read policies for Bitstreams submitted to container
*/
DEFAULT_BITSTREAM_READ = 9,
/**
* Default Read policies for Items submitted to container
*/
DEFAULT_ITEM_READ = 10,
/**
* Administrative actions
*/
ADMIN = 11,
}

View File

@@ -0,0 +1,66 @@
import { autoserialize, autoserializeAs, inheritSerialization } from 'cerialize';
import { BitstreamFormat } from '../../shared/bitstream-format.model';
import { mapsTo } from '../builders/build-decorators';
import { IDToUUIDSerializer } from '../id-to-uuid-serializer';
import { NormalizedObject } from './normalized-object.model';
import { SupportLevel } from './support-level.model';
/**
* Normalized model class for a Bitstream Format
*/
@mapsTo(BitstreamFormat)
@inheritSerialization(NormalizedObject)
export class NormalizedBitstreamFormat extends NormalizedObject {
/**
* Short description of this Bitstream Format
*/
@autoserialize
shortDescription: string;
/**
* Description of this Bitstream Format
*/
@autoserialize
description: string;
/**
* String representing the MIME type of this Bitstream Format
*/
@autoserialize
mimetype: string;
/**
* The level of support the system offers for this Bitstream Format
*/
@autoserialize
supportLevel: SupportLevel;
/**
* True if the Bitstream Format is used to store system information, rather than the content of items in the system
*/
@autoserialize
internal: boolean;
/**
* String representing this Bitstream Format's file extension
*/
@autoserialize
extensions: string;
/**
* Identifier for this Bitstream Format
* Note that this ID is unique for bitstream formats,
* but might not be unique across different object types
*/
@autoserialize
id: string;
/**
* Universally unique identifier for this Bitstream Format
* Consist of a prefix and the id field to ensure the identifier is unique across all object types
*/
@autoserializeAs(new IDToUUIDSerializer('bitstream-format'), 'id')
uuid: string;
}

View File

@@ -5,6 +5,9 @@ import { Bitstream } from '../../shared/bitstream.model';
import { mapsTo, relationship } from '../builders/build-decorators';
import { ResourceType } from '../../shared/resource-type';
/**
* Normalized model class for a DSpace Bitstream
*/
@mapsTo(Bitstream)
@inheritSerialization(NormalizedDSpaceObject)
export class NormalizedBitstream extends NormalizedDSpaceObject {

View File

@@ -5,6 +5,9 @@ import { Bundle } from '../../shared/bundle.model';
import { mapsTo, relationship } from '../builders/build-decorators';
import { ResourceType } from '../../shared/resource-type';
/**
* Normalized model class for a DSpace Bundle
*/
@mapsTo(Bundle)
@inheritSerialization(NormalizedDSpaceObject)
export class NormalizedBundle extends NormalizedDSpaceObject {
@@ -25,6 +28,9 @@ export class NormalizedBundle extends NormalizedDSpaceObject {
*/
owner: string;
/**
* List of Bitstreams that are part of this Bundle
*/
@autoserialize
@relationship(ResourceType.Bitstream, true)
bitstreams: string[];

View File

@@ -1,10 +1,13 @@
import { autoserialize, inheritSerialization, autoserializeAs } from 'cerialize';
import { autoserialize, inheritSerialization } from 'cerialize';
import { NormalizedDSpaceObject } from './normalized-dspace-object.model';
import { Collection } from '../../shared/collection.model';
import { mapsTo, relationship } from '../builders/build-decorators';
import { ResourceType } from '../../shared/resource-type';
/**
* Normalized model class for a DSpace Collection
*/
@mapsTo(Collection)
@inheritSerialization(NormalizedDSpaceObject)
export class NormalizedCollection extends NormalizedDSpaceObject {
@@ -36,6 +39,9 @@ export class NormalizedCollection extends NormalizedDSpaceObject {
@relationship(ResourceType.Community, false)
owner: string;
/**
* List of Items that are part of (not necessarily owned by) this Collection
*/
@autoserialize
@relationship(ResourceType.Item, true)
items: string[];

View File

@@ -1,10 +1,13 @@
import { autoserialize, inheritSerialization, autoserializeAs } from 'cerialize';
import { autoserialize, inheritSerialization } from 'cerialize';
import { NormalizedDSpaceObject } from './normalized-dspace-object.model';
import { Community } from '../../shared/community.model';
import { mapsTo, relationship } from '../builders/build-decorators';
import { ResourceType } from '../../shared/resource-type';
/**
* Normalized model class for a DSpace Community
*/
@mapsTo(Community)
@inheritSerialization(NormalizedDSpaceObject)
export class NormalizedCommunity extends NormalizedDSpaceObject {
@@ -36,8 +39,15 @@ export class NormalizedCommunity extends NormalizedDSpaceObject {
@relationship(ResourceType.Community, false)
owner: string;
/**
* List of Collections that are owned by this Community
*/
@autoserialize
@relationship(ResourceType.Collection, true)
collections: string[];
@autoserialize
@relationship(ResourceType.Community, true)
subcommunities: string[];
}

View File

@@ -5,6 +5,9 @@ import { Item } from '../../shared/item.model';
import { mapsTo, relationship } from '../builders/build-decorators';
import { ResourceType } from '../../shared/resource-type';
/**
* Normalized model class for a DSpace Item
*/
@mapsTo(Item)
@inheritSerialization(NormalizedDSpaceObject)
export class NormalizedItem extends NormalizedDSpaceObject {
@@ -49,9 +52,13 @@ export class NormalizedItem extends NormalizedDSpaceObject {
/**
* The Collection that owns this Item
*/
@autoserialize
@relationship(ResourceType.Collection, false)
owningCollection: string;
/**
* List of Bitstreams that are owned by this Item
*/
@autoserialize
@relationship(ResourceType.Bitstream, true)
bitstreams: string[];

View File

@@ -6,6 +6,8 @@ import { GenericConstructor } from '../../shared/generic-constructor';
import { NormalizedCommunity } from './normalized-community.model';
import { ResourceType } from '../../shared/resource-type';
import { NormalizedObject } from './normalized-object.model';
import { NormalizedBitstreamFormat } from './normalized-bitstream-format.model';
import { NormalizedResourcePolicy } from './normalized-resource-policy.model';
export class NormalizedObjectFactory {
public static getConstructor(type: ResourceType): GenericConstructor<NormalizedObject> {
@@ -25,6 +27,12 @@ export class NormalizedObjectFactory {
case ResourceType.Community: {
return NormalizedCommunity
}
case ResourceType.BitstreamFormat: {
return NormalizedBitstreamFormat
}
case ResourceType.ResourcePolicy: {
return NormalizedResourcePolicy
}
default: {
return undefined;
}

View File

@@ -0,0 +1,49 @@
import { autoserialize, autoserializeAs, inheritSerialization } from 'cerialize';
import { ResourcePolicy } from '../../shared/resource-policy.model';
import { mapsTo, relationship } from '../builders/build-decorators';
import { NormalizedObject } from './normalized-object.model';
import { IDToUUIDSerializer } from '../id-to-uuid-serializer';
import { ResourceType } from '../../shared/resource-type';
import { ActionType } from './action-type.model';
/**
* Normalized model class for a Resource Policy
*/
@mapsTo(ResourcePolicy)
@inheritSerialization(NormalizedObject)
export class NormalizedResourcePolicy extends NormalizedObject {
/**
* The action that is allowed by this Resource Policy
*/
action: ActionType;
/**
* The name for this Resource Policy
*/
@autoserialize
name: string;
/**
* The uuid of the Group this Resource Policy applies to
*/
@relationship(ResourceType.Group, false)
@autoserializeAs(String, 'groupUUID')
group: string;
/**
* Identifier for this Resource Policy
* Note that this ID is unique for resource policies,
* but might not be unique across different object types
*/
@autoserialize
id: string;
/**
* The universally unique identifier for this Resource Policy
* Consist of a prefix and the id field to ensure the identifier is unique across all object types
*/
@autoserializeAs(new IDToUUIDSerializer('resource-policy'), 'id')
uuid: string;
}

View File

@@ -0,0 +1,19 @@
/**
* Enum representing the Support Level of a Bitstream Format
*/
export enum SupportLevel {
/**
* Unknown for Bitstream Formats that are unknown to the system
*/
Unknown = 0,
/**
* Unknown for Bitstream Formats that are known to the system, but not fully supported
*/
Known = 1,
/**
* Supported for Bitstream Formats that are known to the system and fully supported
*/
Supported = 2,
}

View File

@@ -46,7 +46,6 @@ describe('ResponseCacheService', () => {
let testObj: ResponseCacheEntry;
service.get(keys[1]).first().subscribe((entry) => {
console.log(entry);
testObj = entry;
});
expect(testObj.key).toEqual(keys[1]);

View File

@@ -63,6 +63,7 @@ import { RegistryBitstreamformatsResponseParsingService } from './data/registry-
import { NotificationsService } from '../shared/notifications/notifications.service';
import { UploaderService } from '../shared/uploader/uploader.service';
import { BrowseItemsResponseParsingService } from './data/browse-items-response-parsing-service';
import { DSpaceObjectDataService } from './data/dspace-object-data.service';
const IMPORTS = [
CommonModule,
@@ -126,6 +127,7 @@ const PROVIDERS = [
IntegrationResponseParsingService,
UploaderService,
UUIDService,
DSpaceObjectDataService,
// register AuthInterceptor as HttpInterceptor
{
provide: HTTP_INTERCEPTORS,

View File

@@ -4,8 +4,9 @@ import { CacheableObject } from '../cache/object-cache.reducer';
import { PageInfo } from '../shared/page-info.model';
import { ObjectCacheService } from '../cache/object-cache.service';
import { GlobalConfig } from '../../../config/global-config.interface';
import { NormalizedObject } from '../cache/models/normalized-object.model';
import { GenericConstructor } from '../shared/generic-constructor';
import { PaginatedList } from './paginated-list';
import { NormalizedObject } from '../cache/models/normalized-object.model';
function isObjectLevel(halObj: any) {
return isNotEmpty(halObj._links) && hasValue(halObj._links.self);
@@ -17,96 +18,103 @@ function isPaginatedResponse(halObj: any) {
/* tslint:disable:max-classes-per-file */
class ProcessRequestDTO<ObjectDomain> {
[key: string]: ObjectDomain[]
}
export abstract class BaseResponseParsingService {
protected abstract EnvConfig: GlobalConfig;
protected abstract objectCache: ObjectCacheService;
protected abstract objectFactory: any;
protected abstract toCache: boolean;
protected process<ObjectDomain,ObjectType>(data: any, requestHref: string): ProcessRequestDTO<ObjectDomain> {
protected process<ObjectDomain, ObjectType>(data: any, requestHref: string): any {
if (isNotEmpty(data)) {
if (isPaginatedResponse(data)) {
return this.process(data._embedded, requestHref);
if (hasNoValue(data) || (typeof data !== 'object')) {
return data;
} else if (isPaginatedResponse(data)) {
return this.processPaginatedList(data, requestHref);
} else if (Array.isArray(data)) {
return this.processArray(data, requestHref);
} else if (isObjectLevel(data)) {
return { topLevel: this.deserializeAndCache(data, requestHref) };
} else {
const result = new ProcessRequestDTO<ObjectDomain>();
if (Array.isArray(data)) {
result.topLevel = [];
data.forEach((datum) => {
if (isPaginatedResponse(datum)) {
const obj = this.process(datum, requestHref);
result.topLevel = [...result.topLevel, ...this.flattenSingleKeyObject(obj)];
} else {
result.topLevel = [...result.topLevel, ...this.deserializeAndCache<ObjectDomain,ObjectType>(datum, requestHref)];
const object = this.deserialize(data);
if (isNotEmpty(data._embedded)) {
Object
.keys(data._embedded)
.filter((property) => data._embedded.hasOwnProperty(property))
.forEach((property) => {
const parsedObj = this.process<ObjectDomain, ObjectType>(data._embedded[property], requestHref);
if (isNotEmpty(parsedObj)) {
if (isPaginatedResponse(data._embedded[property])) {
object[property] = parsedObj;
object[property].page = parsedObj.page.map((obj) => obj.self);
} else if (isObjectLevel(data._embedded[property])) {
object[property] = parsedObj.self;
} else if (Array.isArray(parsedObj)) {
object[property] = parsedObj.map((obj) => obj.self)
}
}
});
} else {
}
this.cache(object, requestHref);
return object;
}
const result = {};
Object.keys(data)
.filter((property) => data.hasOwnProperty(property))
.filter((property) => hasValue(data[property]))
.forEach((property) => {
if (isPaginatedResponse(data[property])) {
const obj = this.process(data[property], requestHref);
result[property] = this.flattenSingleKeyObject(obj);
} else {
result[property] = this.deserializeAndCache(data[property], requestHref);
}
result[property] = obj;
});
}
return result;
}
}
}
protected deserializeAndCache<ObjectDomain,ObjectType>(obj, requestHref: string): ObjectDomain[] {
if (Array.isArray(obj)) {
let result = [];
obj.forEach((o) => result = [...result, ...this.deserializeAndCache<ObjectDomain,ObjectType>(o, requestHref)]);
return result;
protected processPaginatedList<ObjectDomain, ObjectType>(data: any, requestHref: string): PaginatedList<ObjectDomain> {
const pageInfo: PageInfo = this.processPageInfo(data);
let list = data._embedded;
// Workaround for inconsistency in rest response. Issue: https://github.com/DSpace/dspace-angular/issues/238
if (!Array.isArray(list)) {
list = this.flattenSingleKeyObject(list);
}
const page: ObjectDomain[] = this.processArray(list, requestHref);
return new PaginatedList<ObjectDomain>(pageInfo, page);
}
protected processArray<ObjectDomain, ObjectType>(data: any, requestHref: string): ObjectDomain[] {
let array: ObjectDomain[] = [];
data.forEach((datum) => {
array = [...array, this.process(datum, requestHref)];
}
);
return array;
}
protected deserialize<ObjectDomain, ObjectType>(obj): any {
const type: ObjectType = obj.type;
if (hasValue(type)) {
const normObjConstructor = this.objectFactory.getConstructor(type) as GenericConstructor<ObjectDomain>;
if (hasValue(normObjConstructor)) {
const serializer = new DSpaceRESTv2Serializer(normObjConstructor);
let processed;
if (isNotEmpty(obj._embedded)) {
processed = this.process<ObjectDomain,ObjectType>(obj._embedded, requestHref);
}
const normalizedObj: any = serializer.deserialize(obj);
if (isNotEmpty(processed)) {
const processedList = {};
Object.keys(processed).forEach((key) => {
processedList[key] = processed[key].map((no: NormalizedObject) => (this.toCache) ? no.self : no);
});
Object.assign(normalizedObj, processedList);
}
if (this.toCache) {
this.addToObjectCache(normalizedObj, requestHref);
}
return [normalizedObj] as any;
const res = serializer.deserialize(obj);
return res;
} else {
// TODO: move check to Validator?
// throw new Error(`The server returned an object with an unknown a known type: ${type}`);
return [];
return null;
}
} else {
// TODO: move check to Validator
// throw new Error(`The server returned an object without a type: ${JSON.stringify(obj)}`);
return [];
return null;
}
}
protected cache<ObjectDomain, ObjectType>(obj, requestHref) {
if (this.toCache) {
this.addToObjectCache(obj, requestHref);
}
}
@@ -119,7 +127,7 @@ export abstract class BaseResponseParsingService {
processPageInfo(payload: any): PageInfo {
if (isNotEmpty(payload.page)) {
const pageObj = Object.assign({}, payload.page, {_links: payload._links});
const pageObj = Object.assign({}, payload.page, { _links: payload._links });
const pageInfoObject = new DSpaceRESTv2Serializer(PageInfo).deserialize(pageObj);
if (pageInfoObject.currentPage >= 0) {
Object.assign(pageInfoObject, { currentPage: pageInfoObject.currentPage + 1 });

View File

@@ -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<NormalizedCommunity, Community> {
protected linkPath = 'communities';
protected topLinkPath = 'communities/search/top';
protected cds = this;
constructor(
@@ -31,4 +36,18 @@ export class CommunityDataService extends ComColDataService<NormalizedCommunity,
getEndpoint() {
return this.halService.getEndpoint(this.linkPath);
}
findTop(options: FindAllOptions = {}): Observable<RemoteData<PaginatedList<Community>>> {
const hrefObs = this.getFindAllHref(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<NormalizedCommunity, Community>(hrefObs) as Observable<RemoteData<PaginatedList<Community>>>;
}
}

View File

@@ -1,5 +1,4 @@
import { ConfigSuccessResponse, ErrorResponse } from '../cache/response-cache.models';
import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model';
import { ConfigResponseParsingService } from './config-response-parsing.service';
import { ObjectCacheService } from '../cache/object-cache.service';
import { GlobalConfig } from '../../../config/global-config.interface';
@@ -8,7 +7,8 @@ import { ConfigRequest } from './request.models';
import { Store } from '@ngrx/store';
import { CoreState } from '../core.reducers';
import { SubmissionDefinitionsModel } from '../shared/config/config-submission-definitions.model';
import { SubmissionSectionModel } from '../shared/config/config-submission-section.model';
import { PaginatedList } from './paginated-list';
import { PageInfo } from '../shared/page-info.model';
describe('ConfigResponseParsingService', () => {
let service: ConfigResponseParsingService;
@@ -16,141 +16,143 @@ describe('ConfigResponseParsingService', () => {
const EnvConfig = {} as GlobalConfig;
const store = {} as Store<CoreState>;
const objectCacheService = new ObjectCacheService(store);
let validResponse;
beforeEach(() => {
service = new ConfigResponseParsingService(EnvConfig, objectCacheService);
validResponse = {
payload: {
id: 'traditional',
name: 'traditional',
type: 'submissiondefinition',
isDefault: true,
_links: {
sections: {
href: 'https://rest.api/config/submissiondefinitions/traditional/sections'
},
self: {
href: 'https://rest.api/config/submissiondefinitions/traditional'
}
},
_embedded: {
sections: {
page: {
number: 0,
size: 4,
totalPages: 1, totalElements: 4
},
_embedded: [
{
id: 'traditionalpageone', header: 'submit.progressbar.describe.stepone',
mandatory: true,
sectionType: 'submission-form',
visibility: {
main: null,
other: 'READONLY'
},
type: 'submissionsection',
_links: {
self: {
href: 'https://rest.api/config/submissionsections/traditionalpageone'
},
config: {
href: 'https://rest.api/config/submissionforms/traditionalpageone'
}
}
}, {
id: 'traditionalpagetwo',
header: 'submit.progressbar.describe.steptwo',
mandatory: true,
sectionType: 'submission-form',
visibility: {
main: null,
other: 'READONLY'
},
type: 'submissionsection',
_links: {
self: {
href: 'https://rest.api/config/submissionsections/traditionalpagetwo'
},
config: {
href: 'https://rest.api/config/submissionforms/traditionalpagetwo'
}
}
}, {
id: 'upload',
header: 'submit.progressbar.upload',
mandatory: false,
sectionType: 'upload',
visibility: {
main: null,
other: 'READONLY'
},
type: 'submissionsection',
_links: {
self: {
href: 'https://rest.api/config/submissionsections/upload'
},
config: {
href: 'https://rest.api/config/submissionuploads/upload'
}
}
}, {
id: 'license',
header: 'submit.progressbar.license',
mandatory: true,
sectionType: 'license',
visibility: {
main: null,
other: 'READONLY'
},
type: 'submissionsection',
_links: {
self: {
href: 'https://rest.api/config/submissionsections/license'
}
}
}
],
_links: {
self: {
href: 'https://rest.api/config/submissiondefinitions/traditional/sections'
}
}
}
}
},
statusCode: '200'
};
});
describe('parse', () => {
const validRequest = new ConfigRequest('69f375b5-19f4-4453-8c7a-7dc5c55aafbb', 'https://rest.api/config/submissiondefinitions/traditional');
const validResponse = {
payload: {
id:'traditional',
name:'traditional',
type:'submissiondefinition',
isDefault:true,
_links:{
sections:{
href:'https://rest.api/config/submissiondefinitions/traditional/sections'
},self:{
href:'https://rest.api/config/submissiondefinitions/traditional'
}
},
_embedded:{
sections:{
page:{
number:0,
size:4,
totalPages:1,totalElements:4
},
_embedded:[
{
id:'traditionalpageone',header:'submit.progressbar.describe.stepone',
mandatory:true,
sectionType:'submission-form',
visibility:{
main:null,
other:'READONLY'
},
type:'submissionsection',
_links:{
self:{
href:'https://rest.api/config/submissionsections/traditionalpageone'
},
config:{
href:'https://rest.api/config/submissionforms/traditionalpageone'
}
}
}, {
id:'traditionalpagetwo',
header:'submit.progressbar.describe.steptwo',
mandatory:true,
sectionType:'submission-form',
visibility:{
main:null,
other:'READONLY'
},
type:'submissionsection',
_links:{
self:{
href:'https://rest.api/config/submissionsections/traditionalpagetwo'
},
config:{
href:'https://rest.api/config/submissionforms/traditionalpagetwo'
}
}
}, {
id:'upload',
header:'submit.progressbar.upload',
mandatory:false,
sectionType:'upload',
visibility:{
main:null,
other:'READONLY'
},
type:'submissionsection',
_links:{
self:{
href:'https://rest.api/config/submissionsections/upload'
},
config: {
href:'https://rest.api/config/submissionuploads/upload'
}
}
}, {
id:'license',
header:'submit.progressbar.license',
mandatory:true,
sectionType:'license',
visibility:{
main:null,
other:'READONLY'
},
type:'submissionsection',
_links:{
self:{
href:'https://rest.api/config/submissionsections/license'
}
}
}
],
_links:{
self:'https://rest.api/config/submissiondefinitions/traditional/sections'
}
}
}
},
statusCode:'200'
};
const invalidResponse1 = {
payload: {},
statusCode:'200'
statusCode: '200'
};
const invalidResponse2 = {
payload: {
id:'traditional',
name:'traditional',
type:'submissiondefinition',
isDefault:true,
_links:{},
_embedded:{
sections:{
page:{
number:0,
size:4,
totalPages:1,totalElements:4
id: 'traditional',
name: 'traditional',
type: 'submissiondefinition',
isDefault: true,
_links: {},
_embedded: {
sections: {
page: {
number: 0,
size: 4,
totalPages: 1, totalElements: 4
},
_embedded:[{},{}],
_links:{
self:'https://rest.api/config/submissiondefinitions/traditional/sections'
_embedded: [{}, {}],
_links: {
self: 'https://rest.api/config/submissiondefinitions/traditional/sections'
}
}
}
},
statusCode:'200'
statusCode: '200'
};
const invalidResponse3 = {
@@ -159,61 +161,24 @@ describe('ConfigResponseParsingService', () => {
page: { size: 20, totalElements: 2, totalPages: 1, number: 0 }
}, statusCode: '500'
};
const definitions = [
const pageinfo = Object.assign(new PageInfo(), { elementsPerPage: 4, totalElements: 4, totalPages: 1, currentPage: 1 });
const definitions =
Object.assign(new SubmissionDefinitionsModel(), {
isDefault: true,
name: 'traditional',
type: 'submissiondefinition',
_links: {},
sections: [
Object.assign(new SubmissionSectionModel(), {
header: 'submit.progressbar.describe.stepone',
mandatory: true,
sectionType: 'submission-form',
visibility:{
main:null,
other:'READONLY'
_links: {
sections: 'https://rest.api/config/submissiondefinitions/traditional/sections',
self: 'https://rest.api/config/submissiondefinitions/traditional'
},
type: 'submissionsection',
_links: {}
}),
Object.assign(new SubmissionSectionModel(), {
header: 'submit.progressbar.describe.steptwo',
mandatory: true,
sectionType: 'submission-form',
visibility:{
main:null,
other:'READONLY'
},
type: 'submissionsection',
_links: {}
}),
Object.assign(new SubmissionSectionModel(), {
header: 'submit.progressbar.upload',
mandatory: false,
sectionType: 'upload',
visibility:{
main:null,
other:'READONLY'
},
type: 'submissionsection',
_links: {}
}),
Object.assign(new SubmissionSectionModel(), {
header: 'submit.progressbar.license',
mandatory: true,
sectionType: 'license',
visibility:{
main:null,
other:'READONLY'
},
type: 'submissionsection',
_links: {}
})
]
})
];
self: 'https://rest.api/config/submissiondefinitions/traditional',
sections: new PaginatedList(pageinfo, [
'https://rest.api/config/submissionsections/traditionalpageone',
'https://rest.api/config/submissionsections/traditionalpagetwo',
'https://rest.api/config/submissionsections/upload',
'https://rest.api/config/submissionsections/license'
])
});
it('should return a ConfigSuccessResponse if data contains a valid config endpoint response', () => {
const response = service.parse(validRequest, validResponse);

View File

@@ -29,7 +29,7 @@ export class ConfigResponseParsingService extends BaseResponseParsingService imp
parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse {
if (isNotEmpty(data.payload) && isNotEmpty(data.payload._links) && (data.statusCode === '201' || data.statusCode === '200' || data.statusCode === 'OK')) {
const configDefinition = this.process<ConfigObject,ConfigType>(data.payload, request.href);
return new ConfigSuccessResponse(configDefinition[Object.keys(configDefinition)[0]], data.statusCode, this.processPageInfo(data.payload));
return new ConfigSuccessResponse(configDefinition, data.statusCode, this.processPageInfo(data.payload));
} else {
return new ErrorResponse(
Object.assign(

View File

@@ -0,0 +1,125 @@
import { DataService } from './data.service';
import { NormalizedObject } from '../cache/models/normalized-object.model';
import { ResponseCacheService } from '../cache/response-cache.service';
import { RequestService } from './request.service';
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
import { CoreState } from '../core.reducers';
import { Store } from '@ngrx/store';
import { HALEndpointService } from '../shared/hal-endpoint.service';
import { Observable } from 'rxjs/Observable';
import { FindAllOptions } from './request.models';
import { SortOptions, SortDirection } from '../cache/models/sort-options.model';
const LINK_NAME = 'test';
const ENDPOINT = 'https://rest.api/core';
// tslint:disable:max-classes-per-file
class NormalizedTestObject extends NormalizedObject {
}
class TestService extends DataService<NormalizedTestObject, any> {
constructor(
protected responseCache: ResponseCacheService,
protected requestService: RequestService,
protected rdbService: RemoteDataBuildService,
protected store: Store<CoreState>,
protected linkPath: string,
protected halService: HALEndpointService
) {
super();
}
public getBrowseEndpoint(options: FindAllOptions): Observable<string> {
return Observable.of(ENDPOINT);
}
}
describe('DataService', () => {
let service: TestService;
let options: FindAllOptions;
const responseCache = {} as ResponseCacheService;
const requestService = {} as RequestService;
const halService = {} as HALEndpointService;
const rdbService = {} as RemoteDataBuildService;
const store = {} as Store<CoreState>;
function initTestService(): TestService {
return new TestService(
responseCache,
requestService,
rdbService,
store,
LINK_NAME,
halService
);
}
service = initTestService();
describe('getFindAllHref', () => {
it('should return an observable with the endpoint', () => {
options = {};
(service as any).getFindAllHref(options).subscribe((value) => {
expect(value).toBe(ENDPOINT);
}
);
});
it('should include page in href if currentPage provided in options', () => {
options = { currentPage: 2 };
const expected = `${ENDPOINT}?page=${options.currentPage - 1}`;
(service as any).getFindAllHref(options).subscribe((value) => {
expect(value).toBe(expected);
});
});
it('should include size in href if elementsPerPage provided in options', () => {
options = { elementsPerPage: 5 };
const expected = `${ENDPOINT}?size=${options.elementsPerPage}`;
(service as any).getFindAllHref(options).subscribe((value) => {
expect(value).toBe(expected);
});
});
it('should include sort href if SortOptions provided in options', () => {
const sortOptions = new SortOptions('field1', SortDirection.ASC);
options = { sort: sortOptions};
const expected = `${ENDPOINT}?sort=${sortOptions.field},${sortOptions.direction}`;
(service as any).getFindAllHref(options).subscribe((value) => {
expect(value).toBe(expected);
});
});
it('should include startsWith in href if startsWith provided in options', () => {
options = { startsWith: 'ab' };
const expected = `${ENDPOINT}?startsWith=${options.startsWith}`;
(service as any).getFindAllHref(options).subscribe((value) => {
expect(value).toBe(expected);
});
});
it('should include all provided options in href', () => {
const sortOptions = new SortOptions('field1', SortDirection.DESC)
options = {
currentPage: 6,
elementsPerPage: 10,
sort: sortOptions,
startsWith: 'ab'
}
const expected = `${ENDPOINT}?page=${options.currentPage - 1}&size=${options.elementsPerPage}` +
`&sort=${sortOptions.field},${sortOptions.direction}&startsWith=${options.startsWith}`;
(service as any).getFindAllHref(options).subscribe((value) => {
expect(value).toBe(expected);
});
})
});
});

View File

@@ -41,6 +41,10 @@ export abstract class DataService<TNormalized extends NormalizedObject, TDomain>
args.push(`sort=${options.sort.field},${options.sort.direction}`);
}
if (hasValue(options.startsWith)) {
args.push(`startsWith=${options.startsWith}`);
}
if (isNotEmpty(args)) {
return result.map((href: string) => new URLCombiner(href, `?${args.join('&')}`).toString());
} else {
@@ -71,8 +75,7 @@ export abstract class DataService<TNormalized extends NormalizedObject, TDomain>
.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);

View File

@@ -12,6 +12,7 @@ import { RestRequest } from './request.models';
import { ResponseParsingService } from './parsing.service';
import { BaseResponseParsingService } from './base-response-parsing.service';
import { hasNoValue, hasValue } from '../../shared/empty.util';
@Injectable()
export class DSOResponseParsingService extends BaseResponseParsingService implements ResponseParsingService {
@@ -27,7 +28,16 @@ export class DSOResponseParsingService extends BaseResponseParsingService implem
parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse {
const processRequestDTO = this.process<NormalizedObject,ResourceType>(data.payload, request.href);
const selfLinks = this.flattenSingleKeyObject(processRequestDTO).map((no) => no.self);
let objectList = processRequestDTO;
if (hasNoValue(processRequestDTO)) {
return new DSOSuccessResponse([], data.statusCode, undefined)
}
if (hasValue(processRequestDTO.page)) {
objectList = processRequestDTO.page;
} else if (!Array.isArray(processRequestDTO)) {
objectList = [processRequestDTO];
}
const selfLinks = objectList.map((no) => no.self);
return new DSOSuccessResponse(selfLinks, data.statusCode, this.processPageInfo(data.payload))
}

View File

@@ -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<DSpaceObject> for the object with the given ID', () => {
const result = service.findById(testObject.uuid);
const expected = cold('a', {
a: {
payload: testObject
}
});
expect(result).toBeObservable(expected);
});
});
});

View File

@@ -0,0 +1,52 @@
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';
import { FindAllOptions } from './request.models';
/* tslint:disable:max-classes-per-file */
class DataServiceImpl extends DataService<NormalizedDSpaceObject, DSpaceObject> {
protected linkPath = 'dso';
constructor(
protected responseCache: ResponseCacheService,
protected requestService: RequestService,
protected rdbService: RemoteDataBuildService,
protected store: Store<CoreState>,
protected halService: HALEndpointService) {
super();
}
getBrowseEndpoint(options: FindAllOptions): Observable<string> {
return this.halService.getEndpoint(this.linkPath);
}
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<RemoteData<DSpaceObject>> {
return this.dataService.findById(uuid);
}
}

View File

@@ -8,7 +8,7 @@ export class PaginatedList<T> {
}
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<T> {
}
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<T> {
}
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<T> {
}
get currentPage(): number {
if (hasValue(this.pageInfo)) {
if (hasValue(this.pageInfo) && hasValue(this.pageInfo.currentPage)) {
return this.pageInfo.currentPage;
}
return 1;

View File

@@ -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()

View File

@@ -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));
}
}

View File

@@ -8,6 +8,8 @@ import { CoreState } from '../core.reducers';
import { IntegrationResponseParsingService } from './integration-response-parsing.service';
import { IntegrationRequest } from '../data/request.models';
import { AuthorityValueModel } from './models/authority-value.model';
import { PageInfo } from '../shared/page-info.model';
import { PaginatedList } from '../data/paginated-list';
describe('IntegrationResponseParsingService', () => {
let service: IntegrationResponseParsingService;
@@ -78,7 +80,7 @@ describe('IntegrationResponseParsingService', () => {
},
_links: {
self: 'https://rest.api/integration/authorities/type/entries'
self: { href: 'https://rest.api/integration/authorities/type/entries' }
}
},
statusCode: '200'
@@ -141,39 +143,44 @@ describe('IntegrationResponseParsingService', () => {
},
statusCode: '200'
};
const definitions = [
Object.assign(new AuthorityValueModel(), {
const pageinfo = Object.assign(new PageInfo(), { elementsPerPage: 5, totalElements: 5, totalPages: 1, currentPage: 1 });
const definitions = new PaginatedList(pageinfo,[
Object.assign({}, new AuthorityValueModel(), {
type: 'authority',
display: 'One',
id: 'One',
otherInformation: {},
otherInformation: undefined,
value: 'One'
}),
Object.assign(new AuthorityValueModel(), {
Object.assign({}, new AuthorityValueModel(), {
type: 'authority',
display: 'Two',
id: 'Two',
otherInformation: {},
otherInformation: undefined,
value: 'Two'
}),
Object.assign(new AuthorityValueModel(), {
Object.assign({}, new AuthorityValueModel(), {
type: 'authority',
display: 'Three',
id: 'Three',
otherInformation: {},
otherInformation: undefined,
value: 'Three'
}),
Object.assign(new AuthorityValueModel(), {
Object.assign({}, new AuthorityValueModel(), {
type: 'authority',
display: 'Four',
id: 'Four',
otherInformation: {},
otherInformation: undefined,
value: 'Four'
}),
Object.assign(new AuthorityValueModel(), {
Object.assign({}, new AuthorityValueModel(), {
type: 'authority',
display: 'Five',
id: 'Five',
otherInformation: {},
otherInformation: undefined,
value: 'Five'
})
];
]);
it('should return a IntegrationSuccessResponse if data contains a valid endpoint response', () => {
const response = service.parse(validRequest, validResponse);

Some files were not shown because too many files have changed in this diff Show More