[ADF-2328] filtering support for facets and categories (#3293)

* filtering support for facets and categories

* fix tests

* update variable names
This commit is contained in:
Denys Vuika
2018-05-10 14:23:18 +01:00
committed by Eugenio Romano
parent 440c666583
commit 7afcd24488
10 changed files with 344 additions and 34 deletions

View File

@@ -176,7 +176,8 @@
"APPLY": "Apply",
"CLEAR-ALL": "Clear all",
"SHOW-MORE": "Show more",
"SHOW-LESS": "Show less"
"SHOW-LESS": "Show less",
"FILTER-CATEGORY": "Filter category"
},
"RANGE": {
"FROM": "From",

View File

@@ -19,17 +19,25 @@ import { ResponseFacetQuery } from '../../../facet-query.interface';
import { SearchFilterList } from './search-filter-list.model';
export class ResponseFacetQueryList extends SearchFilterList<ResponseFacetQuery> {
constructor(items: ResponseFacetQuery[] = [], pageSize: number = 5) {
const filtered = items
super(
items
.filter(item => {
return item.count > 0;
})
.map(item => {
return <ResponseFacetQuery> { ...item };
});
}),
pageSize
);
super(filtered, pageSize);
this.filter = (query: ResponseFacetQuery) => {
if (this.filterText && query.label) {
const pattern = (this.filterText || '').toLowerCase();
const label = query.label.toLowerCase();
return label.startsWith(pattern);
}
return true;
};
}
}

View File

@@ -0,0 +1,200 @@
/*!
* @license
* Copyright 2016 Alfresco Software, Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { SearchFilterList } from './search-filter-list.model';
export class Payload {
name: string;
constructor(public id: number) {
this.name = `Payload_${id}`;
}
}
describe('SearchFilterList', () => {
function generateItems(count: number): Payload[] {
return Array(count).fill(null).map((_, id) => new Payload(id));
}
it('should init with external items', () => {
const items = [
new Payload(1),
new Payload(2)
];
const list = new SearchFilterList<Payload>(items);
expect(list.length).toBe(2);
expect(list.items[0]).toBe(items[0]);
expect(list.items[1]).toBe(items[1]);
});
it('should init with default values', () => {
const list = new SearchFilterList();
expect(list.items).toEqual([]);
expect(list.pageSize).toEqual(5);
expect(list.currentPageSize).toEqual(5);
});
it('should init with custom page size', () => {
const list = new SearchFilterList([], 10);
expect(list.pageSize).toEqual(10);
expect(list.currentPageSize).toEqual(10);
});
it('should allow showing more items', () => {
const items = generateItems(6);
const list = new SearchFilterList(items, 4);
expect(list.canShowMoreItems).toBeTruthy();
});
it('should now allow showing more items', () => {
const items = generateItems(6);
const list = new SearchFilterList(items, 6);
expect(list.canShowMoreItems).toBeFalsy();
});
it('should show second page', () => {
const items = generateItems(6);
const list = new SearchFilterList(items, 4);
expect(list.canShowMoreItems).toBeTruthy();
expect(list.visibleItems.length).toBe(4);
list.showMoreItems();
expect(list.currentPageSize).toBe(8);
expect(list.canShowMoreItems).toBeFalsy();
expect(list.visibleItems.length).toBe(6);
});
it('should detect if content fits single page', () => {
const items = generateItems(5);
const list = new SearchFilterList(items, 5);
expect(list.fitsPage).toBeTruthy();
});
it('should detect if content exceeds single page', () => {
const items = generateItems(5);
const list = new SearchFilterList(items, 4);
expect(list.fitsPage).toBeFalsy();
});
it('should allow showing less items', () => {
const items = generateItems(5);
const list = new SearchFilterList(items, 4);
list.showMoreItems();
expect(list.canShowMoreItems).toBeFalsy();
expect(list.canShowLessItems).toBeTruthy();
});
it('should not allow showing less items for single page', () => {
const items = generateItems(5);
const list = new SearchFilterList(items, 5);
expect(list.canShowLessItems).toBeFalsy();
});
it('should clear the collection', () => {
const items = generateItems(5);
const list = new SearchFilterList(items, 5);
list.clear();
expect(list.items.length).toBe(0);
expect(list.visibleItems.length).toBe(0);
});
it('should reset page settings on clear', () => {
const items = generateItems(5);
const list = new SearchFilterList(items, 4);
list.showMoreItems();
expect(list.pageSize).toBe(4);
expect(list.currentPageSize).toBe(8);
list.clear();
expect(list.pageSize).toEqual(4);
expect(list.currentPageSize).toEqual(4);
expect(list.items.length).toBe(0);
});
it('should return visible portion of the page 1', () => {
const items = generateItems(5);
const list = new SearchFilterList(items, 4);
expect(list.length).toBe(5);
expect(list.visibleItems.length).toBe(4);
expect(list.visibleItems[0].id).toBe(0);
expect(list.visibleItems[1].id).toBe(1);
expect(list.visibleItems[2].id).toBe(2);
expect(list.visibleItems[3].id).toBe(3);
expect(list.items[4].id).toBe(4);
});
it('should use custom filter', () => {
const items = generateItems(5);
items[0].name = 'custom';
const list = new SearchFilterList(items, 5);
expect(list.visibleItems.length).toBe(5);
list.filter = (item: Payload): boolean => {
return item.name === 'custom';
};
expect(list.visibleItems.length).toBe(1);
});
it('should update filtered items on filter text change', () => {
const items = generateItems(5);
items[0].name = 'custom';
const list = new SearchFilterList(items, 5);
expect(list.visibleItems.length).toBe(5);
list.filter = (item: Payload): boolean => {
if (list.filterText) {
return item.name.startsWith(list.filterText);
}
return true;
};
expect(list.visibleItems.length).toBe(5);
list.filterText = 'cus';
expect(list.visibleItems.length).toBe(1);
expect(list.visibleItems[0].name).toEqual('custom');
list.filterText = 'P';
expect(list.visibleItems.length).toBe(4);
});
it('should reset filter text on clear', () => {
const list = new SearchFilterList([], 5);
list.filterText = 'test';
list.clear();
expect(list.filterText).toBe('');
});
});

View File

@@ -16,51 +16,95 @@
*/
export class SearchFilterList<T> implements Iterable<T> {
private filteredItems: T[] = [];
private _filterText: string = '';
items: T[] = [];
pageSize: number = 5;
currentPageSize: number = 5;
get visibleItems(): T[] {
return this.items.slice(0, this.currentPageSize);
get filterText(): string {
return this._filterText;
}
set filterText(value: string) {
this._filterText = value;
this.applyFilter();
}
private _filter: (item: T) => boolean = () => true;
get filter(): (item: T) => boolean {
return this._filter;
}
set filter(value: (item: T) => boolean ) {
this._filter = value;
this.applyFilter();
}
private applyFilter() {
if (this.filter) {
this.filteredItems = this.items.filter(this.filter);
} else {
this.filteredItems = this.items;
}
this.currentPageSize = this.pageSize;
}
/** Returns visible portion of the items. */
get visibleItems(): T[] {
return this.filteredItems.slice(0, this.currentPageSize);
}
/** Returns entire collection length including items not displayed on the page. */
get length(): number {
return this.items.length;
}
/** Detects whether more items can be displayed. */
get canShowMoreItems(): boolean {
return this.items.length > this.currentPageSize;
return this.filteredItems.length > this.currentPageSize;
}
/** Detects whether less items can be displayed. */
get canShowLessItems(): boolean {
return this.currentPageSize > this.pageSize;
}
/** Detects whether content fits single page. */
get fitsPage(): boolean {
return this.pageSize > this.items.length;
return this.pageSize >= this.filteredItems.length;
}
constructor(items: T[] = [], pageSize: number = 5) {
this.items = items;
this.filteredItems = items;
this.pageSize = pageSize;
this.currentPageSize = pageSize;
}
/** Display more items. */
showMoreItems() {
if (this.canShowMoreItems) {
this.currentPageSize += this.pageSize;
}
}
/** Display less items. */
showLessItems() {
if (this.canShowLessItems) {
this.currentPageSize -= this.pageSize;
}
}
/** Reset entire collection and page settings. */
clear() {
this.currentPageSize = this.pageSize;
this.items = [];
this.filteredItems = [];
this.filterText = '';
}
[Symbol.iterator](): Iterator<T> {

View File

@@ -21,12 +21,23 @@
<mat-expansion-panel-header>
<mat-panel-title>{{ facetQueriesLabel | translate }}</mat-panel-title>
</mat-expansion-panel-header>
<mat-form-field class="facet-result-filter">
<input
matInput
placeholder="{{ 'SEARCH.FILTER.ACTIONS.FILTER-CATEGORY' | translate }}"
[(ngModel)]="responseFacetQueries.filterText">
<button *ngIf="responseFacetQueries.filterText"
mat-button matSuffix mat-icon-button
(click)="responseFacetQueries.filterText = ''">
<mat-icon>close</mat-icon>
</button>
</mat-form-field>
<div class="checklist">
<ng-container *ngFor="let query of responseFacetQueries">
<mat-checkbox
[checked]="query.$checked"
(change)="onFacetQueryToggle($event, query)">
{{ query.label | translate }} ({{ query.count }})
{{ query.label }} ({{ query.count }})
</mat-checkbox>
</ng-container>
</div>
@@ -52,14 +63,27 @@
(opened)="onFacetFieldExpanded(field)"
(closed)="onFacetFieldCollapsed(field)">
<mat-expansion-panel-header>
<mat-panel-title>{{ field.label | translate }}</mat-panel-title>
<mat-panel-title>{{ field.label }}</mat-panel-title>
</mat-expansion-panel-header>
<mat-form-field class="facet-result-filter">
<input
matInput
placeholder="{{ 'SEARCH.FILTER.ACTIONS.FILTER-CATEGORY' | translate }}"
[(ngModel)]="field.buckets.filterText">
<button *ngIf="field.buckets.filterText"
mat-button matSuffix mat-icon-button
(click)="field.buckets.filterText = ''">
<mat-icon>close</mat-icon>
</button>
</mat-form-field>
<div class="checklist">
<mat-checkbox
*ngFor="let bucket of field.buckets"
[checked]="bucket.$checked"
(change)="onFacetToggle($event, field, bucket)">
{{ (bucket.display || bucket.label) | translate }} ({{ bucket.count }})
{{ bucket.display || bucket.label }} ({{ bucket.count }})
</mat-checkbox>
</div>

View File

@@ -7,6 +7,14 @@
}
}
.facet-result-filter {
width: 100%;
input > {
width: 100%;
}
}
.facet-buttons {
text-align: right;

View File

@@ -18,7 +18,7 @@
import { SearchFilterComponent } from './search-filter.component';
import { SearchQueryBuilderService } from '../../search-query-builder.service';
import { SearchConfiguration } from '../../search-configuration.interface';
import { AppConfigService } from '@alfresco/adf-core';
import { AppConfigService, TranslationMock } from '@alfresco/adf-core';
import { Subject } from 'rxjs/Subject';
import { ResponseFacetQueryList } from './models/response-facet-query-list.model';
@@ -36,7 +36,8 @@ describe('SearchSettingsComponent', () => {
const searchMock: any = {
dataLoaded: new Subject()
};
component = new SearchFilterComponent(queryBuilder, searchMock);
const translationMock = new TranslationMock();
component = new SearchFilterComponent(queryBuilder, searchMock, translationMock);
component.ngOnInit();
});
@@ -118,6 +119,7 @@ describe('SearchSettingsComponent', () => {
});
it('should unselect facet query and update builder', () => {
const translationMock = new TranslationMock();
const config: SearchConfiguration = {
categories: [],
facetQueries: {
@@ -128,7 +130,7 @@ describe('SearchSettingsComponent', () => {
};
appConfig.config.search = config;
queryBuilder = new SearchQueryBuilderService(appConfig, null);
component = new SearchFilterComponent(queryBuilder, null);
component = new SearchFilterComponent(queryBuilder, null, translationMock);
spyOn(queryBuilder, 'update').and.stub();
queryBuilder.filterQueries = [{ query: 'query1' }];

View File

@@ -17,7 +17,7 @@
import { Component, ViewEncapsulation, OnInit } from '@angular/core';
import { MatCheckboxChange } from '@angular/material';
import { SearchService } from '@alfresco/adf-core';
import { SearchService, TranslationService } from '@alfresco/adf-core';
import { SearchQueryBuilderService } from '../../search-query-builder.service';
import { ResponseFacetField } from '../../response-facet-field.interface';
import { FacetFieldBucket } from '../../facet-field-bucket.interface';
@@ -44,7 +44,9 @@ export class SearchFilterComponent implements OnInit {
facetQueriesPageSize = 5;
facetQueriesExpanded = false;
constructor(public queryBuilder: SearchQueryBuilderService, private search: SearchService) {
constructor(public queryBuilder: SearchQueryBuilderService,
private searchService: SearchService,
private translationService: TranslationService) {
this.responseFacetQueries = new ResponseFacetQueryList();
if (queryBuilder.config && queryBuilder.config.facetQueries) {
@@ -62,7 +64,7 @@ export class SearchFilterComponent implements OnInit {
if (this.queryBuilder) {
this.queryBuilder.executed.subscribe(data => {
this.onDataLoaded(data);
this.search.dataLoaded.next(data);
this.searchService.dataLoaded.next(data);
});
}
}
@@ -95,7 +97,7 @@ export class SearchFilterComponent implements OnInit {
}
} else {
query.$checked = false;
this.selectedFacetQueries = this.selectedFacetQueries.filter(q => q !== query.label);
this.selectedFacetQueries = this.selectedFacetQueries.filter(selectedQuery => selectedQuery !== query.label);
if (facetQuery) {
this.queryBuilder.removeFilterQuery(facetQuery.query);
@@ -127,7 +129,7 @@ export class SearchFilterComponent implements OnInit {
unselectFacetQuery(label: string) {
const facetQuery = this.queryBuilder.getFacetQuery(label);
this.selectedFacetQueries = this.selectedFacetQueries.filter(q => q !== label);
this.selectedFacetQueries = this.selectedFacetQueries.filter(selectedQuery => selectedQuery !== label);
this.queryBuilder.removeFilterQuery(facetQuery.query);
this.queryBuilder.update();
@@ -136,7 +138,7 @@ export class SearchFilterComponent implements OnInit {
unselectFacetBucket(bucket: FacetFieldBucket) {
if (bucket) {
const idx = this.selectedBuckets.findIndex(
b => b.$field === bucket.$field && b.label === bucket.label
selectedBucket => selectedBucket.$field === bucket.$field && selectedBucket.label === bucket.label
);
if (idx >= 0) {
@@ -151,17 +153,19 @@ export class SearchFilterComponent implements OnInit {
const context = data.list.context;
if (context) {
const facetQueries = (context.facetQueries || []).map(q => {
q.$checked = this.selectedFacetQueries.includes(q.label);
return q;
const facetQueries = (context.facetQueries || []).map(query => {
query.label = this.translationService.instant(query.label);
query.$checked = this.selectedFacetQueries.includes(query.label);
return query;
});
this.responseFacetQueries = new ResponseFacetQueryList(facetQueries, this.facetQueriesPageSize);
const expandedFields = this.responseFacetFields.filter(f => f.expanded).map(f => f.label);
const expandedFields = this.responseFacetFields.filter(field => field.expanded).map(field => field.label);
this.responseFacetFields = (context.facetsFields || []).map(
field => {
field.label = this.translationService.instant(field.label);
field.pageSize = field.pageSize || 5;
field.currentPageSize = field.pageSize;
field.expanded = expandedFields.includes(field.label);
@@ -169,16 +173,29 @@ export class SearchFilterComponent implements OnInit {
const buckets = (field.buckets || []).map(bucket => {
bucket.$field = field.label;
bucket.$checked = false;
bucket.display = this.translationService.instant(bucket.display);
bucket.label = this.translationService.instant(bucket.label);
const previousBucket = this.selectedBuckets.find(
b => b.$field === bucket.$field && b.label === bucket.label
selectedBucket => selectedBucket.$field === bucket.$field && selectedBucket.label === bucket.label
);
if (previousBucket) {
bucket.$checked = true;
}
return bucket;
});
field.buckets = new SearchFilterList<FacetFieldBucket>(buckets, field.pageSize);
const bucketList = new SearchFilterList<FacetFieldBucket>(buckets, field.pageSize);
bucketList.filter = (bucket: FacetFieldBucket): boolean => {
if (bucket && bucketList.filterText) {
const pattern = (bucketList.filterText || '').toLowerCase();
const label = (bucket.display || bucket.label || '').toLowerCase();
return label.startsWith(pattern);
}
return true;
};
field.buckets = bucketList;
return field;
}
);

View File

@@ -105,4 +105,10 @@ describe('TranslationService', () => {
});
});
it('should return empty string for missing key when getting instant translations', () => {
expect(translationService.instant(null)).toEqual('');
expect(translationService.instant('')).toEqual('');
expect(translationService.instant(undefined)).toEqual('');
});
});

View File

@@ -128,6 +128,6 @@ export class TranslationService {
* @returns Translated text
*/
instant(key: string | Array<string>, interpolateParams?: Object): string | any {
return this.translate.instant(key, interpolateParams);
return key ? this.translate.instant(key, interpolateParams) : '';
}
}