Merge remote-tracking branch 'remotes/origin/master' into submission

This commit is contained in:
Giuseppe Digilio
2019-03-29 15:22:15 +01:00
54 changed files with 2744 additions and 485 deletions

View File

@@ -120,6 +120,7 @@
"pem": "1.12.3",
"reflect-metadata": "0.1.12",
"rxjs": "6.2.2",
"rxjs-spy": "^7.5.1",
"sortablejs": "1.7.0",
"text-mask-core": "5.0.1",
"ts-loader": "^5.2.1",

View File

@@ -289,7 +289,7 @@
"results-per-page": "Results Per Page",
"sort-direction": "Sort Options",
"showing": {
"label": "Now showing items ",
"label": "Now showing ",
"detail": "{{ range }} of {{ total }}"
}
},

View File

@@ -54,8 +54,8 @@
"results-per-page": "Resultaten per pagina",
"sort-direction": "Sorteermogelijkheden",
"showing": {
"label": "Getoonde items ",
"detail": "{{ range }} tot {{ total }}"
"label": "Resultaten ",
"detail": "{{ range }} van {{ total }}"
}
},
"sorting": {
@@ -229,7 +229,7 @@
"validation": {
"pattern": "Deze invoer is niet toegelaten volgens dit patroon: {{ pattern }}.",
"license": {
"notgranted": "U moet de invoerlicentie goedkeuren om de invoer af te werken. Indien u deze licentie momenteel niet kan of mag goedkeuren, kan u uw werk opslaan en de invoer later afwerken. U kan dit nieuwe item ook verwijderen indien u niet voldoet aan de vereisten van de invoer licentie."
"notgranted": "U moet de invoerlicentie goedkeuren om de invoer af te werken. Indien u deze licentie momenteel niet kan of mag goedkeuren, kan u uw werk opslaan en de invoer later afwerken. U kunt dit nieuwe item ook verwijderen indien u niet voldoet aan de vereisten van de invoerlicentie."
}
}
},
@@ -271,7 +271,7 @@
"expired": "Uw sessie is vervallen. Gelieve opnieuw aan te melden."
},
"errors": {
"invalid-user": "Ongeldig email adres of wachtwoord."
"invalid-user": "Ongeldig e-mailadres of wachtwoord."
}
}
}

View File

@@ -1,12 +1,13 @@
<ng-container *ngVar="(communitiesRDObs | async) as communitiesRD">
<div *ngIf="communitiesRD?.hasSucceeded " @fadeInOut>
<ng-container *ngVar="(communitiesRD$ | async) as communitiesRD">
<div *ngIf="communitiesRD?.hasSucceeded ">
<h2>{{'home.top-level-communities.head' | translate}}</h2>
<p class="lead">{{'home.top-level-communities.help' | translate}}</p>
<ds-viewable-collection
[config]="config"
[sortConfig]="sortConfig"
[objects]="communitiesRD"
(paginationChange)="updatePage($event)">
[objects]="communitiesRD$ | async"
[hideGear]="true"
(paginationChange)="onPaginationChange($event)">
</ds-viewable-collection>
</div>
<ds-error *ngIf="communitiesRD?.hasFailed " message="{{'error.top-level-communites' | translate}}"></ds-error>

View File

@@ -1,5 +1,5 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { Observable } from 'rxjs';
import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
import { BehaviorSubject, Observable } from 'rxjs';
import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model';
import { CommunityDataService } from '../../core/data/community-data.service';
import { PaginatedList } from '../../core/data/paginated-list';
@@ -9,7 +9,11 @@ import { Community } from '../../core/shared/community.model';
import { fadeInOut } from '../../shared/animations/fade';
import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model';
import { take } from 'rxjs/operators';
/**
* this component renders the Top-Level Community list
*/
@Component({
selector: 'ds-top-level-community-list',
styleUrls: ['./top-level-community-list.component.scss'],
@@ -18,9 +22,20 @@ import { PaginationComponentOptions } from '../../shared/pagination/pagination-c
animations: [fadeInOut]
})
export class TopLevelCommunityListComponent {
communitiesRDObs: Observable<RemoteData<PaginatedList<Community>>>;
export class TopLevelCommunityListComponent implements OnInit {
/**
* A list of remote data objects of all top communities
*/
communitiesRD$: BehaviorSubject<RemoteData<PaginatedList<Community>>> = new BehaviorSubject<RemoteData<PaginatedList<Community>>>({} as any);
/**
* The pagination configuration
*/
config: PaginationComponentOptions;
/**
* The sorting configuration
*/
sortConfig: SortOptions;
constructor(private cds: CommunityDataService) {
@@ -29,20 +44,34 @@ export class TopLevelCommunityListComponent {
this.config.pageSize = 5;
this.config.currentPage = 1;
this.sortConfig = new SortOptions('dc.title', SortDirection.ASC);
this.updatePage({
page: this.config.currentPage,
pageSize: this.config.pageSize,
sortField: this.sortConfig.field,
direction: this.sortConfig.direction
});
}
updatePage(data) {
this.communitiesRDObs = this.cds.findTop({
currentPage: data.page,
elementsPerPage: data.pageSize,
sort: { field: data.sortField, direction: data.sortDirection }
ngOnInit() {
this.updatePage();
}
/**
* Called when one of the pagination settings is changed
* @param event The new pagination data
*/
onPaginationChange(event) {
this.config.currentPage = event.page;
this.config.pageSize = event.pageSize;
this.sortConfig.field = event.sortField;
this.sortConfig.direction = event.sortDirection;
this.updatePage();
}
/**
* Update the list of top communities
*/
updatePage() {
this.cds.findTop({
currentPage: this.config.currentPage,
elementsPerPage: this.config.pageSize,
sort: { field: this.sortConfig.field, direction: this.sortConfig.direction }
}).pipe(take(1)).subscribe((results) => {
this.communitiesRD$.next(results);
});
}
}

View File

@@ -1,24 +1,9 @@
<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>
<ds-search-facet-selected-option *ngFor="let value of (selectedValues$ | async)" [selectedValue]="value" [filterConfig]="filterConfig" [selectedValues$]="selectedValues$"></ds-search-facet-selected-option>
<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>
<ds-search-facet-option *ngFor="let value of page.page; trackBy: trackUpdate" [filterConfig]="filterConfig" [filterValue]="value" [selectedValues$]="selectedValues$"></ds-search-facet-option>
</div>
</ng-container>
<div class="clearfix toggle-more-filters">

View File

@@ -0,0 +1,9 @@
<a *ngIf="isVisible | async" class="d-flex flex-row"
[routerLink]="[getSearchLink()]"
[queryParams]="addQueryParams" queryParamsHandling="merge">
<input type="checkbox" [checked]="false" class="my-1 align-self-stretch"/>
<span class="filter-value px-1">{{filterValue.value}}</span>
<span class="float-right filter-value-count ml-auto">
<span class="badge badge-secondary badge-pill">{{filterValue.count}}</span>
</span>
</a>

View File

@@ -0,0 +1,121 @@
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 { SearchFacetOptionComponent } from './search-facet-option.component';
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 { of as observableOf } from 'rxjs';
import { SearchService } from '../../../../search-service/search.service';
import { SearchServiceStub } from '../../../../../shared/testing/search-service-stub';
import { Router } from '@angular/router';
import { RouterStub } from '../../../../../shared/testing/router-stub';
import { SearchConfigurationService } from '../../../../search-service/search-configuration.service';
import { SearchFilterService } from '../../search-filter.service';
import { By } from '@angular/platform-browser';
describe('SearchFacetOptionComponent', () => {
let comp: SearchFacetOptionComponent;
let fixture: ComponentFixture<SearchFacetOptionComponent>;
const filterName1 = 'test name';
const value1 = 'testvalue1';
const value2 = 'test2';
const value3 = 'another value3';
const mockFilterConfig = Object.assign(new SearchFilterConfig(), {
name: filterName1,
type: FilterType.range,
hasFacets: false,
isOpenByDefault: false,
pageSize: 2,
minValue: 200,
maxValue: 3000,
});
const value: FacetValue = {
value: value2,
count: 20,
search: ''
};
const searchLink = '/search';
const selectedValues = [value1];
const selectedValues$ = observableOf(selectedValues);
let filterService;
let searchService;
let router;
const page = observableOf(0);
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot(), NoopAnimationsModule, FormsModule],
declarations: [SearchFacetOptionComponent],
providers: [
{ provide: SearchService, useValue: new SearchServiceStub(searchLink) },
{ provide: Router, useValue: new RouterStub() },
{
provide: SearchConfigurationService, useValue: {
searchOptions: observableOf({})
}
},
{
provide: SearchFilterService, useValue: {
getSelectedValuesForFilter: () => selectedValues,
isFilterActiveWithValue: (paramName: string, filterValue: string) => observableOf(true),
getPage: (paramName: string) => page,
/* tslint:disable:no-empty */
incrementPage: (filterName: string) => {
},
resetPage: (filterName: string) => {
}
/* tslint:enable:no-empty */
}
}
],
schemas: [NO_ERRORS_SCHEMA]
}).overrideComponent(SearchFacetOptionComponent, {
set: { changeDetection: ChangeDetectionStrategy.Default }
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(SearchFacetOptionComponent);
comp = fixture.componentInstance; // SearchPageComponent test instance
filterService = (comp as any).filterService;
searchService = (comp as any).searchService;
router = (comp as any).router;
comp.filterValue = value;
comp.selectedValues$ = selectedValues$;
comp.filterConfig = mockFilterConfig;
fixture.detectChanges();
});
describe('when the updateAddParams method is called wih a value', () => {
it('should update the addQueryParams with the new parameter values', () => {
comp.addQueryParams = {};
(comp as any).updateAddParams(selectedValues);
expect(comp.addQueryParams).toEqual({
[mockFilterConfig.paramName]: [value1, value.value],
page: 1
});
});
});
describe('when isVisible emits true', () => {
it('the facet option should be visible', () => {
comp.isVisible = observableOf(true);
fixture.detectChanges();
const linkEl = fixture.debugElement.query(By.css('a'));
expect(linkEl).not.toBeNull();
});
});
describe('when isVisible emits false', () => {
it('the facet option should not be visible', () => {
comp.isVisible = observableOf(false);
fixture.detectChanges();
const linkEl = fixture.debugElement.query(By.css('a'));
expect(linkEl).toBeNull();
});
});
});

View File

@@ -0,0 +1,102 @@
import { combineLatest as observableCombineLatest, Observable, Subscription } from 'rxjs';
import { map, take } from 'rxjs/operators';
import { Component, Input, OnDestroy, OnInit } from '@angular/core';
import { Router } from '@angular/router';
import { FacetValue } from '../../../../search-service/facet-value.model';
import { SearchFilterConfig } from '../../../../search-service/search-filter-config.model';
import { SearchService } from '../../../../search-service/search.service';
import { SearchFilterService } from '../../search-filter.service';
import { SearchConfigurationService } from '../../../../search-service/search-configuration.service';
import { hasValue } from '../../../../../shared/empty.util';
@Component({
selector: 'ds-search-facet-option',
templateUrl: './search-facet-option.component.html',
})
/**
* Represents a single option in a filter facet
*/
export class SearchFacetOptionComponent implements OnInit, OnDestroy {
/**
* A single value for this component
*/
@Input() filterValue: FacetValue;
/**
* The filter configuration for this facet option
*/
@Input() filterConfig: SearchFilterConfig;
/**
* Emits the active values for this filter
*/
@Input() selectedValues$: Observable<string[]>;
/**
* Emits true when this option should be visible and false when it should be invisible
*/
isVisible: Observable<boolean>;
/**
* UI parameters when this filter is added
*/
addQueryParams;
/**
* Subscription to unsubscribe from on destroy
*/
sub: Subscription;
constructor(protected searchService: SearchService,
protected filterService: SearchFilterService,
protected searchConfigService: SearchConfigurationService,
protected router: Router
) {
}
/**
* Initializes all observable instance variables and starts listening to them
*/
ngOnInit(): void {
this.isVisible = this.isChecked().pipe(map((checked: boolean) => !checked));
this.sub = observableCombineLatest(this.selectedValues$, this.searchConfigService.searchOptions)
.subscribe(([selectedValues, searchOptions]) => {
this.updateAddParams(selectedValues)
});
}
/**
* Checks if a value for this filter is currently active
*/
private isChecked(): Observable<boolean> {
return this.filterService.isFilterActiveWithValue(this.filterConfig.paramName, this.filterValue.value);
}
/**
* @returns {string} The base path to the search page
*/
getSearchLink() {
return this.searchService.getSearchLink();
}
/**
* Calculates the parameters that should change if a given value for this filter would be added to the active filters
* @param {string[]} selectedValues The values that are currently selected for this filter
*/
private updateAddParams(selectedValues: string[]): void {
this.addQueryParams = {
[this.filterConfig.paramName]: [...selectedValues, this.filterValue.value],
page: 1
};
}
/**
* Make sure the subscription is unsubscribed from when this component is destroyed
*/
ngOnDestroy(): void {
if (hasValue(this.sub)) {
this.sub.unsubscribe();
}
}
}

View File

@@ -0,0 +1,8 @@
<a *ngIf="isVisible | async" class="d-flex flex-row"
[routerLink]="[getSearchLink()]"
[queryParams]="changeQueryParams" queryParamsHandling="merge">
<span class="filter-value px-1">{{filterValue.value}}</span>
<span class="float-right filter-value-count ml-auto">
<span class="badge badge-secondary badge-pill">{{filterValue.count}}</span>
</span>
</a>

View File

@@ -0,0 +1,125 @@
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 { 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 { of as observableOf } from 'rxjs';
import { SearchService } from '../../../../search-service/search.service';
import { SearchServiceStub } from '../../../../../shared/testing/search-service-stub';
import { Router } from '@angular/router';
import { RouterStub } from '../../../../../shared/testing/router-stub';
import { SearchConfigurationService } from '../../../../search-service/search-configuration.service';
import { SearchFilterService } from '../../search-filter.service';
import { By } from '@angular/platform-browser';
import { SearchFacetRangeOptionComponent } from './search-facet-range-option.component';
import {
RANGE_FILTER_MAX_SUFFIX,
RANGE_FILTER_MIN_SUFFIX
} from '../../search-range-filter/search-range-filter.component';
describe('SearchFacetRangeOptionComponent', () => {
let comp: SearchFacetRangeOptionComponent;
let fixture: ComponentFixture<SearchFacetRangeOptionComponent>;
const filterName1 = 'test name';
const value2 = '20 - 30';
const mockFilterConfig = Object.assign(new SearchFilterConfig(), {
name: filterName1,
type: FilterType.range,
hasFacets: false,
isOpenByDefault: false,
pageSize: 2,
minValue: 200,
maxValue: 3000,
});
const value: FacetValue = {
value: value2,
count: 20,
search: ''
};
const searchLink = '/search';
let filterService;
let searchService;
let router;
const page = observableOf(0);
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot(), NoopAnimationsModule, FormsModule],
declarations: [SearchFacetRangeOptionComponent],
providers: [
{ provide: SearchService, useValue: new SearchServiceStub(searchLink) },
{ provide: Router, useValue: new RouterStub() },
{
provide: SearchConfigurationService, useValue: {
searchOptions: observableOf({})
}
},
{
provide: SearchFilterService, useValue: {
isFilterActiveWithValue: (paramName: string, filterValue: string) => observableOf(true),
getPage: (paramName: string) => page,
/* tslint:disable:no-empty */
incrementPage: (filterName: string) => {
},
resetPage: (filterName: string) => {
}
/* tslint:enable:no-empty */
}
}
],
schemas: [NO_ERRORS_SCHEMA]
}).overrideComponent(SearchFacetRangeOptionComponent, {
set: { changeDetection: ChangeDetectionStrategy.Default }
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(SearchFacetRangeOptionComponent);
comp = fixture.componentInstance; // SearchFacetRangeOptionComponent test instance
filterService = (comp as any).filterService;
searchService = (comp as any).searchService;
router = (comp as any).router;
comp.filterValue = value;
comp.filterConfig = mockFilterConfig;
fixture.detectChanges();
});
describe('when the updateChangeParams method is called wih a value', () => {
it('should update the changeQueryParams with the new parameter values', () => {
comp.changeQueryParams = {};
comp.filterValue = {
value: '50-60',
count: 20,
search: ''
};
(comp as any).updateChangeParams();
expect(comp.changeQueryParams).toEqual({
[mockFilterConfig.paramName + RANGE_FILTER_MIN_SUFFIX]: ['50'],
[mockFilterConfig.paramName + RANGE_FILTER_MAX_SUFFIX]: ['60'],
page: 1
});
});
});
describe('when isVisible emits true', () => {
it('the facet option should be visible', () => {
comp.isVisible = observableOf(true);
fixture.detectChanges();
const linkEl = fixture.debugElement.query(By.css('a'));
expect(linkEl).not.toBeNull();
});
});
describe('when isVisible emits false', () => {
it('the facet option should not be visible', () => {
comp.isVisible = observableOf(false);
fixture.detectChanges();
const linkEl = fixture.debugElement.query(By.css('a'));
expect(linkEl).toBeNull();
});
});
});

View File

@@ -0,0 +1,105 @@
import { Observable, Subscription } from 'rxjs';
import { map } from 'rxjs/operators';
import { Component, Input, OnDestroy, OnInit } from '@angular/core';
import { Router } from '@angular/router';
import { FacetValue } from '../../../../search-service/facet-value.model';
import { SearchFilterConfig } from '../../../../search-service/search-filter-config.model';
import { SearchService } from '../../../../search-service/search.service';
import { SearchFilterService } from '../../search-filter.service';
import {
RANGE_FILTER_MAX_SUFFIX,
RANGE_FILTER_MIN_SUFFIX
} from '../../search-range-filter/search-range-filter.component';
import { SearchConfigurationService } from '../../../../search-service/search-configuration.service';
import { hasValue } from '../../../../../shared/empty.util';
const rangeDelimiter = '-';
@Component({
selector: 'ds-search-facet-range-option',
templateUrl: './search-facet-range-option.component.html',
})
/**
* Represents a single option in a range filter facet
*/
export class SearchFacetRangeOptionComponent implements OnInit, OnDestroy {
/**
* A single value for this component
*/
@Input() filterValue: FacetValue;
/**
* The filter configuration for this facet option
*/
@Input() filterConfig: SearchFilterConfig;
/**
* Emits true when this option should be visible and false when it should be invisible
*/
isVisible: Observable<boolean>;
/**
* UI parameters when this filter is changed
*/
changeQueryParams;
/**
* Subscription to unsubscribe from on destroy
*/
sub: Subscription;
constructor(protected searchService: SearchService,
protected filterService: SearchFilterService,
protected searchConfigService: SearchConfigurationService,
protected router: Router
) {
}
/**
* Initializes all observable instance variables and starts listening to them
*/
ngOnInit(): void {
this.isVisible = this.isChecked().pipe(map((checked: boolean) => !checked));
this.sub = this.searchConfigService.searchOptions.subscribe(() => {
this.updateChangeParams()
});
}
/**
* Checks if a value for this filter is currently active
*/
private isChecked(): Observable<boolean> {
return this.filterService.isFilterActiveWithValue(this.filterConfig.paramName, this.filterValue.value);
}
/**
* @returns {string} The base path to the search page
*/
getSearchLink() {
return this.searchService.getSearchLink();
}
/**
* Calculates the parameters that should change if a given values for this range filter would be changed
*/
private updateChangeParams(): void {
const parts = this.filterValue.value.split(rangeDelimiter);
const min = parts.length > 1 ? parts[0].trim() : this.filterValue.value;
const max = parts.length > 1 ? parts[1].trim() : this.filterValue.value;
this.changeQueryParams = {
[this.filterConfig.paramName + RANGE_FILTER_MIN_SUFFIX]: [min],
[this.filterConfig.paramName + RANGE_FILTER_MAX_SUFFIX]: [max],
page: 1
};
}
/**
* Make sure the subscription is unsubscribed from when this component is destroyed
*/
ngOnDestroy(): void {
if (hasValue(this.sub)) {
this.sub.unsubscribe();
}
}
}

View File

@@ -0,0 +1,6 @@
<a class="d-flex flex-row"
[routerLink]="[getSearchLink()]"
[queryParams]="removeQueryParams" queryParamsHandling="merge">
<input type="checkbox" [checked]="true" class="my-1 align-self-stretch"/>
<span class="filter-value pl-1">{{selectedValue}}</span>
</a>

View File

@@ -0,0 +1,95 @@
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 { SearchFilterConfig } from '../../../../search-service/search-filter-config.model';
import { FilterType } from '../../../../search-service/filter-type.model';
import { FormsModule } from '@angular/forms';
import { of as observableOf } from 'rxjs';
import { SearchService } from '../../../../search-service/search.service';
import { SearchServiceStub } from '../../../../../shared/testing/search-service-stub';
import { Router } from '@angular/router';
import { RouterStub } from '../../../../../shared/testing/router-stub';
import { SearchConfigurationService } from '../../../../search-service/search-configuration.service';
import { SearchFilterService } from '../../search-filter.service';
import { SearchFacetSelectedOptionComponent } from './search-facet-selected-option.component';
describe('SearchFacetSelectedOptionComponent', () => {
let comp: SearchFacetSelectedOptionComponent;
let fixture: ComponentFixture<SearchFacetSelectedOptionComponent>;
const filterName1 = 'test name';
const value1 = 'testvalue1';
const value2 = 'test2';
const mockFilterConfig = Object.assign(new SearchFilterConfig(), {
name: filterName1,
type: FilterType.range,
hasFacets: false,
isOpenByDefault: false,
pageSize: 2,
minValue: 200,
maxValue: 3000,
});
const searchLink = '/search';
const selectedValues = [value1, value2];
const selectedValues$ = observableOf(selectedValues);
let filterService;
let searchService;
let router;
const page = observableOf(0);
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot(), NoopAnimationsModule, FormsModule],
declarations: [SearchFacetSelectedOptionComponent],
providers: [
{ provide: SearchService, useValue: new SearchServiceStub(searchLink) },
{ provide: Router, useValue: new RouterStub() },
{
provide: SearchConfigurationService, useValue: {
searchOptions: observableOf({})
}
},
{
provide: SearchFilterService, useValue: {
getSelectedValuesForFilter: () => selectedValues,
isFilterActiveWithValue: (paramName: string, filterValue: string) => observableOf(true),
getPage: (paramName: string) => page,
/* tslint:disable:no-empty */
incrementPage: (filterName: string) => {
},
resetPage: (filterName: string) => {
}
/* tslint:enable:no-empty */
}
}
],
schemas: [NO_ERRORS_SCHEMA]
}).overrideComponent(SearchFacetSelectedOptionComponent, {
set: { changeDetection: ChangeDetectionStrategy.Default }
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(SearchFacetSelectedOptionComponent);
comp = fixture.componentInstance; // SearchFacetSelectedOptionComponent test instance
filterService = (comp as any).filterService;
searchService = (comp as any).searchService;
router = (comp as any).router;
comp.selectedValue = value2;
comp.selectedValues$ = selectedValues$;
comp.filterConfig = mockFilterConfig;
fixture.detectChanges();
});
describe('when the updateRemoveParams method is called wih a value', () => {
it('should update the removeQueryParams with the new parameter values', () => {
comp.removeQueryParams = {};
(comp as any).updateRemoveParams(selectedValues);
expect(comp.removeQueryParams).toEqual({
[mockFilterConfig.paramName]: [value1],
page: 1
});
});
});
});

View File

@@ -0,0 +1,87 @@
import { combineLatest as observableCombineLatest, Observable, Subscription } from 'rxjs';
import { Component, Input, OnDestroy, OnInit } from '@angular/core';
import { Router } from '@angular/router';
import { SearchFilterConfig } from '../../../../search-service/search-filter-config.model';
import { SearchService } from '../../../../search-service/search.service';
import { SearchFilterService } from '../../search-filter.service';
import { hasValue } from '../../../../../shared/empty.util';
import { SearchConfigurationService } from '../../../../search-service/search-configuration.service';
@Component({
selector: 'ds-search-facet-selected-option',
templateUrl: './search-facet-selected-option.component.html',
})
/**
* Represents a single selected option in a filter facet
*/
export class SearchFacetSelectedOptionComponent implements OnInit, OnDestroy {
/**
* The value for this component
*/
@Input() selectedValue: string;
/**
* The filter configuration for this facet option
*/
@Input() filterConfig: SearchFilterConfig;
/**
* Emits the active values for this filter
*/
@Input() selectedValues$: Observable<string[]>;
/**
* UI parameters when this filter is removed
*/
removeQueryParams;
/**
* Subscription to unsubscribe from on destroy
*/
sub: Subscription;
constructor(protected searchService: SearchService,
protected filterService: SearchFilterService,
protected searchConfigService: SearchConfigurationService,
protected router: Router
) {
}
/**
* Initializes all observable instance variables and starts listening to them
*/
ngOnInit(): void {
this.sub = observableCombineLatest(this.selectedValues$, this.searchConfigService.searchOptions)
.subscribe(([selectedValues, searchOptions]) => {
this.updateRemoveParams(selectedValues)
});
}
/**
* @returns {string} The base path to the search page
*/
getSearchLink() {
return this.searchService.getSearchLink();
}
/**
* Calculates the parameters that should change if a given value for this filter would be removed from the active filters
* @param {string[]} selectedValues The values that are currently selected for this filter
*/
private updateRemoveParams(selectedValues: string[]): void {
this.removeQueryParams = {
[this.filterConfig.paramName]: selectedValues.filter((v) => v !== this.selectedValue),
page: 1
};
}
/**
* Make sure the subscription is unsubscribed from when this component is destroyed
*/
ngOnDestroy(): void {
if (hasValue(this.sub)) {
this.sub.unsubscribe();
}
}
}

View File

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

View File

@@ -3,6 +3,8 @@ 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';
import { GenericConstructor } from '../../../../core/shared/generic-constructor';
import { SearchFacetFilterComponent } from '../search-facet-filter/search-facet-filter.component';
@Component({
selector: 'ds-search-facet-filter-wrapper',
@@ -18,6 +20,10 @@ export class SearchFacetFilterWrapperComponent implements OnInit {
*/
@Input() filterConfig: SearchFilterConfig;
/**
* The constructor of the search facet filter that should be rendered, based on the filter config's type
*/
searchFilter: GenericConstructor<SearchFacetFilterComponent>;
/**
* Injector to inject a child component with the @Input parameters
*/
@@ -30,6 +36,7 @@ export class SearchFacetFilterWrapperComponent implements OnInit {
* Initialize and add the filter config to the injector
*/
ngOnInit(): void {
this.searchFilter = this.getSearchFilter();
this.objectInjector = Injector.create({
providers: [
{ provide: FILTER_CONFIG, useFactory: () => (this.filterConfig), deps: [] }
@@ -41,7 +48,7 @@ export class SearchFacetFilterWrapperComponent implements OnInit {
/**
* Find the correct component based on the filter config's type
*/
getSearchFilter() {
private getSearchFilter() {
const type: FilterType = this.filterConfig.type;
return renderFilterType(type);
}

View File

@@ -120,20 +120,6 @@ 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);
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);
result.subscribe((r) => expect(r[mockFilterConfig.paramName]).toEqual([value2]));
});
});
describe('when the showMore method is called', () => {
beforeEach(() => {
spyOn(filterService, 'incrementPage');

View File

@@ -22,6 +22,7 @@ import { FILTER_CONFIG, SearchFilterService } from '../search-filter.service';
import { SearchConfigurationService } from '../../../search-service/search-configuration.service';
import { getSucceededRemoteData } from '../../../../core/shared/operators';
import { InputSuggestion } from '../../../../shared/input-suggestions/input-suggestions.model';
import { SearchOptions } from '../../../search-options.model';
@Component({
selector: 'ds-search-facet-filter',
@@ -65,7 +66,7 @@ export class SearchFacetFilterComponent implements OnInit, OnDestroy {
/**
* Emits the active values for this filter
*/
selectedValues: Observable<string[]>;
selectedValues$: Observable<string[]>;
private collapseNextUpdate = true;
/**
@@ -73,6 +74,11 @@ export class SearchFacetFilterComponent implements OnInit, OnDestroy {
*/
animationState = 'loading';
/**
* Emits all current search options available in the search URL
*/
searchOptions$: Observable<SearchOptions>;
constructor(protected searchService: SearchService,
protected filterService: SearchFilterService,
protected searchConfigService: SearchConfigurationService,
@@ -87,10 +93,11 @@ export class SearchFacetFilterComponent implements OnInit, OnDestroy {
ngOnInit(): void {
this.filterValues$ = new BehaviorSubject(new RemoteData(true, false, undefined, undefined, undefined));
this.currentPage = this.getCurrentPage().pipe(distinctUntilChanged());
this.selectedValues = this.filterService.getSelectedValuesForFilter(this.filterConfig);
const searchOptions = this.searchConfigService.searchOptions;
this.subs.push(this.searchConfigService.searchOptions.subscribe(() => this.updateFilterValueList()));
const facetValues = observableCombineLatest(searchOptions, this.currentPage).pipe(
this.selectedValues$ = this.filterService.getSelectedValuesForFilter(this.filterConfig);
this.searchOptions$ = this.searchConfigService.searchOptions;
this.subs.push(this.searchOptions$.subscribe(() => this.updateFilterValueList()));
const facetValues = observableCombineLatest(this.searchOptions$, this.currentPage).pipe(
map(([options, page]) => {
return { options, page }
}),
@@ -190,7 +197,7 @@ export class SearchFacetFilterComponent implements OnInit, OnDestroy {
* @param data The string from the input field
*/
onSubmit(data: any) {
this.selectedValues.pipe(take(1)).subscribe((selectedValues) => {
this.selectedValues$.pipe(take(1)).subscribe((selectedValues) => {
if (isNotEmpty(data)) {
this.router.navigate([this.getSearchLink()], {
queryParams:
@@ -204,6 +211,10 @@ export class SearchFacetFilterComponent implements OnInit, OnDestroy {
)
}
/**
* On click, set the input's value to the clicked data
* @param data The value of the option that was clicked
*/
onClick(data: any) {
this.filter = data;
}
@@ -215,34 +226,6 @@ export class SearchFacetFilterComponent implements OnInit, OnDestroy {
return hasValue(o);
}
/**
* 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.pipe(map((selectedValues) => {
return {
[this.filterConfig.paramName]: selectedValues.filter((v) => v !== value),
page: 1
};
}));
}
/**
* Calculates the parameters that should change if a given value for this filter would be added to the active filters
* @param {string} value The value that is added for this filter
* @returns {Observable<any>} The changed filter parameters
*/
getAddParams(value: string): Observable<any> {
return this.selectedValues.pipe(map((selectedValues) => {
return {
[this.filterConfig.paramName]: [...selectedValues, value],
page: 1
};
}));
}
/**
* Unsubscribe from all subscriptions
*/
@@ -259,7 +242,7 @@ export class SearchFacetFilterComponent implements OnInit, OnDestroy {
*/
findSuggestions(data): void {
if (isNotEmpty(data)) {
this.searchConfigService.searchOptions.pipe(take(1)).subscribe(
this.searchOptions$.pipe(take(1)).subscribe(
(options) => {
this.filterSearchResults = this.searchService.getFacetValuesFor(this.filterConfig, 1, options, data.toLowerCase())
.pipe(
@@ -290,6 +273,13 @@ export class SearchFacetFilterComponent implements OnInit, OnDestroy {
getDisplayValue(facet: FacetValue, query: string): string {
return new EmphasizePipe().transform(facet.value, query) + ' (' + facet.count + ')';
}
/**
* Prevent unnecessary rerendering
*/
trackUpdate(index, value: FacetValue) {
return value ? value.search : undefined;
}
}
export const facetLoad = trigger('facetLoad', [

View File

@@ -1,6 +1,7 @@
import { Action } from '@ngrx/store';
import { type } from '../../../shared/ngrx/type';
import { SearchFilterConfig } from '../../search-service/search-filter-config.model';
/**
* For each action type in an action group, make a simple
@@ -12,9 +13,8 @@ import { type } from '../../../shared/ngrx/type';
*/
export const SearchFilterActionTypes = {
COLLAPSE: type('dspace/search-filter/COLLAPSE'),
INITIAL_COLLAPSE: type('dspace/search-filter/INITIAL_COLLAPSE'),
INITIALIZE: type('dspace/search-filter/INITIALIZE'),
EXPAND: type('dspace/search-filter/EXPAND'),
INITIAL_EXPAND: type('dspace/search-filter/INITIAL_EXPAND'),
TOGGLE: type('dspace/search-filter/TOGGLE'),
DECREMENT_PAGE: type('dspace/search-filter/DECREMENT_PAGE'),
INCREMENT_PAGE: type('dspace/search-filter/INCREMENT_PAGE'),
@@ -64,17 +64,15 @@ export class SearchFilterToggleAction extends SearchFilterAction {
}
/**
* Used to set the initial state of a filter to collapsed
* Used to set the initial state of a filter
*/
export class SearchFilterInitialCollapseAction extends SearchFilterAction {
type = SearchFilterActionTypes.INITIAL_COLLAPSE;
export class SearchFilterInitializeAction extends SearchFilterAction {
type = SearchFilterActionTypes.INITIALIZE;
initiallyExpanded;
constructor(filter: SearchFilterConfig) {
super(filter.name);
this.initiallyExpanded = filter.isOpenByDefault;
}
/**
* Used to set the initial state of a filter to expanded
*/
export class SearchFilterInitialExpandAction extends SearchFilterAction {
type = SearchFilterActionTypes.INITIAL_EXPAND;
}
/**

View File

@@ -1,7 +1,7 @@
<div>
<div class="facet-filter d-block mb-3 p-3" *ngIf="active$ | async">
<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 fas float-right"
[ngClass]="(isCollapsed() | async) ? 'fa-plus' : 'fa-minus'"></span></div>
<div [@slide]="(isCollapsed() | async) ? 'collapsed' : 'expanded'" (@slide.start)="startSlide($event)" (@slide.done)="finishSlide($event)" class="search-filter-wrapper" [ngClass]="{'closed' : collapsed}">
[ngClass]="(collapsed$ | async) ? 'fa-plus' : 'fa-minus'"></span></div>
<div [@slide]="(collapsed$ | async) ? 'collapsed' : 'expanded'" (@slide.start)="startSlide($event)" (@slide.done)="finishSlide($event)" class="search-filter-wrapper" [ngClass]="{'closed' : closed}">
<ds-search-facet-filter-wrapper [filterConfig]="filter"></ds-search-facet-filter-wrapper>
</div>
</div>

View File

@@ -1,7 +1,7 @@
@import '../../../../styles/variables.scss';
@import '../../../../styles/mixins.scss';
:host {
:host .facet-filter {
border: 1px solid map-get($theme-colors, light);
cursor: pointer;
.search-filter-wrapper.closed {

View File

@@ -10,6 +10,7 @@ import { SearchService } from '../../search-service/search.service';
import { SearchFilterComponent } from './search-filter.component';
import { SearchFilterConfig } from '../../search-service/search-filter-config.model';
import { FilterType } from '../../search-service/filter-type.model';
import { SearchConfigurationService } from '../../search-service/search-configuration.service';
describe('SearchFilterComponent', () => {
let comp: SearchFilterComponent;
@@ -33,9 +34,7 @@ describe('SearchFilterComponent', () => {
},
expand: (filter) => {
},
initialCollapse: (filter) => {
},
initialExpand: (filter) => {
initializeFilter: (filter) => {
},
getSelectedValuesForFilter: (filter) => {
return observableOf([filterName1, filterName2, filterName3])
@@ -55,6 +54,8 @@ describe('SearchFilterComponent', () => {
getFacetValuesFor: (filter) => mockResults
};
const searchConfigServiceStub = {};
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([]), NoopAnimationsModule],
@@ -65,6 +66,7 @@ describe('SearchFilterComponent', () => {
provide: SearchFilterService,
useValue: mockFilterService
},
{ provide: SearchConfigurationService, useValue: searchConfigServiceStub },
],
schemas: [NO_ERRORS_SCHEMA]
}).overrideComponent(SearchFilterComponent, {
@@ -91,32 +93,21 @@ describe('SearchFilterComponent', () => {
});
});
describe('when the initialCollapse method is triggered', () => {
describe('when the initializeFilter method is triggered', () => {
beforeEach(() => {
spyOn(filterService, 'initialCollapse');
comp.initialCollapse();
spyOn(filterService, 'initializeFilter');
comp.initializeFilter();
});
it('should call initialCollapse with the correct filter configuration name', () => {
expect(filterService.initialCollapse).toHaveBeenCalledWith(mockFilterConfig.name)
});
});
describe('when the initialExpand method is triggered', () => {
beforeEach(() => {
spyOn(filterService, 'initialExpand');
comp.initialExpand();
});
it('should call initialCollapse with the correct filter configuration name', () => {
expect(filterService.initialExpand).toHaveBeenCalledWith(mockFilterConfig.name)
expect(filterService.initializeFilter).toHaveBeenCalledWith(mockFilterConfig)
});
});
describe('when getSelectedValues is called', () => {
let valuesObservable: Observable<string[]>;
beforeEach(() => {
valuesObservable = comp.getSelectedValues();
valuesObservable = (comp as any).getSelectedValues();
});
it('should return an observable containing the existing filters', () => {
@@ -141,7 +132,7 @@ describe('SearchFilterComponent', () => {
let isActive: Observable<boolean>;
beforeEach(() => {
filterService.isCollapsed = () => observableOf(true);
isActive = comp.isCollapsed();
isActive = (comp as any).isCollapsed();
});
it('should return an observable containing true', () => {
@@ -156,7 +147,7 @@ describe('SearchFilterComponent', () => {
let isActive: Observable<boolean>;
beforeEach(() => {
filterService.isCollapsed = () => observableOf(false);
isActive = comp.isCollapsed();
isActive = (comp as any).isCollapsed();
});
it('should return an observable containing false', () => {

View File

@@ -1,11 +1,12 @@
import { take } from 'rxjs/operators';
import { filter, first, map, startWith, switchMap, take } from 'rxjs/operators';
import { Component, Input, OnInit } from '@angular/core';
import { SearchFilterConfig } from '../../search-service/search-filter-config.model';
import { SearchFilterService } from './search-filter.service';
import { Observable } from 'rxjs';
import { BehaviorSubject, Observable, of as observableOf } from 'rxjs';
import { slide } from '../../../shared/animations/slide';
import { isNotEmpty } from '../../../shared/empty.util';
import { SearchService } from '../../search-service/search.service';
import { SearchConfigurationService } from '../../search-service/search-configuration.service';
@Component({
selector: 'ds-search-filter',
@@ -26,9 +27,24 @@ export class SearchFilterComponent implements OnInit {
/**
* True when the filter is 100% collapsed in the UI
*/
collapsed;
closed = true;
constructor(private filterService: SearchFilterService) {
/**
* Emits true when the filter is currently collapsed in the store
*/
collapsed$: Observable<boolean>;
/**
* Emits all currently selected values for this filter
*/
selectedValues$: Observable<string[]>;
/**
* Emits true when the current filter is supposed to be shown
*/
active$: Observable<boolean>;
constructor(private filterService: SearchFilterService, private searchService: SearchService, private searchConfigService: SearchConfigurationService) {
}
/**
@@ -37,11 +53,13 @@ export class SearchFilterComponent implements OnInit {
* Else, the filter should initially be collapsed
*/
ngOnInit() {
this.getSelectedValues().pipe(take(1)).subscribe((isActive) => {
if (this.filter.isOpenByDefault || isNotEmpty(isActive)) {
this.initialExpand();
} else {
this.initialCollapse();
this.selectedValues$ = this.getSelectedValues();
this.active$ = this.isActive();
this.collapsed$ = this.isCollapsed();
this.initializeFilter();
this.selectedValues$.pipe(take(1)).subscribe((selectedValues) => {
if (isNotEmpty(selectedValues)) {
this.filterService.expand(this.filter.name);
}
});
}
@@ -57,30 +75,21 @@ export class SearchFilterComponent implements OnInit {
* 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> {
private isCollapsed(): Observable<boolean> {
return this.filterService.isCollapsed(this.filter.name);
}
/**
* Changes the initial state to collapsed
* Sets the initial state of the filter
*/
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;
initializeFilter() {
this.filterService.initializeFilter(this.filter);
}
/**
* @returns {Observable<string[]>} Emits a list of all values that are currently active for this filter
*/
getSelectedValues(): Observable<string[]> {
private getSelectedValues(): Observable<string[]> {
return this.filterService.getSelectedValuesForFilter(this.filter);
}
@@ -90,7 +99,7 @@ export class SearchFilterComponent implements OnInit {
*/
finishSlide(event: any): void {
if (event.fromState === 'collapsed') {
this.collapsed = false;
this.closed = false;
}
}
@@ -100,7 +109,31 @@ export class SearchFilterComponent implements OnInit {
*/
startSlide(event: any): void {
if (event.toState === 'collapsed') {
this.collapsed = true;
this.closed = true;
}
}
/**
* Check if a given filter is supposed to be shown or not
* @returns {Observable<boolean>} Emits true whenever a given filter config should be shown
*/
private isActive(): Observable<boolean> {
return this.selectedValues$.pipe(
switchMap((isActive) => {
if (isNotEmpty(isActive)) {
return observableOf(true);
} else {
return this.searchConfigService.searchOptions.pipe(
switchMap((options) => {
return this.searchService.getFacetValuesFor(this.filter, 1, options).pipe(
filter((RD) => !RD.isLoading),
map((valuesRD) => {
return valuesRD.payload.totalElements > 0
}),)
}
))
}
}),
startWith(true));
}
}

View File

@@ -1,10 +1,8 @@
import * as deepFreeze from 'deep-freeze';
import {
SearchFilterCollapseAction, SearchFilterExpandAction, SearchFilterIncrementPageAction,
SearchFilterInitialCollapseAction,
SearchFilterInitialExpandAction,
SearchFilterToggleAction,
SearchFilterDecrementPageAction, SearchFilterResetPageAction
SearchFilterDecrementPageAction, SearchFilterResetPageAction, SearchFilterInitializeAction
} from './search-filter.actions';
import { filterReducer } from './search-filter.reducer';
@@ -98,35 +96,39 @@ describe('filterReducer', () => {
filterReducer(state, action);
});
it('should set filterCollapsed to true in response to the INITIAL_COLLAPSE action when no state has been set for this filter', () => {
it('should set filterCollapsed to true in response to the INITIALIZE action with isOpenByDefault to false when no state has been set for this filter', () => {
const state = {};
state[filterName2] = { filterCollapsed: false, page: 1 };
const action = new SearchFilterInitialCollapseAction(filterName1);
const filterConfig = {isOpenByDefault: false, name: filterName1} as any;
const action = new SearchFilterInitializeAction(filterConfig);
const newState = filterReducer(state, action);
expect(newState[filterName1].filterCollapsed).toEqual(true);
});
it('should set filterCollapsed to true in response to the INITIAL_EXPAND action when no state has been set for this filter', () => {
it('should set filterCollapsed to false in response to the INITIALIZE action with isOpenByDefault to true when no state has been set for this filter', () => {
const state = {};
state[filterName2] = { filterCollapsed: true, page: 1 };
const action = new SearchFilterInitialExpandAction(filterName1);
const filterConfig = {isOpenByDefault: true, name: filterName1} as any;
const action = new SearchFilterInitializeAction(filterConfig);
const newState = filterReducer(state, action);
expect(newState[filterName1].filterCollapsed).toEqual(false);
});
it('should not change the state in response to the INITIAL_COLLAPSE action when the state has already been set for this filter', () => {
it('should not change the state in response to the INITIALIZE action with isOpenByDefault to false when the state has already been set for this filter', () => {
const state = {};
state[filterName1] = { filterCollapsed: false, page: 1 };
const action = new SearchFilterInitialCollapseAction(filterName1);
const filterConfig = { isOpenByDefault: true, name: filterName1 } as any;
const action = new SearchFilterInitializeAction(filterConfig);
const newState = filterReducer(state, action);
expect(newState).toEqual(state);
});
it('should not change the state in response to the INITIAL_EXPAND action when the state has already been set for this filter', () => {
it('should not change the state in response to the INITIALIZE action with isOpenByDefault to true when the state has already been set for this filter', () => {
const state = {};
state[filterName1] = { filterCollapsed: true, page: 1 };
const action = new SearchFilterInitialExpandAction(filterName1);
const filterConfig = { isOpenByDefault: false, name: filterName1 } as any;
const action = new SearchFilterInitializeAction(filterConfig);
const newState = filterReducer(state, action);
expect(newState).toEqual(state);
});

View File

@@ -1,5 +1,9 @@
import { SearchFilterAction, SearchFilterActionTypes } from './search-filter.actions';
import { isEmpty } from '../../../shared/empty.util';
import {
SearchFilterAction,
SearchFilterActionTypes,
SearchFilterInitializeAction
} from './search-filter.actions';
import { isEmpty, isNotUndefined } from '../../../shared/empty.util';
/**
* Interface that represents the state for a single filters
@@ -28,27 +32,14 @@ export function filterReducer(state = initialState, action: SearchFilterAction):
switch (action.type) {
case SearchFilterActionTypes.INITIAL_COLLAPSE: {
if (isEmpty(state) || isEmpty(state[action.filterName])) {
case SearchFilterActionTypes.INITIALIZE: {
const initAction = (action as SearchFilterInitializeAction);
return Object.assign({}, state, {
[action.filterName]: {
filterCollapsed: true,
filterCollapsed: !initAction.initiallyExpanded,
page: 1
}
});
}
return state;
}
case SearchFilterActionTypes.INITIAL_EXPAND: {
if (isEmpty(state) || isEmpty(state[action.filterName])) {
return Object.assign({}, state, {
[action.filterName]: {
filterCollapsed: false,
page: 1
}
});
}
return state;
}

View File

@@ -5,8 +5,7 @@ import {
SearchFilterDecrementPageAction,
SearchFilterExpandAction,
SearchFilterIncrementPageAction,
SearchFilterInitialCollapseAction,
SearchFilterInitialExpandAction,
SearchFilterInitializeAction,
SearchFilterResetPageAction,
SearchFilterToggleAction
} from './search-filter.actions';
@@ -62,23 +61,13 @@ describe('SearchFilterService', () => {
service = new SearchFilterService(store, routeServiceStub);
});
describe('when the initialCollapse method is triggered', () => {
describe('when the initializeFilter method is triggered', () => {
beforeEach(() => {
service.initialCollapse(mockFilterConfig.name);
service.initializeFilter(mockFilterConfig);
});
it('SearchFilterInitialCollapseAction should be dispatched to the store', () => {
expect(store.dispatch).toHaveBeenCalledWith(new SearchFilterInitialCollapseAction(mockFilterConfig.name));
});
});
describe('when the initialExpand method is triggered', () => {
beforeEach(() => {
service.initialExpand(mockFilterConfig.name);
});
it('SearchFilterInitialExpandAction should be dispatched to the store', () => {
expect(store.dispatch).toHaveBeenCalledWith(new SearchFilterInitialExpandAction(mockFilterConfig.name));
it('SearchFilterInitializeAction should be dispatched to the store', () => {
expect(store.dispatch).toHaveBeenCalledWith(new SearchFilterInitializeAction(mockFilterConfig));
});
});

View File

@@ -1,6 +1,6 @@
import { combineLatest as observableCombineLatest, Observable } from 'rxjs';
import { Injectable, InjectionToken } from '@angular/core';
import { map } from 'rxjs/operators';
import { distinctUntilChanged, map } from 'rxjs/operators';
import { SearchFiltersState, SearchFilterState } from './search-filter.reducer';
import { createSelector, MemoizedSelector, select, Store } from '@ngrx/store';
import {
@@ -8,8 +8,7 @@ import {
SearchFilterDecrementPageAction,
SearchFilterExpandAction,
SearchFilterIncrementPageAction,
SearchFilterInitialCollapseAction,
SearchFilterInitialExpandAction,
SearchFilterInitializeAction,
SearchFilterResetPageAction,
SearchFilterToggleAction
} from './search-filter.actions';
@@ -17,7 +16,8 @@ import { hasValue, isNotEmpty, } from '../../../shared/empty.util';
import { SearchFilterConfig } from '../../search-service/search-filter-config.model';
import { RouteService } from '../../../shared/services/route.service';
import { Params } from '@angular/router';
import { SearchOptions } from '../../search-options.model';
// const spy = create();
const filterStateSelector = (state: SearchFiltersState) => state.searchFilter;
export const FILTER_CONFIG: InjectionToken<SearchFilterConfig> = new InjectionToken<SearchFilterConfig>('filterConfig');
@@ -60,7 +60,7 @@ export class SearchFilterService {
getSelectedValuesForFilter(filterConfig: SearchFilterConfig): Observable<string[]> {
const values$ = this.routeService.getQueryParameterValues(filterConfig.paramName);
const prefixValues$ = this.routeService.getQueryParamsWithPrefix(filterConfig.paramName + '.').pipe(
map((params: Params) => [].concat(...Object.values(params)))
map((params: Params) => [].concat(...Object.values(params))),
);
return observableCombineLatest(values$, prefixValues$).pipe(
@@ -88,7 +88,8 @@ export class SearchFilterService {
} else {
return false;
}
})
}),
distinctUntilChanged()
);
}
@@ -106,7 +107,8 @@ export class SearchFilterService {
} else {
return 1;
}
}));
}),
distinctUntilChanged());
}
/**
@@ -134,19 +136,11 @@ export class SearchFilterService {
}
/**
* Dispatches an initial collapse action to the store for a given filter
* @param {string} filterName The filter for which the action is dispatched
* Dispatches an initialize action to the store for a given filter
* @param {SearchFilterConfig} filter 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));
public initializeFilter(filter: SearchFilterConfig): void {
this.store.dispatch(new SearchFilterInitializeAction(filter));
}
/**

View File

@@ -1,24 +1,9 @@
<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>
<ds-search-facet-selected-option *ngFor="let value of (selectedValues$ | async)" [selectedValue]="value" [filterConfig]="filterConfig" [selectedValues$]="selectedValues$"></ds-search-facet-selected-option>
<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>
<ds-search-facet-option *ngFor="let value of page.page; trackBy: trackUpdate" [filterConfig]="filterConfig" [filterValue]="value" [selectedValues$]="selectedValues$"></ds-search-facet-option>
</div>
</ng-container>
<div class="clearfix toggle-more-filters">

View File

@@ -24,16 +24,7 @@
</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>
<ds-search-facet-range-option *ngFor="let value of page.page; trackBy: trackUpdate" [filterConfig]="filterConfig" [filterValue]="value"></ds-search-facet-range-option>
</div>
</ng-container>
</div>

View File

@@ -106,16 +106,6 @@ describe('SearchRangeFilterComponent', () => {
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' };

View File

@@ -1,9 +1,4 @@
import {
of as observableOf,
combineLatest as observableCombineLatest,
Observable,
Subscription
} from 'rxjs';
import { combineLatest as observableCombineLatest, Subscription } from 'rxjs';
import { map, startWith } from 'rxjs/operators';
import { isPlatformBrowser } from '@angular/common';
import { Component, Inject, OnDestroy, OnInit, PLATFORM_ID } from '@angular/core';
@@ -23,16 +18,26 @@ import { RouteService } from '../../../../shared/services/route.service';
import { hasValue } from '../../../../shared/empty.util';
import { SearchConfigurationService } from '../../../search-service/search-configuration.service';
/**
* The suffix for a range filters' minimum in the frontend URL
*/
export const RANGE_FILTER_MIN_SUFFIX = '.min';
/**
* The suffix for a range filters' maximum in the frontend URL
*/
export const RANGE_FILTER_MAX_SUFFIX = '.max';
/**
* The date formats that are possible to appear in a date filter
*/
const dateFormats = ['YYYY', 'YYYY-MM', 'YYYY-MM-DD'];
/**
* 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'],
@@ -85,8 +90,8 @@ export class SearchRangeFilterComponent extends SearchFacetFilterComponent imple
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).pipe(startWith(undefined));
const iniMax = this.route.getQueryParameterValue(this.filterConfig.paramName + maxSuffix).pipe(startWith(undefined));
const iniMin = this.route.getQueryParameterValue(this.filterConfig.paramName + RANGE_FILTER_MIN_SUFFIX).pipe(startWith(undefined));
const iniMax = this.route.getQueryParameterValue(this.filterConfig.paramName + RANGE_FILTER_MAX_SUFFIX).pipe(startWith(undefined));
this.sub = observableCombineLatest(iniMin, iniMax).pipe(
map(([min, max]) => {
const minimum = hasValue(min) ? min : this.min;
@@ -96,23 +101,6 @@ export class SearchRangeFilterComponent extends SearchFacetFilterComponent imple
).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 observableOf(
{
[this.filterConfig.paramName + minSuffix]: [min],
[this.filterConfig.paramName + maxSuffix]: [max],
page: 1
});
}
/**
* Submits new custom range values to the range filter from the widget
*/
@@ -122,8 +110,8 @@ export class SearchRangeFilterComponent extends SearchFacetFilterComponent imple
this.router.navigate([this.getSearchLink()], {
queryParams:
{
[this.filterConfig.paramName + minSuffix]: newMin,
[this.filterConfig.paramName + maxSuffix]: newMax
[this.filterConfig.paramName + RANGE_FILTER_MIN_SUFFIX]: newMin,
[this.filterConfig.paramName + RANGE_FILTER_MAX_SUFFIX]: newMax
},
queryParamsHandling: 'merge'
});
@@ -148,8 +136,4 @@ export class SearchRangeFilterComponent extends SearchFacetFilterComponent imple
this.sub.unsubscribe();
}
}
out(call) {
console.log(call);
}
}

View File

@@ -1,26 +1,9 @@
<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">
<ds-search-facet-selected-option *ngFor="let value of (selectedValues$ | async)" [selectedValue]="value" [filterConfig]="filterConfig" [selectedValues$]="selectedValues$"></ds-search-facet-selected-option>
<ng-container *ngFor="let page of (filterValues$ | async)?.payload">
<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>
<ds-search-facet-option *ngFor="let value of page.page; trackBy: trackUpdate" [filterConfig]="filterConfig" [filterValue]="value" [selectedValues$]="selectedValues$"></ds-search-facet-option>
</div>
</ng-container>
<div class="clearfix toggle-more-filters">
@@ -40,6 +23,5 @@
(submitSuggestion)="onSubmit($event)"
(clickSuggestion)="onClick($event)"
(findSuggestions)="findSuggestions($event)"
ngDefaultControl
></ds-input-suggestions>
ngDefaultControl></ds-input-suggestions>
</div>

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 *ngIf="isActive(filter) | async" class="d-block mb-3 p-3" [filter]="filter"></ds-search-filter>
<div *ngFor="let filter of (filters | async)?.payload; trackBy: trackUpdate">
<ds-search-filter [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

@@ -1,12 +1,11 @@
import { Observable, of as observableOf } from 'rxjs';
import { Observable } from 'rxjs';
import { filter, map, mergeMap, startWith, switchMap } from 'rxjs/operators';
import { map } from 'rxjs/operators';
import { Component } from '@angular/core';
import { SearchService } from '../search-service/search.service';
import { RemoteData } from '../../core/data/remote-data';
import { SearchFilterConfig } from '../search-service/search-filter-config.model';
import { SearchConfigurationService } from '../search-service/search-configuration.service';
import { isNotEmpty } from '../../shared/empty.util';
import { SearchFilterService } from './search-filter/search-filter.service';
import { getSucceededRemoteData } from '../../core/shared/operators';
@@ -53,26 +52,9 @@ export class SearchFiltersComponent {
}
/**
* 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
* Prevent unnecessary rerendering
*/
isActive(filterConfig: SearchFilterConfig): Observable<boolean> {
return this.filterService.getSelectedValuesForFilter(filterConfig).pipe(
mergeMap((isActive) => {
if (isNotEmpty(isActive)) {
return observableOf(true);
} else {
return this.searchConfigService.searchOptions.pipe(
switchMap((options) => {
return this.searchService.getFacetValuesFor(filterConfig, 1, options).pipe(
filter((RD) => !RD.isLoading),
map((valuesRD) => {
return valuesRD.payload.totalElements > 0
}),)
}
))
}
}),startWith(true),);
trackUpdate(index, config: SearchFilterConfig) {
return config ? config.name : undefined;
}
}

View File

@@ -62,7 +62,6 @@ export class SearchPageComponent implements OnInit {
constructor(private service: SearchService,
private sidebarService: SearchSidebarService,
private windowService: HostWindowService,
private filterService: SearchFilterService,
private searchConfigService: SearchConfigurationService) {
this.isXsOrSm$ = this.windowService.isXsOrSm();
}

View File

@@ -27,6 +27,9 @@ import { SearchFacetFilterWrapperComponent } from './search-filters/search-filte
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';
import { SearchFacetOptionComponent } from './search-filters/search-filter/search-facet-filter-options/search-facet-option/search-facet-option.component';
import { SearchFacetSelectedOptionComponent } from './search-filters/search-filter/search-facet-filter-options/search-facet-selected-option/search-facet-selected-option.component';
import { SearchFacetRangeOptionComponent } from './search-filters/search-filter/search-facet-filter-options/search-facet-range-option/search-facet-range-option.component';
const effects = [
SearchSidebarEffects
@@ -58,6 +61,9 @@ const effects = [
SearchTextFilterComponent,
SearchHierarchyFilterComponent,
SearchBooleanFilterComponent,
SearchFacetOptionComponent,
SearchFacetSelectedOptionComponent,
SearchFacetRangeOptionComponent
],
providers: [
SearchSidebarService,
@@ -76,6 +82,9 @@ const effects = [
SearchTextFilterComponent,
SearchHierarchyFilterComponent,
SearchBooleanFilterComponent,
SearchFacetOptionComponent,
SearchFacetSelectedOptionComponent,
SearchFacetRangeOptionComponent
]
})

View File

@@ -117,7 +117,7 @@ describe('SearchConfigurationService', () => {
describe('when subscribeToSearchOptions is called', () => {
beforeEach(() => {
service.subscribeToSearchOptions(defaults)
(service as any).subscribeToSearchOptions(defaults)
});
it('should call all getters it needs, but not call any others', () => {
expect(service.getCurrentPagination).not.toHaveBeenCalled();
@@ -131,7 +131,7 @@ describe('SearchConfigurationService', () => {
describe('when subscribeToPaginatedSearchOptions is called', () => {
beforeEach(() => {
service.subscribeToPaginatedSearchOptions(defaults);
(service as any).subscribeToPaginatedSearchOptions(defaults);
});
it('should call all getters it needs', () => {
expect(service.getCurrentPagination).toHaveBeenCalled();

View File

@@ -186,7 +186,7 @@ export class SearchConfigurationService implements OnDestroy {
* @param {SearchOptions} defaults Default values for when no parameters are available
* @returns {Subscription} The subscription to unsubscribe from
*/
subscribeToSearchOptions(defaults: SearchOptions): Subscription {
private subscribeToSearchOptions(defaults: SearchOptions): Subscription {
return observableMerge(
this.getScopePart(defaults.scope),
this.getQueryPart(defaults.query),
@@ -204,7 +204,7 @@ export class SearchConfigurationService implements OnDestroy {
* @param {PaginatedSearchOptions} defaults Default values for when no parameters are available
* @returns {Subscription} The subscription to unsubscribe from
*/
subscribeToPaginatedSearchOptions(defaults: PaginatedSearchOptions): Subscription {
private subscribeToPaginatedSearchOptions(defaults: PaginatedSearchOptions): Subscription {
return observableMerge(
this.getPaginationPart(defaults.pagination),
this.getSortPart(defaults.sort),

View File

@@ -1,34 +1,44 @@
import { Injectable } from '@angular/core';
import { createSelector, MemoizedSelector, select, Store } from '@ngrx/store';
import { applyPatch, Operation } from 'fast-json-patch';
import { combineLatest as observableCombineLatest, Observable } from 'rxjs';
import { distinctUntilChanged, filter, map, mergeMap, take, } from 'rxjs/operators';
import { Injectable } from '@angular/core';
import { MemoizedSelector, select, Store } from '@ngrx/store';
import { IndexName } from '../index/index.reducer';
import { CacheableObject, ObjectCacheEntry } from './object-cache.reducer';
import { hasNoValue, hasValue, isNotEmpty } from '../../shared/empty.util';
import { CoreState } from '../core.reducers';
import { coreSelector } from '../core.selectors';
import { RestRequestMethod } from '../data/rest-request-method';
import { selfLinkFromUuidSelector } from '../index/index.selectors';
import { GenericConstructor } from '../shared/generic-constructor';
import { NormalizedObjectFactory } from './models/normalized-object-factory';
import { NormalizedObject } from './models/normalized-object.model';
import {
AddPatchObjectCacheAction,
AddToObjectCacheAction,
ApplyPatchObjectCacheAction,
RemoveFromObjectCacheAction
} from './object-cache.actions';
import { hasNoValue, isNotEmpty } from '../../shared/empty.util';
import { GenericConstructor } from '../shared/generic-constructor';
import { coreSelector, CoreState } from '../core.reducers';
import { pathSelector } from '../shared/selectors';
import { NormalizedObjectFactory } from './models/normalized-object-factory';
import { NormalizedObject } from './models/normalized-object.model';
import { applyPatch, Operation } from 'fast-json-patch';
import { CacheableObject, ObjectCacheEntry, ObjectCacheState } from './object-cache.reducer';
import { AddToSSBAction } from './server-sync-buffer.actions';
import { RestRequestMethod } from '../data/rest-request-method';
function selfLinkFromUuidSelector(uuid: string): MemoizedSelector<CoreState, string> {
return pathSelector<CoreState, string>(coreSelector, 'index', IndexName.OBJECT, uuid);
}
/**
* The base selector function to select the object cache in the store
*/
const objectCacheSelector = createSelector(
coreSelector,
(state: CoreState) => state['cache/object']
);
function entryFromSelfLinkSelector(selfLink: string): MemoizedSelector<CoreState, ObjectCacheEntry> {
return pathSelector<CoreState, ObjectCacheEntry>(coreSelector, 'cache/object', selfLink);
}
/**
* Selector function to select an object entry by self link from the cache
* @param selfLink The self link of the object
*/
const entryFromSelfLinkSelector =
(selfLink: string): MemoizedSelector<CoreState, ObjectCacheEntry> => createSelector(
objectCacheSelector,
(state: ObjectCacheState) => state[selfLink],
);
/**
* A service to interact with the object cache

View File

@@ -1,6 +1,7 @@
import { delay, exhaustMap, map, switchMap, take } from 'rxjs/operators';
import { Inject, Injectable } from '@angular/core';
import { Actions, Effect, ofType } from '@ngrx/effects';
import { coreSelector } from '../core.selectors';
import {
AddToSSBAction,
CommitSSBAction,
@@ -9,7 +10,7 @@ import {
} from './server-sync-buffer.actions';
import { GLOBAL_CONFIG } from '../../../config';
import { GlobalConfig } from '../../../config/global-config.interface';
import { coreSelector, CoreState } from '../core.reducers';
import { CoreState } from '../core.reducers';
import { Action, createSelector, MemoizedSelector, select, Store } from '@ngrx/store';
import { ServerSyncBufferEntry, ServerSyncBufferState } from './server-sync-buffer.reducer';
import { combineLatest as observableCombineLatest, of as observableOf } from 'rxjs';

View File

@@ -4,7 +4,7 @@ import {
} from '@ngrx/store';
import { objectCacheReducer, ObjectCacheState } from './cache/object-cache.reducer';
import { indexReducer, IndexState } from './index/index.reducer';
import { indexReducer, MetaIndexState } from './index/index.reducer';
import { requestReducer, RequestState } from './data/request.reducer';
import { authReducer, AuthState } from './auth/auth.reducer';
import { jsonPatchOperationsReducer, JsonPatchOperationsState } from './json-patch/json-patch-operations.reducer';
@@ -19,7 +19,7 @@ export interface CoreState {
'cache/syncbuffer': ServerSyncBufferState,
'cache/object-updates': ObjectUpdatesState
'data/request': RequestState,
'index': IndexState,
'index': MetaIndexState,
'auth': AuthState,
'json/patch': JsonPatchOperationsState
}
@@ -33,5 +33,3 @@ export const coreReducers: ActionReducerMap<CoreState> = {
'auth': authReducer,
'json/patch': jsonPatchOperationsReducer
};
export const coreSelector = createFeatureSelector<CoreState>('core');

View File

@@ -0,0 +1,7 @@
import { createFeatureSelector } from '@ngrx/store';
import { CoreState } from './core.reducers';
/**
* Base selector to select the core state from the store
*/
export const coreSelector = createFeatureSelector<CoreState>('core');

View File

@@ -1,6 +1,7 @@
import { Injectable } from '@angular/core';
import { createSelector, MemoizedSelector, select, Store } from '@ngrx/store';
import { coreSelector, CoreState } from '../../core.reducers';
import { CoreState } from '../../core.reducers';
import { coreSelector } from '../../core.selectors';
import {
FieldState,
FieldUpdates,

View File

@@ -2,6 +2,7 @@ import * as ngrx from '@ngrx/store';
import { ActionsSubject, Store } from '@ngrx/store';
import { cold, getTestScheduler, hot } from 'jasmine-marbles';
import { BehaviorSubject, EMPTY, of as observableOf } from 'rxjs';
import { getMockObjectCacheService } from '../../shared/mocks/mock-object-cache.service';
import { defaultUUID, getMockUUIDService } from '../../shared/mocks/mock-uuid.service';
import { ObjectCacheService } from '../cache/object-cache.service';

View File

@@ -4,41 +4,44 @@ import { createSelector, MemoizedSelector, select, Store } from '@ngrx/store';
import { Observable, race as observableRace } from 'rxjs';
import { filter, mergeMap, take } from 'rxjs/operators';
import { AppState } from '../../app.reducer';
import { hasValue, isNotEmpty } from '../../shared/empty.util';
import { CacheableObject } from '../cache/object-cache.reducer';
import { ObjectCacheService } from '../cache/object-cache.service';
import { coreSelector, CoreState } from '../core.reducers';
import { IndexName, IndexState } from '../index/index.reducer';
import { pathSelector } from '../shared/selectors';
import { CoreState } from '../core.reducers';
import { IndexName, IndexState, MetaIndexState } from '../index/index.reducer';
import {
originalRequestUUIDFromRequestUUIDSelector,
requestIndexSelector,
uuidFromHrefSelector
} from '../index/index.selectors';
import { UUIDService } from '../shared/uuid.service';
import { RequestConfigureAction, RequestExecuteAction, RequestRemoveAction } from './request.actions';
import { GetRequest, RestRequest } from './request.models';
import { RequestEntry } from './request.reducer';
import { RequestEntry, RequestState } from './request.reducer';
import { CommitSSBAction } from '../cache/server-sync-buffer.actions';
import { RestRequestMethod } from './rest-request-method';
import { AddToIndexAction, RemoveFromIndexBySubstringAction } from '../index/index.actions';
import { coreSelector } from '../core.selectors';
@Injectable()
export class RequestService {
private requestsOnTheirWayToTheStore: string[] = [];
/**
* The base selector function to select the request state in the store
*/
const requestCacheSelector = createSelector(
coreSelector,
(state: CoreState) => state['data/request']
);
constructor(private objectCache: ObjectCacheService,
private uuidService: UUIDService,
private store: Store<CoreState>,
private indexStore: Store<IndexState>) {
}
private entryFromUUIDSelector(uuid: string): MemoizedSelector<CoreState, RequestEntry> {
return pathSelector<CoreState, RequestEntry>(coreSelector, 'data/request', uuid);
}
private uuidFromHrefSelector(href: string): MemoizedSelector<CoreState, string> {
return pathSelector<CoreState, string>(coreSelector, 'index', IndexName.REQUEST, href);
}
private originalUUIDFromUUIDSelector(uuid: string): MemoizedSelector<CoreState, string> {
return pathSelector<CoreState, string>(coreSelector, 'index', IndexName.UUID_MAPPING, uuid);
/**
* Selector function to select a request entry by uuid from the cache
* @param uuid The uuid of the request
*/
const entryFromUUIDSelector = (uuid: string): MemoizedSelector<CoreState, RequestEntry> => createSelector(
requestCacheSelector,
(state: RequestState) => {
return hasValue(state) ? state[uuid] : undefined;
}
);
/**
* Create a selector that fetches a list of request UUIDs from a given index substate of which the request href
@@ -47,29 +50,37 @@ export class RequestService {
* @param name The name of the index substate we're fetching request UUIDs from
* @param href Substring that the request's href should contain
*/
private uuidsFromHrefSubstringSelector(selector: MemoizedSelector<any, IndexState>, name: string, href: string): MemoizedSelector<any, string[]> {
return createSelector(selector, (state: IndexState) => this.getUuidsFromHrefSubstring(state, name, href));
}
const uuidsFromHrefSubstringSelector =
(selector: MemoizedSelector<AppState, IndexState>, href: string): MemoizedSelector<AppState, string[]> => createSelector(
selector,
(state: IndexState) => getUuidsFromHrefSubstring(state, href)
);
/**
* Fetch a list of request UUIDs from a given index substate of which the request href contains a given substring
* @param state The IndexState
* @param name The name of the index substate we're fetching request UUIDs from
* @param href Substring that the request's href should contain
*/
private getUuidsFromHrefSubstring(state: IndexState, name: string, href: string): string[] {
const getUuidsFromHrefSubstring = (state: IndexState, href: string): string[] => {
let result = [];
if (isNotEmpty(state)) {
const subState = state[name];
if (isNotEmpty(subState)) {
for (const value in subState) {
if (value.indexOf(href) > -1) {
result = [...result, subState[value]];
}
}
}
result = Object.values(state)
.filter((value: string) => value.startsWith(href));
}
return result;
};
/**
* A service to interact with the request state in the store
*/
@Injectable()
export class RequestService {
private requestsOnTheirWayToTheStore: string[] = [];
constructor(private objectCache: ObjectCacheService,
private uuidService: UUIDService,
private store: Store<CoreState>,
private indexStore: Store<MetaIndexState>) {
}
generateRequestId(): string {
@@ -100,11 +111,11 @@ export class RequestService {
*/
getByUUID(uuid: string): Observable<RequestEntry> {
return observableRace(
this.store.pipe(select(this.entryFromUUIDSelector(uuid))),
this.store.pipe(select(entryFromUUIDSelector(uuid))),
this.store.pipe(
select(this.originalUUIDFromUUIDSelector(uuid)),
select(originalRequestUUIDFromRequestUUIDSelector(uuid)),
mergeMap((originalUUID) => {
return this.store.pipe(select(this.entryFromUUIDSelector(originalUUID)))
return this.store.pipe(select(entryFromUUIDSelector(originalUUID)))
},
))
);
@@ -115,7 +126,7 @@ export class RequestService {
*/
getByHref(href: string): Observable<RequestEntry> {
return this.store.pipe(
select(this.uuidFromHrefSelector(href)),
select(uuidFromHrefSelector(href)),
mergeMap((uuid: string) => this.getByUUID(uuid))
);
}
@@ -152,7 +163,7 @@ export class RequestService {
*/
removeByHrefSubstring(href: string) {
this.store.pipe(
select(this.uuidsFromHrefSubstringSelector(pathSelector<CoreState, IndexState>(coreSelector, 'index'), IndexName.REQUEST, href)),
select(uuidsFromHrefSubstringSelector(requestIndexSelector, href)),
take(1)
).subscribe((uuids: string[]) => {
for (const uuid of uuids) {

View File

@@ -1,6 +1,6 @@
import * as deepFreeze from 'deep-freeze';
import { IndexName, indexReducer, IndexState } from './index.reducer';
import { IndexName, indexReducer, MetaIndexState } from './index.reducer';
import { AddToIndexAction, RemoveFromIndexBySubstringAction, RemoveFromIndexByValueAction } from './index.actions';
class NullAction extends AddToIndexAction {
@@ -17,7 +17,7 @@ describe('requestReducer', () => {
const key2 = '1911e8a4-6939-490c-b58b-a5d70f8d91fb';
const val1 = 'https://dspace7.4science.it/dspace-spring-rest/api/core/items/567a639f-f5ff-4126-807c-b7d0910808c8';
const val2 = 'https://dspace7.4science.it/dspace-spring-rest/api/core/items/1911e8a4-6939-490c-b58b-a5d70f8d91fb';
const testState: IndexState = {
const testState: MetaIndexState = {
[IndexName.OBJECT]: {
[key1]: val1
},[IndexName.REQUEST]: {

View File

@@ -1,26 +1,57 @@
import {
AddToIndexAction,
IndexAction,
IndexActionTypes,
AddToIndexAction,
RemoveFromIndexByValueAction, RemoveFromIndexBySubstringAction
RemoveFromIndexBySubstringAction,
RemoveFromIndexByValueAction
} from './index.actions';
/**
* An enum containing all index names
*/
export enum IndexName {
// Contains all objects in the object cache indexed by UUID
OBJECT = 'object/uuid-to-self-link',
// contains all requests in the request cache indexed by UUID
REQUEST = 'get-request/href-to-uuid',
/**
* Contains the UUIDs of requests that were sent to the server and
* have their responses cached, indexed by the UUIDs of requests that
* weren't sent because the response they requested was already cached
*/
UUID_MAPPING = 'get-request/configured-to-cache-uuid'
}
export type IndexState = {
[name in IndexName]: {
/**
* The state of a single index
*/
export interface IndexState {
[key: string]: string
}
/**
* The state that contains all indices
*/
export type MetaIndexState = {
[name in IndexName]: IndexState
}
// Object.create(null) ensures the object has no default js properties (e.g. `__proto__`)
const initialState: IndexState = Object.create(null);
const initialState: MetaIndexState = Object.create(null);
export function indexReducer(state = initialState, action: IndexAction): IndexState {
/**
* The Index Reducer
*
* @param state
* the current state
* @param action
* the action to perform on the state
* @return MetaIndexState
* the new state
*/
export function indexReducer(state = initialState, action: IndexAction): MetaIndexState {
switch (action.type) {
case IndexActionTypes.ADD: {
@@ -41,7 +72,17 @@ export function indexReducer(state = initialState, action: IndexAction): IndexSt
}
}
function addToIndex(state: IndexState, action: AddToIndexAction): IndexState {
/**
* Add an entry to a given index
*
* @param state
* The MetaIndexState that contains all indices
* @param action
* The AddToIndexAction containing the value to add, and the index to add it to
* @return MetaIndexState
* the new state
*/
function addToIndex(state: MetaIndexState, action: AddToIndexAction): MetaIndexState {
const subState = state[action.payload.name];
const newSubState = Object.assign({}, subState, {
[action.payload.key]: action.payload.value
@@ -52,7 +93,17 @@ function addToIndex(state: IndexState, action: AddToIndexAction): IndexState {
return obs;
}
function removeFromIndexByValue(state: IndexState, action: RemoveFromIndexByValueAction): IndexState {
/**
* Remove a entries that contain a given value from a given index
*
* @param state
* The MetaIndexState that contains all indices
* @param action
* The RemoveFromIndexByValueAction containing the value to remove, and the index to remove it from
* @return MetaIndexState
* the new state
*/
function removeFromIndexByValue(state: MetaIndexState, action: RemoveFromIndexByValueAction): MetaIndexState {
const subState = state[action.payload.name];
const newSubState = Object.create(null);
for (const value in subState) {
@@ -66,11 +117,16 @@ function removeFromIndexByValue(state: IndexState, action: RemoveFromIndexByValu
}
/**
* Remove values from the IndexState's substate that contain a given substring
* @param state The IndexState to remove values from
* @param action The RemoveFromIndexByValueAction containing the necessary information to remove the values
* Remove entries that contain a given substring from a given index
*
* @param state
* The MetaIndexState that contains all indices
* @param action
* The RemoveFromIndexByValueAction the substring to remove, and the index to remove it from
* @return MetaIndexState
* the new state
*/
function removeFromIndexBySubstring(state: IndexState, action: RemoveFromIndexByValueAction): IndexState {
function removeFromIndexBySubstring(state: MetaIndexState, action: RemoveFromIndexByValueAction): MetaIndexState {
const subState = state[action.payload.name];
const newSubState = Object.create(null);
for (const value in subState) {

View File

@@ -0,0 +1,94 @@
import { createSelector, MemoizedSelector } from '@ngrx/store';
import { AppState } from '../../app.reducer';
import { hasValue } from '../../shared/empty.util';
import { CoreState } from '../core.reducers';
import { coreSelector } from '../core.selectors';
import { IndexName, IndexState, MetaIndexState } from './index.reducer';
/**
* Return the MetaIndexState based on the CoreSate
*
* @returns
* a MemoizedSelector to select the MetaIndexState
*/
export const metaIndexSelector: MemoizedSelector<AppState, MetaIndexState> = createSelector(
coreSelector,
(state: CoreState) => state.index
);
/**
* Return the object index based on the MetaIndexState
* It contains all objects in the object cache indexed by UUID
*
* @returns
* a MemoizedSelector to select the object index
*/
export const objectIndexSelector: MemoizedSelector<AppState, IndexState> = createSelector(
metaIndexSelector,
(state: MetaIndexState) => state[IndexName.OBJECT]
);
/**
* Return the request index based on the MetaIndexState
*
* @returns
* a MemoizedSelector to select the request index
*/
export const requestIndexSelector: MemoizedSelector<AppState, IndexState> = createSelector(
metaIndexSelector,
(state: MetaIndexState) => state[IndexName.REQUEST]
);
/**
* Return the request UUID mapping index based on the MetaIndexState
*
* @returns
* a MemoizedSelector to select the request UUID mapping
*/
export const requestUUIDIndexSelector: MemoizedSelector<AppState, IndexState> = createSelector(
metaIndexSelector,
(state: MetaIndexState) => state[IndexName.UUID_MAPPING]
);
/**
* Return the self link of an object in the object-cache based on its UUID
*
* @param uuid
* the UUID for which you want to find the matching self link
* @returns
* a MemoizedSelector to select the self link
*/
export const selfLinkFromUuidSelector =
(uuid: string): MemoizedSelector<AppState, string> => createSelector(
objectIndexSelector,
(state: IndexState) => hasValue(state) ? state[uuid] : undefined
);
/**
* Return the UUID of a GET request based on its href
*
* @param href
* the href of the GET request
* @returns
* a MemoizedSelector to select the UUID
*/
export const uuidFromHrefSelector =
(href: string): MemoizedSelector<AppState, string> => createSelector(
requestIndexSelector,
(state: IndexState) => hasValue(state) ? state[href] : undefined
);
/**
* Return the UUID of a cached request based on the UUID of a request
* that wasn't sent because the response was already cached
*
* @param uuid
* The UUID of the new request
* @returns
* a MemoizedSelector to select the UUID of the cached request
*/
export const originalRequestUUIDFromRequestUUIDSelector =
(uuid: string): MemoizedSelector<AppState, string> => createSelector(
requestUUIDIndexSelector,
(state: IndexState) => hasValue(state) ? state[uuid] : undefined
);

View File

@@ -1,17 +0,0 @@
import { createSelector, MemoizedSelector } from '@ngrx/store';
import { hasNoValue, isEmpty } from '../../shared/empty.util';
export function pathSelector<From, To>(selector: MemoizedSelector<any, From>, ...path: string[]): MemoizedSelector<any, To> {
return createSelector(selector, (state: any) => getSubState(state, path));
}
function getSubState(state: any, path: string[]) {
const current = path[0];
const remainingPath = path.slice(1);
const subState = state[current];
if (hasNoValue(subState) || isEmpty(remainingPath)) {
return subState;
} else {
return getSubState(subState, remainingPath);
}
}

View File

@@ -32,5 +32,4 @@ export class ErrorComponent {
this.subscription.unsubscribe();
}
}
}

View File

@@ -2,6 +2,11 @@
[sortConfig]="sortConfig"
[objects]="objects"
[hideGear]="hideGear"
(paginationChange)="onPaginationChange($event)"
(pageChange)="onPageChange($event)"
(pageSizeChange)="onPageSizeChange($event)"
(sortDirectionChange)="onSortDirectionChange($event)"
(sortFieldChange)="onSortFieldChange($event)"
*ngIf="getViewMode()===viewModeEnum.List">
</ds-object-list>
@@ -9,6 +14,11 @@
[sortConfig]="sortConfig"
[objects]="objects"
[hideGear]="hideGear"
(paginationChange)="onPaginationChange($event)"
(pageChange)="onPageChange($event)"
(pageSizeChange)="onPageSizeChange($event)"
(sortDirectionChange)="onSortDirectionChange($event)"
(sortFieldChange)="onSortFieldChange($event)"
*ngIf="getViewMode()===viewModeEnum.Grid">
</ds-object-grid>

View File

@@ -4,24 +4,36 @@ import { ActivatedRoute, NavigationEnd, Params, Router, } from '@angular/router'
import { distinctUntilChanged, filter, map } from 'rxjs/operators';
import { Observable } from 'rxjs';
import { select, Store } from '@ngrx/store';
import { detect } from 'rxjs-spy';
import { AppState } from '../../app.reducer';
import { AddUrlToHistoryAction } from '../history/history.actions';
import { historySelector } from '../history/selectors';
/**
* Service to keep track of the current query parameters
*/
@Injectable()
export class RouteService {
constructor(private route: ActivatedRoute, private router: Router, private store: Store<AppState>) {
}
/**
* Retrieves all query parameter values based on a parameter name
* @param paramName The name of the parameter to look for
*/
getQueryParameterValues(paramName: string): Observable<string[]> {
return this.route.queryParamMap.pipe(
map((params) => [...params.getAll(paramName)]),
distinctUntilChanged()
distinctUntilChanged((a, b) => JSON.stringify(a) === JSON.stringify(b))
);
}
/**
* Retrieves a single query parameter values based on a parameter name
* @param paramName The name of the parameter to look for
*/
getQueryParameterValue(paramName: string): Observable<string> {
return this.route.queryParamMap.pipe(
map((params) => params.get(paramName)),
@@ -29,6 +41,10 @@ export class RouteService {
);
}
/**
* Checks if the query parameter currently exists in the route
* @param paramName The name of the parameter to look for
*/
hasQueryParam(paramName: string): Observable<boolean> {
return this.route.queryParamMap.pipe(
map((params) => params.has(paramName)),
@@ -36,6 +52,11 @@ export class RouteService {
);
}
/**
* Checks if the query parameter with a specific value currently exists in the route
* @param paramName The name of the parameter to look for
* @param paramValue The value of the parameter to look for
*/
hasQueryParamWithValue(paramName: string, paramValue: string): Observable<boolean> {
return this.route.queryParamMap.pipe(
map((params) => params.getAll(paramName).indexOf(paramValue) > -1),
@@ -43,6 +64,10 @@ export class RouteService {
);
}
/**
* Retrieves all query parameters of which the parameter name starts with the given prefix
* @param prefix The prefix of the parameter name to look for
*/
getQueryParamsWithPrefix(prefix: string): Observable<Params> {
return this.route.queryParamMap.pipe(
map((qparams) => {
@@ -54,7 +79,8 @@ export class RouteService {
});
return params;
}),
distinctUntilChanged((a, b) => JSON.stringify(a) === JSON.stringify(b)));
distinctUntilChanged((a, b) => JSON.stringify(a) === JSON.stringify(b)),
);
}
public saveRouting(): void {

1497
yarn.lock

File diff suppressed because it is too large Load Diff