mirror of
https://github.com/Alfresco/alfresco-ng2-components.git
synced 2025-07-24 17:32:15 +00:00
[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:
committed by
Eugenio Romano
parent
440c666583
commit
7afcd24488
@@ -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",
|
||||
|
@@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
|
@@ -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('');
|
||||
});
|
||||
|
||||
});
|
@@ -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> {
|
||||
|
@@ -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>
|
||||
|
||||
|
@@ -7,6 +7,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
.facet-result-filter {
|
||||
width: 100%;
|
||||
|
||||
input > {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.facet-buttons {
|
||||
text-align: right;
|
||||
|
||||
|
@@ -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' }];
|
||||
|
@@ -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;
|
||||
}
|
||||
);
|
||||
|
@@ -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('');
|
||||
});
|
||||
|
||||
});
|
||||
|
@@ -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) : '';
|
||||
}
|
||||
}
|
||||
|
Reference in New Issue
Block a user