[ADF-2128] facet container component (#3094)

* (wip) facet container

* shaping out the API

* code lint fixes

* radiobox facet example

* fields selector facet

* search limits support

* scope locations facet example

* move custom search to 'search.query' config

* use facet fields and queries from the config file

* use facet filters

* use facet buckets in query

* preserve expanded/checked states

* code cleanup and binding fixes

* fix apis after rebase

* extract query builder into separate class

* code improvements

* full chip list (merge facet fields with queries)

* placeholder for range requests

* move search infrastructure to ADF core

* cleanup code

* auto-search on init

* move search components to the content services

* selected facets chip list

* split into separate components at ADF level

* move the rest of the implementation to ADF

* facet builder fixes and tests

* translation support for category names

* docs placeholders

* update language level

* unit tests and packaging updates

* fix after rebase

* remove fdescribe

* some docs on search settings

* rename components as per review

* simplify chip list as per review

* turn query builder into service

* improve search service, integrate old search results

* fix node selector integration

* move service to the top module

* update tests

* remove fdescribe

* update tests

* test fixes

* test fixes

* test updates

* fix tests

* code and test fixes

* remove fit

* fix tests

* fix tests

* remove obsolete test

* increase bundle threshold

* update docs to reflect PR changes

* fix docs
This commit is contained in:
Denys Vuika 2018-03-29 11:34:09 +01:00 committed by Eugenio Romano
parent d6f51c22aa
commit ed48994e67
59 changed files with 2328 additions and 183 deletions

View File

@ -9,7 +9,6 @@
"**/.happypack": true
},
"editor.renderIndentGuides": true,
"tslint.configFile": "ng2-components/tslint.json",
"markdownlint.config": {
"MD032": false,
"MD004": false,

View File

@ -51,6 +51,118 @@
"label": "Simplified Chinese"
}
],
"search": {
"limits": {
"permissionEvaluationTime": null,
"permissionEvaluationCount": null
},
"filterQueries": [
{ "query": "TYPE:'cm:folder' OR TYPE:'cm:content'" },
{ "query": "NOT cm:creator:System" }
],
"facetFields": {
"facets": [
{ "field": "content.mimetype", "mincount": 1, "label": "Type" },
{ "field": "content.size", "mincount": 1, "label": "Size" },
{ "field": "creator", "mincount": 1, "label": "Creator" },
{ "field": "modifier", "mincount": 1, "label": "Modifier" }
]
},
"facetQueries": [
{ "query": "created:2018", "label": "Created This Year" },
{ "query": "content.mimetype", "label": "Type" },
{ "query": "content.size:[0 TO 10240]", "label": "Size: xtra small"},
{ "query": "content.size:[10240 TO 102400]", "label": "Size: small"},
{ "query": "content.size:[102400 TO 1048576]", "label": "Size: medium" },
{ "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" }
],
"query": {
"categories": [
{
"id": "broken",
"name": "Broken Facet",
"enabled": false,
"expanded": false,
"component": {
"selector": "adf-search-text",
"settings": {
"field": "fieldname"
}
}
},
{
"id": "queryName",
"name": "Name",
"enabled": true,
"expanded": true,
"component": {
"selector": "adf-search-text",
"settings": {
"pattern": "cm:name:'(.*?)'",
"field": "cm:name",
"placeholder": "Enter the name"
}
}
},
{
"id": "queryFields",
"name": "Fields",
"enabled": true,
"expanded": false,
"component": {
"selector": "adf-search-fields",
"settings": {
"field": null,
"options": [
{ "name": "Name", "value": "name", "fields": ["name"], "default": true },
{ "name": "File Size", "value": "content.sizeInBytes", "fields": ["content"], "default": true },
{ "name": "Modified On", "value": "modifiedAt", "fields": ["modifiedAt"], "default": true },
{ "name": "Modified By", "value": "modifiedByUser.displayName", "fields": ["modifiedByUser"], "default": true }
]
}
}
},
{
"id": "queryType",
"name": "Type",
"enabled": true,
"expanded": false,
"component": {
"selector": "adf-search-radio",
"settings": {
"field": null,
"options": [
{ "name": "None", "value": "", "default": true },
{ "name": "All", "value": "TYPE:'cm:folder' OR TYPE:'cm:content'" },
{ "name": "Folder", "value": "TYPE:'cm:folder'" },
{ "name": "Document", "value": "TYPE:'cm:content'" }
]
}
}
},
{
"id": "queryLocations",
"name": "Locations",
"enabled": true,
"expanded": false,
"component": {
"selector": "adf-search-scope-locations",
"settings": {
"field": null,
"options": [
{ "name": "Default", "value": "nodes", "default": true },
{ "name": "Nodes", "value": "nodes" },
{ "name": "Deleted Nodes", "value": "deleted-nodes" },
{ "name": "Versions", "value": "versions" }
]
}
}
}
]
}
},
"pagination": {
"size": 25,
"supportedPageSizes": [ 5, 10, 15, 20 ]

View File

@ -50,20 +50,19 @@ import { ProcessAttachmentsComponent } from './components/process-service/proces
import { SharedLinkViewComponent } from './components/shared-link-view/shared-link-view.component';
import { DemoPermissionComponent } from './components/permissions/demo-permissions.component';
@NgModule({
imports: [
BrowserModule,
BrowserAnimationsModule,
ReactiveFormsModule,
BrowserModule,
routing,
FormsModule,
AdfModule,
MaterialModule,
ThemePickerModule,
FlexLayoutModule,
ChartsModule,
HttpClientModule
HttpClientModule,
AdfModule
],
declarations: [
AppComponent,
@ -98,7 +97,8 @@ import { DemoPermissionComponent } from './components/permissions/demo-permissio
OverlayViewerComponent,
SharedLinkViewComponent,
FormLoadingComponent,
DemoPermissionComponent
DemoPermissionComponent,
FormLoadingComponent
],
providers: [
{ provide: AppConfigService, useClass: DebugAppConfigService },

View File

@ -1,3 +1,4 @@
<adf-search [searchTerm]="searchedWord"
[maxResults]="maxItems"
[skipResults]="skipCount"
@ -5,7 +6,15 @@
#search>
</adf-search>
<app-files-component
<div class="adf-search-results__facets">
<adf-search-chip-list [searchFilter]="searchFilter"></adf-search-chip-list>
</div>
<div class="adf-search-results">
<adf-search-filter #searchFilter></adf-search-filter>
<div class="adf-search-results__content">
<app-files-component
[currentFolderId]="null"
[nodeResult]="resultNodePageList"
[disableDragArea]="true"
@ -16,4 +25,6 @@
(loadNext)="onRefreshPagination($event)"
(turnedPreviousPage)="onRefreshPagination($event)"
(deleteElementSuccess)="onDeleteElementSuccess($event)">
</app-files-component>
</app-files-component>
</div>
</div>

View File

@ -1,3 +1,20 @@
.adf-search-results {
display: flex;
.adf-search-settings {
width: 260px;
border: 1px solid #eee;
}
&__facets {
margin: 5px;
}
&__content {
flex: 1;
}
}
div.search-results-container {
padding: 0 20px 20px 20px;
}

View File

@ -18,7 +18,7 @@
import { Component, OnInit, Optional, ViewChild } from '@angular/core';
import { Router, ActivatedRoute, Params } from '@angular/router';
import { NodePaging, Pagination } from 'alfresco-js-api';
import { SearchComponent } from '@alfresco/adf-content-services';
import { SearchComponent, SearchQueryBuilderService } from '@alfresco/adf-content-services';
import { UserPreferencesService } from '@alfresco/adf-core';
@Component({
@ -40,6 +40,7 @@ export class SearchResultComponent implements OnInit {
constructor(public router: Router,
private preferences: UserPreferencesService,
private queryBuilder: SearchQueryBuilderService,
@Optional() private route: ActivatedRoute) {
this.maxItems = this.preferences.paginationSize;
}
@ -48,6 +49,8 @@ export class SearchResultComponent implements OnInit {
if (this.route) {
this.route.params.forEach((params: Params) => {
this.searchedWord = params.hasOwnProperty(this.queryParamName) ? params[this.queryParamName] : null;
this.queryBuilder.queryFragments['queryName'] = `cm:name:'${this.searchedWord}'`;
this.queryBuilder.update();
});
}
this.maxItems = this.preferences.paginationSize;

View File

@ -0,0 +1,13 @@
---
Added: v2.3.0
Status: Active
---
# Search Chip List Component
```html
<adf-search-chip-list [searchFilter]="searchFilter"></adf-search-chip-list>
<adf-search-filter #searchFilter></adf-search-filter>
```
![Selected Facets](../docassets/images/selected-facets.png)

View File

@ -0,0 +1,154 @@
---
Added: v2.3.0
Status: Active
---
# Search Settings Component
Represents a main container component for custom search and faceted search settings.
## Usage example
```html
<adf-search-filter #settings></adf-search-filter>
```
The component is based on dynamically created Widgets to modify the resulting query and options,
and the `Query Builder` to build and execute the search queries.
## Query Builder Service
Stores information from all the custom search and faceted search widgets,
compiles and runs the final Search query.
The Query Builder is UI agnostic and does not rely on Angular components.
It is possible to reuse it with multiple component implementations.
Allows custom widgets to populate and edit the following parts of the resulting query:
- categories
- query fragments that form query expression
- include fields
- scope settings
- filter queries
- facet fields
- range queries
```ts
constructor(queryBuilder: QueryBuilderService) {
queryBuilder.updated.subscribe(query => {
this.queryBuilder.execute();
});
queryBuilder.executed.subscribe(data => {
this.onDataLoaded(data);
});
}
```
## Configuration
The configuration should be provided via the `search` entry in the `app.config.json` file.
Below is an example configuration:
```json
{
"search": {
"limits": {
"permissionEvaluationTime": null,
"permissionEvaluationCount": null
},
"filterQueries": [
{ "query": "TYPE:'cm:folder' OR TYPE:'cm:content'" },
{ "query": "NOT cm:creator:System" }
],
"facetFields": {
"facets": [
{ "field": "content.mimetype", "mincount": 1, "label": "Type" },
{ "field": "content.size", "mincount": 1, "label": "Size" },
{ "field": "creator", "mincount": 1, "label": "Creator" },
{ "field": "modifier", "mincount": 1, "label": "Modifier" }
]
},
"facetQueries": [
{ "query": "created:2018", "label": "Created This Year" },
{ "query": "content.mimetype", "label": "Type" },
{ "query": "content.size:[0 TO 10240]", "label": "Size: xtra small"},
{ "query": "content.size:[10240 TO 102400]", "label": "Size: small"},
{ "query": "content.size:[102400 TO 1048576]", "label": "Size: medium" },
{ "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" }
],
"query": {
"categories": [
{
"id": "queryName",
"name": "Name",
"enabled": true,
"expanded": true,
"component": {
"selector": "adf-search-text",
"settings": {
"pattern": "cm:name:'(.*?)'",
"field": "cm:name",
"placeholder": "Enter the name"
}
}
}
]
}
}
}
```
## Categories
The Search Settings component and Query Builder require `categories` section provided within the configuration.
Categories are needed to build Widgets so that users can modify the search query at runtime. Every Category can be represented by a single Angular component, either simple or composite one.
```ts
export interface SearchCategory {
id: string;
name: string;
enabled: boolean;
expanded: boolean;
component: {
selector: string;
settings: SearchWidgetSettings;
};
}
```
The interface above also describes entries in the `search.query.categories` section for the `app.config.json`.
![Search Categories](../docassets/images/search-categories-01.png)
### Properties
For the property types please refer to the `SearchCategory` interface.
| Property | Description |
| --- | --- |
| id | Unique identifier of the category. Also used to access QueryBuilder customisations for a particular widget. |
| name | Public display name for the category. |
| enabled | Toggles category availability. Set to `false` if you want to exclude a category from processing. |
| expanded | Toggles the expanded state of the category. Use it |
| component.selector | The id of the Angular component selector to render the Category |
| component.settings | An object containing component specific settings. Put any properties needed for the target component. |
Every component can expect different set of settings.
For example Number editors may parse minimum and maximum values, while Text editors can support value formats or length constraints.
You can use `component.settings` to pass any information to your custom Widget using the following interface:
```ts
export interface SearchWidgetSettings {
field: string;
[indexer: string]: any;
}
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

View File

@ -51,10 +51,8 @@ describe('ContentNodeSelectorComponent', () => {
const debounceSearch = 200;
let component: ContentNodeSelectorPanelComponent;
let fixture: ComponentFixture<ContentNodeSelectorPanelComponent>;
let searchService: SearchService;
let contentNodeSelectorService: ContentNodeSelectorService;
let searchSpy: jasmine.Spy;
let cnSearchSpy: jasmine.Spy;
let _observer: Observer<NodePaging>;
@ -104,10 +102,8 @@ describe('ContentNodeSelectorComponent', () => {
component = fixture.componentInstance;
component.debounceSearch = 0;
searchService = TestBed.get(SearchService);
contentNodeSelectorService = TestBed.get(ContentNodeSelectorService);
cnSearchSpy = spyOn(contentNodeSelectorService, 'search').and.callThrough();
searchSpy = spyOn(searchService, 'searchByQueryBody').and.callFake(() => {
searchSpy = spyOn(contentNodeSelectorService, 'search').and.callFake(() => {
return Observable.create((observer: Observer<NodePaging>) => {
_observer = observer;
});
@ -283,32 +279,6 @@ describe('ContentNodeSelectorComponent', () => {
describe('Search functionality', () => {
let getCorrespondingNodeIdsSpy;
function defaultSearchOptions(searchTerm, rootNodeId = undefined, skipCount = 0) {
const parentFiltering = rootNodeId ? [{ query: `ANCESTOR:'workspace://SpacesStore/${rootNodeId}'` }] : [];
let defaultSearchNode: any = {
query: {
query: searchTerm ? `${searchTerm}* OR name:${searchTerm}*` : searchTerm
},
include: ['path', 'allowableOperations'],
paging: {
maxItems: 25,
skipCount: skipCount
},
filterQueries: [
{ query: "TYPE:'cm:folder'" },
{ query: 'NOT cm:creator:System' },
...parentFiltering
],
scope: {
locations: ['nodes']
}
};
return defaultSearchNode;
}
beforeEach(() => {
const documentListService = TestBed.get(DocumentListService);
const expectedDefaultFolderNode = <MinimalNodeEntryEntity> { path: { elements: [] } };
@ -331,11 +301,11 @@ describe('ContentNodeSelectorComponent', () => {
fixture.detectChanges();
});
it('should load the results by calling the search api on search change', (done) => {
it('should load the results on search change', (done) => {
typeToSearchBox('kakarot');
setTimeout(() => {
expect(searchSpy).toHaveBeenCalledWith(defaultSearchOptions('kakarot'));
expect(searchSpy).toHaveBeenCalledWith('kakarot', undefined, 0, 25);
done();
}, 300);
});
@ -350,7 +320,7 @@ describe('ContentNodeSelectorComponent', () => {
}, 300);
});
it('should call the search api on changing the site selectbox\'s value', (done) => {
it('should search on changing the site selectbox value', (done) => {
typeToSearchBox('vegeta');
setTimeout(() => {
@ -360,50 +330,50 @@ describe('ContentNodeSelectorComponent', () => {
fixture.whenStable().then(() => {
expect(searchSpy.calls.count()).toBe(2, 'Search count should be two after the site change');
expect(searchSpy.calls.argsFor(1)).toEqual([defaultSearchOptions('vegeta', 'namek')] );
expect(searchSpy.calls.argsFor(1)).toEqual([ 'vegeta', 'namek', 0, 25] );
done();
});
}, 300);
});
it('should call the content node selector\'s search with the right parameters on changing the site selectbox\'s value', (done) => {
it('should call the content node selector search with the right parameters on changing the site selectbox value', (done) => {
typeToSearchBox('vegeta');
setTimeout(() => {
expect(cnSearchSpy.calls.count()).toBe(1);
expect(searchSpy.calls.count()).toBe(1);
component.siteChanged(<SiteEntry> { entry: { guid: '-sites-' } });
fixture.whenStable().then(() => {
expect(cnSearchSpy).toHaveBeenCalled();
expect(cnSearchSpy.calls.count()).toBe(2);
expect(cnSearchSpy).toHaveBeenCalledWith('vegeta', '-sites-', 0, 25);
expect(searchSpy).toHaveBeenCalled();
expect(searchSpy.calls.count()).toBe(2);
expect(searchSpy).toHaveBeenCalledWith('vegeta', '-sites-', 0, 25);
done();
});
}, 300);
});
it('should call the content node selector\'s search with the right parameters on changing the site selectbox\'s value from a custom dropdown menu', (done) => {
it('should call the content node selector search with the right parameters on changing the site selectbox value from a custom dropdown menu', (done) => {
component.dropdownSiteList = <SitePaging> {list: {entries: [<SiteEntry> { entry: { guid: '-sites-' } }, <SiteEntry> { entry: { guid: 'namek' } }]}};
fixture.detectChanges();
typeToSearchBox('vegeta');
setTimeout(() => {
expect(cnSearchSpy.calls.count()).toBe(1);
expect(searchSpy.calls.count()).toBe(1);
component.siteChanged(<SiteEntry> { entry: { guid: '-sites-' } });
fixture.whenStable().then(() => {
expect(cnSearchSpy).toHaveBeenCalled();
expect(cnSearchSpy.calls.count()).toBe(2);
expect(cnSearchSpy).toHaveBeenCalledWith('vegeta', '-sites-', 0, 25, ['123456testId', '09876543testId']);
expect(searchSpy).toHaveBeenCalled();
expect(searchSpy.calls.count()).toBe(2);
expect(searchSpy).toHaveBeenCalledWith('vegeta', '-sites-', 0, 25, ['123456testId', '09876543testId']);
done();
});
}, 300);
});
it('should get the corresponding node ids before the search call on changing the site selectbox\'s value from a custom dropdown menu', (done) => {
it('should get the corresponding node ids before the search call on changing the site selectbox value from a custom dropdown menu', (done) => {
component.dropdownSiteList = <SitePaging> {list: {entries: [<SiteEntry> { entry: { guid: '-sites-' } }, <SiteEntry> { entry: { guid: 'namek' } }]}};
fixture.detectChanges();
@ -531,7 +501,7 @@ describe('ContentNodeSelectorComponent', () => {
component.siteChanged(<SiteEntry> { entry: { guid: 'namek' } });
expect(searchSpy.calls.count()).toBe(2);
expect(searchSpy.calls.argsFor(1)).toEqual([defaultSearchOptions('piccolo', 'namek')]);
expect(searchSpy.calls.argsFor(1)).toEqual([ 'piccolo', 'namek', 0, 25 ]);
component.clear();
@ -682,14 +652,14 @@ describe('ContentNodeSelectorComponent', () => {
}, 300);
});
it('button\'s callback should load the next batch of results by calling the search api', async(() => {
it('button callback should load the next batch of results by calling the search api', async(() => {
const skipCount = 8;
component.searchTerm = 'kakarot';
component.getNextPageOfSearch({ skipCount });
fixture.whenStable().then(() => {
expect(searchSpy).toHaveBeenCalledWith(defaultSearchOptions('kakarot', undefined, skipCount));
expect(searchSpy).toHaveBeenCalledWith( 'kakarot', undefined, skipCount, 25);
});
}));
@ -703,7 +673,7 @@ describe('ContentNodeSelectorComponent', () => {
expect(pagination).not.toBeNull();
});
it('button\'s callback should load the next batch of folder results when there is no searchTerm', () => {
it('button callback should load the next batch of folder results when there is no searchTerm', () => {
const skipCount = 5;
component.searchTerm = '';

View File

@ -73,6 +73,8 @@ export class ContentNodeSelectorService {
}
};
return this.searchService.searchByQueryBody(defaultSearchNode);
return Observable.fromPromise(
this.searchService.searchByQueryBody(defaultSearchNode)
);
}
}

View File

@ -43,6 +43,7 @@ import { PropertyDescriptorsService } from './content-metadata/services/property
import { ContentMetadataConfigFactory } from './content-metadata/services/config/content-metadata-config.factory';
import { BasicPropertiesService } from './content-metadata/services/basic-properties.service';
import { PropertyGroupTranslatorService } from './content-metadata/services/property-groups-translator.service';
import { SearchQueryBuilderService } from './search/search-query-builder.service';
@NgModule({
imports: [
@ -81,7 +82,8 @@ import { PropertyGroupTranslatorService } from './content-metadata/services/prop
PropertyDescriptorsService,
ContentMetadataConfigFactory,
BasicPropertiesService,
PropertyGroupTranslatorService
PropertyGroupTranslatorService,
SearchQueryBuilderService
],
exports: [
CoreModule,

View File

@ -31,7 +31,8 @@ import {
MatRippleModule,
MatExpansionModule,
MatSelectModule,
MatSlideToggleModule
MatSlideToggleModule,
MatCheckboxModule
} from '@angular/material';
export function modules() {
@ -50,7 +51,8 @@ export function modules() {
MatOptionModule,
MatExpansionModule,
MatSelectModule,
MatSlideToggleModule
MatSlideToggleModule,
MatCheckboxModule
];
}

View File

@ -4,6 +4,7 @@
"src": "../content-services/",
"dest": "../dist/content-services/",
"lib": {
"languageLevel": [ "dom", "es2016" ],
"licensePath": "../config/assets/license_header_add.txt",
"comments" : "none",
"entryFile": "./public-api.ts",

View File

@ -0,0 +1,20 @@
<mat-chip-list>
<ng-container *ngIf="searchFilter && searchFilter.selectedFacetQueries">
<mat-chip
*ngFor="let label of searchFilter.selectedFacetQueries"
[removable]="true"
(remove)="searchFilter.unselectFacetQuery(label)">
{{ label }}
<mat-icon matChipRemove>cancel</mat-icon>
</mat-chip>
</ng-container>
<ng-container *ngIf="searchFilter && searchFilter.selectedBuckets">
<mat-chip
*ngFor="let bucket of searchFilter.selectedBuckets"
[removable]="true"
(remove)="searchFilter.unselectFacetBucket(bucket)">
{{ bucket.display || bucket.label }}
<mat-icon matChipRemove>cancel</mat-icon>
</mat-chip>
</ng-container>
</mat-chip-list>

View File

@ -0,0 +1,31 @@
/*!
* @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 { Component, ViewEncapsulation, Input } from '@angular/core';
import { SearchFilterComponent } from '../../components/search-filter/search-filter.component';
@Component({
selector: 'adf-search-chip-list',
templateUrl: './search-chip-list.component.html',
encapsulation: ViewEncapsulation.None,
host: { class: 'adf-search-chip-list' }
})
export class SearchChipListComponent {
@Input()
searchFilter: SearchFilterComponent;
}

View File

@ -20,7 +20,6 @@ import { async, discardPeriodicTasks, fakeAsync, ComponentFixture, TestBed, tick
import { By } from '@angular/platform-browser';
import { AuthenticationService, SearchService } from '@alfresco/adf-core';
import { ThumbnailService } from '@alfresco/adf-core';
import { Observable } from 'rxjs/Observable';
import { noResult, results } from '../../mock';
import { SearchControlComponent } from './search-control.component';
import { SearchTriggerDirective } from './search-trigger.directive';
@ -83,9 +82,9 @@ describe('SearchControlComponent', () => {
}));
it('should emit searchChange when search term input changed', async(() => {
spyOn(searchService, 'search').and.callFake(() => {
return Observable.of({ entry: { list: [] } });
});
spyOn(searchService, 'search').and.returnValue(
Promise.resolve({ entry: { list: [] } })
);
component.searchChange.subscribe(value => {
expect(value).toBe('customSearchTerm');
});
@ -97,7 +96,7 @@ describe('SearchControlComponent', () => {
it('should update FAYT search when user inputs a valid term', async(() => {
typeWordIntoSearchInput('customSearchTerm');
spyOn(component, 'isSearchBarActive').and.returnValue(true);
spyOn(searchService, 'search').and.returnValue(Observable.of(results));
spyOn(searchService, 'search').and.returnValue(Promise.resolve(results));
fixture.detectChanges();
fixture.whenStable().then(() => {
@ -111,7 +110,7 @@ describe('SearchControlComponent', () => {
it('should NOT update FAYT term when user inputs an empty string as search term ', async(() => {
typeWordIntoSearchInput('');
spyOn(component, 'isSearchBarActive').and.returnValue(true);
spyOn(searchService, 'search').and.returnValue(Observable.of(results));
spyOn(searchService, 'search').and.returnValue(Promise.resolve(results));
fixture.detectChanges();
fixture.whenStable().then(() => {
@ -179,7 +178,7 @@ describe('SearchControlComponent', () => {
});
spyOn(component, 'isSearchBarActive').and.returnValue(true);
spyOn(searchService, 'search').and.returnValue(Observable.of(results));
spyOn(searchService, 'search').and.returnValue(Promise.resolve(results));
fixture.detectChanges();
let inputDebugElement = debugElement.query(By.css('#adf-control-input'));
@ -199,7 +198,7 @@ describe('SearchControlComponent', () => {
it('should make autocomplete list control visible when search box has focus and there is a search result', (done) => {
spyOn(component, 'isSearchBarActive').and.returnValue(true);
spyOn(searchService, 'search').and.returnValue(Observable.of(results));
spyOn(searchService, 'search').and.returnValue(Promise.resolve(results));
fixture.detectChanges();
typeWordIntoSearchInput('TEST');
@ -214,7 +213,7 @@ describe('SearchControlComponent', () => {
it('should show autocomplete list noe results when search box has focus and there is search result with length 0', async(() => {
spyOn(component, 'isSearchBarActive').and.returnValue(true);
spyOn(searchService, 'search').and.returnValue(Observable.of(noResult));
spyOn(searchService, 'search').and.returnValue(Promise.resolve(noResult));
fixture.detectChanges();
typeWordIntoSearchInput('NO RES');
@ -228,7 +227,7 @@ describe('SearchControlComponent', () => {
it('should hide autocomplete list results when the search box loses focus', (done) => {
spyOn(component, 'isSearchBarActive').and.returnValue(true);
spyOn(searchService, 'search').and.returnValue(Observable.of(results));
spyOn(searchService, 'search').and.returnValue(Promise.resolve(results));
fixture.detectChanges();
let inputDebugElement = debugElement.query(By.css('#adf-control-input'));
@ -249,7 +248,7 @@ describe('SearchControlComponent', () => {
it('should keep autocomplete list control visible when user tabs into results', async(() => {
spyOn(component, 'isSearchBarActive').and.returnValue(true);
spyOn(searchService, 'search').and.returnValue(Observable.of(results));
spyOn(searchService, 'search').and.returnValue(Promise.resolve(results));
fixture.detectChanges();
let inputDebugElement = debugElement.query(By.css('#adf-control-input'));
@ -269,7 +268,7 @@ describe('SearchControlComponent', () => {
it('should close the autocomplete when user press ESCAPE', (done) => {
spyOn(component, 'isSearchBarActive').and.returnValue(true);
spyOn(searchService, 'search').and.returnValue(Observable.of(results));
spyOn(searchService, 'search').and.returnValue(Promise.resolve(results));
fixture.detectChanges();
let inputDebugElement = debugElement.query(By.css('#adf-control-input'));
@ -293,7 +292,7 @@ describe('SearchControlComponent', () => {
it('should close the autocomplete when user press ENTER on input', (done) => {
spyOn(component, 'isSearchBarActive').and.returnValue(true);
spyOn(searchService, 'search').and.returnValue(Observable.of(results));
spyOn(searchService, 'search').and.returnValue(Promise.resolve(results));
fixture.detectChanges();
let inputDebugElement = debugElement.query(By.css('#adf-control-input'));
@ -317,7 +316,7 @@ describe('SearchControlComponent', () => {
it('should focus input element when autocomplete list is cancelled', async(() => {
spyOn(component, 'isSearchBarActive').and.returnValue(true);
spyOn(searchService, 'search').and.returnValue(Observable.of(results));
spyOn(searchService, 'search').and.returnValue(Promise.resolve(results));
fixture.detectChanges();
let inputDebugElement = debugElement.query(By.css('#adf-control-input'));
@ -333,7 +332,7 @@ describe('SearchControlComponent', () => {
}));
it('should NOT display a autocomplete list control when configured not to', async(() => {
spyOn(searchService, 'search').and.returnValue(Observable.of(results));
spyOn(searchService, 'search').and.returnValue(Promise.resolve(results));
component.liveSearchEnabled = false;
fixture.detectChanges();
@ -345,7 +344,7 @@ describe('SearchControlComponent', () => {
}));
it('should select the first item on autocomplete list when ARROW DOWN is pressed on input', async(() => {
spyOn(searchService, 'search').and.returnValue(Observable.of(results));
spyOn(searchService, 'search').and.returnValue(Promise.resolve(results));
fixture.detectChanges();
typeWordIntoSearchInput('TEST');
let inputDebugElement = debugElement.query(By.css('#adf-control-input'));
@ -361,7 +360,7 @@ describe('SearchControlComponent', () => {
}));
it('should select the second item on autocomplete list when ARROW DOWN is pressed on list', async(() => {
spyOn(searchService, 'search').and.returnValue(Observable.of(results));
spyOn(searchService, 'search').and.returnValue(Promise.resolve(results));
fixture.detectChanges();
let inputDebugElement = debugElement.query(By.css('#adf-control-input'));
typeWordIntoSearchInput('TEST');
@ -382,7 +381,7 @@ describe('SearchControlComponent', () => {
}));
it('should focus the input search when ARROW UP is pressed on the first list item', (done) => {
spyOn(searchService, 'search').and.returnValue(Observable.of(results));
spyOn(searchService, 'search').and.returnValue(Promise.resolve(results));
fixture.detectChanges();
let inputDebugElement = debugElement.query(By.css('#adf-control-input'));
typeWordIntoSearchInput('TEST');
@ -494,7 +493,7 @@ describe('SearchControlComponent', () => {
it('should emit a option clicked event when item is clicked', async(() => {
spyOn(component, 'isSearchBarActive').and.returnValue(true);
spyOn(searchService, 'search').and.returnValue(Observable.of(results));
spyOn(searchService, 'search').and.returnValue(Promise.resolve(results));
component.optionClicked.subscribe((item) => {
expect(item.entry.id).toBe('123');
});
@ -510,7 +509,7 @@ describe('SearchControlComponent', () => {
it('should set deactivate the search after element is clicked', (done) => {
spyOn(component, 'isSearchBarActive').and.returnValue(true);
spyOn(searchService, 'search').and.returnValue(Observable.of(results));
spyOn(searchService, 'search').and.returnValue(Promise.resolve(results));
component.optionClicked.subscribe((item) => {
window.setTimeout(() => {
expect(component.subscriptAnimationState).toBe('inactive');
@ -530,7 +529,7 @@ describe('SearchControlComponent', () => {
it('should NOT reset the search term after element is clicked', async(() => {
spyOn(component, 'isSearchBarActive').and.returnValue(true);
spyOn(searchService, 'search').and.returnValue(Observable.of(results));
spyOn(searchService, 'search').and.returnValue(Promise.resolve(results));
component.optionClicked.subscribe((item) => {
expect(component.searchTerm).not.toBeFalsy();
expect(component.searchTerm).toBe('TEST');
@ -585,7 +584,7 @@ describe('SearchControlComponent - No result custom', () => {
it('should display the custom no results when it is configured', async(() => {
const noResultCustomMessage = 'BANDI IS NOTHING';
componentCustom.setCustomMessageForNoResult(noResultCustomMessage);
spyOn(searchServiceCustom, 'search').and.returnValue(Observable.of(noResult));
spyOn(searchServiceCustom, 'search').and.returnValue(Promise.resolve(noResult));
fixtureCustom.detectChanges();
let inputDebugElement = fixtureCustom.debugElement.query(By.css('#adf-control-input'));

View File

@ -0,0 +1,6 @@
<mat-checkbox
*ngFor="let option of settings.options"
[checked]="option.checked"
(change)="changeHandler($event, option)">
{{ option.name }}
</mat-checkbox>

View File

@ -0,0 +1,8 @@
.adf-search-fields {
display: flex;
flex-direction: column;
.mat-checkbox {
margin: 5px;
}
}

View File

@ -0,0 +1,70 @@
/*!
* @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 { Component, ViewEncapsulation, OnInit, Input } from '@angular/core';
import { MatCheckboxChange } from '@angular/material';
import { SearchWidget } from '../../search-widget.interface';
import { SearchWidgetSettings } from '../../search-widget-settings.interface';
import { SearchQueryBuilderService } from '../../search-query-builder.service';
@Component({
selector: 'adf-search-fields',
templateUrl: './search-fields.component.html',
styleUrls: ['./search-fields.component.scss'],
encapsulation: ViewEncapsulation.None,
host: { class: 'adf-search-fields' }
})
export class SearchFieldsComponent implements SearchWidget, OnInit {
@Input()
value: string;
id: string;
settings: SearchWidgetSettings;
context: SearchQueryBuilderService;
ngOnInit() {
const defaultOptions = (this.settings.options || [])
.filter(opt => opt.default)
.map(opt => {
opt.checked = true;
return opt;
});
if (defaultOptions.length > 0) {
this.flush(defaultOptions);
}
}
changeHandler(event: MatCheckboxChange, option: any) {
option.checked = event.checked;
this.flush(this.settings.options);
}
flush(opts: any[] = []) {
const checkedValues = opts
.filter(v => v.checked)
.map(v => v.fields)
.reduce((prev, curr) => {
return prev.concat(curr);
}, []);
this.context.fields[this.id] = checkedValues;
this.context.update();
}
}

View File

@ -0,0 +1,54 @@
<mat-accordion multi="true" displayMode="flat">
<mat-expansion-panel
*ngFor="let category of queryBuilder.categories"
[expanded]="category.expanded"
(opened)="onCategoryExpanded(category)"
(closed)="onCategoryCollapsed(category)">
<mat-expansion-panel-header>
<mat-panel-title>
{{ category.name | translate }}
</mat-panel-title>
</mat-expansion-panel-header>
<adf-search-widget-container
[id]="category.id"
[selector]="category.component.selector"
[settings]="category.component.settings">
</adf-search-widget-container>
</mat-expansion-panel>
<mat-expansion-panel>
<mat-expansion-panel-header>
<mat-panel-title>Facet Queries</mat-panel-title>
</mat-expansion-panel-header>
<div class="checklist">
<ng-container *ngFor="let query of responseFacetQueries">
<mat-checkbox
*ngIf="query.count > 0"
[checked]="query.$checked"
(change)="onFacetQueryToggle($event, query)">
{{ query.label }} ({{ query.count }})
</mat-checkbox>
</ng-container>
</div>
</mat-expansion-panel>
<mat-expansion-panel
*ngFor="let field of responseFacetFields"
[expanded]="field.$expanded"
(opened)="onFacetFieldExpanded(field)"
(closed)="onFacetFieldCollapsed(field)">
<mat-expansion-panel-header>
<mat-panel-title>{{ field.label }}</mat-panel-title>
</mat-expansion-panel-header>
<div class="checklist">
<mat-checkbox
*ngFor="let bucket of field.buckets"
[checked]="bucket.$checked"
(change)="onFacetToggle($event, field, bucket)">
{{ bucket.display || bucket.label }} ({{ bucket.count }})
</mat-checkbox>
</div>
</mat-expansion-panel>
</mat-accordion>

View File

@ -0,0 +1,8 @@
.checklist {
display: flex;
flex-direction: column;
.mat-checkbox {
margin: 5px;
}
}

View File

@ -0,0 +1,337 @@
/*!
* @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 { 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 { Subject } from 'rxjs/Subject';
describe('SearchSettingsComponent', () => {
let component: SearchFilterComponent;
let queryBuilder: SearchQueryBuilderService;
let appConfig: AppConfigService;
beforeEach(() => {
appConfig = new AppConfigService(null);
appConfig.config.search = {};
queryBuilder = new SearchQueryBuilderService(appConfig, null);
const searchMock: any = {
dataLoaded: new Subject()
};
component = new SearchFilterComponent(queryBuilder, searchMock);
component.ngOnInit();
});
it('should subscribe to query builder executed event', () => {
spyOn(component, 'onDataLoaded').and.stub();
const data = {};
queryBuilder.executed.next(data);
expect(component.onDataLoaded).toHaveBeenCalledWith(data);
});
it('should update category model on expand', () => {
const category: any = { expanded: false };
component.onCategoryExpanded(category);
expect(category.expanded).toBeTruthy();
});
it('should update category model on collapse', () => {
const category: any = { expanded: true };
component.onCategoryCollapsed(category);
expect(category.expanded).toBeFalsy();
});
it('should update facet field model on expand', () => {
const field: any = { $expanded: false };
component.onFacetFieldExpanded(field);
expect(field.$expanded).toBeTruthy();
});
it('should update facet field model on collapse', () => {
const field: any = { $expanded: true };
component.onFacetFieldCollapsed(field);
expect(field.$expanded).toBeFalsy();
});
it('should update bucket model and query builder on facet toggle', () => {
spyOn(queryBuilder, 'update').and.stub();
const event: any = { checked: true };
const field: any = {};
const bucket: any = { $checked: false, filterQuery: 'q1' };
component.onFacetToggle(event, field, bucket);
expect(component.selectedBuckets.length).toBe(1);
expect(component.selectedBuckets[0]).toEqual(bucket);
expect(queryBuilder.filterQueries.length).toBe(1);
expect(queryBuilder.filterQueries[0].query).toBe('q1');
expect(queryBuilder.update).toHaveBeenCalled();
});
it('should update bucket model and query builder on facet un-toggle', () => {
spyOn(queryBuilder, 'update').and.stub();
const event: any = { checked: false };
const field: any = { label: 'f1' };
const bucket: any = { $checked: true, filterQuery: 'q1', $field: 'f1', label: 'b1' };
component.selectedBuckets.push(bucket);
queryBuilder.addFilterQuery(bucket.filterQuery);
component.onFacetToggle(event, field, bucket);
expect(bucket.$checked).toBeFalsy();
expect(component.selectedBuckets.length).toBe(0);
expect(queryBuilder.filterQueries.length).toBe(0);
expect(queryBuilder.update).toHaveBeenCalled();
});
it('should unselect facet query and update builder', () => {
const config: SearchConfiguration = {
facetQueries: [
{ label: 'q1', query: 'query1' }
]
};
appConfig.config.search = config;
queryBuilder = new SearchQueryBuilderService(appConfig, null);
component = new SearchFilterComponent(queryBuilder, null);
spyOn(queryBuilder, 'update').and.stub();
queryBuilder.filterQueries = [{ query: 'query1' }];
component.selectedFacetQueries = ['q1'];
component.unselectFacetQuery('q1');
expect(component.selectedFacetQueries.length).toBe(0);
expect(queryBuilder.filterQueries.length).toBe(0);
expect(queryBuilder.update).toHaveBeenCalled();
});
it('should unselect facet bucket and update builder', () => {
spyOn(queryBuilder, 'update').and.stub();
const bucket: any = { $checked: true, filterQuery: 'q1', $field: 'f1', label: 'b1' };
component.selectedBuckets.push(bucket);
queryBuilder.filterQueries.push({ query: 'q1' });
component.unselectFacetBucket(bucket);
expect(component.selectedBuckets.length).toBe(0);
expect(queryBuilder.filterQueries.length).toBe(0);
expect(queryBuilder.update).toHaveBeenCalled();
});
it('should fetch facet queries from response payload', () => {
component.responseFacetQueries = [];
const queries = [
{ label: 'q1', query: 'query1' },
{ label: 'q2', query: 'query2' }
];
const data = {
list: {
context: {
facetQueries: queries
}
}
};
component.onDataLoaded(data);
expect(component.responseFacetQueries.length).toBe(2);
expect(component.responseFacetQueries).toEqual(queries);
});
it('should not fetch facet queries from response payload', () => {
component.responseFacetQueries = [];
const data = {
list: {
context: {
facetQueries: null
}
}
};
component.onDataLoaded(data);
expect(component.responseFacetQueries.length).toBe(0);
});
it('should restore checked state for new response facet queries', () => {
component.selectedFacetQueries = ['q3'];
component.responseFacetQueries = [];
const queries = [
{ label: 'q1', query: 'query1' },
{ label: 'q2', query: 'query2' }
];
const data = {
list: {
context: {
facetQueries: queries
}
}
};
component.onDataLoaded(data);
expect(component.responseFacetQueries.length).toBe(2);
expect((<any> component.responseFacetQueries[0]).$checked).toBeFalsy();
expect((<any> component.responseFacetQueries[1]).$checked).toBeFalsy();
});
it('should not restore checked state for new response facet queries', () => {
component.selectedFacetQueries = ['q2'];
component.responseFacetQueries = [];
const queries = [
{ label: 'q1', query: 'query1' },
{ label: 'q2', query: 'query2' }
];
const data = {
list: {
context: {
facetQueries: queries
}
}
};
component.onDataLoaded(data);
expect(component.responseFacetQueries.length).toBe(2);
expect((<any> component.responseFacetQueries[0]).$checked).toBeFalsy();
expect((<any> component.responseFacetQueries[1]).$checked).toBeTruthy();
});
it('should fetch facet fields from response payload', () => {
component.responseFacetFields = [];
const fields = [
{ label: 'f1', buckets: [] },
{ label: 'f2', buckets: [] }
];
const data = {
list: {
context: {
facetsFields: fields
}
}
};
component.onDataLoaded(data);
expect(component.responseFacetFields).toEqual(fields);
});
it('should restore expanded state for new response facet fields', () => {
component.responseFacetFields = [
{ label: 'f1', buckets: [] },
{ label: 'f2', buckets: [], $expanded: true }
];
const fields = [
{ label: 'f1', buckets: [] },
{ label: 'f2', buckets: [] }
];
const data = {
list: {
context: {
facetsFields: fields
}
}
};
component.onDataLoaded(data);
expect(component.responseFacetFields.length).toBe(2);
expect(component.responseFacetFields[0].$expanded).toBeFalsy();
expect(component.responseFacetFields[1].$expanded).toBeTruthy();
});
it('should restore checked buckets for new response facet fields', () => {
const bucket1 = { label: 'b1', $field: 'f1', count: 1, filterQuery: 'q1' };
const bucket2 = { label: 'b2', $field: 'f2', count: 1, filterQuery: 'q2' };
component.selectedBuckets = [ bucket2 ];
component.responseFacetFields = [
{ label: 'f2', buckets: [] }
];
const data = {
list: {
context: {
facetsFields: [
{ label: 'f1', buckets: [ bucket1 ] },
{ label: 'f2', buckets: [ bucket2 ] }
]
}
}
};
component.onDataLoaded(data);
expect(component.responseFacetFields.length).toBe(2);
expect(component.responseFacetFields[0].buckets[0].$checked).toBeFalsy();
expect(component.responseFacetFields[1].buckets[0].$checked).toBeTruthy();
});
it('should reset queries and fields on empty response payload', () => {
component.responseFacetQueries = [<any> {}, <any> {}];
component.responseFacetFields = [<any> {}, <any> {}];
const data = {
list: {
context: {
facetQueries: null,
facetsFields: null
}
}
};
component.onDataLoaded(data);
expect(component.responseFacetQueries.length).toBe(0);
expect(component.responseFacetFields.length).toBe(0);
});
it('should update query builder only when has bucket to unselect', () => {
spyOn(queryBuilder, 'update').and.stub();
component.unselectFacetBucket(null);
expect(queryBuilder.update).not.toHaveBeenCalled();
});
});

View File

@ -0,0 +1,172 @@
/*!
* @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 { 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';
@Component({
selector: 'adf-search-filter',
templateUrl: './search-filter.component.html',
styleUrls: ['./search-filter.component.scss'],
encapsulation: ViewEncapsulation.None,
host: { class: 'adf-search-filter' }
})
export class SearchFilterComponent implements OnInit {
selectedFacetQueries: string[] = [];
selectedBuckets: FacetFieldBucket[] = [];
responseFacetQueries: FacetQuery[] = [];
responseFacetFields: ResponseFacetField[] = [];
constructor(private queryBuilder: SearchQueryBuilderService, private search: SearchService) {
this.queryBuilder.updated.subscribe(query => {
this.queryBuilder.execute();
});
}
ngOnInit() {
if (this.queryBuilder) {
this.queryBuilder.executed.subscribe(data => {
this.onDataLoaded(data);
this.search.dataLoaded.next(data);
});
}
}
onCategoryExpanded(category: SearchCategory) {
category.expanded = true;
}
onCategoryCollapsed(category: SearchCategory) {
category.expanded = false;
}
onFacetFieldExpanded(field: ResponseFacetField) {
field.$expanded = true;
}
onFacetFieldCollapsed(field: ResponseFacetField) {
field.$expanded = false;
}
onFacetQueryToggle(event: MatCheckboxChange, query: ResponseFacetQuery) {
const facetQuery = this.queryBuilder.getFacetQuery(query.label);
if (event.checked) {
query.$checked = true;
this.selectedFacetQueries.push(facetQuery.label);
if (facetQuery) {
this.queryBuilder.addFilterQuery(facetQuery.query);
}
} else {
query.$checked = false;
this.selectedFacetQueries = this.selectedFacetQueries.filter(q => q !== query.label);
if (facetQuery) {
this.queryBuilder.removeFilterQuery(facetQuery.query);
}
}
this.queryBuilder.update();
}
onFacetToggle(event: MatCheckboxChange, field: ResponseFacetField, bucket: FacetFieldBucket) {
if (event.checked) {
bucket.$checked = true;
this.selectedBuckets.push({ ...bucket });
this.queryBuilder.addFilterQuery(bucket.filterQuery);
} else {
bucket.$checked = false;
const idx = this.selectedBuckets.findIndex(
b => b.$field === bucket.$field && b.label === bucket.label
);
if (idx >= 0) {
this.selectedBuckets.splice(idx, 1);
}
this.queryBuilder.removeFilterQuery(bucket.filterQuery);
}
this.queryBuilder.update();
}
unselectFacetQuery(label: string) {
const facetQuery = this.queryBuilder.getFacetQuery(label);
this.selectedFacetQueries = this.selectedFacetQueries.filter(q => q !== label);
this.queryBuilder.removeFilterQuery(facetQuery.query);
this.queryBuilder.update();
}
unselectFacetBucket(bucket: FacetFieldBucket) {
if (bucket) {
const idx = this.selectedBuckets.findIndex(
b => b.$field === bucket.$field && b.label === bucket.label
);
if (idx >= 0) {
this.selectedBuckets.splice(idx, 1);
}
this.queryBuilder.removeFilterQuery(bucket.filterQuery);
this.queryBuilder.update();
}
}
onDataLoaded(data: any) {
const context = data.list.context;
if (context) {
this.responseFacetQueries = (context.facetQueries || []).map(q => {
q.$checked = this.selectedFacetQueries.includes(q.label);
return q;
});
const expandedFields = this.responseFacetFields.filter(f => f.$expanded).map(f => f.label);
this.responseFacetFields = (context.facetsFields || []).map(
(field: ResponseFacetField) => {
field.$expanded = expandedFields.includes(field.label);
(field.buckets || []).forEach(bucket => {
bucket.$field = field.label;
bucket.$checked = false;
const previousBucket = this.selectedBuckets.find(
b => b.$field === bucket.$field && b.label === bucket.label
);
if (previousBucket) {
bucket.$checked = true;
}
});
return field;
}
);
} else {
this.responseFacetQueries = [];
this.responseFacetFields = [];
}
}
}

View File

@ -0,0 +1,6 @@
<mat-radio-group [(ngModel)]="value" (change)="changeHandler($event)">
<mat-radio-button
*ngFor="let option of settings.options" [value]="option.value">
{{ option.name }}
</mat-radio-button>
</mat-radio-group>

View File

@ -0,0 +1,10 @@
.adf-search-radio {
.mat-radio-group {
display: inline-flex;
flex-direction: column;
}
.mat-radio-button {
margin: 5px;
}
}

View File

@ -0,0 +1,68 @@
/*!
* @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 { Component, ViewEncapsulation, OnInit, Input } from '@angular/core';
import { MatRadioChange } from '@angular/material';
import { SearchWidget } from '../../search-widget.interface';
import { SearchWidgetSettings } from '../../search-widget-settings.interface';
import { SearchQueryBuilderService } from '../../search-query-builder.service';
@Component({
selector: 'adf-search-radio',
templateUrl: './search-radio.component.html',
styleUrls: ['./search-radio.component.scss'],
encapsulation: ViewEncapsulation.None,
host: { class: 'adf-search-radio' }
})
export class SearchRadioComponent implements SearchWidget, OnInit {
@Input()
value: string;
id: string;
settings: SearchWidgetSettings;
context: SearchQueryBuilderService;
ngOnInit() {
this.setValue(
this.getSelectedValue()
);
}
private getSelectedValue(): string {
const options: any[] = this.settings['options'] || [];
if (options && options.length > 0) {
let selected = options.find(opt => opt.default);
if (!selected) {
selected = options[0];
}
return selected.value;
}
return null;
}
private setValue(newValue: string) {
this.value = newValue;
this.context.queryFragments[this.id] = newValue;
this.context.update();
}
changeHandler(event: MatRadioChange) {
this.setValue(event.value);
}
}

View File

@ -0,0 +1,11 @@
<mat-form-field>
<mat-select
[(value)]="value"
(selectionChange)="changeHandler($event)">
<mat-option
*ngFor="let option of settings.options"
[value]="option.value">
{{option.name}}
</mat-option>
</mat-select>
</mat-form-field>

View File

@ -0,0 +1,57 @@
/*!
* @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 { Component, ViewEncapsulation, OnInit, Input } from '@angular/core';
import { MatSelectChange } from '@angular/material';
import { SearchWidget } from '../../search-widget.interface';
import { SearchWidgetSettings } from '../../search-widget-settings.interface';
import { SearchQueryBuilderService } from '../../search-query-builder.service';
@Component({
selector: 'adf-search-scope-locations',
templateUrl: './search-scope-locations.component.html',
encapsulation: ViewEncapsulation.None,
host: { class: 'adf-search-scope-locations' }
})
export class SearchScopeLocationsComponent implements SearchWidget, OnInit {
@Input()
value: string;
id: string;
settings: SearchWidgetSettings;
context: SearchQueryBuilderService;
ngOnInit() {
const defaultSelection = (this.settings.options || []).find(opt => opt.default);
if (defaultSelection) {
this.flush(defaultSelection.value);
}
}
changeHandler(event: MatSelectChange) {
this.flush(event.value);
}
flush(value: string) {
this.value = value;
this.context.scope.locations = value;
this.context.update();
}
}

View File

@ -0,0 +1,7 @@
<mat-form-field>
<input
matInput
[placeholder]="settings?.placeholder"
[value]="value"
(change)="onChangedHandler($event)">
</mat-form-field>

View File

@ -0,0 +1,57 @@
/*!
* @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 { 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';
@Component({
selector: 'adf-search-text',
templateUrl: './search-text.component.html',
encapsulation: ViewEncapsulation.None,
host: { class: 'adf-search-text' }
})
export class SearchTextComponent implements SearchWidget, OnInit {
@Input()
value = '';
id: string;
settings: SearchWidgetSettings;
context: SearchQueryBuilderService;
ngOnInit() {
if (this.context && this.settings) {
const pattern = new RegExp(this.settings.pattern, 'g');
const match = pattern.exec(this.context.queryFragments[this.id] || '');
if (match && match.length > 1) {
this.value = match[1];
}
}
}
onChangedHandler(event) {
this.value = event.target.value;
if (this.value) {
this.context.queryFragments[this.id] = `${this.settings.field}:'${this.value}'`;
this.context.update();
}
}
}

View File

@ -0,0 +1,74 @@
/*!
* @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 { Component, Input, ViewChild, ViewContainerRef, OnInit, OnDestroy, Compiler, ModuleWithComponentFactories, ComponentRef } from '@angular/core';
import { SearchWidgetsModule } from './search-widgets.module';
import { SearchQueryBuilderService } from '../../search-query-builder.service';
@Component({
selector: 'adf-search-widget-container',
template: '<div #content></div>'
})
export class SearchWidgetContainerComponent implements OnInit, OnDestroy {
@ViewChild('content', { read: ViewContainerRef })
content: ViewContainerRef;
@Input()
id: string;
@Input()
selector: string;
@Input()
settings: any;
@Input()
config: any;
private module: ModuleWithComponentFactories<SearchWidgetsModule>;
private componentRef: ComponentRef<any>;
constructor(compiler: Compiler, private queryBuilder: SearchQueryBuilderService) {
this.module = compiler.compileModuleAndAllComponentsSync(SearchWidgetsModule);
}
ngOnInit() {
const factory = this.module.componentFactories.find(f => f.selector === this.selector);
if (factory) {
this.content.clear();
this.componentRef = this.content.createComponent(factory, 0);
this.setupWidget(this.componentRef);
}
}
private setupWidget(ref: ComponentRef<any>) {
if (ref && ref.instance) {
ref.instance.id = this.id;
ref.instance.settings = { ...this.settings };
ref.instance.context = this.queryBuilder;
}
}
ngOnDestroy() {
if (this.componentRef) {
this.componentRef.destroy();
this.componentRef = null;
}
}
}

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 { NgModule } from '@angular/core';
import { MatButtonModule, MatInputModule, MatRadioModule, MatCheckboxModule, MatSelectModule } from '@angular/material';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { CommonModule } from '@angular/common';
import { SearchTextComponent } from '../search-text/search-text.component';
import { SearchRadioComponent } from '../search-radio/search-radio.component';
import { SearchFieldsComponent } from '../search-fields/search-fields.component';
import { SearchScopeLocationsComponent } from '../search-scope-locations/search-scope-locations.component';
@NgModule({
imports: [
CommonModule,
FormsModule,
ReactiveFormsModule,
MatButtonModule,
MatInputModule,
MatRadioModule,
MatCheckboxModule,
MatSelectModule
],
declarations: [
SearchTextComponent,
SearchRadioComponent,
SearchFieldsComponent,
SearchScopeLocationsComponent
],
exports: [
SearchTextComponent,
SearchRadioComponent,
SearchFieldsComponent,
SearchScopeLocationsComponent
],
entryComponents: [
SearchTextComponent,
SearchRadioComponent,
SearchFieldsComponent,
SearchScopeLocationsComponent
]
})
export class SearchWidgetsModule {
}

View File

@ -18,19 +18,18 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { SearchService } from '@alfresco/adf-core';
import { QueryBody } from 'alfresco-js-api';
import { Observable } from 'rxjs/Observable';
import { SearchModule } from '../../index';
import { differentResult, folderResult, result, SimpleSearchTestComponent } from '../../mock';
function fakeNodeResultSearch(searchNode: QueryBody): Observable<any> {
function fakeNodeResultSearch(searchNode: QueryBody): Promise<any> {
if (searchNode && searchNode.query.query === 'FAKE_SEARCH_EXMPL') {
return Observable.of(differentResult);
return Promise.resolve(differentResult);
}
if (searchNode && searchNode.filterQueries.length === 1 &&
searchNode.filterQueries[0].query === "TYPE:'cm:folder'") {
return Observable.of(folderResult);
return Promise.resolve(folderResult);
}
return Observable.of(result);
return Promise.resolve(result);
}
describe('SearchComponent', () => {
@ -60,8 +59,10 @@ describe('SearchComponent', () => {
});
it('should clear results straight away when a new search term is entered', (done) => {
spyOn(searchService, 'search')
.and.returnValues(Observable.of(result), Observable.of(differentResult));
spyOn(searchService, 'search').and.returnValues(
Promise.resolve(result),
Promise.resolve(differentResult)
);
component.setSearchWordTo('searchTerm');
fixture.detectChanges();
@ -82,7 +83,7 @@ describe('SearchComponent', () => {
it('should display the returned search results', (done) => {
spyOn(searchService, 'search')
.and.returnValue(Observable.of(result));
.and.returnValue(Promise.resolve(result));
component.setSearchWordTo('searchTerm');
fixture.detectChanges();
@ -96,7 +97,7 @@ describe('SearchComponent', () => {
it('should emit error event when search call fail', (done) => {
spyOn(searchService, 'search')
.and.returnValue(Observable.fromPromise(Promise.reject({ status: 402 })));
.and.returnValue(Promise.reject({ status: 402 }));
component.setSearchWordTo('searchTerm');
fixture.detectChanges();
fixture.whenStable().then(() => {
@ -108,8 +109,10 @@ describe('SearchComponent', () => {
});
it('should be able to hide the result panel', (done) => {
spyOn(searchService, 'search')
.and.returnValues(Observable.of(result), Observable.of(differentResult));
spyOn(searchService, 'search').and.returnValues(
Promise.resolve(result),
Promise.resolve(differentResult)
);
component.setSearchWordTo('searchTerm');
fixture.detectChanges();
@ -160,8 +163,7 @@ describe('SearchComponent', () => {
});
it('should perform a search with a defaultNode if no searchnode is given', (done) => {
spyOn(searchService, 'search')
.and.returnValue(Observable.of(result));
spyOn(searchService, 'search').and.returnValue(Promise.resolve(result));
component.setSearchWordTo('searchTerm');
fixture.detectChanges();
fixture.whenStable().then(() => {

View File

@ -115,6 +115,10 @@ export class SearchComponent implements AfterContentInit, OnChanges {
this.loadSearchResults(searchedWord);
});
searchService.dataLoaded.subscribe(
data => this.onSearchDataLoaded(data),
error => this.onSearchDataError(error)
);
}
ngAfterContentInit() {
@ -153,29 +157,36 @@ export class SearchComponent implements AfterContentInit, OnChanges {
private loadSearchResults(searchTerm?: string) {
this.resetResults();
if (searchTerm) {
let search$;
if (this.queryBody) {
search$ = this.searchService.searchByQueryBody(this.queryBody);
this.searchService.searchByQueryBody(this.queryBody).then(
result => this.onSearchDataLoaded(result),
err => this.onSearchDataError(err)
);
} else {
search$ = this.searchService
.search(searchTerm, this.maxResults, this.skipResults);
this.searchService.search(searchTerm, this.maxResults, this.skipResults).then(
result => this.onSearchDataLoaded(result),
err => this.onSearchDataError(err)
);
}
search$.subscribe(
results => {
this.results = <NodePaging> results;
} else {
this.cleanResults();
}
}
onSearchDataLoaded(data: NodePaging) {
if (data) {
this.results = data;
this.resultLoaded.emit(this.results);
this.isOpen = true;
this.setVisibility();
},
error => {
if (error.status !== 400) {
}
}
onSearchDataError(error) {
if (error && error.status !== 400) {
this.results = null;
this.error.emit(error);
}
});
} else {
this.cleanResults();
}
}
hidePanel() {

View File

@ -0,0 +1,26 @@
/*!
* @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.
*/
export interface FacetFieldBucket {
count: number;
display?: string;
label: string;
filterQuery: string;
$checked?: boolean;
$field?: string;
}

View File

@ -0,0 +1,24 @@
/*!
* @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.
*/
export interface FacetField {
field: string;
label: string;
mincount?: number;
$checked?: boolean;
}

View File

@ -0,0 +1,21 @@
/*!
* @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.
*/
export interface FacetQuery {
query: string;
label: string;
}

View File

@ -0,0 +1,20 @@
/*!
* @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.
*/
export interface FilterQuery {
query: string;
}

View File

@ -15,7 +15,22 @@
* limitations under the License.
*/
export { FacetFieldBucket } from './facet-field-bucket.interface';
export { FacetField } from './facet-field.interface';
export { FacetQuery } from './facet-query.interface';
export { FilterQuery } from './filter-query.interface';
export { ResponseFacetField } from './response-facet-field.interface';
export { ResponseFacetQuery } from './response-facet-query.interface';
export { SearchCategory } from './search-category.interface';
export { SearchWidgetSettings } from './search-widget-settings.interface';
export { SearchWidget } from './search-widget.interface';
export { SearchConfiguration } from './search-configuration.interface';
export { SearchQueryBuilderService } from './search-query-builder.service';
export { SearchRange } from './search-range.interface';
export * from './components/search.component';
export * from './components/search-control.component';
export * from './components/search-trigger.directive';
export * from './components/empty-search-result.component';
export * from './components/search-filter/search-filter.component';
export * from './components/search-chip-list/search-chip-list.component';

View File

@ -0,0 +1,25 @@
/*!
* @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 { FacetFieldBucket } from './facet-field-bucket.interface';
export interface ResponseFacetField {
label: string;
buckets: Array<FacetFieldBucket>;
$expanded?: boolean;
}

View File

@ -0,0 +1,23 @@
/*!
* @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.
*/
export interface ResponseFacetQuery {
label: string;
mincount: number;
$checked?: boolean;
}

View File

@ -0,0 +1,29 @@
/*!
* @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 { SearchWidgetSettings } from './search-widget-settings.interface';
export interface SearchCategory {
id: string;
name: string;
enabled: boolean;
expanded: boolean;
component: {
selector: string;
settings: SearchWidgetSettings;
};
}

View File

@ -0,0 +1,36 @@
/*!
* @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 { FilterQuery } from './filter-query.interface';
import { FacetQuery } from './facet-query.interface';
import { FacetField } from './facet-field.interface';
import { SearchCategory } from './search-category.interface';
export interface SearchConfiguration {
query?: {
categories: Array<SearchCategory>
};
limits?: {
permissionEvaluationTime?: number;
permissionEvaluationCount?: number;
};
filterQueries?: Array<FilterQuery>;
facetQueries?: Array<FacetQuery>;
facetFields?: {
facets: Array<FacetField>
};
}

View File

@ -0,0 +1,369 @@
/*!
* @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 { SearchQueryBuilderService } from './search-query-builder.service';
import { SearchConfiguration } from './search-configuration.interface';
import { AppConfigService } from '@alfresco/adf-core';
describe('SearchQueryBuilder', () => {
const buildConfig = (searchSettings): AppConfigService => {
const config = new AppConfigService(null);
config.config.search = searchSettings;
return config;
};
it('should throw error if configuration not provided', () => {
expect(() => {
const appConfig = new AppConfigService(null);
// tslint:disable-next-line:no-unused-expression
new SearchQueryBuilderService(appConfig, null);
}).toThrowError('Search configuration not found.');
});
it('should use only enabled categories', () => {
const config: SearchConfiguration = {
query: {
categories: [
<any> { id: 'cat1', enabled: true },
<any> { id: 'cat2', enabled: false },
<any> { id: 'cat3', enabled: true }
]
}
};
const builder = new SearchQueryBuilderService(buildConfig(config), null);
expect(builder.categories.length).toBe(2);
expect(builder.categories[0].id).toBe('cat1');
expect(builder.categories[1].id).toBe('cat3');
});
it('should fetch filter queries from config', () => {
const config: SearchConfiguration = {
query: {
categories: []
},
filterQueries: [
{ query: 'query1' },
{ query: 'query2' }
]
};
const builder = new SearchQueryBuilderService(buildConfig(config), null);
expect(builder.filterQueries.length).toBe(2);
expect(builder.filterQueries[0].query).toBe('query1');
expect(builder.filterQueries[1].query).toBe('query2');
});
it('should setup default location scope', () => {
const builder = new SearchQueryBuilderService(buildConfig({}), null);
expect(builder.scope).toBeDefined();
expect(builder.scope.locations).toBeNull();
});
it('should add new filter query', () => {
const builder = new SearchQueryBuilderService(buildConfig({}), null);
builder.addFilterQuery('q1');
expect(builder.filterQueries.length).toBe(1);
expect(builder.filterQueries[0].query).toBe('q1');
});
it('should not add empty filter query', () => {
const builder = new SearchQueryBuilderService(buildConfig({}), null);
builder.addFilterQuery(null);
builder.addFilterQuery('');
expect(builder.filterQueries.length).toBe(0);
});
it('should not add duplicate filter query', () => {
const builder = new SearchQueryBuilderService(buildConfig({}), null);
builder.addFilterQuery('q1');
builder.addFilterQuery('q1');
builder.addFilterQuery('q1');
expect(builder.filterQueries.length).toBe(1);
expect(builder.filterQueries[0].query).toBe('q1');
});
it('should remove filter query', () => {
const builder = new SearchQueryBuilderService(buildConfig({}), null);
builder.addFilterQuery('q1');
builder.addFilterQuery('q2');
expect(builder.filterQueries.length).toBe(2);
builder.removeFilterQuery('q1');
expect(builder.filterQueries.length).toBe(1);
expect(builder.filterQueries[0].query).toBe('q2');
});
it('should not remove empty query', () => {
const builder = new SearchQueryBuilderService(buildConfig({}), null);
builder.addFilterQuery('q1');
builder.addFilterQuery('q2');
expect(builder.filterQueries.length).toBe(2);
builder.removeFilterQuery(null);
builder.removeFilterQuery('');
expect(builder.filterQueries.length).toBe(2);
});
it('should fetch facet query from config', () => {
const config: SearchConfiguration = {
facetQueries: [
{ query: 'q1', label: 'query1' },
{ query: 'q2', label: 'query2' }
]
};
const builder = new SearchQueryBuilderService(buildConfig(config), null);
const query = builder.getFacetQuery('query2');
expect(query.query).toBe('q2');
expect(query.label).toBe('query2');
});
it('should not fetch empty facet query from the config', () => {
const config: SearchConfiguration = {
facetQueries: [
{ query: 'q1', label: 'query1' }
]
};
const builder = new SearchQueryBuilderService(buildConfig(config), null);
const query1 = builder.getFacetQuery('');
expect(query1).toBeNull();
const query2 = builder.getFacetQuery(null);
expect(query2).toBeNull();
});
it('should build query and raise an event on update', async () => {
const builder = new SearchQueryBuilderService(buildConfig({}), null);
const query = {};
spyOn(builder, 'buildQuery').and.returnValue(query);
let eventArgs;
builder.updated.subscribe(args => eventArgs = args);
await builder.execute();
expect(eventArgs).toBe(query);
});
it('should build query and raise an event on execute', async () => {
const data = {};
const api = jasmine.createSpyObj('api', ['search']);
api.search.and.returnValue(data);
const builder = new SearchQueryBuilderService(buildConfig({}), api);
spyOn(builder, 'buildQuery').and.returnValue({});
let eventArgs;
builder.executed.subscribe(args => eventArgs = args);
await builder.execute();
expect(eventArgs).toBe(data);
});
it('should require a query fragment to build query', () => {
const config: SearchConfiguration = {
query: {
categories: [
<any> { id: 'cat1', enabled: true }
]
}
};
const builder = new SearchQueryBuilderService(buildConfig(config), null);
builder.queryFragments['cat1'] = null;
const compiled = builder.buildQuery();
expect(compiled).toBeNull();
});
it('should build query with single fragment', () => {
const config: SearchConfiguration = {
query: {
categories: [
<any> { id: 'cat1', enabled: true }
]
}
};
const builder = new SearchQueryBuilderService(buildConfig(config), null);
builder.queryFragments['cat1'] = 'cm:name:test';
const compiled = builder.buildQuery();
expect(compiled.query.query).toBe('(cm:name:test)');
});
it('should build query with multiple fragments', () => {
const config: SearchConfiguration = {
query: {
categories: [
<any> { id: 'cat1', enabled: true },
<any> { id: 'cat2', enabled: true }
]
}
};
const builder = new SearchQueryBuilderService(buildConfig(config), null);
builder.queryFragments['cat1'] = 'cm:name:test';
builder.queryFragments['cat2'] = 'NOT cm:creator:System';
const compiled = builder.buildQuery();
expect(compiled.query.query).toBe(
'(cm:name:test) AND (NOT cm:creator:System)'
);
});
it('should build query with custom fields', () => {
const config: SearchConfiguration = {
query: {
categories: [
<any> { id: 'cat1', enabled: true },
<any> { id: 'cat2', enabled: true }
]
}
};
const builder = new SearchQueryBuilderService(buildConfig(config), null);
builder.queryFragments['cat1'] = 'cm:name:test';
builder.fields['cat1'] = ['field1', 'field3'];
builder.fields['cat2'] = ['field2', 'field3'];
const compiled = builder.buildQuery();
expect(compiled.fields).toEqual(['field1', 'field3', 'field2']);
});
it('should build query with empty custom fields', () => {
const config: SearchConfiguration = {
query: {
categories: [
<any> { id: 'cat1', enabled: true },
<any> { id: 'cat2', enabled: true }
]
}
};
const builder = new SearchQueryBuilderService(buildConfig(config), null);
builder.queryFragments['cat1'] = 'cm:name:test';
builder.fields['cat1'] = [];
builder.fields['cat2'] = null;
const compiled = builder.buildQuery();
expect(compiled.fields).toEqual([]);
});
it('should build query with custom filter queries', () => {
const config: SearchConfiguration = {
query: {
categories: [
<any> { id: 'cat1', enabled: true }
]
}
};
const builder = new SearchQueryBuilderService(buildConfig(config), null);
builder.queryFragments['cat1'] = 'cm:name:test';
builder.addFilterQuery('query1');
const compiled = builder.buildQuery();
expect(compiled.filterQueries).toEqual(
[{ query: 'query1' }]
);
});
it('should build query with custom facet queries', () => {
const config: SearchConfiguration = {
query: {
categories: [
<any> { id: 'cat1', enabled: true }
]
},
facetQueries: [
{ 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);
});
it('should build query with custom facet fields', () => {
const config: SearchConfiguration = {
query: {
categories: [
<any> { id: 'cat1', enabled: true }
]
},
facetFields: {
facets: [
{ field: 'field1', label: 'field1' },
{ field: 'field2', label: 'field2' }
]
}
};
const builder = new SearchQueryBuilderService(buildConfig(config), null);
builder.queryFragments['cat1'] = 'cm:name:test';
const compiled = builder.buildQuery();
expect(compiled.facetFields).toEqual(config.facetFields);
});
it('should build query with custom limits', () => {
const config: SearchConfiguration = {
query: {
categories: [
<any> { id: 'cat1', enabled: true }
]
},
limits: {
permissionEvaluationCount: 100,
permissionEvaluationTime: 100
}
};
const builder = new SearchQueryBuilderService(buildConfig(config), null);
builder.queryFragments['cat1'] = 'cm:name:test';
const compiled = builder.buildQuery();
expect(compiled.limits).toEqual(config.limits);
});
it('should build query with custom scope', () => {
const config: SearchConfiguration = {
query: {
categories: [
<any> { id: 'cat1', enabled: true }
]
}
};
const builder = new SearchQueryBuilderService(buildConfig(config), null);
builder.queryFragments['cat1'] = 'cm:name:test';
builder.scope.locations = 'custom';
const compiled = builder.buildQuery();
expect(compiled.scope.locations).toEqual('custom');
});
});

View File

@ -0,0 +1,143 @@
/*!
* @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 { Injectable } from '@angular/core';
import { Subject } from 'rxjs/Subject';
import { AlfrescoApiService, AppConfigService } from '@alfresco/adf-core';
import { QueryBody } from 'alfresco-js-api';
import { SearchCategory } from './search-category.interface';
import { FilterQuery } from './filter-query.interface';
import { SearchRange } from './search-range.interface';
import { SearchConfiguration } from './search-configuration.interface';
import { FacetQuery } from './facet-query.interface';
@Injectable()
export class SearchQueryBuilderService {
updated: Subject<QueryBody> = new Subject();
executed: Subject<any> = new Subject();
categories: Array<SearchCategory> = [];
queryFragments: { [id: string]: string } = {};
fields: { [id: string]: string[] } = {};
scope: { locations?: string };
filterQueries: FilterQuery[] = [];
ranges: { [id: string]: SearchRange } = {};
config: SearchConfiguration;
constructor(appConfig: AppConfigService, private api: AlfrescoApiService) {
this.config = appConfig.get<SearchConfiguration>('search');
if (!this.config) {
throw new Error('Search configuration not found.');
}
if (this.config.query && this.config.query.categories) {
this.categories = this.config.query.categories.filter(f => f.enabled);
}
this.filterQueries = this.config.filterQueries || [];
this.scope = {
locations: null
};
}
addFilterQuery(query: string): void {
if (query) {
const existing = this.filterQueries.find(q => q.query === query);
if (!existing) {
this.filterQueries.push({ query: query });
}
}
}
removeFilterQuery(query: string): void {
if (query) {
this.filterQueries = this.filterQueries.filter(f => f.query !== query);
}
}
getFacetQuery(label: string): FacetQuery {
if (label) {
const queries = this.config.facetQueries || [];
return queries.find(q => q.label === label);
}
return null;
}
update(): void {
const query = this.buildQuery();
this.updated.next(query);
}
async execute() {
const query = this.buildQuery();
const data = await this.api.searchApi.search(query);
this.executed.next(data);
}
buildQuery(): QueryBody {
let query = '';
const fields: string[] = [];
this.categories.forEach(facet => {
const customQuery = this.queryFragments[facet.id];
if (customQuery) {
if (query.length > 0) {
query += ' AND ';
}
query += `(${customQuery})`;
}
const customFields = this.fields[facet.id];
if (customFields && customFields.length > 0) {
for (const field of customFields) {
if (!fields.includes(field)) {
fields.push(field);
}
}
}
});
if (query) {
const result: QueryBody = {
query: {
query: query,
language: 'afts'
},
include: ['path', 'allowableOperations'],
fields: fields,
/*
paging: {
maxItems: maxResults,
skipCount: skipCount
},
*/
filterQueries: this.filterQueries,
facetQueries: this.config.facetQueries,
facetFields: this.config.facetFields,
limits: this.config.limits,
scope: this.scope
};
return result;
}
return null;
}
}

View File

@ -0,0 +1,28 @@
/*!
* @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.
*/
export interface SearchRange {
field: string;
start: string;
end: string;
gap: string;
hardend: boolean;
other: Array<string>;
include: Array<string>;
label: string;
excludeFilters: Array<string>;
}

View File

@ -0,0 +1,21 @@
/*!
* @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.
*/
export interface SearchWidgetSettings {
field: string;
[indexer: string]: any;
}

View File

@ -0,0 +1,25 @@
/*!
* @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 { SearchWidgetSettings } from './search-widget-settings.interface';
import { SearchQueryBuilderService } from './search-query-builder.service';
export interface SearchWidget {
id: string;
settings?: SearchWidgetSettings;
context?: SearchQueryBuilderService;
}

View File

@ -28,12 +28,17 @@ import { SearchTriggerDirective } from './components/search-trigger.directive';
import { SearchControlComponent } from './components/search-control.component';
import { SearchComponent } from './components/search.component';
import { EmptySearchResultComponent } from './components/empty-search-result.component';
import { SearchWidgetContainerComponent } from './components/search-widget-container/search-widget-container.component';
import { SearchFilterComponent } from './components/search-filter/search-filter.component';
import { SearchChipListComponent } from './components/search-chip-list/search-chip-list.component';
export const ALFRESCO_SEARCH_DIRECTIVES: any[] = [
SearchComponent,
SearchControlComponent,
SearchTriggerDirective,
EmptySearchResultComponent
EmptySearchResultComponent,
SearchFilterComponent,
SearchChipListComponent
];
@NgModule({
@ -46,10 +51,15 @@ export const ALFRESCO_SEARCH_DIRECTIVES: any[] = [
TranslateModule
],
declarations: [
...ALFRESCO_SEARCH_DIRECTIVES
...ALFRESCO_SEARCH_DIRECTIVES,
SearchWidgetContainerComponent
],
exports: [
...ALFRESCO_SEARCH_DIRECTIVES
...ALFRESCO_SEARCH_DIRECTIVES,
SearchWidgetContainerComponent
],
entryComponents: [
SearchWidgetContainerComponent
]
})
export class SearchModule {}

View File

@ -4,6 +4,7 @@
"src": "./core/",
"dest": "../dist/core/",
"lib": {
"languageLevel": [ "dom", "es2016" ],
"licensePath": "../config/assets/license_header_add.txt",
"comments" : "none",
"entryFile": "./public-api.ts",

View File

@ -19,7 +19,7 @@ import { Injectable } from '@angular/core';
import {
AlfrescoApi, ContentApi, FavoritesApi, NodesApi,
PeopleApi, RenditionsApi, SharedlinksApi, SitesApi,
VersionsApi, ClassesApi
VersionsApi, ClassesApi, SearchApi
} from 'alfresco-js-api';
import * as alfrescoApi from 'alfresco-js-api';
import { AppConfigService } from '../app-config/app-config.service';
@ -62,7 +62,7 @@ export class AlfrescoApiService {
return this.getInstance().core.peopleApi;
}
get searchApi() {
get searchApi(): SearchApi {
return this.getInstance().search.searchApi;
}

View File

@ -57,7 +57,7 @@ describe('SearchService', () => {
it('should call search API with no additional options', (done) => {
let searchTerm = 'searchTerm63688';
spyOn(searchMockApi.core.queriesApi, 'findNodes').and.returnValue(Promise.resolve(fakeSearch));
service.getNodeQueryResults(searchTerm).subscribe(
service.getNodeQueryResults(searchTerm).then(
() => {
expect(searchMockApi.core.queriesApi.findNodes).toHaveBeenCalledWith(searchTerm, undefined);
done();
@ -72,7 +72,7 @@ describe('SearchService', () => {
nodeType: 'cm:content'
};
spyOn(searchMockApi.core.queriesApi, 'findNodes').and.returnValue(Promise.resolve(fakeSearch));
service.getNodeQueryResults(searchTerm, options).subscribe(
service.getNodeQueryResults(searchTerm, options).then(
() => {
expect(searchMockApi.core.queriesApi.findNodes).toHaveBeenCalledWith(searchTerm, options);
done();
@ -81,7 +81,7 @@ describe('SearchService', () => {
});
it('should return search results returned from the API', (done) => {
service.getNodeQueryResults('').subscribe(
service.getNodeQueryResults('').then(
(res: any) => {
expect(res).toBeDefined();
expect(res).toEqual(fakeSearch);
@ -92,7 +92,7 @@ describe('SearchService', () => {
it('should notify errors returned from the API', (done) => {
spyOn(searchMockApi.core.queriesApi, 'findNodes').and.returnValue(Promise.reject(mockError));
service.getNodeQueryResults('').subscribe(
service.getNodeQueryResults('').then(
() => {},
(res: any) => {
expect(res).toBeDefined();
@ -101,17 +101,4 @@ describe('SearchService', () => {
}
);
});
it('should notify a general error if the API does not return a specific error', (done) => {
spyOn(searchMockApi.core.queriesApi, 'findNodes').and.returnValue(Promise.reject(null));
service.getNodeQueryResults('').subscribe(
() => {},
(res: any) => {
expect(res).toBeDefined();
expect(res).toEqual('Server error');
done();
}
);
});
});

View File

@ -17,45 +17,41 @@
import { Injectable } from '@angular/core';
import { NodePaging, QueryBody } from 'alfresco-js-api';
import { Observable } from 'rxjs/Observable';
import { AlfrescoApiService } from './alfresco-api.service';
import { AuthenticationService } from './authentication.service';
import 'rxjs/add/observable/throw';
import { Subject } from 'rxjs/Subject';
import { AlfrescoApiService } from './alfresco-api.service';
import { SearchConfigurationService } from './search-configuration.service';
@Injectable()
export class SearchService {
constructor(public authService: AuthenticationService,
private apiService: AlfrescoApiService,
dataLoaded: Subject<NodePaging> = new Subject();
constructor(private apiService: AlfrescoApiService,
private searchConfigurationService: SearchConfigurationService) {
}
getNodeQueryResults(term: string, options?: SearchOptions): Observable<NodePaging> {
return Observable.fromPromise(this.apiService.getInstance().core.queriesApi.findNodes(term, options))
.map(res => <NodePaging> res)
.catch(err => this.handleError(err));
async getNodeQueryResults(term: string, options?: SearchOptions): Promise<NodePaging> {
const data = await this.apiService.getInstance().core.queriesApi.findNodes(term, options);
this.dataLoaded.next(data);
return data;
}
search(searchTerm: string, maxResults: number, skipCount: number): Observable<NodePaging> {
const searchQuery = Object.assign(this.searchConfigurationService.generateQueryBody(searchTerm, maxResults, skipCount));
const promise = this.apiService.getInstance().search.searchApi.search(searchQuery);
async search(searchTerm: string, maxResults: number, skipCount: number): Promise<NodePaging> {
const searchQuery = this.searchConfigurationService.generateQueryBody(searchTerm, maxResults, skipCount);
const data = await this.apiService.searchApi.search(searchQuery);
return Observable
.fromPromise(promise)
.catch(err => this.handleError(err));
this.dataLoaded.next(data);
return data;
}
searchByQueryBody(queryBody: QueryBody): Observable<NodePaging> {
const promise = this.apiService.getInstance().search.searchApi.search(queryBody);
async searchByQueryBody(queryBody: QueryBody): Promise<NodePaging> {
const data = await this.apiService.searchApi.search(queryBody);
return Observable
.fromPromise(promise)
.catch(err => this.handleError(err));
}
private handleError(error: any): Observable<any> {
return Observable.throw(error || 'Server error');
this.dataLoaded.next(data);
return data;
}
}

View File

@ -16,10 +16,8 @@
*/
import { async, TestBed } from '@angular/core/testing';
import { TranslateLoader, TranslateModule } from '@ngx-translate/core';
import { SettingsService } from './settings.service';
import { AppConfigModule } from '../app-config/app-config.module';
import { TranslateLoaderService } from './translate-loader.service';
describe('SettingsService', () => {
@ -28,13 +26,7 @@ describe('SettingsService', () => {
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [
AppConfigModule,
TranslateModule.forRoot({
loader: {
provide: TranslateLoader,
useClass: TranslateLoaderService
}
})
AppConfigModule
],
providers: [
SettingsService

View File

@ -4,6 +4,7 @@
"src": "../insights/",
"dest": "../dist/insights/",
"lib": {
"languageLevel": [ "dom", "es2016" ],
"licensePath": "../config/assets/license_header_add.txt",
"comments" : "none",
"entryFile": "./public-api.ts",

View File

@ -168,7 +168,7 @@
"bundlesize": [
{
"path": "./dist/content-services/bundles/adf-content-services.umd.js",
"maxSize": "50 kb"
"maxSize": "60 kb"
},
{
"path": "./dist/process-services/bundles/adf-process-services.umd.js",

View File

@ -4,6 +4,7 @@
"src": "../process-services/",
"dest": "../dist/process-services/",
"lib": {
"languageLevel": [ "dom", "es2016" ],
"licensePath": "../config/assets/license_header_add.txt",
"comments" : "none",
"entryFile": "./public-api.ts",