search bug fixes and documentation updates (#3256)

* bug fixes for search

* test fixes

* bug fixes for search
This commit is contained in:
Denys Vuika
2018-05-03 10:28:20 +01:00
committed by Eugenio Romano
parent a9ab0af640
commit 856c4fd7f5
19 changed files with 423 additions and 78 deletions

View File

@@ -65,7 +65,9 @@
{ "field": "modifier", "mincount": 1, "label": "Modifier" },
{ "field": "created", "mincount": 1, "label": "Created" }
],
"facetQueries": [
"facetQueries": {
"label": "My facet queries",
"queries": [
{ "query": "created:2018", "label": "Created This Year" },
{ "query": "content.mimetype", "label": "Type" },
{ "query": "content.size:[0 TO 10240]", "label": "Size: xtra small"},
@@ -74,7 +76,8 @@
{ "query": "content.size:[1048576 TO 16777216]", "label": "Size: large" },
{ "query": "content.size:[16777216 TO 134217728]", "label": "Size: xtra large" },
{ "query": "content.size:[134217728 TO MAX]", "label": "Size: XX large" }
],
]
},
"categories": [
{
"id": "queryName",

View File

@@ -19,6 +19,12 @@ Represents a main container component for custom search and faceted search setti
The component is based on dynamically created widgets to modify the resulting query and options,
and the [Search Query Builder service](search-query-builder.service.md)\` to build and execute the search queries.
Before you begin with customizations, check also the following articles:
- [Search API](https://docs.alfresco.com/5.2/concepts/search-api.html)
- [Alfresco Full Text Search Reference](https://docs.alfresco.com/5.2/concepts/rm-searchsyntax-intro.html)
- [ACS API Explorer](https://api-explorer.alfresco.com/api-explorer/#!/search/search)
### Configuration
The configuration should be provided via the `search` entry in the `app.config.json` file.
@@ -121,6 +127,9 @@ The interface above also describes entries in the `search.query.categories` sect
![Search Categories](../docassets/images/search-categories-01.png)
Important note: you need at least one category field to be provided in order to execute the query,
so that filters and selected facets are applied.
### Settings
Every use case will have a different set of settings.
@@ -161,10 +170,16 @@ If there are more than 5 entries, the "Show more" button is displayed to allow d
### Facet Queries
Provides a custom category based on admin-defined facet queries.
```json
{
"search": {
"facetQueries": [
"facetQueries": {
"label": "Facet queries",
"pageSize": 5,
"expanded": true,
"queries": [
{ "query": "created:2018", "label": "Created This Year" },
{ "query": "content.mimetype", "label": "Type" },
{ "query": "content.size:[0 TO 10240]", "label": "Size: xtra small"},
@@ -176,9 +191,14 @@ If there are more than 5 entries, the "Show more" button is displayed to allow d
]
}
}
}
```
The queries declared in the `facetQueries` are collected into a single collapsible category.
Only the queries that have 1 or more response entries are displayed at runtime.
Based on the `pageSize` value, the component provides a `Show more` button to display more items.
You can also provide a custom `label` (or i18n resource key) for the resulting collapsible category.
![Facet Queries](../docassets/images/search-facet-queries.png)
@@ -350,6 +370,13 @@ Provides ability to select a numeric range based on `min` and `max` values in th
![Slider Widget](../docassets/images/search-slider.png)
### Resetting slider value
Slider widget comes with a `Clear` button that allows users to reset selected value to the initial state.
This helps to undo changes for scenarios where minimal value (like 0 or predefined number) still should not be used in a query.
Upon clicking the `Clear` button slider will be reset to the `min` value or `0`, and underlying fragment is removed from the resulting query.
### Text Widget
```json
@@ -377,6 +404,9 @@ Provides ability to select a numeric range based on `min` and `max` values in th
![Text Widget](../docassets/images/search-text.png)
Important note: you need at least one category field to be provided in order to execute the query,
so that filters and selected facets are applied.
## Custom Widgets
### Implementing custom widget

View File

@@ -174,7 +174,8 @@
"ACTIONS": {
"CLEAR": "Clear",
"APPLY": "Apply",
"CLEAR-ALL": "Clear all"
"CLEAR-ALL": "Clear all",
"SHOW-MORE": "Show more"
},
"RANGE": {
"FROM": "From",

View File

@@ -0,0 +1,58 @@
/*!
* @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 { ResponseFacetQuery } from '../../../facet-query.interface';
export class ResponseFacetQueryList {
items: ResponseFacetQuery[] = [];
pageSize: number = 5;
currentPageSize: number = 5;
get visibleItems(): ResponseFacetQuery[] {
return this.items.slice(0, this.currentPageSize);
}
get length(): number {
return this.items.length;
}
constructor(items: ResponseFacetQuery[] = [], pageSize: number = 5) {
this.items = items
.filter(item => {
return item.count > 0;
})
.map(item => {
return <ResponseFacetQuery> { ...item };
});
this.pageSize = pageSize;
this.currentPageSize = pageSize;
}
hasMoreItems(): boolean {
return this.items.length > this.currentPageSize;
}
showMoreItems() {
this.currentPageSize += this.pageSize;
}
clear() {
this.currentPageSize = this.pageSize;
this.items = [];
}
}

View File

@@ -17,20 +17,25 @@
</adf-search-widget-container>
</mat-expansion-panel>
<mat-expansion-panel>
<mat-expansion-panel [expanded]="facetQueriesExpanded">
<mat-expansion-panel-header>
<mat-panel-title>Facet Queries</mat-panel-title>
<mat-panel-title>{{ facetQueriesLabel | translate }}</mat-panel-title>
</mat-expansion-panel-header>
<div class="checklist">
<ng-container *ngFor="let query of responseFacetQueries">
<ng-container *ngFor="let query of responseFacetQueries.visibleItems">
<mat-checkbox
*ngIf="query.count > 0"
[checked]="query.$checked"
(change)="onFacetQueryToggle($event, query)">
{{ query.label }} ({{ query.count }})
</mat-checkbox>
</ng-container>
</div>
<button mat-button
*ngIf="responseFacetQueries.hasMoreItems()"
(click)="responseFacetQueries.showMoreItems()">
{{ 'SEARCH.FILTER.ACTIONS.SHOW-MORE' | translate }}
<mat-icon>keyboard_arrow_down</mat-icon>
</button>
</mat-expansion-panel>
<mat-expansion-panel
@@ -53,7 +58,7 @@
<button mat-button
*ngIf="field.hasMoreItems()"
(click)="field.showMoreItems()">
Show more
{{ 'SEARCH.FILTER.ACTIONS.SHOW-MORE' | translate }}
<mat-icon>keyboard_arrow_down</mat-icon>
</button>
</mat-expansion-panel>

View File

@@ -20,6 +20,7 @@ import { SearchQueryBuilderService } from '../../search-query-builder.service';
import { SearchConfiguration } from '../../search-configuration.interface';
import { AppConfigService } from '@alfresco/adf-core';
import { Subject } from 'rxjs/Subject';
import { ResponseFacetQueryList } from './models/response-facet-query-list.model';
describe('SearchSettingsComponent', () => {
@@ -119,9 +120,11 @@ describe('SearchSettingsComponent', () => {
it('should unselect facet query and update builder', () => {
const config: SearchConfiguration = {
categories: [],
facetQueries: [
facetQueries: {
queries: [
{ label: 'q1', query: 'query1' }
]
}
};
appConfig.config.search = config;
queryBuilder = new SearchQueryBuilderService(appConfig, null);
@@ -155,10 +158,10 @@ describe('SearchSettingsComponent', () => {
});
it('should fetch facet queries from response payload', () => {
component.responseFacetQueries = [];
component.responseFacetQueries = new ResponseFacetQueryList();
const queries = [
{ label: 'q1', query: 'query1' },
{ label: 'q2', query: 'query2' }
{ label: 'q1', query: 'query1', count: 1 },
{ label: 'q2', query: 'query2', count: 1 }
];
const data = {
list: {
@@ -171,11 +174,11 @@ describe('SearchSettingsComponent', () => {
component.onDataLoaded(data);
expect(component.responseFacetQueries.length).toBe(2);
expect(component.responseFacetQueries).toEqual(queries);
expect(component.responseFacetQueries.items).toEqual(queries);
});
it('should not fetch facet queries from response payload', () => {
component.responseFacetQueries = [];
component.responseFacetQueries = new ResponseFacetQueryList();
const data = {
list: {
@@ -192,11 +195,11 @@ describe('SearchSettingsComponent', () => {
it('should restore checked state for new response facet queries', () => {
component.selectedFacetQueries = ['q3'];
component.responseFacetQueries = [];
component.responseFacetQueries = new ResponseFacetQueryList();
const queries = [
{ label: 'q1', query: 'query1' },
{ label: 'q2', query: 'query2' }
{ label: 'q1', query: 'query1', count: 1 },
{ label: 'q2', query: 'query2', count: 1 }
];
const data = {
list: {
@@ -209,17 +212,17 @@ describe('SearchSettingsComponent', () => {
component.onDataLoaded(data);
expect(component.responseFacetQueries.length).toBe(2);
expect((<any> component.responseFacetQueries[0]).$checked).toBeFalsy();
expect((<any> component.responseFacetQueries[1]).$checked).toBeFalsy();
expect((<any> component.responseFacetQueries.items[0]).$checked).toBeFalsy();
expect((<any> component.responseFacetQueries.items[1]).$checked).toBeFalsy();
});
it('should not restore checked state for new response facet queries', () => {
component.selectedFacetQueries = ['q2'];
component.responseFacetQueries = [];
component.responseFacetQueries = new ResponseFacetQueryList();
const queries = [
{ label: 'q1', query: 'query1' },
{ label: 'q2', query: 'query2' }
{ label: 'q1', query: 'query1', count: 1 },
{ label: 'q2', query: 'query2', count: 1 }
];
const data = {
list: {
@@ -232,8 +235,8 @@ describe('SearchSettingsComponent', () => {
component.onDataLoaded(data);
expect(component.responseFacetQueries.length).toBe(2);
expect((<any> component.responseFacetQueries[0]).$checked).toBeFalsy();
expect((<any> component.responseFacetQueries[1]).$checked).toBeTruthy();
expect((<any> component.responseFacetQueries.items[0]).$checked).toBeFalsy();
expect((<any> component.responseFacetQueries.items[1]).$checked).toBeTruthy();
});
it('should fetch facet fields from response payload', () => {
@@ -309,7 +312,7 @@ describe('SearchSettingsComponent', () => {
});
it('should reset queries and fields on empty response payload', () => {
component.responseFacetQueries = [<any> {}, <any> {}];
component.responseFacetQueries = new ResponseFacetQueryList([<any> {}, <any> {}]);
component.responseFacetFields = [<any> {}, <any> {}];
const data = {

View File

@@ -19,11 +19,11 @@ import { Component, ViewEncapsulation, OnInit } from '@angular/core';
import { MatCheckboxChange } from '@angular/material';
import { SearchService } from '@alfresco/adf-core';
import { SearchQueryBuilderService } from '../../search-query-builder.service';
import { FacetQuery } from '../../facet-query.interface';
import { ResponseFacetField } from '../../response-facet-field.interface';
import { FacetFieldBucket } from '../../facet-field-bucket.interface';
import { SearchCategory } from '../../search-category.interface';
import { ResponseFacetQuery } from '../../response-facet-query.interface';
import { ResponseFacetQueryList } from './models/response-facet-query-list.model';
@Component({
selector: 'adf-search-filter',
@@ -36,10 +36,20 @@ export class SearchFilterComponent implements OnInit {
selectedFacetQueries: string[] = [];
selectedBuckets: FacetFieldBucket[] = [];
responseFacetQueries: FacetQuery[] = [];
responseFacetQueries: ResponseFacetQueryList;
responseFacetFields: ResponseFacetField[] = [];
facetQueriesLabel: string = 'Facet Queries';
facetQueriesPageSize = 5;
facetQueriesExpanded = false;
constructor(public queryBuilder: SearchQueryBuilderService, private search: SearchService) {
if (queryBuilder.config && queryBuilder.config.facetQueries) {
this.facetQueriesLabel = queryBuilder.config.facetQueries.label || 'Facet Queries';
this.facetQueriesPageSize = queryBuilder.config.facetQueries.pageSize || 5;
this.facetQueriesExpanded = queryBuilder.config.facetQueries.expanded;
}
this.queryBuilder.updated.subscribe(query => {
this.queryBuilder.execute();
});
@@ -138,11 +148,13 @@ export class SearchFilterComponent implements OnInit {
const context = data.list.context;
if (context) {
this.responseFacetQueries = (context.facetQueries || []).map(q => {
const facetQueries = (context.facetQueries || []).map(q => {
q.$checked = this.selectedFacetQueries.includes(q.label);
return q;
});
this.responseFacetQueries = new ResponseFacetQueryList(facetQueries, this.facetQueriesPageSize);
const expandedFields = this.responseFacetFields.filter(f => f.expanded).map(f => f.label);
this.responseFacetFields = (context.facetsFields || []).map(
@@ -180,7 +192,7 @@ export class SearchFilterComponent implements OnInit {
}
);
} else {
this.responseFacetQueries = [];
this.responseFacetQueries = new ResponseFacetQueryList([], this.facetQueriesPageSize);
this.responseFacetFields = [];
}
}

View File

@@ -1,8 +1,15 @@
<mat-slider
[value]="value"
[(value)]="value"
[disabled]="disabled"
[min]="min"
[max]="max"
[step]="step"
[thumbLabel]="thumbLabel"
(change)="onChangedHandler($event)">
</mat-slider>
<div>
<button mat-button color="primary" (click)="reset()">
{{ 'SEARCH.FILTER.ACTIONS.CLEAR' | translate }}
</button>
</div>

View File

@@ -70,4 +70,62 @@ describe('SearchSliderComponent', () => {
expect(context.update).toHaveBeenCalled();
});
it('should reset the value for query builder', () => {
const settings: any = {
field: 'field1',
min: 10,
max: 100,
step: 2,
thumbLabel: true
};
const context: any = {
queryFragments: {},
update() {}
};
component.settings = settings;
component.context = context;
component.value = 20;
component.id = 'slider';
component.ngOnInit();
spyOn(context, 'update').and.stub();
component.reset();
expect(component.value).toBe(settings.min);
expect(context.queryFragments['slider']).toBe('');
expect(context.update).toHaveBeenCalled();
});
it('should reset to 0 if min not provided', () => {
const settings: any = {
field: 'field1',
min: null,
max: 100,
step: 2,
thumbLabel: true
};
const context: any = {
queryFragments: {},
update() {}
};
component.settings = settings;
component.context = context;
component.value = 20;
component.id = 'slider';
component.ngOnInit();
spyOn(context, 'update').and.stub();
component.reset();
expect(component.value).toBe(0);
expect(context.queryFragments['slider']).toBe('');
expect(context.update).toHaveBeenCalled();
});
});

View File

@@ -15,7 +15,7 @@
* limitations under the License.
*/
import { Component, ViewEncapsulation, OnInit } from '@angular/core';
import { Component, ViewEncapsulation, OnInit, Input } from '@angular/core';
import { SearchWidget } from '../../search-widget.interface';
import { SearchWidgetSettings } from '../../search-widget-settings.interface';
import { SearchQueryBuilderService } from '../../search-query-builder.service';
@@ -37,7 +37,9 @@ export class SearchSliderComponent implements SearchWidget, OnInit {
min: number;
max: number;
thumbLabel = false;
value: number;
@Input()
value: number | null;
ngOnInit() {
if (this.settings) {
@@ -57,11 +59,23 @@ export class SearchSliderComponent implements SearchWidget, OnInit {
}
}
reset() {
this.value = this.min || 0;
this.updateQuery(null);
}
onChangedHandler(event: MatSliderChange) {
this.value = event.value;
this.updateQuery(this.value);
}
private updateQuery(value: number | null) {
if (this.id && this.context && this.settings && this.settings.field) {
this.context.queryFragments[this.id] = `${this.settings.field}:[0 TO ${this.value}]`;
if (value === null) {
this.context.queryFragments[this.id] = '';
} else {
this.context.queryFragments[this.id] = `${this.settings.field}:[0 TO ${value}]`;
}
this.context.update();
}
}

View File

@@ -2,6 +2,9 @@
<input
matInput
[placeholder]="settings?.placeholder"
[value]="value"
[(ngModel)]="value"
(change)="onChangedHandler($event)">
<button mat-button *ngIf="value" matSuffix mat-icon-button (click)="reset()">
<mat-icon>close</mat-icon>
</button>
</mat-form-field>

View File

@@ -0,0 +1,5 @@
.adf-search-text {
.mat-input-container {
width: 100%
}
}

View File

@@ -0,0 +1,88 @@
/*!
* @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 { SearchTextComponent } from './search-text.component';
describe('SearchTextComponent', () => {
let component: SearchTextComponent;
beforeEach(() => {
component = new SearchTextComponent();
component.id = 'text';
component.settings = {
'pattern': "cm:name:'(.*?)'",
'field': 'cm:name',
'placeholder': 'Enter the name'
};
component.context = <any> {
queryFragments: {},
update() {}
};
});
it('should parse value from the context at startup', () => {
component.context.queryFragments[component.id] = "cm:name:'secret.pdf'";
component.ngOnInit();
expect(component.value).toEqual('secret.pdf');
});
it('should not parse value when pattern not defined', () => {
component.settings.pattern = null;
component.context.queryFragments[component.id] = "cm:name:'secret.pdf'";
component.ngOnInit();
expect(component.value).toEqual('');
});
it('should update query builder on change', () => {
spyOn(component.context, 'update').and.stub();
component.onChangedHandler({
target: {
value: 'top-secret.doc'
}
});
expect(component.value).toBe('top-secret.doc');
expect(component.context.queryFragments[component.id]).toBe("cm:name:'top-secret.doc'");
expect(component.context.update).toHaveBeenCalled();
});
it('should reset query builder', () => {
component.onChangedHandler({
target: {
value: 'top-secret.doc'
}
});
expect(component.value).toBe('top-secret.doc');
expect(component.context.queryFragments[component.id]).toBe("cm:name:'top-secret.doc'");
component.onChangedHandler({
target: {
value: ''
}
});
expect(component.value).toBe('');
expect(component.context.queryFragments[component.id]).toBe('');
});
});

View File

@@ -23,6 +23,7 @@ import { SearchQueryBuilderService } from '../../search-query-builder.service';
@Component({
selector: 'adf-search-text',
templateUrl: './search-text.component.html',
styleUrls: ['./search-text.component.scss'],
encapsulation: ViewEncapsulation.None,
host: { class: 'adf-search-text' }
})
@@ -36,7 +37,7 @@ export class SearchTextComponent implements SearchWidget, OnInit {
context: SearchQueryBuilderService;
ngOnInit() {
if (this.context && this.settings) {
if (this.context && this.settings && this.settings.pattern) {
const pattern = new RegExp(this.settings.pattern, 'g');
const match = pattern.exec(this.context.queryFragments[this.id] || '');
@@ -46,10 +47,19 @@ export class SearchTextComponent implements SearchWidget, OnInit {
}
}
reset() {
this.value = '';
this.updateQuery(null);
}
onChangedHandler(event) {
this.value = event.target.value;
if (this.value) {
this.context.queryFragments[this.id] = `${this.settings.field}:'${this.value}'`;
this.updateQuery(this.value);
}
private updateQuery(value: string) {
if (this.context && this.settings && this.settings.field) {
this.context.queryFragments[this.id] = value ? `${this.settings.field}:'${value}'` : '';
this.context.update();
}
}

View File

@@ -19,3 +19,9 @@ export interface FacetQuery {
query: string;
label: string;
}
export interface ResponseFacetQuery {
label?: string;
filterQuery?: string;
count?: number;
}

View File

@@ -25,6 +25,11 @@ export interface SearchConfiguration {
fields?: Array<string>;
categories: Array<SearchCategory>;
filterQueries?: Array<FilterQuery>;
facetQueries?: Array<FacetQuery>;
facetQueries?: {
label?: string;
pageSize?: number;
expanded?: boolean;
queries: Array<FacetQuery>;
};
facetFields?: Array<FacetField>;
}

View File

@@ -120,10 +120,12 @@ describe('SearchQueryBuilder', () => {
it('should fetch facet query from config', () => {
const config: SearchConfiguration = {
categories: [],
facetQueries: [
facetQueries: {
queries: [
{ query: 'q1', label: 'query1' },
{ query: 'q2', label: 'query2' }
]
}
};
const builder = new SearchQueryBuilderService(buildConfig(config), null);
const query = builder.getFacetQuery('query2');
@@ -135,9 +137,11 @@ describe('SearchQueryBuilder', () => {
it('should not fetch empty facet query from the config', () => {
const config: SearchConfiguration = {
categories: [],
facetQueries: [
facetQueries: {
queries: [
{ query: 'q1', label: 'query1' }
]
}
};
const builder = new SearchQueryBuilderService(buildConfig(config), null);
@@ -273,15 +277,17 @@ describe('SearchQueryBuilder', () => {
categories: [
<any> { id: 'cat1', enabled: true }
],
facetQueries: [
facetQueries: {
queries: [
{ query: 'q1', label: 'q2' }
]
}
};
const builder = new SearchQueryBuilderService(buildConfig(config), null);
builder.queryFragments['cat1'] = 'cm:name:test';
const compiled = builder.buildQuery();
expect(compiled.facetQueries).toEqual(config.facetQueries);
expect(compiled.facetQueries).toEqual(config.facetQueries.queries);
});
it('should build query with custom facet fields', () => {

View File

@@ -69,7 +69,7 @@ export class SearchQueryBuilderService {
getFacetQuery(label: string): FacetQuery {
if (label) {
const queries = this.config.facetQueries || [];
const queries = this.config.facetQueries.queries || [];
return queries.find(q => q.label === label);
}
return null;
@@ -115,7 +115,7 @@ export class SearchQueryBuilderService {
paging: this.paging,
fields: this.config.fields,
filterQueries: this.filterQueries,
facetQueries: this.config.facetQueries,
facetQueries: this.facetQueries,
facetFields: this.facetFields
};
@@ -125,6 +125,18 @@ export class SearchQueryBuilderService {
return null;
}
private get facetQueries(): FacetQuery[] {
const config = this.config.facetQueries;
if (config && config.queries && config.queries.length > 0) {
return config.queries.map(query => {
return <FacetQuery> { ...query };
});
}
return null;
}
private get facetFields(): RequestFacetFields {
const facetFields = this.config.facetFields;

View File

@@ -539,6 +539,23 @@
}
},
"facetQueries": {
"type": "object",
"required": ["label", "queries"],
"properties": {
"label": {
"description": "Category label text",
"type": "string"
},
"pageSize": {
"description": "Default page size of the category",
"type": "number"
},
"expanded": {
"description": "Toggles expanded state of the category",
"type": "boolean"
},
"queries": {
"description": "List of custom facet queries",
"type": "array",
"items": {
"type": "object",
@@ -548,6 +565,8 @@
"label": { "type": "string" }
}
}
}
}
},
"categories": {
"type": "array",