diff --git a/.vscode/settings.json b/.vscode/settings.json index dfd9fe7bf0..d1d4ace8ba 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -9,7 +9,6 @@ "**/.happypack": true }, "editor.renderIndentGuides": true, - "tslint.configFile": "ng2-components/tslint.json", "markdownlint.config": { "MD032": false, "MD004": false, diff --git a/demo-shell/src/app.config.json b/demo-shell/src/app.config.json index 93d0b0aff8..8536010b14 100644 --- a/demo-shell/src/app.config.json +++ b/demo-shell/src/app.config.json @@ -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 ] diff --git a/demo-shell/src/app/app.module.ts b/demo-shell/src/app/app.module.ts index 7b286246e4..fdbfa8edfc 100644 --- a/demo-shell/src/app/app.module.ts +++ b/demo-shell/src/app/app.module.ts @@ -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 }, diff --git a/demo-shell/src/app/components/search/search-result.component.html b/demo-shell/src/app/components/search/search-result.component.html index e8f461e9e1..ae34f6275c 100644 --- a/demo-shell/src/app/components/search/search-result.component.html +++ b/demo-shell/src/app/components/search/search-result.component.html @@ -1,3 +1,4 @@ + - - +
+ +
+ +
+ + +
+ + +
+
diff --git a/demo-shell/src/app/components/search/search-result.component.scss b/demo-shell/src/app/components/search/search-result.component.scss index a8a1a2930a..5fa1001ce1 100644 --- a/demo-shell/src/app/components/search/search-result.component.scss +++ b/demo-shell/src/app/components/search/search-result.component.scss @@ -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; } diff --git a/demo-shell/src/app/components/search/search-result.component.ts b/demo-shell/src/app/components/search/search-result.component.ts index 7b7288a946..0cf4408601 100644 --- a/demo-shell/src/app/components/search/search-result.component.ts +++ b/demo-shell/src/app/components/search/search-result.component.ts @@ -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; @@ -59,8 +62,8 @@ export class SearchResultComponent implements OnInit { } onRefreshPagination(pagination: Pagination) { - this.maxItems = pagination.maxItems; - this.skipCount = pagination.skipCount; + this.maxItems = pagination.maxItems; + this.skipCount = pagination.skipCount; } onDeleteElementSuccess(element: any) { diff --git a/docs/content-services/search-chip-list.component.md b/docs/content-services/search-chip-list.component.md new file mode 100644 index 0000000000..89ae0441c7 --- /dev/null +++ b/docs/content-services/search-chip-list.component.md @@ -0,0 +1,13 @@ +--- +Added: v2.3.0 +Status: Active +--- + +# Search Chip List Component + +```html + + +``` + +![Selected Facets](../docassets/images/selected-facets.png) \ No newline at end of file diff --git a/docs/content-services/search-filter.component.md b/docs/content-services/search-filter.component.md new file mode 100644 index 0000000000..d112480990 --- /dev/null +++ b/docs/content-services/search-filter.component.md @@ -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 + +``` + +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; +} +``` \ No newline at end of file diff --git a/docs/docassets/images/search-categories-01.png b/docs/docassets/images/search-categories-01.png new file mode 100644 index 0000000000..b0b243b823 Binary files /dev/null and b/docs/docassets/images/search-categories-01.png differ diff --git a/docs/docassets/images/selected-facets.png b/docs/docassets/images/selected-facets.png new file mode 100644 index 0000000000..952e8651c5 Binary files /dev/null and b/docs/docassets/images/selected-facets.png differ diff --git a/lib/content-services/content-node-selector/content-node-selector-panel.component.spec.ts b/lib/content-services/content-node-selector/content-node-selector-panel.component.spec.ts index 5378aa2656..b075762eb2 100644 --- a/lib/content-services/content-node-selector/content-node-selector-panel.component.spec.ts +++ b/lib/content-services/content-node-selector/content-node-selector-panel.component.spec.ts @@ -51,10 +51,8 @@ describe('ContentNodeSelectorComponent', () => { const debounceSearch = 200; let component: ContentNodeSelectorPanelComponent; let fixture: ComponentFixture; - let searchService: SearchService; let contentNodeSelectorService: ContentNodeSelectorService; let searchSpy: jasmine.Spy; - let cnSearchSpy: jasmine.Spy; let _observer: Observer; @@ -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) => { _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 = { 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( { 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 = {list: {entries: [ { entry: { guid: '-sites-' } }, { entry: { guid: 'namek' } }]}}; fixture.detectChanges(); typeToSearchBox('vegeta'); setTimeout(() => { - expect(cnSearchSpy.calls.count()).toBe(1); + expect(searchSpy.calls.count()).toBe(1); component.siteChanged( { 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 = {list: {entries: [ { entry: { guid: '-sites-' } }, { entry: { guid: 'namek' } }]}}; fixture.detectChanges(); @@ -531,7 +501,7 @@ describe('ContentNodeSelectorComponent', () => { component.siteChanged( { 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 = ''; diff --git a/lib/content-services/content-node-selector/content-node-selector.service.ts b/lib/content-services/content-node-selector/content-node-selector.service.ts index 1efd084ccb..9cc7a54caf 100644 --- a/lib/content-services/content-node-selector/content-node-selector.service.ts +++ b/lib/content-services/content-node-selector/content-node-selector.service.ts @@ -73,6 +73,8 @@ export class ContentNodeSelectorService { } }; - return this.searchService.searchByQueryBody(defaultSearchNode); + return Observable.fromPromise( + this.searchService.searchByQueryBody(defaultSearchNode) + ); } } diff --git a/lib/content-services/content.module.ts b/lib/content-services/content.module.ts index 482e2055ae..0287f954b9 100644 --- a/lib/content-services/content.module.ts +++ b/lib/content-services/content.module.ts @@ -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, diff --git a/lib/content-services/material.module.ts b/lib/content-services/material.module.ts index 488f82a6ec..eccbe975f3 100644 --- a/lib/content-services/material.module.ts +++ b/lib/content-services/material.module.ts @@ -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 ]; } diff --git a/lib/content-services/ng-package.json b/lib/content-services/ng-package.json index f0cbd098b4..dca9672374 100644 --- a/lib/content-services/ng-package.json +++ b/lib/content-services/ng-package.json @@ -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", diff --git a/lib/content-services/search/components/search-chip-list/search-chip-list.component.html b/lib/content-services/search/components/search-chip-list/search-chip-list.component.html new file mode 100644 index 0000000000..030001c329 --- /dev/null +++ b/lib/content-services/search/components/search-chip-list/search-chip-list.component.html @@ -0,0 +1,20 @@ + + + + {{ label }} + cancel + + + + + {{ bucket.display || bucket.label }} + cancel + + + diff --git a/lib/content-services/search/components/search-chip-list/search-chip-list.component.ts b/lib/content-services/search/components/search-chip-list/search-chip-list.component.ts new file mode 100644 index 0000000000..9459dadc74 --- /dev/null +++ b/lib/content-services/search/components/search-chip-list/search-chip-list.component.ts @@ -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; +} diff --git a/lib/content-services/search/components/search-control.component.spec.ts b/lib/content-services/search/components/search-control.component.spec.ts index 509c2d0084..05d395edbc 100644 --- a/lib/content-services/search/components/search-control.component.spec.ts +++ b/lib/content-services/search/components/search-control.component.spec.ts @@ -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')); diff --git a/lib/content-services/search/components/search-fields/search-fields.component.html b/lib/content-services/search/components/search-fields/search-fields.component.html new file mode 100644 index 0000000000..8dd4a54b35 --- /dev/null +++ b/lib/content-services/search/components/search-fields/search-fields.component.html @@ -0,0 +1,6 @@ + + {{ option.name }} + diff --git a/lib/content-services/search/components/search-fields/search-fields.component.scss b/lib/content-services/search/components/search-fields/search-fields.component.scss new file mode 100644 index 0000000000..bb65a956fb --- /dev/null +++ b/lib/content-services/search/components/search-fields/search-fields.component.scss @@ -0,0 +1,8 @@ +.adf-search-fields { + display: flex; + flex-direction: column; + + .mat-checkbox { + margin: 5px; + } +} diff --git a/lib/content-services/search/components/search-fields/search-fields.component.ts b/lib/content-services/search/components/search-fields/search-fields.component.ts new file mode 100644 index 0000000000..688c915a96 --- /dev/null +++ b/lib/content-services/search/components/search-fields/search-fields.component.ts @@ -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(); + } +} diff --git a/lib/content-services/search/components/search-filter/search-filter.component.html b/lib/content-services/search/components/search-filter/search-filter.component.html new file mode 100644 index 0000000000..159329e01c --- /dev/null +++ b/lib/content-services/search/components/search-filter/search-filter.component.html @@ -0,0 +1,54 @@ + + + + + + {{ category.name | translate }} + + + + + + + + + Facet Queries + +
+ + + {{ query.label }} ({{ query.count }}) + + +
+
+ + + + {{ field.label }} + +
+ + {{ bucket.display || bucket.label }} ({{ bucket.count }}) + +
+
+ +
diff --git a/lib/content-services/search/components/search-filter/search-filter.component.scss b/lib/content-services/search/components/search-filter/search-filter.component.scss new file mode 100644 index 0000000000..a7928425ad --- /dev/null +++ b/lib/content-services/search/components/search-filter/search-filter.component.scss @@ -0,0 +1,8 @@ +.checklist { + display: flex; + flex-direction: column; + + .mat-checkbox { + margin: 5px; + } +} diff --git a/lib/content-services/search/components/search-filter/search-filter.component.spec.ts b/lib/content-services/search/components/search-filter/search-filter.component.spec.ts new file mode 100644 index 0000000000..f1a914dfb4 --- /dev/null +++ b/lib/content-services/search/components/search-filter/search-filter.component.spec.ts @@ -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(( component.responseFacetQueries[0]).$checked).toBeFalsy(); + expect(( 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(( component.responseFacetQueries[0]).$checked).toBeFalsy(); + expect(( 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 = [ {}, {}]; + component.responseFacetFields = [ {}, {}]; + + 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(); + }); + +}); diff --git a/lib/content-services/search/components/search-filter/search-filter.component.ts b/lib/content-services/search/components/search-filter/search-filter.component.ts new file mode 100644 index 0000000000..2da95f2867 --- /dev/null +++ b/lib/content-services/search/components/search-filter/search-filter.component.ts @@ -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 = []; + } + } + +} diff --git a/lib/content-services/search/components/search-radio/search-radio.component.html b/lib/content-services/search/components/search-radio/search-radio.component.html new file mode 100644 index 0000000000..35ef0e3b7b --- /dev/null +++ b/lib/content-services/search/components/search-radio/search-radio.component.html @@ -0,0 +1,6 @@ + + + {{ option.name }} + + diff --git a/lib/content-services/search/components/search-radio/search-radio.component.scss b/lib/content-services/search/components/search-radio/search-radio.component.scss new file mode 100644 index 0000000000..4229945846 --- /dev/null +++ b/lib/content-services/search/components/search-radio/search-radio.component.scss @@ -0,0 +1,10 @@ +.adf-search-radio { + .mat-radio-group { + display: inline-flex; + flex-direction: column; + } + + .mat-radio-button { + margin: 5px; + } +} diff --git a/lib/content-services/search/components/search-radio/search-radio.component.ts b/lib/content-services/search/components/search-radio/search-radio.component.ts new file mode 100644 index 0000000000..bfa64b393d --- /dev/null +++ b/lib/content-services/search/components/search-radio/search-radio.component.ts @@ -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); + } +} diff --git a/lib/content-services/search/components/search-scope-locations/search-scope-locations.component.html b/lib/content-services/search/components/search-scope-locations/search-scope-locations.component.html new file mode 100644 index 0000000000..17cfe5077f --- /dev/null +++ b/lib/content-services/search/components/search-scope-locations/search-scope-locations.component.html @@ -0,0 +1,11 @@ + + + + {{option.name}} + + + diff --git a/lib/content-services/search/components/search-scope-locations/search-scope-locations.component.ts b/lib/content-services/search/components/search-scope-locations/search-scope-locations.component.ts new file mode 100644 index 0000000000..06051ce4ec --- /dev/null +++ b/lib/content-services/search/components/search-scope-locations/search-scope-locations.component.ts @@ -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(); + } +} diff --git a/lib/content-services/search/components/search-text/search-text.component.html b/lib/content-services/search/components/search-text/search-text.component.html new file mode 100644 index 0000000000..4b4c1049c2 --- /dev/null +++ b/lib/content-services/search/components/search-text/search-text.component.html @@ -0,0 +1,7 @@ + + + diff --git a/lib/content-services/search/components/search-text/search-text.component.ts b/lib/content-services/search/components/search-text/search-text.component.ts new file mode 100644 index 0000000000..985928a8fa --- /dev/null +++ b/lib/content-services/search/components/search-text/search-text.component.ts @@ -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(); + } + } + +} diff --git a/lib/content-services/search/components/search-widget-container/search-widget-container.component.ts b/lib/content-services/search/components/search-widget-container/search-widget-container.component.ts new file mode 100644 index 0000000000..b337fe9783 --- /dev/null +++ b/lib/content-services/search/components/search-widget-container/search-widget-container.component.ts @@ -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: '
' +}) +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; + private componentRef: ComponentRef; + + 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) { + 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; + } + } + +} diff --git a/lib/content-services/search/components/search-widget-container/search-widgets.module.ts b/lib/content-services/search/components/search-widget-container/search-widgets.module.ts new file mode 100644 index 0000000000..c7d322442d --- /dev/null +++ b/lib/content-services/search/components/search-widget-container/search-widgets.module.ts @@ -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 { +} diff --git a/lib/content-services/search/components/search.component.spec.ts b/lib/content-services/search/components/search.component.spec.ts index 1671b2537b..fefabbf62c 100644 --- a/lib/content-services/search/components/search.component.spec.ts +++ b/lib/content-services/search/components/search.component.spec.ts @@ -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 { +function fakeNodeResultSearch(searchNode: QueryBody): Promise { 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(() => { diff --git a/lib/content-services/search/components/search.component.ts b/lib/content-services/search/components/search.component.ts index e7d210d3f6..701a304fbb 100644 --- a/lib/content-services/search/components/search.component.ts +++ b/lib/content-services/search/components/search.component.ts @@ -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,31 +157,38 @@ 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 = results; - this.resultLoaded.emit(this.results); - this.isOpen = true; - this.setVisibility(); - }, - error => { - if (error.status !== 400) { - this.results = null; - this.error.emit(error); - } - }); } else { this.cleanResults(); } } + onSearchDataLoaded(data: NodePaging) { + if (data) { + this.results = data; + this.resultLoaded.emit(this.results); + this.isOpen = true; + this.setVisibility(); + } + } + + onSearchDataError(error) { + if (error && error.status !== 400) { + this.results = null; + this.error.emit(error); + } + } + hidePanel() { if (this.isOpen) { this._classList['adf-search-show'] = false; diff --git a/lib/content-services/search/facet-field-bucket.interface.ts b/lib/content-services/search/facet-field-bucket.interface.ts new file mode 100644 index 0000000000..47bcab9fcd --- /dev/null +++ b/lib/content-services/search/facet-field-bucket.interface.ts @@ -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; +} diff --git a/lib/content-services/search/facet-field.interface.ts b/lib/content-services/search/facet-field.interface.ts new file mode 100644 index 0000000000..5edb82408a --- /dev/null +++ b/lib/content-services/search/facet-field.interface.ts @@ -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; +} diff --git a/lib/content-services/search/facet-query.interface.ts b/lib/content-services/search/facet-query.interface.ts new file mode 100644 index 0000000000..3a83586447 --- /dev/null +++ b/lib/content-services/search/facet-query.interface.ts @@ -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; +} diff --git a/lib/content-services/search/filter-query.interface.ts b/lib/content-services/search/filter-query.interface.ts new file mode 100644 index 0000000000..6cd924dca2 --- /dev/null +++ b/lib/content-services/search/filter-query.interface.ts @@ -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; +} diff --git a/lib/content-services/search/public-api.ts b/lib/content-services/search/public-api.ts index 5c13bfe876..18ad1f7552 100644 --- a/lib/content-services/search/public-api.ts +++ b/lib/content-services/search/public-api.ts @@ -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'; diff --git a/lib/content-services/search/response-facet-field.interface.ts b/lib/content-services/search/response-facet-field.interface.ts new file mode 100644 index 0000000000..f14bf6d230 --- /dev/null +++ b/lib/content-services/search/response-facet-field.interface.ts @@ -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; + + $expanded?: boolean; +} diff --git a/lib/content-services/search/response-facet-query.interface.ts b/lib/content-services/search/response-facet-query.interface.ts new file mode 100644 index 0000000000..2a6df4149f --- /dev/null +++ b/lib/content-services/search/response-facet-query.interface.ts @@ -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; +} diff --git a/lib/content-services/search/search-category.interface.ts b/lib/content-services/search/search-category.interface.ts new file mode 100644 index 0000000000..48d050bb95 --- /dev/null +++ b/lib/content-services/search/search-category.interface.ts @@ -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; + }; +} diff --git a/lib/content-services/search/search-configuration.interface.ts b/lib/content-services/search/search-configuration.interface.ts new file mode 100644 index 0000000000..ed6a406bd2 --- /dev/null +++ b/lib/content-services/search/search-configuration.interface.ts @@ -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 + }; + limits?: { + permissionEvaluationTime?: number; + permissionEvaluationCount?: number; + }; + filterQueries?: Array; + facetQueries?: Array; + facetFields?: { + facets: Array + }; +} diff --git a/lib/content-services/search/search-query-builder.service.spec.ts b/lib/content-services/search/search-query-builder.service.spec.ts new file mode 100644 index 0000000000..bf95d5e1e7 --- /dev/null +++ b/lib/content-services/search/search-query-builder.service.spec.ts @@ -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: [ + { id: 'cat1', enabled: true }, + { id: 'cat2', enabled: false }, + { 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: [ + { 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: [ + { 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: [ + { id: 'cat1', enabled: true }, + { 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: [ + { id: 'cat1', enabled: true }, + { 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: [ + { id: 'cat1', enabled: true }, + { 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: [ + { 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: [ + { 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: [ + { 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: [ + { 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: [ + { 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'); + + }); + +}); diff --git a/lib/content-services/search/search-query-builder.service.ts b/lib/content-services/search/search-query-builder.service.ts new file mode 100644 index 0000000000..92a2c3c0ac --- /dev/null +++ b/lib/content-services/search/search-query-builder.service.ts @@ -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 = new Subject(); + executed: Subject = new Subject(); + + categories: Array = []; + 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('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; + } +} diff --git a/lib/content-services/search/search-range.interface.ts b/lib/content-services/search/search-range.interface.ts new file mode 100644 index 0000000000..9fb03eb7dc --- /dev/null +++ b/lib/content-services/search/search-range.interface.ts @@ -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; + include: Array; + label: string; + excludeFilters: Array; +} diff --git a/lib/content-services/search/search-widget-settings.interface.ts b/lib/content-services/search/search-widget-settings.interface.ts new file mode 100644 index 0000000000..cd3ad0a0a7 --- /dev/null +++ b/lib/content-services/search/search-widget-settings.interface.ts @@ -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; +} diff --git a/lib/content-services/search/search-widget.interface.ts b/lib/content-services/search/search-widget.interface.ts new file mode 100644 index 0000000000..c05bd863de --- /dev/null +++ b/lib/content-services/search/search-widget.interface.ts @@ -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; +} diff --git a/lib/content-services/search/search.module.ts b/lib/content-services/search/search.module.ts index 14095f3d1b..a120a55157 100644 --- a/lib/content-services/search/search.module.ts +++ b/lib/content-services/search/search.module.ts @@ -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 {} diff --git a/lib/core/ng-package.json b/lib/core/ng-package.json index 66164087bf..3222faefdc 100644 --- a/lib/core/ng-package.json +++ b/lib/core/ng-package.json @@ -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", diff --git a/lib/core/services/alfresco-api.service.ts b/lib/core/services/alfresco-api.service.ts index b6336f7c0f..e18475f30a 100644 --- a/lib/core/services/alfresco-api.service.ts +++ b/lib/core/services/alfresco-api.service.ts @@ -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; } diff --git a/lib/core/services/search.service.spec.ts b/lib/core/services/search.service.spec.ts index 7065393197..4f15792957 100644 --- a/lib/core/services/search.service.spec.ts +++ b/lib/core/services/search.service.spec.ts @@ -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(); - } - ); - }); - }); diff --git a/lib/core/services/search.service.ts b/lib/core/services/search.service.ts index c8d2c91966..7d0722a719 100644 --- a/lib/core/services/search.service.ts +++ b/lib/core/services/search.service.ts @@ -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 = new Subject(); + + constructor(private apiService: AlfrescoApiService, private searchConfigurationService: SearchConfigurationService) { } - getNodeQueryResults(term: string, options?: SearchOptions): Observable { - return Observable.fromPromise(this.apiService.getInstance().core.queriesApi.findNodes(term, options)) - .map(res => res) - .catch(err => this.handleError(err)); + async getNodeQueryResults(term: string, options?: SearchOptions): Promise { + 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 { - 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 { + 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 { - const promise = this.apiService.getInstance().search.searchApi.search(queryBody); + async searchByQueryBody(queryBody: QueryBody): Promise { + const data = await this.apiService.searchApi.search(queryBody); - return Observable - .fromPromise(promise) - .catch(err => this.handleError(err)); - } - - private handleError(error: any): Observable { - return Observable.throw(error || 'Server error'); + this.dataLoaded.next(data); + return data; } } diff --git a/lib/core/services/settings.service.spec.ts b/lib/core/services/settings.service.spec.ts index bd10592566..168838b6fd 100644 --- a/lib/core/services/settings.service.spec.ts +++ b/lib/core/services/settings.service.spec.ts @@ -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 diff --git a/lib/insights/ng-package.json b/lib/insights/ng-package.json index d2efff3269..00d4388b9a 100644 --- a/lib/insights/ng-package.json +++ b/lib/insights/ng-package.json @@ -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", diff --git a/lib/package.json b/lib/package.json index 6e12eee1f1..52202d66f8 100644 --- a/lib/package.json +++ b/lib/package.json @@ -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", diff --git a/lib/process-services/ng-package.json b/lib/process-services/ng-package.json index 7e40f8b402..720f94e4ce 100644 --- a/lib/process-services/ng-package.json +++ b/lib/process-services/ng-package.json @@ -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",