45621: ngrx filter facets

This commit is contained in:
Lotte Hofstede
2017-11-08 16:04:20 +01:00
parent 532741d073
commit 91f4ee6ed1
18 changed files with 410 additions and 46 deletions

View File

@@ -96,14 +96,18 @@
"reset": "Reset filters",
"facet-filter": {
"show-more": "Show more",
"show-less": "Show less",
"author": {
"placeholder": "Author name"
"placeholder": "Author name",
"head": "Author"
},
"scope": {
"placeholder": "Scope filter"
"placeholder": "Scope filter",
"head": "Scope"
},
"subject": {
"placeholder": "Subject"
"placeholder": "Subject",
"head": "Subject"
}
}
}

View File

@@ -1,8 +1,15 @@
<a *ngFor="let value of filterValues" class="d-block" [routerLink]="[getSearchLink()]" [queryParams]="getQueryParams(value) | async">
<input type="checkbox" [checked]="isChecked(value)"/>
<span class="filter-value">{{value.value}}</span>
<span class="filter-value-count float-right">({{value.count}})</span>
<a *ngFor="let value of filterValues; let i=index" class="d-block" [routerLink]="[getSearchLink()]"
[queryParams]="getQueryParams(value)">
<ng-template [ngIf]="i < (facetCount | async)">
<input type="checkbox" [checked]="isChecked(value)"/>
<span class="filter-value">{{value.value}}</span>
<span class="filter-value-count float-right">({{value.count}})</span>
</ng-template>
</a>
<a href="">{{"search.filters.facet-filter.show-more" | translate}}</a>
<a *ngIf="filterValues.length > (facetCount | async)" (click)="showMore()">{{"search.filters.facet-filter.show-more"
| translate}}</a>
<a *ngIf="(currentPage | async) > 1" (click)="showLess()">{{"search.filters.facet-filter.show-less" |
translate}}</a>
<input type="text" [placeholder]="'search.filters.facet-filter.' + filterConfig.name + '.placeholder'| translate"/>
<input type="text"
[placeholder]="'search.filters.facet-filter.' + filterConfig.name + '.placeholder'| translate"/>

View File

@@ -1,9 +1,10 @@
import { Component, Input } from '@angular/core';
import { Component, Input, OnInit } from '@angular/core';
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 { ActivatedRoute } from '@angular/router';
import { Params } from '@angular/router';
import { Observable } from 'rxjs/Observable';
import { SearchFilterService } from '../search-filter.service';
/**
* This component renders a simple item page.
@@ -17,25 +18,48 @@ import { Observable } from 'rxjs/Observable';
templateUrl: './search-facet-filter.component.html',
})
export class SidebarFacetFilterComponent {
export class SidebarFacetFilterComponent implements OnInit {
@Input() filterValues: FacetValue[];
@Input() filterConfig: SearchFilterConfig;
currentPage: Observable<number>;
constructor(private searchService: SearchService, private route: ActivatedRoute) {
constructor(private filterService: SearchFilterService) {
}
ngOnInit(): void {
this.currentPage = this.filterService.getPage(this.filterConfig.name);
}
isChecked(value: FacetValue) {
return this.searchService.isFilterActive(this.filterConfig.name, value.value);
return this.filterService.isFilterActive(this.filterConfig.name, value.value);
}
getSearchLink() {
return this.searchService.getSearchLink();
return this.filterService.searchLink;
}
getQueryParams(value: FacetValue): Observable<any> {
const params = {};
params[this.filterConfig.paramName] = value.value;
return this.route.queryParams.map((p) => Object.assign({}, p, params))
getQueryParams(value: FacetValue): Params {
return this.filterService.switchFilterInURL(this.filterConfig, value.value);
}
get facetCount(): Observable<number> {
const resultCount = this.filterValues.length;
return this.currentPage.map((page: number) => {
const max = page * this.filterConfig.pageSize;
return max > resultCount ? resultCount : max;
});
}
showMore() {
this.filterService.increasePage(this.filterConfig.name);
}
showLess() {
this.filterService.decreasePage(this.filterConfig.name);
}
getCurrentPage(): Observable<number> {
return this.filterService.getPage(this.filterConfig.name);
}
}

View File

@@ -0,0 +1,58 @@
import { Action } from '@ngrx/store';
import { type } from '../../../shared/ngrx/type';
/**
* For each action type in an action group, make a simple
* enum object for all of this group's action types.
*
* The 'type' utility function coerces strings into string
* literal types and runs a simple check to guarantee all
* action types in the application are unique.
*/
export const SearchFilterActionTypes = {
COLLAPSE: type('dspace/search-filter/COLLAPSE'),
INITIAL_COLLAPSE: type('dspace/search-filter/INITIAL_COLLAPSE'),
EXPAND: type('dspace/search-filter/EXPAND'),
INITIAL_EXPAND: type('dspace/search-filter/INITIAL_EXPAND'),
TOGGLE: type('dspace/search-filter/TOGGLE'),
DECREASE_PAGE: type('dspace/search-filter/DECREASE_PAGE'),
INCREASE_PAGE: type('dspace/search-filter/INCREASE_PAGE')
};
export class SearchFilterAction implements Action {
filterName: string;
type;
constructor(name: string) {
this.filterName = name;
}
}
/* tslint:disable:max-classes-per-file */
export class SearchFilterCollapseAction extends SearchFilterAction {
type = SearchFilterActionTypes.COLLAPSE;
}
export class SearchFilterExpandAction extends SearchFilterAction {
type = SearchFilterActionTypes.EXPAND;
}
export class SearchFilterToggleAction extends SearchFilterAction {
type = SearchFilterActionTypes.TOGGLE;
}
export class SearchFilterInitialCollapseAction extends SearchFilterAction {
type = SearchFilterActionTypes.INITIAL_COLLAPSE;
}
export class SearchFilterInitialExpandAction extends SearchFilterAction {
type = SearchFilterActionTypes.INITIAL_EXPAND;
}
export class SearchFilterDecreasePageAction extends SearchFilterAction {
type = SearchFilterActionTypes.DECREASE_PAGE;
}
export class SearchFilterIncreasePageAction extends SearchFilterAction {
type = SearchFilterActionTypes.INCREASE_PAGE;
}
/* tslint:enable:max-classes-per-file */

View File

@@ -1,4 +1,8 @@
<div>
<div (click)="toggle()" class="filter-name">{{filter.name}} <span class="fa float-right" [ngClass]="isCollapsed ? 'fa-plus' : 'fa-minus'"></span></div>
<ds-search-facet-filter [filterConfig]="filter" [filterValues]="filterValues.payload | async"></ds-search-facet-filter>
<div (click)="toggle()" class="filter-name">{{filter.name}} <span class="fa float-right"
[ngClass]="(isCollapsed() | async) ? 'fa-plus' : 'fa-minus'"></span></div>
<div [@slide]="(isCollapsed() | async) ? 'collapsed' : 'expanded'" class="search-filter-wrapper">
<ds-search-facet-filter [filterConfig]="filter"
[filterValues]="filterValues.payload | async"></ds-search-facet-filter>
</div>
</div>

View File

@@ -1,2 +1,6 @@
@import '../../../../styles/variables.scss';
@import '../../../../styles/mixins.scss';
@import '../../../../styles/mixins.scss';
.search-filter-wrapper {
overflow: hidden;
}

View File

@@ -3,6 +3,9 @@ import { SearchFilterConfig } from '../../search-service/search-filter-config.mo
import { SearchService } from '../../search-service/search.service';
import { RemoteData } from '../../../core/data/remote-data';
import { FacetValue } from '../../search-service/facet-value.model';
import { SearchFilterService } from './search-filter.service';
import { Observable } from 'rxjs/Observable';
import { slide } from '../../../shared/animations/slide';
/**
* This component renders a simple item page.
@@ -14,21 +17,38 @@ import { FacetValue } from '../../search-service/facet-value.model';
selector: 'ds-search-filter',
styleUrls: ['./search-filter.component.scss'],
templateUrl: './search-filter.component.html',
animations: [slide]
})
export class SidebarFilterComponent implements OnInit {
@Input() filter: SearchFilterConfig;
filterValues: RemoteData<FacetValue[]>;
isCollapsed = false;
constructor(private searchService: SearchService) {
constructor(private searchService: SearchService, private filterService: SearchFilterService) {
}
ngOnInit() {
this.filterValues = this.searchService.getFacetValuesFor(this.filter.name);
if (this.filter.isOpenByDefault) {
this.initialExpand();
} else {
this.initialCollapse();
}
}
toggle() {
this.isCollapsed = !this.isCollapsed;
this.filterService.toggle(this.filter.name);
}
}
isCollapsed(): Observable<boolean> {
return this.filterService.isCollapsed(this.filter.name);
}
initialCollapse() {
this.filterService.initialCollapse(this.filter.name);
}
initialExpand() {
this.filterService.initialExpand(this.filter.name);
}
}

View File

@@ -0,0 +1,95 @@
import { SearchFilterAction, SearchFilterActionTypes } from './search-filter.actions';
import { isEmpty } from '../../../shared/empty.util';
export interface SearchFilterState {
filterCollapsed: boolean,
page: number
}
export interface SearchFiltersState {
[name: string]: SearchFilterState
}
const initialState: SearchFiltersState = Object.create(null);
export function filterReducer(state = initialState, action: SearchFilterAction): SearchFiltersState {
switch (action.type) {
case SearchFilterActionTypes.INITIAL_COLLAPSE: {
if (isEmpty(state) || isEmpty(state[action.filterName])) {
return Object.assign({}, state, {
[action.filterName]: {
filterCollapsed: true,
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;
}
case SearchFilterActionTypes.COLLAPSE: {
return Object.assign({}, state, {
[action.filterName]: {
filterCollapsed: true,
page: state[action.filterName].page
}
});
}
case SearchFilterActionTypes.EXPAND: {
return Object.assign({}, state, {
[action.filterName]: {
filterCollapsed: false,
page: state[action.filterName].page
}
});
}
case SearchFilterActionTypes.DECREASE_PAGE: {
return Object.assign({}, state, {
[action.filterName]: {
filterCollapsed: state[action.filterName].filterCollapsed,
page: state[action.filterName].page - 1
}
});
}
case SearchFilterActionTypes.INCREASE_PAGE: {
return Object.assign({}, state, {
[action.filterName]: {
filterCollapsed: state[action.filterName].filterCollapsed,
page: state[action.filterName].page + 1
}
});
}
case SearchFilterActionTypes.TOGGLE: {
return Object.assign({}, state, {
[action.filterName]: {
filterCollapsed: !state[action.filterName].filterCollapsed,
page: state[action.filterName].page
}
});
}
default: {
return state;
}
}
}

View File

@@ -0,0 +1,136 @@
import { Injectable, OnDestroy } from '@angular/core';
import { SearchFiltersState, SearchFilterState } from './search-filter.reducer';
import { createSelector, MemoizedSelector, Store } from '@ngrx/store';
import { Observable } from 'rxjs/Observable';
import { AppState } from '../../../app.reducer';
import {
SearchFilterCollapseAction, SearchFilterDecreasePageAction, SearchFilterIncreasePageAction,
SearchFilterInitialCollapseAction,
SearchFilterInitialExpandAction,
SearchFilterToggleAction
} from './search-filter.actions';
import { hasValue, isNotEmpty } from '../../../shared/empty.util';
import { ActivatedRoute, Params, Router } from '@angular/router';
import { SearchFilterConfig } from '../../search-service/search-filter-config.model';
import { RemoteData } from '../../../core/data/remote-data';
import { PageInfo } from '../../../core/shared/page-info.model';
import { FacetValue } from '../../search-service/facet-value.model';
import { FilterType } from '../../search-service/filter-type.model';
import { SearchService } from '../../search-service/search.service';
const filterStateSelector = (state: SearchFiltersState) => state.searchFilter;
@Injectable()
export class SearchFilterService implements OnDestroy {
private sub;
constructor(private store: Store<SearchFiltersState>,
private route: ActivatedRoute,
private router: Router,
private searchService: SearchService) {
}
isFilterActive(filterName: string, filterValue: string): boolean {
let filterConfig: SearchFilterConfig;
this.sub = this.searchService.getConfig().payload
.subscribe((configuration) => filterConfig = configuration
.find((config: SearchFilterConfig) => config.name === filterName));
return isNotEmpty(this.route.snapshot.queryParams[filterConfig.paramName]) && [...this.route.snapshot.queryParams[filterConfig.paramName]].indexOf(filterValue, 0) > -1;
}
switchFilterInURL(filterConfig: SearchFilterConfig, value: string) {
console.log(this.route.snapshot.queryParams);
if (this.isFilterActive(filterConfig.name, value)) {
return this.removeQueryParameter(filterConfig.paramName, value);
} else {
return this.addQueryParameter(filterConfig.paramName, value);
}
}
addQueryParameter(paramName: string, value: string): Params {
const currentParams = this.route.snapshot.queryParams;
const newParam = {};
if ((currentParams[paramName])) {
newParam[paramName] = [...currentParams[paramName], value];
} else {
newParam[paramName] = [value];
}
return Object.assign({}, currentParams, newParam);
}
removeQueryParameter(paramName: string, value: string): Params {
const currentParams = this.route.snapshot.queryParams;
const newParam = {};
let currentFilterParams = [...currentParams[paramName]];
if (isNotEmpty(currentFilterParams)) {
const index = currentFilterParams.indexOf(value, 0);
if (index > -1) {
currentFilterParams = currentFilterParams.splice(index, 1);
}
newParam[paramName] = currentFilterParams;
}
return Object.assign({}, currentParams, newParam);
}
get searchLink() {
return this.searchService.searchLink;
}
isCollapsed(filterName: string): Observable<boolean> {
return this.store.select(filterByNameSelector(filterName))
.map((object: SearchFilterState) => object.filterCollapsed);
}
getPage(filterName: string): Observable<number> {
return this.store.select(filterByNameSelector(filterName))
.map((object: SearchFilterState) => object.page);
}
public collapse(filterName: string): void {
this.store.dispatch(new SearchFilterCollapseAction(filterName));
}
public expand(filterName: string): void {
this.store.dispatch(new SearchFilterCollapseAction(filterName));
}
public toggle(filterName: string): void {
this.store.dispatch(new SearchFilterToggleAction(filterName));
}
public initialCollapse(filterName: string): void {
this.store.dispatch(new SearchFilterInitialCollapseAction(filterName));
}
public initialExpand(filterName: string): void {
this.store.dispatch(new SearchFilterInitialExpandAction(filterName));
}
public decreasePage(filterName: string): void {
this.store.dispatch(new SearchFilterDecreasePageAction(filterName));
}
public increasePage(filterName: string): void {
this.store.dispatch(new SearchFilterIncreasePageAction(filterName));
}
ngOnDestroy(): void {
if (this.sub !== undefined) {
this.sub.unsubscribe();
}
}
}
function filterByNameSelector(name: string): MemoizedSelector<SearchFiltersState, SearchFilterState> {
return keySelector<SearchFilterState>(name);
}
export function keySelector<T>(key: string): MemoizedSelector<SearchFiltersState, T> {
return createSelector(filterStateSelector, (state: SearchFilterState) => {
if (hasValue(state)) {
return state[key];
} else {
return undefined;
}
});
}

View File

@@ -13,7 +13,7 @@
<div class="row">
<div id="search-body"
class="row-offcanvas row-offcanvas-left"
[@slideInOut]="(isSidebarCollapsed() | async) ? 'collapsed' : 'expanded'">
[@pushInOut]="(isSidebarCollapsed() | async) ? 'collapsed' : 'expanded'">
<ds-search-sidebar *ngIf="(isMobileView | async)" class="col-12"
id="search-sidebar-xs"
resultCount="{{(results.pageInfo | async)?.totalElements}}"

View File

@@ -11,7 +11,7 @@ import { CommunityDataService } from '../core/data/community-data.service';
import { isNotEmpty } from '../shared/empty.util';
import { Community } from '../core/shared/community.model';
import { Observable } from 'rxjs/Observable';
import { slideInOut } from '../shared/animations/slide';
import { pushInOut } from '../shared/animations/push';
import { HostWindowService } from '../shared/host-window.service';
import { SearchSidebarService } from './search-sidebar/search-sidebar.service';
@@ -25,7 +25,7 @@ import { SearchSidebarService } from './search-sidebar/search-sidebar.service';
selector: 'ds-search-page',
styleUrls: ['./search-page.component.scss'],
templateUrl: './search-page.component.html',
animations: [slideInOut]
animations: [pushInOut]
})
export class SearchPageComponent implements OnInit, OnDestroy {

View File

@@ -15,6 +15,7 @@ import { EffectsModule } from '@ngrx/effects';
import { SidebarFiltersComponent } from './search-filters/search-filters.component';
import { SidebarFilterComponent } from './search-filters/search-filter/search-filter.component';
import { SidebarFacetFilterComponent } from './search-filters/search-filter/search-facet-filter/search-facet-filter.component';
import { SearchFilterService } from './search-filters/search-filter/search-filter.service';
const effects = [
SearchSidebarEffects
@@ -40,7 +41,8 @@ const effects = [
],
providers: [
SearchService,
SearchSidebarService
SearchSidebarService,
SearchFilterService
],
entryComponents: [
ItemSearchResultListElementComponent,

View File

@@ -5,6 +5,7 @@ export class SearchFilterConfig {
name: string;
type: FilterType;
hasFacets: boolean;
pageSize = 3;
isOpenByDefault: boolean;
/**
* Name of this configuration that can be used in a url

View File

@@ -235,11 +235,6 @@ export class SearchService implements OnDestroy {
return params;
}
isFilterActive(filterName: string, filterValue: string): boolean {
const filterConfig = this.config.find((config: SearchFilterConfig) => config.name === filterName);
return isNotEmpty(this.router.url.match(filterConfig.paramName + '=' + encodeURI(filterValue) + '(&(.*))?$'));
}
getSearchLink() {
return this.searchLink;
}
@@ -249,5 +244,4 @@ export class SearchService implements OnDestroy {
this.sub.unsubscribe();
}
}
}

View File

@@ -2,7 +2,6 @@ import { Injectable } from '@angular/core';
import { Effect, Actions } from '@ngrx/effects'
import * as fromRouter from '@ngrx/router-store';
import { HostWindowActionTypes } from '../../shared/host-window.actions';
import { SearchSidebarCollapseAction } from './search-sidebar.actions';
@Injectable()

View File

@@ -7,12 +7,17 @@ import {
SearchSidebarState,
sidebarReducer
} from './+search-page/search-sidebar/search-sidebar.reducer';
import {
filterReducer,
SearchFiltersState
} from './+search-page/search-filters/search-filter/search-filter.reducer';
export interface AppState {
router: fromRouter.RouterReducerState;
hostWindow: HostWindowState;
header: HeaderState;
searchSidebar: SearchSidebarState;
searchFilter: SearchFiltersState;
}
export const appReducers: ActionReducerMap<AppState> = {
@@ -20,4 +25,5 @@ export const appReducers: ActionReducerMap<AppState> = {
hostWindow: hostWindowReducer,
header: headerReducer,
searchSidebar: sidebarReducer,
searchFilter: filterReducer
};

View File

@@ -0,0 +1,16 @@
import { animate, state, transition, trigger, style } from '@angular/animations';
export const pushInOut = trigger('pushInOut', [
/*
state('expanded', style({ right: '100%' }));
state('collapsed', style({ right: 0 }));
*/
state('expanded', style({ left: '100%' })),
state('collapsed', style({ left: 0 })),
transition('expanded <=> collapsed', animate(250)),
]);

View File

@@ -1,16 +1,10 @@
import { animate, state, transition, trigger, style } from '@angular/animations';
export const slideInOut = trigger('slideInOut', [
export const slide = trigger('slide', [
/*
state('expanded', style({ right: '100%' }));
state('expanded', style({ height: '*' })),
state('collapsed', style({ right: 0 }));
*/
state('expanded', style({ left: '100%' })),
state('collapsed', style({ left: 0 })),
state('collapsed', style({ height: 0 })),
transition('expanded <=> collapsed', animate(250)),
]);