diff --git a/demo-shell/src/app.config.json b/demo-shell/src/app.config.json index 666c6448c8..600f4e6ebc 100644 --- a/demo-shell/src/app.config.json +++ b/demo-shell/src/app.config.json @@ -122,7 +122,7 @@ "expanded": true, "mincount": 1, "queries": [ - { "query": "created:2018", "label": "SEARCH.FACET_QUERIES.CREATED_THIS_YEAR" }, + { "query": "created:2019", "label": "SEARCH.FACET_QUERIES.CREATED_THIS_YEAR" }, { "query": "content.mimetype:text/html", "label": "SEARCH.FACET_QUERIES.MIMETYPE", "group":"Type facet queries" }, { "query": "content.size:[0 TO 10240]", "label": "SEARCH.FACET_QUERIES.XTRASMALL", "group":"Size facet queries"}, { "query": "content.size:[10240 TO 102400]", "label": "SEARCH.FACET_QUERIES.SMALL", "group":"Size facet queries"}, @@ -132,6 +132,30 @@ { "query": "content.size:[134217728 TO MAX]", "label": "SEARCH.FACET_QUERIES.XXTRALARGE", "group":"Size facet queries" } ] }, + "facetIntervals":{ + "expanded": true, + "intervals":[ + { + "label":"TheCreated", + "field":"cm:created", + "sets":[ + { "label":"lastYear", "start":"2018", "end":"2019", "endInclusive":false }, + { "label":"currentYear", "start":"NOW/YEAR", "end":"NOW/YEAR+1YEAR" }, + { "label":"earlier", "start":"*", "end":"2018", "endInclusive":false } + ] + }, + { + "label":"TheModified", + "field":"cm:modified", + "sets":[ + { "label":"2017", "start":"2017", "end":"2018", "endInclusive":false }, + { "label":"2017-2018", "start":"2017", "end":"2018", "endInclusive":true }, + { "label":"currentYear", "start":"NOW/YEAR", "end":"NOW/YEAR+1YEAR" }, + { "label":"earlierThan2017", "start":"*", "end":"2017", "endInclusive":false } + ] + } + ] + }, "categories": [ { "id": "queryName", diff --git a/docs/content-services/search-filter.component.md b/docs/content-services/search-filter.component.md index 2fe05f716e..b4af1f0de0 100644 --- a/docs/content-services/search-filter.component.md +++ b/docs/content-services/search-filter.component.md @@ -352,6 +352,56 @@ The default page size of 5 will be used if you set the value to 0 or omit it ent ![Facet Queries](../docassets/images/search-facet-queries.png) +### Facet Intervals + +These provide custom categories based on admin defined ranges inside `intervals`. What is wanted for every interval can be specified exactly in the config file, and having overlapping ranges could also be possible. + +#### FacetIntervals Properties +| Name | Type | Description | +| ---- | ---- | ----------- | +|intervals|array|Specifies the fields to facet by interval.| +|expanded|boolean|Toggles expanded state of the facet intervals.| +Note: `sets` parameter from Search API (Sets the intervals for all fields) is not yet supported. + + +```json +{ + "search": { + "facetIntervals":{ + "expanded": true, + "intervals":[ + { + "label":"TheCreated", + "field":"cm:created", + "sets":[ + { "label":"lastYear", "start":"2017", "end":"2018", "endInclusive":false }, + { "label":"currentYear", "start":"NOW/YEAR", "end":"NOW/YEAR+1YEAR" }, + { "label":"earlier", "start":"*", "end":"2017", "endInclusive":false } + ] + }, + { + "label":"TheModified", + "field":"cm:modified", + "sets":[ + { "label":"2016", "start":"2017", "end":"2018", "endInclusive":false }, + { "label":"currentYear", "start":"NOW/YEAR", "end":"NOW/YEAR+1YEAR" }, + { "label":"earlierThan2017", "start":"*", "end":"2017", "endInclusive":false } + ] + } + ] + } + } +} +``` + +You can specify a value for the `mincount` property inside each `intervals` item to set the minimum count required for a facet interval to be displayed. By default, only the intervals that have 1 or more response entries are displayed at runtime. +Check the [schema.json](https://github.com/Alfresco/alfresco-ng2-components/blob/master/lib/core/app-config/schema.json) +for more details about what is the structure and the properties of `intervals` that you can set inside the configuration file. + +Each `intervals` item defined is collected into its collapsible category identified uniquely by its `label`. The top code snippet will result in the following display of the facet intervals: + +![Facet Intervals](../docassets/images/search-facet-intervals.png) + ## See also - [Search Query Builder service](search-query-builder.service.md) diff --git a/docs/docassets/images/search-facet-intervals.png b/docs/docassets/images/search-facet-intervals.png new file mode 100644 index 0000000000..b9eccd6361 Binary files /dev/null and b/docs/docassets/images/search-facet-intervals.png differ diff --git a/e2e/pages/adf/searchFiltersPage.ts b/e2e/pages/adf/searchFiltersPage.ts index cc46338836..bb4ac547d7 100644 --- a/e2e/pages/adf/searchFiltersPage.ts +++ b/e2e/pages/adf/searchFiltersPage.ts @@ -37,6 +37,8 @@ export class SearchFiltersPage { 'mat-expansion-panel[data-automation-id="expansion-panel-My facet queries"]')); facetQueriesTypeGroup = element(by.css('mat-expansion-panel[data-automation-id="expansion-panel-Type facet queries"]')); facetQueriesSizeGroup = element(by.css('mat-expansion-panel[data-automation-id="expansion-panel-Size facet queries"]')); + facetIntervalsByCreated = element(by.css('mat-expansion-panel[data-automation-id="expansion-panel-TheCreated"]')); + facetIntervalsByModified = element(by.css('mat-expansion-panel[data-automation-id="expansion-panel-TheModified"]')); checkSearchFiltersIsDisplayed() { Util.waitUntilElementIsVisible(this.searchFilters); @@ -110,6 +112,16 @@ export class SearchFiltersPage { return this; } + checkFacetIntervalsByCreatedIsDisplayed() { + this.searchCategoriesPage.checkFilterIsDisplayed(this.facetIntervalsByCreated); + return this; + } + + checkFacetIntervalsByModifiedIsDisplayed() { + this.searchCategoriesPage.checkFilterIsDisplayed(this.facetIntervalsByModified); + return this; + } + isTypeFacetQueryGroupPresent() { return this.facetQueriesTypeGroup.isPresent(); } diff --git a/e2e/search/search-filters.e2e.ts b/e2e/search/search-filters.e2e.ts index 526317dd92..8756651c49 100644 --- a/e2e/search/search-filters.e2e.ts +++ b/e2e/search/search-filters.e2e.ts @@ -207,4 +207,12 @@ describe('Search Filters', () => { expect(searchFiltersPage.isTypeFacetQueryGroupPresent()).toBe(false); expect(searchFiltersPage.isSizeFacetQueryGroupPresent()).toBe(false); }); + + it('[C297509] Should display search intervals under specified labels from config', () => { + browser.get(TestConfig.adf.url + '/search;q=*'); + + searchFiltersPage.checkFacetIntervalsByCreatedIsDisplayed() + .checkFacetIntervalsByModifiedIsDisplayed(); + }); + }); 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 04a5510416..e33fc432df 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 @@ -202,8 +202,8 @@ describe('SearchFilterComponent', () => { queryBuilder.config = { categories: [], facetFields: { fields: [ - { label: 'f1', field: 'f1' }, - { label: 'f2', field: 'f2' } + { label: 'f1', field: 'f1', mincount: 0 }, + { label: 'f2', field: 'f2', mincount: 0 } ]}, facetQueries: { queries: [] @@ -593,4 +593,92 @@ describe('SearchFilterComponent', () => { expect(entry.checked).toBeFalsy(); } }); + + it('should fetch facet intervals from response payload', () => { + component.responseFacets = null; + queryBuilder.config = { + categories: [], + facetIntervals: { + intervals: [ + { label: 'test_intervals1', field: 'f1', sets: [ + { label: 'interval1', start: 's1', end: 'e1'}, + { label: 'interval2', start: 's2', end: 'e2'} + ]}, + { label: 'test_intervals2', field: 'f2', sets: [ + { label: 'interval3', start: 's3', end: 'e3'}, + { label: 'interval4', start: 's4', end: 'e4'} + ]} + ] + } + }; + + const response1 = [ + { label: 'interval1', filterQuery: 'query1', metrics: [{ value: { count: 1 }}]}, + { label: 'interval2', filterQuery: 'query2', metrics: [{ value: { count: 2 }}]} + ]; + const response2 = [ + { label: 'interval3', filterQuery: 'query3', metrics: [{ value: { count: 3 }}]}, + { label: 'interval4', filterQuery: 'query4', metrics: [{ value: { count: 4 }}]} + ]; + const data = { + list: { + context: { + facets: [ + { type: 'interval', label: 'test_intervals1', buckets: response1 }, + { type: 'interval', label: 'test_intervals2', buckets: response2 } + ] + } + } + }; + + component.onDataLoaded(data); + + expect(component.responseFacets.length).toBe(2); + expect(component.responseFacets[0].buckets.length).toEqual(2); + expect(component.responseFacets[1].buckets.length).toEqual(2); + }); + + it('should filter out the fetched facet intervals that have bucket values less than their set mincount', () => { + component.responseFacets = null; + queryBuilder.config = { + categories: [], + facetIntervals: { + intervals: [ + { label: 'test_intervals1', field: 'f1', mincount: 2, sets: [ + { label: 'interval1', start: 's1', end: 'e1'}, + { label: 'interval2', start: 's2', end: 'e2'} + ]}, + { label: 'test_intervals2', field: 'f2', mincount: 5, sets: [ + { label: 'interval3', start: 's3', end: 'e3'}, + { label: 'interval4', start: 's4', end: 'e4'} + ]} + ] + } + }; + + const response1 = [ + { label: 'interval1', filterQuery: 'query1', metrics: [{ value: { count: 1 }}]}, + { label: 'interval2', filterQuery: 'query2', metrics: [{ value: { count: 2 }}]} + ]; + const response2 = [ + { label: 'interval3', filterQuery: 'query3', metrics: [{ value: { count: 3 }}]}, + { label: 'interval4', filterQuery: 'query4', metrics: [{ value: { count: 4 }}]} + ]; + const data = { + list: { + context: { + facets: [ + { type: 'interval', label: 'test_intervals1', buckets: response1 }, + { type: 'interval', label: 'test_intervals2', buckets: response2 } + ] + } + } + }; + + component.onDataLoaded(data); + + expect(component.responseFacets.length).toBe(2); + expect(component.responseFacets[0].buckets.length).toEqual(1); + expect(component.responseFacets[1].buckets.length).toEqual(0); + }); }); 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 81ecf2cc89..b618447c8b 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 @@ -41,8 +41,9 @@ export class SearchFilterComponent implements OnInit, OnDestroy { private facetQueriesPageSize = this.DEFAULT_PAGE_SIZE; facetQueriesLabel: string = 'Facet Queries'; - facetQueriesExpanded = false; - facetFieldsExpanded = false; + facetExpanded = { + 'default': false + }; selectedBuckets: Array<{ field: FacetField, bucket: FacetFieldBucket }> = []; @@ -52,10 +53,13 @@ export class SearchFilterComponent implements OnInit, OnDestroy { if (queryBuilder.config && queryBuilder.config.facetQueries) { this.facetQueriesLabel = queryBuilder.config.facetQueries.label || 'Facet Queries'; this.facetQueriesPageSize = queryBuilder.config.facetQueries.pageSize || this.DEFAULT_PAGE_SIZE; - this.facetQueriesExpanded = queryBuilder.config.facetQueries.expanded; + this.facetExpanded['query'] = queryBuilder.config.facetQueries.expanded; } if (queryBuilder.config && queryBuilder.config.facetFields) { - this.facetFieldsExpanded = queryBuilder.config.facetFields.expanded; + this.facetExpanded['field'] = queryBuilder.config.facetFields.expanded; + } + if (queryBuilder.config && queryBuilder.config.facetIntervals) { + this.facetExpanded['interval'] = queryBuilder.config.facetIntervals.expanded; } this.queryBuilder.updated.pipe( @@ -146,7 +150,7 @@ export class SearchFilterComponent implements OnInit, OnDestroy { } shouldExpand(field: FacetField): boolean { - return field.type === 'query' ? this.facetQueriesExpanded : this.facetFieldsExpanded; + return this.facetExpanded[field.type] || this.facetExpanded['default']; } onDataLoaded(data: any) { @@ -162,8 +166,9 @@ 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); + this.responseFacets = responseFacetFields.concat(...responseGroupedFacetQueries, ...responseFacetIntervals); } else { this.responseFacets = this.responseFacets @@ -184,12 +189,11 @@ export class SearchFilterComponent implements OnInit, OnDestroy { } } - private parseFacetFields(context: ResultSetContext): FacetField[] { - const configFacetFields = this.queryBuilder.config.facetFields && this.queryBuilder.config.facetFields.fields || []; - + private parseFacetItems(context: ResultSetContext, configFacetFields, itemType): FacetField[] { return configFacetFields.map((field) => { - const responseField = (context.facets || []).find((response) => response.type === 'field' && response.label === field.label) || {}; - const responseBuckets = this.getResponseBuckets(responseField); + const responseField = (context.facets || []).find((response) => response.type === itemType && response.label === field.label) || {}; + const responseBuckets = this.getResponseBuckets(responseField) + .filter(this.getFilterByMinCount(field.mincount)); const bucketList = new SearchFilterList(responseBuckets, field.pageSize); bucketList.filter = (bucket: FacetFieldBucket): boolean => { @@ -212,6 +216,16 @@ export class SearchFilterComponent implements OnInit, OnDestroy { }); } + private parseFacetFields(context: ResultSetContext): FacetField[] { + const configFacetFields = this.queryBuilder.config.facetFields && this.queryBuilder.config.facetFields.fields || []; + return this.parseFacetItems(context, configFacetFields, 'field'); + } + + private parseFacetIntervals(context: ResultSetContext): FacetField[] { + const configFacetIntervals = this.queryBuilder.config.facetIntervals && this.queryBuilder.config.facetIntervals.intervals || []; + return this.parseFacetItems(context, configFacetIntervals, 'interval'); + } + private parseFacetQueries(context: ResultSetContext): FacetField[] { const configFacetQueries = this.queryBuilder.config.facetQueries && this.queryBuilder.config.facetQueries.queries || []; const configGroups = configFacetQueries.reduce((acc, query) => { @@ -225,10 +239,13 @@ export class SearchFilterComponent implements OnInit, OnDestroy { }, []); 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 responseBuckets = this.getResponseQueryBuckets(responseField, configGroups[group]); + const responseBuckets = this.getResponseQueryBuckets(responseField, configGroups[group]) + .filter(mincountFilter); const bucketList = new SearchFilterList(responseBuckets, this.facetQueriesPageSize); bucketList.filter = (bucket: FacetFieldBucket): boolean => { @@ -278,12 +295,6 @@ export class SearchFilterComponent implements OnInit, OnDestroy { display: respBucket.display, label: respBucket.label }; - }).filter((bucket) => { - let mincount = this.queryBuilder.config.facetQueries.mincount; - if (mincount === undefined) { - mincount = 1; - } - return bucket.count >= mincount; }); } @@ -291,4 +302,14 @@ export class SearchFilterComponent implements OnInit, OnDestroy { return (!!bucket && !!bucket.metrics && bucket.metrics[0] && bucket.metrics[0].value && bucket.metrics[0].value.count) || 0; } + + private getFilterByMinCount(mincountInput: number) { + return (bucket) => { + let mincount = mincountInput; + if (mincount === undefined) { + mincount = 1; + } + return bucket.count >= mincount; + }; + } } diff --git a/lib/content-services/search/search-configuration.interface.ts b/lib/content-services/search/search-configuration.interface.ts index 5853a3d1f4..598883db78 100644 --- a/lib/content-services/search/search-configuration.interface.ts +++ b/lib/content-services/search/search-configuration.interface.ts @@ -38,6 +38,10 @@ export interface SearchConfiguration { expanded?: boolean; fields: Array; }; + facetIntervals?: { + expanded?: boolean; + intervals: Array; + }; sorting?: { options: Array; defaults: Array; diff --git a/lib/content-services/search/search-query-builder.service.spec.ts b/lib/content-services/search/search-query-builder.service.spec.ts index cc4aec4633..79e2df7434 100644 --- a/lib/content-services/search/search-query-builder.service.spec.ts +++ b/lib/content-services/search/search-query-builder.service.spec.ts @@ -371,6 +371,39 @@ describe('SearchQueryBuilder', () => { expect(compiled.facetFields.facets).toEqual(jasmine.objectContaining(config.facetFields.fields)); }); + it('should build query with custom facet intervals', () => { + const config: SearchConfiguration = { + categories: [ + { id: 'cat1', enabled: true } + ], + facetIntervals: { + intervals: [ + { + label: 'test_intervals1', + field: 'f1', + sets: [ + { label: 'interval1', start: 's1', end: 'e1' }, + { label: 'interval2', start: 's2', end: 'e2' } + ] + }, + { + label: 'test_intervals2', + field: 'f2', + sets: [ + { label: 'interval3', start: 's3', end: 'e3' }, + { label: 'interval4', start: 's4', end: 'e4' } + ] + } + ] + } + }; + const builder = new SearchQueryBuilderService(buildConfig(config), null); + builder.queryFragments['cat1'] = 'cm:name:test'; + + const compiled = builder.buildQuery(); + expect(compiled.facetIntervals).toEqual(jasmine.objectContaining(config.facetIntervals)); + }); + it('should build query with sorting', () => { const config: SearchConfiguration = { fields: [], diff --git a/lib/content-services/search/search-query-builder.service.ts b/lib/content-services/search/search-query-builder.service.ts index 0c6294c4e0..c6037242c3 100644 --- a/lib/content-services/search/search-query-builder.service.ts +++ b/lib/content-services/search/search-query-builder.service.ts @@ -224,6 +224,7 @@ export class SearchQueryBuilderService { fields: this.config.fields, filterQueries: this.filterQueries, facetQueries: this.facetQueries, + facetIntervals: this.facetIntervals, facetFields: this.facetFields, sort: this.sort }; @@ -280,6 +281,20 @@ export class SearchQueryBuilderService { return false; } + /** + * Checks if FacetIntervals has been defined + * @returns True if defined, false otherwise + */ + get hasFacetIntervals(): boolean { + if (this.config + && this.config.facetIntervals + && this.config.facetIntervals.intervals + && this.config.facetIntervals.intervals.length > 0) { + return true; + } + return false; + } + protected get sort(): RequestSortDefinitionInner[] { return this.sorting.map((def) => { return new RequestSortDefinitionInner({ @@ -301,6 +316,22 @@ export class SearchQueryBuilderService { return null; } + protected get facetIntervals(): any { + if (this.hasFacetIntervals) { + const configIntervals = this.config.facetIntervals; + + return { + intervals: configIntervals.intervals.map((interval) => { + label: interval.label, + field: interval.field, + sets: interval.sets + }) + }; + } + + return null; + } + protected getFinalQuery(): string { let query = ''; diff --git a/lib/core/app-config/schema.json b/lib/core/app-config/schema.json index 3f101f4931..8eaf04ee1a 100644 --- a/lib/core/app-config/schema.json +++ b/lib/core/app-config/schema.json @@ -980,6 +980,81 @@ } } }, + "facetIntervals": { + "type": "object", + "required": [ + "intervals" + ], + "properties": { + "intervals": { + "description": "List of facet intervals", + "type": "array", + "items": { + "type": "object", + "required": [ + "label", + "field", + "sets" + ], + "properties": { + "label": { + "description": "This specifies the label to use to identify the field facet.", + "type": "string" + }, + "field": { + "description": "This specifies the field to facet on.", + "type": "string" + }, + "sets": { + "type": "array", + "items": { + "type": "object", + "required": [ + "label", + "start", + "end" + ], + "properties": { + "label": { + "description": "This specifies the label to use to identify the set.", + "type": "string" + }, + "start": { + "description": "This specifies the start of the range.", + "type": "string" + }, + "end": { + "description": "This specifies the end of the range.", + "type": "string" + }, + "startInclusive": { + "description": "When true, the set will include values greater or equal to 'start'. The default value is true.", + "type": "boolean" + }, + "endInclusive": { + "description": "When true, the set will include values less than or equal to 'end'. The default value is true.", + "type": "boolean" + } + } + } + }, + "pageSize": { + "type": "number", + "description": "Display page size" + }, + "mincount": { + "type": "number", + "description": "This specifies the minimum count required for a facet interval to be displayed. The default value is 1." + } + } + } + }, + "expanded": { + "description": "Toggles expanded state of the facet intervals", + "type": "boolean" + } + } + }, "facetQueries": { "type": "object", "required": [ @@ -1017,6 +1092,7 @@ "type": "string" }, "label": { + "description": "Unique identifier for the query", "type": "string" }, "group": {