diff --git a/demo-shell/src/app.config.json b/demo-shell/src/app.config.json index e2d2501839..99d0a1e7bd 100644 --- a/demo-shell/src/app.config.json +++ b/demo-shell/src/app.config.json @@ -102,6 +102,7 @@ } ] }, + "resetButton": true, "filterQueries": [ { "query": "TYPE:'cm:folder' OR TYPE:'cm:content'" }, { "query": "NOT cm:creator:System" } diff --git a/demo-shell/src/app/components/search/search-result.component.html b/demo-shell/src/app/components/search/search-result.component.html index 4a97f107cd..9f05f5954a 100644 --- a/demo-shell/src/app/components/search/search-result.component.html +++ b/demo-shell/src/app/components/search/search-result.component.html @@ -1,5 +1,5 @@
- +
diff --git a/docs/content-services/search-chip-list.component.md b/docs/content-services/search-chip-list.component.md index 170c29c523..05823061be 100644 --- a/docs/content-services/search-chip-list.component.md +++ b/docs/content-services/search-chip-list.component.md @@ -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. | diff --git a/docs/content-services/search-filter.component.md b/docs/content-services/search-filter.component.md index 9666a0f693..59394b2f74 100644 --- a/docs/content-services/search-filter.component.md +++ b/docs/content-services/search-filter.component.md @@ -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: diff --git a/lib/content-services/i18n/en.json b/lib/content-services/i18n/en.json index 25624894c6..17022cb032 100644 --- a/lib/content-services/i18n/en.json +++ b/lib/content-services/i18n/en.json @@ -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", diff --git a/lib/content-services/search/components/search-chip-list/search-chip-list.component.html b/lib/content-services/search/components/search-chip-list/search-chip-list.component.html index dd5fa52184..88c98c1013 100644 --- a/lib/content-services/search/components/search-chip-list/search-chip-list.component.html +++ b/lib/content-services/search/components/search-chip-list/search-chip-list.component.html @@ -1,5 +1,14 @@ + + {{ 'SEARCH.FILTER.BUTTONS.CLEAR-ALL.LABEL' | translate }} + + implements Iterable { 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 { let pointer = 0; let items = this.visibleItems; diff --git a/lib/content-services/search/components/search-filter/search-filter.component.html b/lib/content-services/search/components/search-filter/search-filter.component.html index cb20027fa0..591abd8bfb 100644 --- a/lib/content-services/search/components/search-filter/search-filter.component.html +++ b/lib/content-services/search/components/search-filter/search-filter.component.html @@ -1,5 +1,13 @@ +
- {{ bucket.display || bucket.label | translate }} - ( - {{ bucket.count }} - ) + {{ bucket.display || bucket.label | translate }} {{ getBucketCountDisplay(bucket) }}
diff --git a/lib/content-services/search/components/search-filter/search-filter.component.spec.ts b/lib/content-services/search/components/search-filter/search-filter.component.spec.ts index e33fc432df..265b7adf3c 100644 --- a/lib/content-services/search/components/search-filter/search-filter.component.spec.ts +++ b/lib/content-services/search/components/search-filter/search-filter.component.spec.ts @@ -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 = [ - { 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 = [ - { 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 = [ - { 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 = { diff --git a/lib/content-services/search/components/search-filter/search-filter.component.ts b/lib/content-services/search/components/search-filter/search-filter.component.ts index 58ea6042db..178323e9aa 100644 --- a/lib/content-services/search/components/search-filter/search-filter.component.ts +++ b/lib/content-services/search/components/search-filter/search-filter.component.ts @@ -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(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(responseBuckets, field.pageSize); + bucketList.filter = this.getBucketFilterFunction(bucketList); + + if (!this.responseFacets) { + this.responseFacets = []; } - return true; - }; - - return { - ...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( { + ...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(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(responseBuckets, this.facetQueriesPageSize); + bucketList.filter = this.getBucketFilterFunction(bucketList); + + if (!this.responseFacets) { + this.responseFacets = []; } - return true; - }; - - result.push( { - field: group, - type: responseField.type, - label: group, - pageSize: this.DEFAULT_PAGE_SIZE, - currentPageSize: this.DEFAULT_PAGE_SIZE, - buckets: bucketList - }); + this.responseFacets.push( { + 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; + }; + } } diff --git a/lib/content-services/search/components/search-radio/search-radio.component.ts b/lib/content-services/search/components/search-radio/search-radio.component.ts index 2842e8d88e..18146ae9fb 100644 --- a/lib/content-services/search/components/search-radio/search-radio.component.ts +++ b/lib/content-services/search/components/search-radio/search-radio.component.ts @@ -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 { diff --git a/lib/content-services/search/search-configuration.interface.ts b/lib/content-services/search/search-configuration.interface.ts index bd2e249276..7dff04b770 100644 --- a/lib/content-services/search/search-configuration.interface.ts +++ b/lib/content-services/search/search-configuration.interface.ts @@ -27,6 +27,7 @@ export interface SearchConfiguration { categories: SearchCategory[]; filterQueries?: FilterQuery[]; filterWithContains?: boolean; + resetButton?: boolean; facetQueries?: { label?: string; pageSize?: number; diff --git a/lib/core/app-config/schema.json b/lib/core/app-config/schema.json index c29f531bb1..33eddb0584 100644 --- a/lib/core/app-config/schema.json +++ b/lib/core/app-config/schema.json @@ -926,6 +926,9 @@ "filterWithContains": { "type": "boolean" }, + "resetButton": { + "type": "boolean" + }, "facetFields": { "type": "object", "required": [