From ed48994e675abf595323c20c479dadb523858a5e Mon Sep 17 00:00:00 2001 From: Denys Vuika Date: Thu, 29 Mar 2018 11:34:09 +0100 Subject: [PATCH] [ADF-2128] facet container component (#3094) * (wip) facet container * shaping out the API * code lint fixes * radiobox facet example * fields selector facet * search limits support * scope locations facet example * move custom search to 'search.query' config * use facet fields and queries from the config file * use facet filters * use facet buckets in query * preserve expanded/checked states * code cleanup and binding fixes * fix apis after rebase * extract query builder into separate class * code improvements * full chip list (merge facet fields with queries) * placeholder for range requests * move search infrastructure to ADF core * cleanup code * auto-search on init * move search components to the content services * selected facets chip list * split into separate components at ADF level * move the rest of the implementation to ADF * facet builder fixes and tests * translation support for category names * docs placeholders * update language level * unit tests and packaging updates * fix after rebase * remove fdescribe * some docs on search settings * rename components as per review * simplify chip list as per review * turn query builder into service * improve search service, integrate old search results * fix node selector integration * move service to the top module * update tests * remove fdescribe * update tests * test fixes * test fixes * test updates * fix tests * code and test fixes * remove fit * fix tests * fix tests * remove obsolete test * increase bundle threshold * update docs to reflect PR changes * fix docs --- .vscode/settings.json | 1 - demo-shell/src/app.config.json | 112 ++++++ demo-shell/src/app/app.module.ts | 10 +- .../search/search-result.component.html | 35 +- .../search/search-result.component.scss | 17 + .../search/search-result.component.ts | 9 +- .../search-chip-list.component.md | 13 + .../search-filter.component.md | 154 ++++++++ .../docassets/images/search-categories-01.png | Bin 0 -> 55720 bytes docs/docassets/images/selected-facets.png | Bin 0 -> 10265 bytes ...tent-node-selector-panel.component.spec.ts | 70 +--- .../content-node-selector.service.ts | 4 +- lib/content-services/content.module.ts | 4 +- lib/content-services/material.module.ts | 6 +- lib/content-services/ng-package.json | 1 + .../search-chip-list.component.html | 20 + .../search-chip-list.component.ts | 31 ++ .../search-control.component.spec.ts | 43 +- .../search-fields.component.html | 6 + .../search-fields.component.scss | 8 + .../search-fields/search-fields.component.ts | 70 ++++ .../search-filter.component.html | 54 +++ .../search-filter.component.scss | 8 + .../search-filter.component.spec.ts | 337 ++++++++++++++++ .../search-filter/search-filter.component.ts | 172 ++++++++ .../search-radio/search-radio.component.html | 6 + .../search-radio/search-radio.component.scss | 10 + .../search-radio/search-radio.component.ts | 68 ++++ .../search-scope-locations.component.html | 11 + .../search-scope-locations.component.ts | 57 +++ .../search-text/search-text.component.html | 7 + .../search-text/search-text.component.ts | 57 +++ .../search-widget-container.component.ts | 74 ++++ .../search-widgets.module.ts | 58 +++ .../components/search.component.spec.ts | 28 +- .../search/components/search.component.ts | 45 ++- .../search/facet-field-bucket.interface.ts | 26 ++ .../search/facet-field.interface.ts | 24 ++ .../search/facet-query.interface.ts | 21 + .../search/filter-query.interface.ts | 20 + lib/content-services/search/public-api.ts | 15 + .../search/response-facet-field.interface.ts | 25 ++ .../search/response-facet-query.interface.ts | 23 ++ .../search/search-category.interface.ts | 29 ++ .../search/search-configuration.interface.ts | 36 ++ .../search-query-builder.service.spec.ts | 369 ++++++++++++++++++ .../search/search-query-builder.service.ts | 143 +++++++ .../search/search-range.interface.ts | 28 ++ .../search-widget-settings.interface.ts | 21 + .../search/search-widget.interface.ts | 25 ++ lib/content-services/search/search.module.ts | 16 +- lib/core/ng-package.json | 1 + lib/core/services/alfresco-api.service.ts | 4 +- lib/core/services/search.service.spec.ts | 21 +- lib/core/services/search.service.ts | 44 +-- lib/core/services/settings.service.spec.ts | 10 +- lib/insights/ng-package.json | 1 + lib/package.json | 2 +- lib/process-services/ng-package.json | 1 + 59 files changed, 2328 insertions(+), 183 deletions(-) create mode 100644 docs/content-services/search-chip-list.component.md create mode 100644 docs/content-services/search-filter.component.md create mode 100644 docs/docassets/images/search-categories-01.png create mode 100644 docs/docassets/images/selected-facets.png create mode 100644 lib/content-services/search/components/search-chip-list/search-chip-list.component.html create mode 100644 lib/content-services/search/components/search-chip-list/search-chip-list.component.ts create mode 100644 lib/content-services/search/components/search-fields/search-fields.component.html create mode 100644 lib/content-services/search/components/search-fields/search-fields.component.scss create mode 100644 lib/content-services/search/components/search-fields/search-fields.component.ts create mode 100644 lib/content-services/search/components/search-filter/search-filter.component.html create mode 100644 lib/content-services/search/components/search-filter/search-filter.component.scss create mode 100644 lib/content-services/search/components/search-filter/search-filter.component.spec.ts create mode 100644 lib/content-services/search/components/search-filter/search-filter.component.ts create mode 100644 lib/content-services/search/components/search-radio/search-radio.component.html create mode 100644 lib/content-services/search/components/search-radio/search-radio.component.scss create mode 100644 lib/content-services/search/components/search-radio/search-radio.component.ts create mode 100644 lib/content-services/search/components/search-scope-locations/search-scope-locations.component.html create mode 100644 lib/content-services/search/components/search-scope-locations/search-scope-locations.component.ts create mode 100644 lib/content-services/search/components/search-text/search-text.component.html create mode 100644 lib/content-services/search/components/search-text/search-text.component.ts create mode 100644 lib/content-services/search/components/search-widget-container/search-widget-container.component.ts create mode 100644 lib/content-services/search/components/search-widget-container/search-widgets.module.ts create mode 100644 lib/content-services/search/facet-field-bucket.interface.ts create mode 100644 lib/content-services/search/facet-field.interface.ts create mode 100644 lib/content-services/search/facet-query.interface.ts create mode 100644 lib/content-services/search/filter-query.interface.ts create mode 100644 lib/content-services/search/response-facet-field.interface.ts create mode 100644 lib/content-services/search/response-facet-query.interface.ts create mode 100644 lib/content-services/search/search-category.interface.ts create mode 100644 lib/content-services/search/search-configuration.interface.ts create mode 100644 lib/content-services/search/search-query-builder.service.spec.ts create mode 100644 lib/content-services/search/search-query-builder.service.ts create mode 100644 lib/content-services/search/search-range.interface.ts create mode 100644 lib/content-services/search/search-widget-settings.interface.ts create mode 100644 lib/content-services/search/search-widget.interface.ts 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 0000000000000000000000000000000000000000..b0b243b8236ababdeb43a21f432557e5f9c32b75 GIT binary patch literal 55720 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 literal 0 HcmV?d00001 diff --git a/docs/docassets/images/selected-facets.png b/docs/docassets/images/selected-facets.png new file mode 100644 index 0000000000000000000000000000000000000000..952e8651c5cd54fb851db1f7e549b286e73bf52f GIT binary patch literal 10265 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 ougzb~tbW`G{c8>1gR$-c_N|7c { 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",