[ADF-3401] Search filters - fix facet update (#4249)

* [ADF-3401] refactoring - different way to call the facet parsers

* [ADF-3401] fix duplicate search call

* [ADF-3401] add new fields and buckets from the response

- If a facet is already displayed, just update the bucket count, else add the new facet to the responseFields
- this way any existing filters are preserved, the collapsed state is preserved, facet selection is preserved

* [ADF-3401] reset & clear all selections buttons

* [ADF-3401] delete facets that are not in the response

- for better UX, prevent deletion of items from the category where there is a selected item
- clean-up reset buttons

* [ADF-3401] apply filters to the newly created items

* [ADF-3401] update tests

* [ADF-3401] fix after rebase

* [ADF-3401] Code refactoring

* [ADF-3401] show count value inside tooltip

* [ADF-3401] translatable strings

* [ADF-3401] move 'Clear all selections' button to search-chip-list

* [ADF-3401] option to configure having a reset button for filters

* [ADF-3401] code cleanup and improvements after review

* Update lib/content-services/search/components/search-filter/search-filter.component.ts

Co-Authored-By: suzanadirla <dirla.silvia.suzana@gmail.com>

* [ADF-3401] Better namings

* fix failing e2e tests on search radio

* [ADF-3401] add documentation for search resetButton
This commit is contained in:
Suzana Dirla
2019-03-05 16:08:38 +02:00
committed by Eugenio Romano
parent 933a7256a3
commit fb11cc879d
14 changed files with 212 additions and 85 deletions

View File

@@ -102,6 +102,7 @@
}
]
},
"resetButton": true,
"filterQueries": [
{ "query": "TYPE:'cm:folder' OR TYPE:'cm:content'" },
{ "query": "NOT cm:creator:System" }

View File

@@ -1,5 +1,5 @@
<div class="adf-search-results__facets">
<adf-search-chip-list [searchFilter]="searchFilter"></adf-search-chip-list>
<adf-search-chip-list [searchFilter]="searchFilter" [clearAll]="true"></adf-search-chip-list>
</div>
<div class="adf-search-results">

View File

@@ -25,3 +25,4 @@ Displays search criteria as a set of "chips".
| Name | Type | Default value | Description |
| ---- | ---- | ------------- | ----------- |
| searchFilter | [`SearchFilterComponent`](../content-services/search-filter.component.md) | | Search filter to supply the data for the chips. |
| clearAll | boolean | false | Enables or disables the display of a clear-all-filters button. |

View File

@@ -133,6 +133,17 @@ You can choose to filter facet field results using 'contains' instead of 'starts
}
```
You can choose to display a reset button by setting the 'resetButton' value to true.
This 'clean up' button would make it easier for the final user to remove all bucket selections and all search filtering.
```json
{
"search": {
"resetButton": true
}
}
```
You can also provide a set of queries that are always executed alongside the user-defined
settings:

View File

@@ -198,6 +198,16 @@
"SHOW-LESS": "Show less",
"FILTER-CATEGORY": "Filter category"
},
"BUTTONS": {
"CLEAR-ALL": {
"LABEL": "Clear all",
"TOOLTIP": "This will remove all selections"
},
"RESET-ALL": {
"LABEL": "Reset all",
"TOOLTIP": "This will reset all selections and all filters"
}
},
"RANGE": {
"FROM": "From",
"TO": "To",

View File

@@ -1,5 +1,14 @@
<mat-chip-list>
<ng-container *ngIf="searchFilter && searchFilter.selectedBuckets.length">
<mat-chip *ngIf="clearAll && searchFilter.selectedBuckets.length > 1"
color="primary"
selected
matTooltip="{{ 'SEARCH.FILTER.BUTTONS.CLEAR-ALL.TOOLTIP' | translate }}"
matTooltipPosition="right"
(click)="searchFilter.resetAllSelectedBuckets()">
{{ 'SEARCH.FILTER.BUTTONS.CLEAR-ALL.LABEL' | translate }}
</mat-chip>
<mat-chip
*ngFor="let selection of searchFilter.selectedBuckets"
[removable]="true"

View File

@@ -29,4 +29,8 @@ export class SearchChipListComponent {
/** Search filter to supply the data for the chips. */
@Input()
searchFilter: SearchFilterComponent;
/** Flag used to enable the display of a clear-all-filters button. */
@Input()
clearAll: boolean = false;
}

View File

@@ -109,6 +109,22 @@ export class SearchFilterList<T> implements Iterable<T> {
this.filterText = '';
}
addItem(item: T) {
if (!item) {
return;
}
this.items.push(item);
this.applyFilter();
}
deleteItem(item: T) {
const removeIndex = this.items.indexOf(item);
if (removeIndex > -1) {
this.items.splice(removeIndex, 1);
this.filteredItems.splice(removeIndex, 1);
}
}
[Symbol.iterator](): Iterator<T> {
let pointer = 0;
let items = this.visibleItems;

View File

@@ -1,5 +1,13 @@
<mat-accordion multi="true" displayMode="flat">
<button *ngIf="displayResetButton && responseFacets"
mat-button
color="primary"
matTooltip="{{ 'SEARCH.FILTER.BUTTONS.RESET-ALL.TOOLTIP' | translate }}"
matTooltipPosition="right"
(click)="resetAll()">
{{ 'SEARCH.FILTER.BUTTONS.RESET-ALL.LABEL' | translate }}
</button>
<mat-expansion-panel
*ngFor="let category of queryBuilder.categories"
[attr.data-automation-id]="'expansion-panel-'+category.name"
@@ -45,13 +53,10 @@
[attr.data-automation-id]="'checkbox-'+field.label+'-'+(bucket.display || bucket.label)"
(change)="onToggleBucket($event, field, bucket)">
<div
matTooltip="{{ bucket.display || bucket.label | translate }}"
matTooltip="{{ bucket.display || bucket.label | translate }} {{ getBucketCountDisplay(bucket) }}"
matTooltipPosition="right"
class="adf-facet-label">
{{ bucket.display || bucket.label | translate }}
<span *ngIf="bucket.count!==null">(</span>
{{ bucket.count }}
<span *ngIf="bucket.count!==null">)</span>
{{ bucket.display || bucket.label | translate }} {{ getBucketCountDisplay(bucket) }}
</div>
</mat-checkbox>
</div>

View File

@@ -193,7 +193,7 @@ describe('SearchFilterComponent', () => {
component.onDataLoaded(data);
expect(component.responseFacets.length).toBe(0);
expect(component.responseFacets).toBeNull();
});
it('should fetch facet fields from response payload', () => {
@@ -397,10 +397,10 @@ describe('SearchFilterComponent', () => {
};
component.responseFacets = <any> [
{ label: 'f1', field: 'f1', buckets: {items: [
{ type: 'field', label: 'f1', field: 'f1', buckets: {items: [
{ label: 'b1', count: 10, filterQuery: 'filter', checked: true },
{ label: 'b2', count: 1, filterQuery: 'filter2' }] }},
{ label: 'f2', field: 'f2', buckets: {items: [] }}
{ type: 'field', label: 'f2', field: 'f2', buckets: {items: [] }}
];
component.queryBuilder.addUserFacetBucket({ label: 'f1', field: 'f1' }, component.responseFacets[0].buckets.items[0]);
@@ -437,10 +437,10 @@ describe('SearchFilterComponent', () => {
};
component.responseFacets = <any> [
{ label: 'f1', field: 'f1', buckets: {items: [
{ type: 'field', label: 'f1', field: 'f1', buckets: {items: [
{ label: 'b1', count: 10, filterQuery: 'filter', checked: true },
{ label: 'b2', count: 1, filterQuery: 'filter2' }] }},
{ label: 'f2', field: 'f2', buckets: {items: [] }}
{ type: 'field', label: 'f2', field: 'f2', buckets: {items: [] }}
];
component.queryBuilder.addUserFacetBucket({ label: 'f1', field: 'f1' }, component.responseFacets[0].buckets.items[0]);
@@ -477,10 +477,10 @@ describe('SearchFilterComponent', () => {
};
component.responseFacets = <any> [
{ label: 'f1', field: 'f1', buckets: {items: [
{ type: 'field', label: 'f1', field: 'f1', buckets: {items: [
{ label: 'b1', count: 10, filterQuery: 'filter', checked: true },
{ label: 'b2', count: 1, filterQuery: 'filter2' }] }},
{ label: 'f2', field: 'f2', buckets: {items: [] }}
{ type: 'field', label: 'f2', field: 'f2', buckets: {items: [] }}
];
component.queryBuilder.addUserFacetBucket({ label: 'f1', field: 'f1' }, component.responseFacets[0].buckets.items[0]);
const data = {

View File

@@ -37,6 +37,11 @@ export class SearchFilterComponent implements OnInit, OnDestroy {
private DEFAULT_PAGE_SIZE = 5;
isAlive = true;
/** All facet field items to be displayed in the component. These are updated according to the response.
* When a new search is performed, the already existing items are updated with the new bucket count values and
* the newly received items are added to the responseFacets.
*/
responseFacets: FacetField[] = null;
private facetQueriesPageSize = this.DEFAULT_PAGE_SIZE;
@@ -44,7 +49,7 @@ export class SearchFilterComponent implements OnInit, OnDestroy {
facetExpanded = {
'default': false
};
displayResetButton: boolean;
selectedBuckets: Array<{ field: FacetField, bucket: FacetFieldBucket }> = [];
constructor(public queryBuilder: SearchQueryBuilderService,
@@ -61,6 +66,7 @@ export class SearchFilterComponent implements OnInit, OnDestroy {
if (queryBuilder.config && queryBuilder.config.facetIntervals) {
this.facetExpanded['interval'] = queryBuilder.config.facetIntervals.expanded;
}
this.displayResetButton = this.queryBuilder.config && !!this.queryBuilder.config.resetButton;
this.queryBuilder.updated.pipe(
takeWhile(() => this.isAlive)
@@ -149,6 +155,24 @@ export class SearchFilterComponent implements OnInit, OnDestroy {
}
}
resetAllSelectedBuckets() {
this.responseFacets.forEach((field) => {
if (field && field.buckets) {
for (let bucket of field.buckets.items) {
bucket.checked = false;
this.queryBuilder.removeUserFacetBucket(field, bucket);
}
this.updateSelectedBuckets();
}
});
this.queryBuilder.update();
}
resetAll() {
this.resetAllSelectedBuckets();
this.responseFacets = null;
}
shouldExpand(field: FacetField): boolean {
return this.facetExpanded[field.type] || this.facetExpanded['default'];
}
@@ -164,69 +188,53 @@ export class SearchFilterComponent implements OnInit, OnDestroy {
}
private parseFacets(context: ResultSetContext) {
if (!this.responseFacets) {
const responseFacetFields = this.parseFacetFields(context);
const responseFacetIntervals = this.parseFacetIntervals(context);
const responseGroupedFacetQueries = this.parseFacetQueries(context);
this.responseFacets = responseFacetFields.concat(...responseGroupedFacetQueries, ...responseFacetIntervals);
} else {
this.responseFacets = this.responseFacets
.map((field) => {
let responseField = (context.facets || []).find((response) => response.label === field.label && response.type === field.type);
(field && field.buckets && field.buckets.items || [])
.map((bucket) => {
const responseBucket = ((responseField && responseField.buckets) || []).find((respBucket) => respBucket.label === bucket.label);
bucket.count = responseBucket ? this.getCountValue(responseBucket) : 0;
return bucket;
});
return field;
});
}
this.parseFacetFields(context);
this.parseFacetIntervals(context);
this.parseFacetQueries(context);
}
private parseFacetItems(context: ResultSetContext, configFacetFields: FacetField[], itemType: string): FacetField[] {
return configFacetFields.map((field) => {
const responseField = (context.facets || []).find((response) => response.type === itemType && response.label === field.label) || {};
private parseFacetItems(context: ResultSetContext, configFacetFields: FacetField[], itemType: string) {
configFacetFields.forEach((field) => {
const responseField = this.findFacet(context, itemType, field.label);
const responseBuckets = this.getResponseBuckets(responseField, field)
.filter(this.getFilterByMinCount(field.mincount));
const alreadyExistingField = this.findResponseFacet(itemType, field.label);
const bucketList = new SearchFilterList<FacetFieldBucket>(responseBuckets, field.pageSize);
bucketList.filter = (bucket: FacetFieldBucket): boolean => {
if (bucket && bucketList.filterText) {
const pattern = (bucketList.filterText || '').toLowerCase();
const label = (this.translationService.instant(bucket.display) || this.translationService.instant(bucket.label)).toLowerCase();
return this.queryBuilder.config.filterWithContains ? label.indexOf(pattern) !== -1 : label.startsWith(pattern);
if (alreadyExistingField) {
const alreadyExistingBuckets = alreadyExistingField.buckets && alreadyExistingField.buckets.items || [];
this.updateExistingBuckets(responseField, responseBuckets, alreadyExistingField, alreadyExistingBuckets);
} else if (responseField) {
const bucketList = new SearchFilterList<FacetFieldBucket>(responseBuckets, field.pageSize);
bucketList.filter = this.getBucketFilterFunction(bucketList);
if (!this.responseFacets) {
this.responseFacets = [];
}
return true;
};
return <FacetField> {
...field,
type: responseField.type,
label: field.label,
pageSize: field.pageSize | this.DEFAULT_PAGE_SIZE,
currentPageSize: field.pageSize | this.DEFAULT_PAGE_SIZE,
buckets: bucketList
};
this.responseFacets.push(<FacetField> {
...field,
type: responseField.type || itemType,
label: field.label,
pageSize: field.pageSize | this.DEFAULT_PAGE_SIZE,
currentPageSize: field.pageSize | this.DEFAULT_PAGE_SIZE,
buckets: bucketList
});
}
});
}
private parseFacetFields(context: ResultSetContext): FacetField[] {
private parseFacetFields(context: ResultSetContext) {
const configFacetFields = this.queryBuilder.config.facetFields && this.queryBuilder.config.facetFields.fields || [];
return this.parseFacetItems(context, configFacetFields, 'field');
this.parseFacetItems(context, configFacetFields, 'field');
}
private parseFacetIntervals(context: ResultSetContext): FacetField[] {
private parseFacetIntervals(context: ResultSetContext) {
const configFacetIntervals = this.queryBuilder.config.facetIntervals && this.queryBuilder.config.facetIntervals.intervals || [];
return this.parseFacetItems(context, configFacetIntervals, 'interval');
this.parseFacetItems(context, configFacetIntervals, 'interval');
}
private parseFacetQueries(context: ResultSetContext): FacetField[] {
private parseFacetQueries(context: ResultSetContext) {
const configFacetQueries = this.queryBuilder.config.facetQueries && this.queryBuilder.config.facetQueries.queries || [];
const configGroups = configFacetQueries.reduce((acc, query) => {
const group = this.queryBuilder.getQueryGroup(query);
@@ -238,36 +246,38 @@ export class SearchFilterComponent implements OnInit, OnDestroy {
return acc;
}, []);
const result = [];
const mincount = this.queryBuilder.config.facetQueries && this.queryBuilder.config.facetQueries.mincount;
const mincountFilter = this.getFilterByMinCount(mincount);
Object.keys(configGroups).forEach((group) => {
const responseField = (context.facets || []).find((response) => response.type === 'query' && response.label === group) || {};
const responseField = this.findFacet(context, 'query', group);
const responseBuckets = this.getResponseQueryBuckets(responseField, configGroups[group])
.filter(mincountFilter);
const alreadyExistingField = this.findResponseFacet('query', group);
const bucketList = new SearchFilterList<FacetFieldBucket>(responseBuckets, this.facetQueriesPageSize);
bucketList.filter = (bucket: FacetFieldBucket): boolean => {
if (bucket && bucketList.filterText) {
const pattern = (bucketList.filterText || '').toLowerCase();
const label = (this.translationService.instant(bucket.display) || this.translationService.instant(bucket.label)).toLowerCase();
return this.queryBuilder.config.filterWithContains ? label.indexOf(pattern) !== -1 : label.startsWith(pattern);
if (alreadyExistingField) {
const alreadyExistingBuckets = alreadyExistingField.buckets && alreadyExistingField.buckets.items || [];
this.updateExistingBuckets(responseField, responseBuckets, alreadyExistingField, alreadyExistingBuckets);
} else if (responseField) {
const bucketList = new SearchFilterList<FacetFieldBucket>(responseBuckets, this.facetQueriesPageSize);
bucketList.filter = this.getBucketFilterFunction(bucketList);
if (!this.responseFacets) {
this.responseFacets = [];
}
return true;
};
result.push(<FacetField> {
field: group,
type: responseField.type,
label: group,
pageSize: this.DEFAULT_PAGE_SIZE,
currentPageSize: this.DEFAULT_PAGE_SIZE,
buckets: bucketList
});
this.responseFacets.push(<FacetField> {
field: group,
type: responseField.type || 'query',
label: group,
pageSize: this.DEFAULT_PAGE_SIZE,
currentPageSize: this.DEFAULT_PAGE_SIZE,
buckets: bucketList
});
}
});
return result;
}
private getResponseBuckets(responseField: GenericFacetResponse, configField: FacetField): FacetFieldBucket[] {
@@ -304,6 +314,10 @@ export class SearchFilterComponent implements OnInit, OnDestroy {
|| 0;
}
getBucketCountDisplay(bucket: FacetFieldBucket): string {
return bucket.count === null ? '' : `(${bucket.count})`;
}
private getFilterByMinCount(mincountInput: number) {
return (bucket) => {
let mincount = mincountInput;
@@ -342,4 +356,55 @@ export class SearchFilterComponent implements OnInit, OnDestroy {
return `${fieldName}:${startLimit}"${start}" TO "${end}"${endLimit}`;
}
private findFacet(context: ResultSetContext, itemType: string, fieldLabel: string): GenericFacetResponse {
return (context.facets || []).find((response) => response.type === itemType && response.label === fieldLabel) || {};
}
private findResponseFacet(itemType: string, fieldLabel: string): FacetField {
return (this.responseFacets || []).find((response) => response.type === itemType && response.label === fieldLabel);
}
private updateExistingBuckets(responseField, responseBuckets, alreadyExistingField, alreadyExistingBuckets) {
const bucketsToDelete = [];
alreadyExistingBuckets
.map((bucket) => {
const responseBucket = ((responseField && responseField.buckets) || []).find((respBucket) => respBucket.label === bucket.label);
if (!responseBucket) {
bucketsToDelete.push(bucket);
}
bucket.count = this.getCountValue(responseBucket);
return bucket;
});
const hasSelection = this.selectedBuckets
.find((selBuckets) => alreadyExistingField.label === selBuckets.field.label && alreadyExistingField.type === selBuckets.field.type);
if (!hasSelection && bucketsToDelete.length) {
bucketsToDelete.forEach((bucket) => {
alreadyExistingField.buckets.deleteItem(bucket);
});
}
responseBuckets.forEach((respBucket) => {
const existingBucket = alreadyExistingBuckets.find((oldBucket) => oldBucket.label === respBucket.label);
if (!existingBucket) {
alreadyExistingField.buckets.addItem(respBucket);
}
});
}
private getBucketFilterFunction (bucketList) {
return (bucket: FacetFieldBucket): boolean => {
if (bucket && bucketList.filterText) {
const pattern = (bucketList.filterText || '').toLowerCase();
const label = (this.translationService.instant(bucket.display) || this.translationService.instant(bucket.label)).toLowerCase();
return this.queryBuilder.config.filterWithContains ? label.indexOf(pattern) !== -1 : label.startsWith(pattern);
}
return true;
};
}
}

View File

@@ -62,9 +62,10 @@ export class SearchRadioComponent implements SearchWidget, OnInit {
}
}
this.setValue(
this.getSelectedValue()
);
const initialValue = this.getSelectedValue();
if (initialValue !== null) {
this.setValue(initialValue);
}
}
private getSelectedValue(): string {

View File

@@ -27,6 +27,7 @@ export interface SearchConfiguration {
categories: SearchCategory[];
filterQueries?: FilterQuery[];
filterWithContains?: boolean;
resetButton?: boolean;
facetQueries?: {
label?: string;
pageSize?: number;

View File

@@ -926,6 +926,9 @@
"filterWithContains": {
"type": "boolean"
},
"resetButton": {
"type": "boolean"
},
"facetFields": {
"type": "object",
"required": [