diff --git a/demo-shell/resources/i18n/en.json b/demo-shell/resources/i18n/en.json index a02f2c2f6f..7b3e72ddec 100644 --- a/demo-shell/resources/i18n/en.json +++ b/demo-shell/resources/i18n/en.json @@ -56,6 +56,7 @@ "APP_LAYOUT": { "APP": "App", "APP_NAME": "ADF Demo Application", + "FILTERED_SEARCH": "FILTER HEADER", "HOME": "Home", "NODE-SELECTOR": "Node Selector", "SITES": "Sites", diff --git a/demo-shell/src/app.config.json b/demo-shell/src/app.config.json index ae31f568bb..149260b4bd 100644 --- a/demo-shell/src/app.config.json +++ b/demo-shell/src/app.config.json @@ -434,6 +434,176 @@ ] } }, + "search-headers": { + "app:fields": [ + "cm:name", + "cm:title", + "cm:description", + "ia:whatEvent", + "ia:descriptionEvent", + "lnk:title", + "lnk:description", + "TEXT", + "TAG" + ], + "categories": [ + { + "id": "queryName", + "name": "Name", + "columnKey": "name", + "enabled": true, + "expanded": true, + "component": { + "selector": "text", + "settings": { + "pattern": "cm:name:'(.*?)'", + "field": "cm:name", + "placeholder": "Enter the name" + } + } + }, + { + "id": "checkList", + "name": "Check List", + "columnKey":"$thumbnail", + "enabled": true, + "component": { + "selector": "check-list", + "settings": { + "pageSize": 5, + "operator": "OR", + "options": [ + { + "name": "Folder", + "value": "TYPE:'cm:folder'" + }, + { + "name": "Document", + "value": "TYPE:'cm:content'" + } + ] + } + } + }, + { + "id": "contentSizeRange", + "name": "Content Size (range)", + "enabled": true, + "component": { + "selector": "number-range", + "settings": { + "field": "cm:content.size", + "format": "[{FROM} TO {TO}]" + } + } + }, + { + "id": "contentSize", + "name": "SEARCH.CATEGORIES.SIZE", + "columnKey":"content.sizeInBytes", + "enabled": true, + "component": { + "selector": "check-list", + "settings": { + "options": [ + { + "name": "Small", + "value": "content.size:[0 TO 1048576>" + }, + { + "name": "Medium", + "value": "content.size:[1048576 TO 52428800]" + }, + { + "name": "Large", + "value": "content.size:<52428800 TO 524288000]" + }, + { + "name": "Huge", + "value": "content.size:<524288000 TO MAX]" + } + ] + } + } + }, + { + "id": "createdDateRange", + "name": "Created Date (range)", + "columnKey": "createdAt", + "enabled": true, + "component": { + "selector": "date-range", + "settings": { + "field": "cm:created", + "dateFormat": "DD-MMM-YY", + "maxDate": "today" + } + } + } + ], + "highlight": { + "prefix": " ", + "postfix": " ", + "mergeContiguous": true, + "fields": [ + { + "field": "cm:title" + }, + { + "field": "description", + "prefix": "(", + "postfix": ")" + } + ] + }, + "sorting": { + "options": [ + { + "key": "name", + "label": "Name", + "type": "FIELD", + "field": "cm:name", + "ascending": true + }, + { + "key": "content.sizeInBytes", + "label": "Size", + "type": "FIELD", + "field": "content.size", + "ascending": true + }, + { + "key": "createdByUser", + "label": "Author", + "type": "FIELD", + "field": "cm:creator", + "ascending": true + }, + { + "key": "createdAt", + "label": "Created", + "type": "FIELD", + "field": "cm:created", + "ascending": true + }, + { + "key": "score", + "label": "Relevance", + "type": "FIELD", + "field": "score", + "ascending": false + } + ], + "defaults": [ + { + "key": "score", + "type": "FIELD", + "field": "score", + "ascending": false + } + ] + } + }, "pagination": { "size": 20, "supportedPageSizes": [ diff --git a/demo-shell/src/app/app.module.ts b/demo-shell/src/app/app.module.ts index 0f4229df87..78543a2114 100644 --- a/demo-shell/src/app/app.module.ts +++ b/demo-shell/src/app/app.module.ts @@ -102,6 +102,7 @@ import localePl from '@angular/common/locales/pl'; import localeFi from '@angular/common/locales/fi'; import localeDa from '@angular/common/locales/da'; import localeSv from '@angular/common/locales/sv'; +import { FilteredSearchComponent } from './components/files/filtered-search.component'; registerLocaleData(localeFr); registerLocaleData(localeDe); @@ -158,6 +159,7 @@ registerLocaleData(localeSv); FormNodeViewerComponent, AppsViewComponent, FilesComponent, + FilteredSearchComponent, FormComponent, FormListComponent, VersionManagerDialogAdapterComponent, diff --git a/demo-shell/src/app/app.routes.ts b/demo-shell/src/app/app.routes.ts index 3a787e3ab4..936e42e107 100644 --- a/demo-shell/src/app/app.routes.ts +++ b/demo-shell/src/app/app.routes.ts @@ -59,6 +59,7 @@ import { FormCloudDemoComponent } from './components/app-layout/cloud/form-demo/ import { ConfirmDialogExampleComponent } from './components/confirm-dialog/confirm-dialog-example.component'; import { DemoErrorComponent } from './components/error/demo-error.component'; import { TaskHeaderCloudDemoComponent } from './components/cloud/task-header-cloud-demo.component'; +import { FilteredSearchComponent } from './components/files/filtered-search.component'; export const appRoutes: Routes = [ { path: 'login', loadChildren: 'app/components/login/login.module#AppLoginModule' }, { path: 'logout', component: LogoutComponent }, @@ -251,6 +252,21 @@ export const appRoutes: Routes = [ component: FilesComponent, canActivate: [AuthGuardEcm] }, + { + path: 'filtered-search', + component: FilteredSearchComponent, + canActivate: [AuthGuardEcm] + }, + { + path: 'filtered-search/:id', + component: FilteredSearchComponent, + canActivate: [AuthGuardEcm] + }, + { + path: 'filtered-search/:id/display/:mode', + component: FilteredSearchComponent, + canActivate: [AuthGuardEcm] + }, { path: 'extensions/document-list/presets', canActivate: [AuthGuardEcm], diff --git a/demo-shell/src/app/components/app-layout/app-layout.component.ts b/demo-shell/src/app/components/app-layout/app-layout.component.ts index 95b006c5b5..334c159f47 100644 --- a/demo-shell/src/app/components/app-layout/app-layout.component.ts +++ b/demo-shell/src/app/components/app-layout/app-layout.component.ts @@ -40,6 +40,7 @@ export class AppLayoutComponent implements OnInit, OnDestroy { ] }, { href: '/files', icon: 'folder_open', title: 'APP_LAYOUT.CONTENT_SERVICES' }, + { href: '/filtered-search', icon: 'rowing', title: 'APP_LAYOUT.FILTERED_SEARCH' }, { href: '/breadcrumb', icon: 'label', title: 'APP_LAYOUT.BREADCRUMB' }, { href: '/notifications', icon: 'alarm', title: 'APP_LAYOUT.NOTIFICATIONS' }, { href: '/card-view', icon: 'view_headline', title: 'APP_LAYOUT.CARD_VIEW' }, diff --git a/demo-shell/src/app/components/files/files.component.html b/demo-shell/src/app/components/files/files.component.html index 75f5654843..e4a6b2bdd2 100644 --- a/demo-shell/src/app/components/files/files.component.html +++ b/demo-shell/src/app/components/files/files.component.html @@ -244,6 +244,17 @@ (folderChange)="onFolderChange($event)" (permissionError)="handlePermissionError($event)" (name-click)="documentList.onNodeDblClick($event.detail?.node)"> + + + + + +

You don't have permissions

diff --git a/demo-shell/src/app/components/files/files.component.ts b/demo-shell/src/app/components/files/files.component.ts index 360cde330d..13114fe47d 100644 --- a/demo-shell/src/app/components/files/files.component.ts +++ b/demo-shell/src/app/components/files/files.component.ts @@ -28,7 +28,7 @@ import { AlfrescoApiService, AuthenticationService, AppConfigService, AppConfigValues, ContentService, TranslationService, FileUploadEvent, FolderCreatedEvent, LogService, NotificationService, UploadService, DataRow, UserPreferencesService, - PaginationComponent, FormValues, DisplayMode, InfinitePaginationComponent, HighlightDirective, + PaginationComponent, FormValues, DisplayMode, ShowHeaderMode, InfinitePaginationComponent, HighlightDirective, SharedLinksApiService } from '@alfresco/adf-core'; @@ -104,7 +104,7 @@ export class FilesComponent implements OnInit, OnChanges, OnDestroy { showSettingsPanel = true; @Input() - showHeader = true; + showHeader: string = ShowHeaderMode.Always; @Input() selectionMode = 'multiple'; @@ -157,6 +157,12 @@ export class FilesComponent implements OnInit, OnChanges, OnDestroy { @Input() searchTerm = ''; + @Input() + navigationRoute = '/files'; + + @Input() + enableCustomHeaderFilter = false; + @Output() documentListReady: EventEmitter = new EventEmitter(); @@ -195,10 +201,12 @@ export class FilesComponent implements OnInit, OnChanges, OnDestroy { stickyHeader: boolean; warnOnMultipleUploads = false; thumbnails = false; + enableCustomPermissionMessage = false; enableMediumTimeFormat = false; displayEmptyMetadata = false; hyperlinkNavigation = false; + filtersStates: any[] = []; constructor(private notificationService: NotificationService, private uploadService: UploadService, @@ -361,7 +369,7 @@ export class FilesComponent implements OnInit, OnChanges, OnDestroy { } onFolderChange($event) { - this.router.navigate(['/files', $event.value.id, 'display', this.displayMode]); + this.router.navigate([this.navigationRoute, $event.value.id, 'display', this.displayMode]); } handlePermissionError(event: any) { @@ -519,22 +527,32 @@ export class FilesComponent implements OnInit, OnChanges, OnDestroy { onChangePageSize(event: Pagination): void { this.preference.paginationSize = event.maxItems; + this.pagination.maxItems = event.maxItems; + this.pagination.skipCount = event.skipCount; this.changedPageSize.emit(event); } onChangePageNumber(event: Pagination): void { + this.pagination.maxItems = event.maxItems; + this.pagination.skipCount = event.skipCount; this.changedPageNumber.emit(event); } onNextPage(event: Pagination): void { + this.pagination.maxItems = event.maxItems; + this.pagination.skipCount = event.skipCount; this.turnedNextPage.emit(event); } loadNextBatch(event: Pagination): void { + this.pagination.maxItems = event.maxItems; + this.pagination.skipCount = event.skipCount; this.loadNext.emit(event); } onPrevPage(event: Pagination): void { + this.pagination.maxItems = event.maxItems; + this.pagination.skipCount = event.skipCount; this.turnedPreviousPage.emit(event); } @@ -630,4 +648,14 @@ export class FilesComponent implements OnInit, OnChanges, OnDestroy { } return ''; } + + onFilterUpdate(newNodePaging: NodePaging) { + this.nodeResult = newNodePaging; + } + + onAllFilterCleared() { + this.documentList.node = null; + this.documentList.reload(); + } + } diff --git a/demo-shell/src/app/components/files/filtered-search.component.html b/demo-shell/src/app/components/files/filtered-search.component.html new file mode 100644 index 0000000000..56eaa79e5b --- /dev/null +++ b/demo-shell/src/app/components/files/filtered-search.component.html @@ -0,0 +1,8 @@ + + diff --git a/demo-shell/src/app/components/files/filtered-search.component.ts b/demo-shell/src/app/components/files/filtered-search.component.ts new file mode 100644 index 0000000000..70fb4e29f7 --- /dev/null +++ b/demo-shell/src/app/components/files/filtered-search.component.ts @@ -0,0 +1,42 @@ +/*! + * @license + * Copyright 2019 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, Optional } from '@angular/core'; +import { SEARCH_QUERY_SERVICE_TOKEN, SearchHeaderQueryBuilderService } from '@alfresco/adf-content-services'; +import { ActivatedRoute, Params } from '@angular/router'; + +@Component({ + selector: 'app-filtered-search-component', + templateUrl: './filtered-search.component.html', + providers: [{ provide: SEARCH_QUERY_SERVICE_TOKEN, useClass: SearchHeaderQueryBuilderService}] +}) +export class FilteredSearchComponent { + + navigationRoute = '/filtered-search'; + currentFolderId = '-my-'; + + constructor(@Optional() private route: ActivatedRoute) { + if (this.route) { + this.route.params.forEach((params: Params) => { + if (params['id'] && this.currentFolderId !== params['id']) { + this.currentFolderId = params['id']; + } + }); + } + } + +} 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 50bcf9d3eb..0a9b3abd87 100644 --- a/demo-shell/src/app/components/search/search-result.component.ts +++ b/demo-shell/src/app/components/search/search-result.component.ts @@ -15,10 +15,10 @@ * limitations under the License. */ -import { Component, OnInit, Optional, OnDestroy } from '@angular/core'; +import { Component, OnInit, Optional, OnDestroy, Inject } from '@angular/core'; import { Router, ActivatedRoute, Params } from '@angular/router'; import { Pagination, ResultSetPaging } from '@alfresco/js-api'; -import { SearchQueryBuilderService } from '@alfresco/adf-content-services'; +import { SearchQueryBuilderService, SEARCH_QUERY_SERVICE_TOKEN } from '@alfresco/adf-content-services'; import { UserPreferencesService, SearchService, AppConfigService } from '@alfresco/adf-core'; import { Subject } from 'rxjs'; import { takeUntil } from 'rxjs/operators'; @@ -27,7 +27,7 @@ import { takeUntil } from 'rxjs/operators'; selector: 'app-search-result-component', templateUrl: './search-result.component.html', styleUrls: ['./search-result.component.scss'], - providers: [SearchService, SearchQueryBuilderService] + providers: [SearchService, { provide: SEARCH_QUERY_SERVICE_TOKEN, useClass: SearchQueryBuilderService}] }) export class SearchResultComponent implements OnInit, OnDestroy { @@ -44,7 +44,7 @@ export class SearchResultComponent implements OnInit, OnDestroy { constructor(public router: Router, private config: AppConfigService, private preferences: UserPreferencesService, - private queryBuilder: SearchQueryBuilderService, + @Inject(SEARCH_QUERY_SERVICE_TOKEN) private queryBuilder: SearchQueryBuilderService, @Optional() private route: ActivatedRoute) { queryBuilder.paging = { maxItems: this.preferences.paginationSize, diff --git a/docs/content-services/components/document-list.component.md b/docs/content-services/components/document-list.component.md index 19f8c30415..a58f790d14 100644 --- a/docs/content-services/components/document-list.component.md +++ b/docs/content-services/components/document-list.component.md @@ -75,7 +75,7 @@ Displays the documents from a repository. | rowStyle | `string` | | The inline style to apply to every row. See the Angular NgStyle docs for more details and usage examples. | | rowStyleClass | `string` | | The CSS class to apply to every row | | selectionMode | `string` | "single" | Row selection mode. Can be null, `single` or `multiple`. For `multiple` mode, you can use Cmd (macOS) or Ctrl (Win) modifier key to toggle selection for multiple rows. | -| showHeader | `boolean` | true | Toggles the header | +| showHeader | `ShowHeaderMode` | "data" | Indicate which is the desired behaviour for the header. Can have 3 values `always`, `never` and `data`. `data` won't display the header if the datatable is empty and will display it otherwise. | | sorting | `string[]` | ['name', 'asc'] | Defines default sorting. The format is an array of 2 strings `[key, direction]` i.e. `['name', 'desc']` or `['name', 'asc']`. Set this value only if you want to override the default sorting detected by the component based on columns. | | sortingMode | `string` | "client" | Defines sorting mode. Can be either `client` (items in the list are sorted client-side) or `server` (the ordering supplied by the server is used without further client-side sorting). Note that the `server` option _does not_ request the server to sort the data before delivering it. | | stickyHeader | `boolean` | false | Toggles the sticky header mode. | diff --git a/docs/content-services/components/search-header.component.md b/docs/content-services/components/search-header.component.md new file mode 100644 index 0000000000..19b2382f88 --- /dev/null +++ b/docs/content-services/components/search-header.component.md @@ -0,0 +1,81 @@ +--- +Title: SearchHeader component +Added: v3.9.0 +Status: Active +Last reviewed: 2020-19-06 +--- +# [SearchHeader component](../../../lib/content-services/src/lib/search/components/search-header/search-header.component.ts "Defined in search-header.component.ts") + +Displays a button opening a menu designed to filter a document list. + +![SearchHeader demo](../../docassets/images/search-header-demo.png) + +## Contents + +- [Basic usage](#basic-usage) +- [Class members](#class-members) + - [Properties](#properties) + - [Events](#events) +- [Details](#details) + - [example](#example) +- [See also](#see-also) + +## Basic usage + +**app.component.html** + +```html + + + + + + + + +``` + +**app.config.json** + +```json +``` + +This component is designed to be used as transcluded inside the document list component. With the good configurations it will allow the user to filter the data displayed by that component. + +## Class members + +### Properties + +| Name | Type | Default value | Description | +| ---- | ---- | ------------- | ----------- | +| col | `DataColumn` | | The column the filter will be applied on. | +| currentFolderNodeId | `string` | | The id of the current folder of the document list. | +| maxItems | `number` | | Pagination parameter coming from the document list. | +| skipCount | `number` | | An other pagination parameter coming from the document list. | +| widgetContainer | `SearchWidgetContainerComponent` | | View of the child facet widget generated inside the menu. Used to control that widget through the apply and clear buttons. | +| isActive | `boolean` | | A boolean telling if the current data displayed in the document list is affected by that filter. | +| category | `SearchCategory` | | The category of the filter. It contains the information regarding the way the filter is filtering the data. This is get with the column information and the configuration of the search-header inside the config file | +| isFilterServiceActive | `boolean` | | Boolean to check if the SearchHeaderQueryBuilderService is active | + +### Events + +| Name | Type | Description | +| ---- | ---- | ----------- | +| update | `EventEmitter` | Emitted when the result of the filter is received from the API, should be passed to the `node` member of the document list. | +| clear | `EventEmitter` | Emitted when the last of all the filters is cleared. This should be used to trigger a `reload()` of the document list with no `node` member. | + + +## See also + +- [Document list component](document-list.component.md) +- [Search filter component](search-filter.component.md) +- [Search component](search.component.md) +- [Datatable component](../../core/components/datatable.component.md) +- [Search Query Builder service](../services/search-query-builder.service.md) diff --git a/docs/core/components/datatable.component.md b/docs/core/components/datatable.component.md index 4e8125158e..c00e626384 100644 --- a/docs/core/components/datatable.component.md +++ b/docs/core/components/datatable.component.md @@ -360,7 +360,7 @@ Learm more about styling your datatable: [Customizing the component's styles](#c | rowStyleClass | `string` | "" | The CSS class to apply to every row. | | rows | `any[]` | \[] | The rows that the datatable will show. | | selectionMode | `string` | "single" | Row selection mode. Can be none, `single` or `multiple`. For `multiple` mode, you can use Cmd (macOS) or Ctrl (Win) modifier key to toggle selection for multiple rows. | -| showHeader | `boolean` | true | Toggles the header. | +| showHeader | `ShowHeaderMode` | "data" | Indicate which is the desired behaviour for the header. Can have 3 values `always`, `never` and `data`. `data` won't display the header if the datatable is empty and will display it otherwise. | | sorting | `any[]` | \[] | Define the sort order of the datatable. Possible values are : [`created`, `desc`], [`created`, `asc`], [`due`, `desc`], [`due`, `asc`] | | stickyHeader | `boolean` | false | Toggles the sticky header mode. | diff --git a/docs/docassets/images/search-header-demo.png b/docs/docassets/images/search-header-demo.png new file mode 100644 index 0000000000..20038cc445 Binary files /dev/null and b/docs/docassets/images/search-header-demo.png differ diff --git a/lib/content-services/src/lib/document-list/components/document-list.component.html b/lib/content-services/src/lib/document-list/components/document-list.component.html index cc38bf6588..e471b197c2 100644 --- a/lib/content-services/src/lib/document-list/components/document-list.component.html +++ b/lib/content-services/src/lib/document-list/components/document-list.component.html @@ -11,7 +11,7 @@ [loading]="loading" [display]="display" [noPermission]="noPermission" - [showHeader]="!isEmpty() && showHeader" + [showHeader]="showHeader" [rowMenuCacheEnabled]="false" [stickyHeader]="stickyHeader" [allowFiltering]="allowFiltering" @@ -24,9 +24,13 @@ (row-unselect)="onNodeUnselect($event.detail)" [class.adf-datatable-gallery-thumbnails]="data.thumbnails"> - - - + + + + + + diff --git a/lib/content-services/src/lib/document-list/components/document-list.component.scss b/lib/content-services/src/lib/document-list/components/document-list.component.scss index a180a03c15..aae8c1b5af 100644 --- a/lib/content-services/src/lib/document-list/components/document-list.component.scss +++ b/lib/content-services/src/lib/document-list/components/document-list.component.scss @@ -207,11 +207,4 @@ } } } - - .adf-filter-button { - mat-icon { - height: 18px; - width: 18px; - } - } } diff --git a/lib/content-services/src/lib/document-list/components/document-list.component.spec.ts b/lib/content-services/src/lib/document-list/components/document-list.component.spec.ts index 2504a138e7..7b7172f8ca 100644 --- a/lib/content-services/src/lib/document-list/components/document-list.component.spec.ts +++ b/lib/content-services/src/lib/document-list/components/document-list.component.spec.ts @@ -25,7 +25,8 @@ import { DataColumn, DataTableComponent, DataTableModule, - ObjectDataTableAdapter + ObjectDataTableAdapter, + ShowHeaderMode } from '@alfresco/adf-core'; import { Subject, of, throwError } from 'rxjs'; import { @@ -330,7 +331,7 @@ describe('DocumentList', () => { }); it('should hide the header if showHeader is false', () => { - documentList.showHeader = false; + documentList.showHeader = ShowHeaderMode.Data; fixture.detectChanges(); @@ -338,7 +339,7 @@ describe('DocumentList', () => { }); it('should show the header if showHeader is true', () => { - documentList.showHeader = true; + documentList.showHeader = ShowHeaderMode.Data; fixture.detectChanges(); diff --git a/lib/content-services/src/lib/document-list/components/document-list.component.ts b/lib/content-services/src/lib/document-list/components/document-list.component.ts index 4b7716f37d..ad18122746 100644 --- a/lib/content-services/src/lib/document-list/components/document-list.component.ts +++ b/lib/content-services/src/lib/document-list/components/document-list.component.ts @@ -30,6 +30,7 @@ import { DataSorting, DataTableComponent, DisplayMode, + ShowHeaderMode, ObjectDataColumn, PaginatedComponent, AppConfigService, @@ -40,6 +41,7 @@ import { CustomLoadingContentTemplateDirective, CustomNoPermissionTemplateDirective, CustomEmptyContentTemplateDirective, + CustomHeaderFilterTemplateDirective, RequestPaginationModel, AlfrescoApiService, UserPreferenceValues, @@ -91,6 +93,9 @@ export class DocumentListComponent implements OnInit, OnChanges, OnDestroy, Afte @ContentChild(CustomEmptyContentTemplateDirective) customNoContentTemplate: CustomEmptyContentTemplateDirective; + @ContentChild(CustomHeaderFilterTemplateDirective) + customHeaderFilterTemplate: CustomHeaderFilterTemplateDirective; + /** Include additional information about the node in the server request. For example: association, isLink, isLocked and others. */ @Input() includeFields: string[]; @@ -123,7 +128,7 @@ export class DocumentListComponent implements OnInit, OnChanges, OnDestroy, Afte /** Toggles the header */ @Input() - showHeader: boolean = true; + showHeader: string = ShowHeaderMode.Data; /** User interaction for folder navigation or file preview. * Valid values are "click" and "dblclick". Default value: "dblclick" @@ -308,7 +313,7 @@ export class DocumentListComponent implements OnInit, OnChanges, OnDestroy, Afte noPermission: boolean = false; selection = new Array(); $folderNode: Subject = new Subject(); - allowFiltering: boolean = false; + allowFiltering: boolean = true; // @deprecated 3.0.0 folderNode: Node; @@ -854,5 +859,4 @@ export class DocumentListComponent implements OnInit, OnChanges, OnDestroy, Afte this.setLoadingState(false); this.error.emit(err); } - } diff --git a/lib/content-services/src/lib/document-list/components/filter-menu/filter-menu.component.html b/lib/content-services/src/lib/document-list/components/filter-menu/filter-menu.component.html deleted file mode 100644 index c161f11f68..0000000000 --- a/lib/content-services/src/lib/document-list/components/filter-menu/filter-menu.component.html +++ /dev/null @@ -1,16 +0,0 @@ - - - -
{{col.title}}
- - - - -
diff --git a/lib/content-services/src/lib/document-list/document-list.module.ts b/lib/content-services/src/lib/document-list/document-list.module.ts index 0f37fa5629..c68b545bd5 100644 --- a/lib/content-services/src/lib/document-list/document-list.module.ts +++ b/lib/content-services/src/lib/document-list/document-list.module.ts @@ -32,7 +32,6 @@ import { LibraryStatusColumnComponent } from './components/library-status-column import { LibraryRoleColumnComponent } from './components/library-role-column/library-role-column.component'; import { LibraryNameColumnComponent } from './components/library-name-column/library-name-column.component'; import { NameColumnComponent } from './components/name-column/name-column.component'; -import { FilterMenuComponent } from './components/filter-menu/filter-menu.component'; @NgModule({ imports: [ @@ -51,8 +50,7 @@ import { FilterMenuComponent } from './components/filter-menu/filter-menu.compon LibraryNameColumnComponent, NameColumnComponent, ContentActionComponent, - ContentActionListComponent, - FilterMenuComponent + ContentActionListComponent ], exports: [ DocumentListComponent, diff --git a/lib/content-services/src/lib/i18n/en.json b/lib/content-services/src/lib/i18n/en.json index 594ef711a7..35779dd621 100644 --- a/lib/content-services/src/lib/i18n/en.json +++ b/lib/content-services/src/lib/i18n/en.json @@ -62,11 +62,6 @@ "VIEW": "View", "REMOVE": "Remove", "DOWNLOAD": "Download" - }, - "FILTER_MENU" : { - "FILTER_BY": "Filter by {{ category }}", - "CLEAR": "Clear", - "APPLY": "Apply" } }, "ALFRESCO_DOCUMENT_LIST": { @@ -289,6 +284,12 @@ "PROCESS_ACTION": "Start Process" } } + }, + "SEARCH_HEADER" : { + "TITLE":"Filter", + "FILTER_BY": "Filter by {{ category }}", + "CLEAR": "Clear", + "APPLY": "Apply" } }, "PERMISSION": { diff --git a/lib/content-services/src/lib/material.module.ts b/lib/content-services/src/lib/material.module.ts index 0c131c7625..b142c11212 100644 --- a/lib/content-services/src/lib/material.module.ts +++ b/lib/content-services/src/lib/material.module.ts @@ -36,7 +36,8 @@ import { MatSlideToggleModule, MatRadioModule, MatSliderModule, - MatTreeModule + MatTreeModule, + MatBadgeModule } from '@angular/material'; export function modules() { @@ -60,7 +61,8 @@ export function modules() { MatSlideToggleModule, MatRadioModule, MatSliderModule, - MatTreeModule + MatTreeModule, + MatBadgeModule ]; } diff --git a/lib/content-services/src/lib/mock/document-list.component.mock.ts b/lib/content-services/src/lib/mock/document-list.component.mock.ts index e3f60aa0da..365a9179c8 100644 --- a/lib/content-services/src/lib/mock/document-list.component.mock.ts +++ b/lib/content-services/src/lib/mock/document-list.component.mock.ts @@ -15,9 +15,9 @@ * limitations under the License. */ -import { Node } from '@alfresco/js-api'; +import { Node, NodePaging } from '@alfresco/js-api'; -export const fakeNodeWithCreatePermission = new Node({ +export const fakeNodeWithCreatePermission = new Node({ isFile: false, createdByUser: { id: 'admin', displayName: 'Administrator' }, modifiedAt: '2017-06-08T13:53:46.495Z', @@ -193,3 +193,25 @@ export const fakeGetSiteMembership = { }] } }; + +export const fakeNodePaging: NodePaging = { + list: { + pagination: { + count: 5, + hasMoreItems: false, + totalItems: 5, + skipCount: 0, + maxItems: 100 + }, entries: [{ + entry: fakeNodeWithNoPermission + }, { + entry: fakeNodeWithNoPermission + }, { + entry: fakeNodeWithNoPermission + }, { + entry: fakeNodeWithNoPermission + }, { + entry: fakeNodeWithNoPermission + }] + } +}; diff --git a/lib/content-services/src/lib/search/base-query-builder.service.ts b/lib/content-services/src/lib/search/base-query-builder.service.ts new file mode 100644 index 0000000000..709a63f297 --- /dev/null +++ b/lib/content-services/src/lib/search/base-query-builder.service.ts @@ -0,0 +1,439 @@ +/*! + * @license + * Copyright 2019 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'; +import { AlfrescoApiService, AppConfigService } from '@alfresco/adf-core'; +import { + QueryBody, + RequestFacetFields, + RequestFacetField, + RequestSortDefinitionInner, + ResultSetPaging, + RequestHighlight +} 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'; +import { SearchSortingDefinition } from './search-sorting-definition.interface'; +import { FacetField } from './facet-field.interface'; +import { FacetFieldBucket } from './facet-field-bucket.interface'; + +@Injectable({ + providedIn: 'root' +}) +export abstract class BaseQueryBuilderService { + + private _userQuery = ''; + + updated = new Subject(); + executed = new Subject(); + error = new Subject(); + + categories: Array = []; + queryFragments: { [id: string]: string } = {}; + filterQueries: FilterQuery[] = []; + paging: { maxItems?: number; skipCount?: number } = null; + sorting: Array = []; + + protected userFacetBuckets: { [key: string]: Array } = {}; + + get userQuery(): string { + return this._userQuery; + } + + set userQuery(value: string) { + value = (value || '').trim(); + this._userQuery = value ? `(${value})` : ''; + } + + config: SearchConfiguration = { + categories: [] + }; + + // TODO: to be supported in future iterations + ranges: { [id: string]: SearchRange } = {}; + + constructor(protected appConfig: AppConfigService, protected alfrescoApiService: AlfrescoApiService) { + this.setUpConfiguration(); + } + + public abstract loadConfiguration(): SearchConfiguration; + + public abstract isFilterServiceActive(): boolean; + + public setUpConfiguration() { + const currentConfig = this.loadConfiguration(); + this.setUpSearchConfiguration(currentConfig); + } + + private setUpSearchConfiguration(currentConfiguration: SearchConfiguration) { + if (currentConfiguration) { + this.config = JSON.parse(JSON.stringify(currentConfiguration)); + this.categories = (this.config.categories || []).filter( + category => category.enabled + ); + this.filterQueries = this.config.filterQueries || []; + this.userFacetBuckets = {}; + if (this.config.sorting) { + this.sorting = this.config.sorting.defaults || []; + } + } + } + + /** + * Adds a facet bucket to a field. + * @param field The target field + * @param bucket Bucket to add + */ + addUserFacetBucket(field: FacetField, bucket: FacetFieldBucket) { + if (field && field.field && bucket) { + const buckets = this.userFacetBuckets[field.field] || []; + const existing = buckets.find((facetBucket) => facetBucket.label === bucket.label); + if (!existing) { + buckets.push(bucket); + } + this.userFacetBuckets[field.field] = buckets; + } + } + + /** + * Gets the buckets currently added to a field + * @param field The target fields + * @returns Bucket array + */ + getUserFacetBuckets(field: string) { + return this.userFacetBuckets[field] || []; + } + + /** + * Removes an existing bucket from a field. + * @param field The target field + * @param bucket Bucket to remove + */ + removeUserFacetBucket(field: FacetField, bucket: FacetFieldBucket) { + if (field && field.field && bucket) { + const buckets = this.userFacetBuckets[field.field] || []; + this.userFacetBuckets[field.field] = buckets + .filter((facetBucket) => facetBucket.label !== bucket.label); + } + } + + /** + * Adds a filter query to the current query. + * @param query Query string to add + */ + addFilterQuery(query: string): void { + if (query) { + const existing = this.filterQueries.find((filterQuery) => filterQuery.query === query); + if (!existing) { + this.filterQueries.push({ query: query }); + } + } + } + + /** + * Removes an existing filter query. + * @param query The query to remove + */ + removeFilterQuery(query: string): void { + if (query) { + this.filterQueries = this.filterQueries + .filter((filterQuery) => filterQuery.query !== query); + } + } + + /** + * Gets a facet query by label. + * @param label Label of the query + * @returns Facet query data + */ + getFacetQuery(label: string): FacetQuery { + if (label && this.hasFacetQueries) { + const result = this.config.facetQueries.queries.find((query) => query.label === label); + if (result) { + return { ...result }; + } + } + return null; + } + + /** + * Gets a facet field by label. + * @param label Label of the facet field + * @returns Facet field data + */ + getFacetField(label: string): FacetField { + if (label) { + const fields = this.config.facetFields.fields || []; + const result = fields.find((field) => field.label === label); + if (result) { + result.label = this.getSupportedLabel(result.label); + return { ...result }; + } + } + return null; + } + + /** + * Builds the current query and triggers the `updated` event. + */ + update(): void { + const query = this.buildQuery(); + this.updated.next(query); + } + + /** + * Builds and executes the current query. + * @returns Nothing + */ + async execute() { + try { + const query = this.buildQuery(); + if (query) { + const resultSetPaging: ResultSetPaging = await this.alfrescoApiService.searchApi.search(query); + this.executed.next(resultSetPaging); + } + } catch (error) { + this.error.next(error); + + this.executed.next({ + list: { + pagination: { + totalItems: 0 + }, + entries: [] + } + }); + } + } + + /** + * Builds the current query. + * @returns The finished query + */ + buildQuery(): QueryBody { + const query = this.getFinalQuery(); + + const include = this.config.include || []; + if (include.length === 0) { + include.push('path', 'allowableOperations'); + } + + if (query) { + const result: QueryBody = { + query: { + query: query, + language: 'afts' + }, + include: include, + paging: this.paging, + fields: this.config.fields, + filterQueries: this.filterQueries, + facetQueries: this.facetQueries, + facetIntervals: this.facetIntervals, + facetFields: this.facetFields, + sort: this.sort, + highlight: this.highlight + }; + + result['facetFormat'] = 'V2'; + return result; + } + + return null; + } + + /** + * Gets the primary sorting definition. + * @returns The primary sorting definition + */ + getPrimarySorting(): SearchSortingDefinition { + if (this.sorting && this.sorting.length > 0) { + return this.sorting[0]; + } + return null; + } + + /** + * Gets all pre-configured sorting options that users can choose from. + * @returns Pre-configured sorting options + */ + getSortingOptions(): SearchSortingDefinition[] { + if (this.config && this.config.sorting) { + return this.config.sorting.options || []; + } + return []; + } + + /** + * Gets the query group. + * @param query Target query + * @returns Query group + */ + getQueryGroup(query) { + return query.group || this.config.facetQueries.label || 'Facet Queries'; + } + + /** + * Checks if FacetQueries has been defined + * @returns True if defined, false otherwise + */ + get hasFacetQueries(): boolean { + if (this.config + && this.config.facetQueries + && this.config.facetQueries.queries + && this.config.facetQueries.queries.length > 0) { + return true; + } + return false; + } + + /** + * Checks if FacetIntervals has been defined + * @returns True if defined, false otherwise + */ + get hasFacetIntervals(): boolean { + if (this.config + && this.config.facetIntervals + && this.config.facetIntervals.intervals + && this.config.facetIntervals.intervals.length > 0) { + return true; + } + return false; + } + + get hasFacetHighlight(): boolean { + return this.config && this.config.highlight ? true : false; + } + + protected get sort(): RequestSortDefinitionInner[] { + return this.sorting.map((def) => { + return new RequestSortDefinitionInner({ + type: def.type, + field: def.field, + ascending: def.ascending + }); + }); + } + + protected get facetQueries(): FacetQuery[] { + if (this.hasFacetQueries) { + return this.config.facetQueries.queries.map((query) => { + query.group = this.getQueryGroup(query); + return { ...query }; + }); + } + + return null; + } + + protected get facetIntervals(): any { + if (this.hasFacetIntervals) { + const configIntervals = this.config.facetIntervals; + + return { + intervals: configIntervals.intervals.map((interval) => { + label: this.getSupportedLabel(interval.label), + field: interval.field, + sets: interval.sets.map((set) => { + label: this.getSupportedLabel(set.label), + start: set.start, + end: set.end, + startInclusive: set.startInclusive, + endInclusive: set.endInclusive + }) + }) + }; + } + + return null; + } + + protected get highlight(): RequestHighlight { + return this.hasFacetHighlight ? this.config.highlight : null; + } + + protected getFinalQuery(): string { + let query = ''; + + this.categories.forEach((facet) => { + const customQuery = this.queryFragments[facet.id]; + if (customQuery) { + if (query.length > 0) { + query += ' AND '; + } + query += `(${customQuery})`; + } + }); + + let result = [this.userQuery, query] + .filter((entry) => entry) + .join(' AND '); + + if (this.userFacetBuckets) { + Object.keys(this.userFacetBuckets).forEach((key) => { + const subQuery = (this.userFacetBuckets[key] || []) + .filter((bucket) => bucket.filterQuery) + .map((bucket) => bucket.filterQuery) + .join(' OR '); + if (subQuery) { + if (result.length > 0) { + result += ' AND '; + } + result += `(${subQuery})`; + } + }); + } + + return result; + } + + protected get facetFields(): RequestFacetFields { + const facetFields = this.config.facetFields && this.config.facetFields.fields; + + if (facetFields && facetFields.length > 0) { + return { + facets: facetFields.map((facet) => { + field: facet.field, + mincount: facet.mincount, + label: this.getSupportedLabel(facet.label), + limit: facet.limit, + offset: facet.offset, + prefix: facet.prefix + }) + }; + } + + return null; + } + + /** + * Encloses a label name with double quotes if it contains whitespace characters. + * @param configLabel Original label text + * @returns Label, possibly with quotes if it contains spaces + */ + getSupportedLabel(configLabel: string): string { + const spaceInsideLabelIndex = configLabel.search(/\s/g); + if (spaceInsideLabelIndex > -1) { + return `"${configLabel}"`; + } + return configLabel; + } +} diff --git a/lib/content-services/src/lib/search/components/search-date-range/search-date-range.component.ts b/lib/content-services/src/lib/search/components/search-date-range/search-date-range.component.ts index a0a5d24fa7..7e40f4a6dd 100644 --- a/lib/content-services/src/lib/search/components/search-date-range/search-date-range.component.ts +++ b/lib/content-services/src/lib/search/components/search-date-range/search-date-range.component.ts @@ -125,6 +125,10 @@ export class SearchDateRangeComponent implements SearchWidget, OnInit, OnDestroy } } + applyCurrentForm() { + this.apply(this.form.value, this.form.valid); + } + reset() { this.form.reset({ from: '', diff --git a/lib/content-services/src/lib/search/components/search-filter/search-filter.component.spec.ts b/lib/content-services/src/lib/search/components/search-filter/search-filter.component.spec.ts index bc610a5be0..2411ecc1e6 100644 --- a/lib/content-services/src/lib/search/components/search-filter/search-filter.component.spec.ts +++ b/lib/content-services/src/lib/search/components/search-filter/search-filter.component.spec.ts @@ -59,8 +59,8 @@ describe('SearchFilterComponent', () => { }); beforeEach(() => { - queryBuilder = TestBed.get(SearchQueryBuilderService); fixture = TestBed.createComponent(SearchFilterComponent); + queryBuilder = fixture.componentInstance.queryBuilder; appConfigService = TestBed.get(AppConfigService); const translationService = fixture.debugElement.injector.get(TranslationService); spyOn(translationService, 'instant').and.callFake((key) => key ? `${key}_translated` : null); @@ -737,7 +737,7 @@ describe('SearchFilterComponent', () => { it('should not show the disabled widget', async(() => { appConfigService.config.search = { categories: disabledCategories }; - queryBuilder.resetToDefaults(); + queryBuilder.setUpConfiguration(); fixture.detectChanges(); fixture.whenStable().then(() => { @@ -748,7 +748,7 @@ describe('SearchFilterComponent', () => { it('should show the widget in expanded mode', async(() => { appConfigService.config.search = { categories: expandedCategories }; - queryBuilder.resetToDefaults(); + queryBuilder.setUpConfiguration(); fixture.detectChanges(); fixture.whenStable().then(() => { @@ -769,7 +769,7 @@ describe('SearchFilterComponent', () => { it('should show the widgets only if configured', async(() => { appConfigService.config.search = { categories: simpleCategories }; - queryBuilder.resetToDefaults(); + queryBuilder.setUpConfiguration(); fixture.detectChanges(); fixture.whenStable().then(() => { @@ -784,7 +784,7 @@ describe('SearchFilterComponent', () => { it('should be update the search query when name changed', async( async () => { spyOn(queryBuilder, 'update').and.stub(); appConfigService.config.search = searchFilter; - queryBuilder.resetToDefaults(); + queryBuilder.setUpConfiguration(); fixture.detectChanges(); await fixture.whenStable(); @@ -805,7 +805,7 @@ describe('SearchFilterComponent', () => { it('should show the long facet options list with pagination', () => { const panel = '[data-automation-id="expansion-panel-Size facet queries"]'; appConfigService.config.search = searchFilter; - queryBuilder.resetToDefaults(); + queryBuilder.setUpConfiguration(); fixture.detectChanges(); queryBuilder.executed.next( mockSearchResult); @@ -871,7 +871,7 @@ describe('SearchFilterComponent', () => { delete filter.facetQueries; appConfigService.config.search = filter; - queryBuilder.resetToDefaults(); + queryBuilder.setUpConfiguration(); fixture.detectChanges(); queryBuilder.executed.next( mockSearchResult); @@ -884,7 +884,7 @@ describe('SearchFilterComponent', () => { it('should search the facets options and select it', () => { const panel = '[data-automation-id="expansion-panel-Size facet queries"]'; appConfigService.config.search = searchFilter; - queryBuilder.resetToDefaults(); + queryBuilder.setUpConfiguration(); fixture.detectChanges(); queryBuilder.executed.next( mockSearchResult); fixture.detectChanges(); @@ -930,7 +930,7 @@ describe('SearchFilterComponent', () => { const panel1 = '[data-automation-id="expansion-panel-Size facet queries"]'; const panel2 = '[data-automation-id="expansion-panel-Type facet queries"]'; appConfigService.config.search = searchFilter; - queryBuilder.resetToDefaults(); + queryBuilder.setUpConfiguration(); fixture.detectChanges(); queryBuilder.executed.next( mockSearchResult); fixture.detectChanges(); diff --git a/lib/content-services/src/lib/search/components/search-filter/search-filter.component.ts b/lib/content-services/src/lib/search/components/search-filter/search-filter.component.ts index de79107c80..408b283b95 100644 --- a/lib/content-services/src/lib/search/components/search-filter/search-filter.component.ts +++ b/lib/content-services/src/lib/search/components/search-filter/search-filter.component.ts @@ -15,7 +15,7 @@ * limitations under the License. */ -import { Component, ViewEncapsulation, OnInit, OnDestroy } from '@angular/core'; +import { Component, ViewEncapsulation, OnInit, OnDestroy, Inject } from '@angular/core'; import { MatCheckboxChange } from '@angular/material'; import { SearchService, TranslationService } from '@alfresco/adf-core'; import { SearchQueryBuilderService } from '../../search-query-builder.service'; @@ -25,6 +25,7 @@ import { SearchFilterList } from './models/search-filter-list.model'; import { takeUntil } from 'rxjs/operators'; import { GenericBucket, GenericFacetResponse, ResultSetContext, ResultSetPaging } from '@alfresco/js-api'; import { Subject } from 'rxjs'; +import { SEARCH_QUERY_SERVICE_TOKEN } from '../../search-query-service.token'; export interface SelectedBucket { field: FacetField; @@ -58,7 +59,7 @@ export class SearchFilterComponent implements OnInit, OnDestroy { private onDestroy$ = new Subject(); - constructor(public queryBuilder: SearchQueryBuilderService, + constructor(@Inject(SEARCH_QUERY_SERVICE_TOKEN) public queryBuilder: SearchQueryBuilderService, private searchService: SearchService, private translationService: TranslationService) { if (queryBuilder.config && queryBuilder.config.facetQueries) { diff --git a/lib/content-services/src/lib/search/components/search-header/search-header.component.html b/lib/content-services/src/lib/search/components/search-header/search-header.component.html new file mode 100644 index 0000000000..d158919cda --- /dev/null +++ b/lib/content-services/src/lib/search/components/search-header/search-header.component.html @@ -0,0 +1,37 @@ +
+
+ + + +
+
{{ 'SEARCH.SEARCH_HEADER.TITLE' | translate }}
+ + +
+ + + + +
+
+
diff --git a/lib/content-services/src/lib/search/components/search-header/search-header.component.scss b/lib/content-services/src/lib/search/components/search-header/search-header.component.scss new file mode 100644 index 0000000000..bd6aa2b3b2 --- /dev/null +++ b/lib/content-services/src/lib/search/components/search-header/search-header.component.scss @@ -0,0 +1,72 @@ +@mixin adf-filter-menu-theme($theme) { + $primary: map-get($theme, primary); + $foreground: map-get($theme, foreground); + $background: map-get($theme, background); + + .adf-filter { + + &-button { + margin-left: -7px !important; + + .adf-icon { + opacity: 1; + color: mat-color($foreground, icon); + + &-active { + color: mat-color($primary); + } + } + + .mat-icon { + height: 18px; + width: 18px; + } + + .mat-badge-content { + width: 10px; + height: 10px; + top: -3px !important; + right: -6px !important; + } + } + + &-container { + display: flex; + flex-direction: column; + padding: 15px 15px 10px; + + .adf-facet-buttons { + display: none; + } + + .adf-search-check-list { + padding: 5px 0; + } + } + + &-title { + font-size: 1.1em; + padding-bottom: 5px; + } + + &-actions { + background-color: mat-color($background, background); + display: flex; + justify-content: flex-end; + padding: 15px; + + > button { + font-size: 0.9em; + } + } + + &-apply-button { + background-color: mat-color($background, hover); + } + } + + .mat-menu-panel.adf-filter-menu .mat-menu-content { + min-width: 260px; + padding: 0; + } +} diff --git a/lib/content-services/src/lib/search/components/search-header/search-header.component.spec.ts b/lib/content-services/src/lib/search/components/search-header/search-header.component.spec.ts new file mode 100644 index 0000000000..9b814a42f3 --- /dev/null +++ b/lib/content-services/src/lib/search/components/search-header/search-header.component.spec.ts @@ -0,0 +1,147 @@ +/*! + * @license + * Copyright 2019 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 { Subject } from 'rxjs'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { SearchService, setupTestBed, AlfrescoApiService } from '@alfresco/adf-core'; +import { SearchHeaderComponent } from './search-header.component'; +import { SearchHeaderQueryBuilderService } from '../../search-header-query-builder.service'; +import { ContentTestingModule } from '../../../testing/content.testing.module'; +import { fakeNodePaging } from '../../../mock'; +import { SEARCH_QUERY_SERVICE_TOKEN } from '../../search-query-service.token'; +import { By } from '@angular/platform-browser'; +import { SimpleChange } from '@angular/core'; + +const mockCategory: any = { + 'id': 'queryName', + 'name': 'Name', + 'columnKey': 'name', + 'enabled': true, + 'expanded': true, + 'component': { + 'selector': 'text', + 'settings': { + 'pattern': "cm:name:'(.*?)'", + 'field': 'cm:name', + 'placeholder': 'Enter the name' + } + } +}; + +describe('SearchHeaderComponent', () => { + let fixture: ComponentFixture; + let component: SearchHeaderComponent; + let queryBuilder: SearchHeaderQueryBuilderService; + let alfrescoApiService: AlfrescoApiService; + + const searchMock: any = { + dataLoaded: new Subject() + }; + + setupTestBed({ + imports: [ + TranslateModule.forRoot(), + ContentTestingModule + ], + providers: [ + { provide: SearchService, useValue: searchMock }, + { provide: SEARCH_QUERY_SERVICE_TOKEN, useClass: SearchHeaderQueryBuilderService } + ] + }); + + beforeEach(() => { + fixture = TestBed.createComponent(SearchHeaderComponent); + component = fixture.componentInstance; + queryBuilder = fixture.componentInstance['searchHeaderQueryBuilder']; + alfrescoApiService = TestBed.get(AlfrescoApiService); + component.col = {key: '123', type: 'text'}; + spyOn(queryBuilder, 'getCategoryForColumn').and.returnValue(mockCategory); + fixture.detectChanges(); + }); + + afterEach(() => { + fixture.destroy(); + }); + + it('should show the filter when a category is found', async () => { + expect(queryBuilder.isFilterServiceActive()).toBe(true); + const element = fixture.nativeElement.querySelector('.adf-filter'); + expect(element).not.toBeNull(); + expect(element).not.toBeUndefined(); + }); + + it('should emit the node paging received from the queryBuilder after the filter gets applied', async (done) => { + spyOn(alfrescoApiService.searchApi, 'search').and.returnValue(Promise.resolve(fakeNodePaging)); + spyOn(queryBuilder, 'buildQuery').and.returnValue({}); + component.update.subscribe((newNodePaging) => { + expect(newNodePaging).toBe(fakeNodePaging); + done(); + }); + const menuButton: HTMLButtonElement = fixture.nativeElement.querySelector('#filter-menu-button'); + menuButton.click(); + fixture.detectChanges(); + await fixture.whenStable(); + const applyButton = fixture.debugElement.query(By.css('#apply-filter-button')); + applyButton.triggerEventHandler('click', {}); + fixture.detectChanges(); + await fixture.whenStable(); + }); + + it('should execute a new query when the page size is changed', async (done) => { + spyOn(alfrescoApiService.searchApi, 'search').and.returnValue(Promise.resolve(fakeNodePaging)); + spyOn(queryBuilder, 'buildQuery').and.returnValue({}); + component.update.subscribe((newNodePaging) => { + expect(newNodePaging).toBe(fakeNodePaging); + done(); + }); + const maxItem = new SimpleChange(10, 20, false); + component.ngOnChanges({ 'maxItems': maxItem }); + fixture.detectChanges(); + await fixture.whenStable(); + }); + + it('should execute a new query when a new page is requested', async (done) => { + spyOn(alfrescoApiService.searchApi, 'search').and.returnValue(Promise.resolve(fakeNodePaging)); + spyOn(queryBuilder, 'buildQuery').and.returnValue({}); + component.update.subscribe((newNodePaging) => { + expect(newNodePaging).toBe(fakeNodePaging); + done(); + }); + const skipCount = new SimpleChange(0, 10, false); + component.ngOnChanges({ 'skipCount': skipCount }); + fixture.detectChanges(); + await fixture.whenStable(); + }); + + it('should emit the clear event when no filter has been selected', async (done) => { + spyOn(queryBuilder, 'isNoFilterActive').and.returnValue(true); + spyOn(alfrescoApiService.searchApi, 'search').and.returnValue(Promise.resolve(fakeNodePaging)); + spyOn(queryBuilder, 'buildQuery').and.returnValue({}); + spyOn(component.widgetContainer, 'resetInnerWidget').and.stub(); + const fakeEvent = jasmine.createSpyObj('event', ['stopPropagation']); + component.clear.subscribe(() => { + done(); + }); + const menuButton: HTMLButtonElement = fixture.nativeElement.querySelector('#filter-menu-button'); + menuButton.click(); + fixture.detectChanges(); + await fixture.whenStable(); + const clearButton = fixture.debugElement.query(By.css('#clear-filter-button')); + clearButton.triggerEventHandler('click', fakeEvent); + fixture.detectChanges(); + }); +}); diff --git a/lib/content-services/src/lib/search/components/search-header/search-header.component.ts b/lib/content-services/src/lib/search/components/search-header/search-header.component.ts new file mode 100644 index 0000000000..44704e7a1c --- /dev/null +++ b/lib/content-services/src/lib/search/components/search-header/search-header.component.ts @@ -0,0 +1,137 @@ +/*! + * @license + * Copyright 2019 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, Output, OnInit, OnChanges, EventEmitter, SimpleChanges, ViewEncapsulation, ViewChild, Inject, OnDestroy } from '@angular/core'; +import { DataColumn } from '@alfresco/adf-core'; +import { SearchWidgetContainerComponent } from '../search-widget-container/search-widget-container.component'; +import { SearchHeaderQueryBuilderService } from '../../search-header-query-builder.service'; +import { NodePaging } from '@alfresco/js-api'; +import { SearchCategory } from '../../search-category.interface'; +import { SEARCH_QUERY_SERVICE_TOKEN } from '../../search-query-service.token'; +import { Subject } from 'rxjs'; +import { takeUntil } from 'rxjs/operators'; + +@Component({ + selector: 'adf-search-header', + templateUrl: './search-header.component.html', + styleUrls: ['./search-header.component.scss'], + encapsulation: ViewEncapsulation.None +}) +export class SearchHeaderComponent implements OnInit, OnChanges, OnDestroy { + @Input() + col: DataColumn; + + @Input() + currentFolderNodeId: string; + + @Input() + maxItems: number; + + @Input() + skipCount: number; + + @Output() + update: EventEmitter = new EventEmitter(); + + @Output() + clear: EventEmitter = new EventEmitter(); + + @ViewChild(SearchWidgetContainerComponent) + widgetContainer: SearchWidgetContainerComponent; + + public isActive: boolean; + + category: SearchCategory; + isFilterServiceActive: boolean; + + private onDestroy$ = new Subject(); + + constructor(@Inject(SEARCH_QUERY_SERVICE_TOKEN) private searchHeaderQueryBuilder: SearchHeaderQueryBuilderService) { + this.isFilterServiceActive = this.searchHeaderQueryBuilder.isFilterServiceActive(); + } + + ngOnInit() { + this.category = this.searchHeaderQueryBuilder.getCategoryForColumn( + this.col.key + ); + + this.searchHeaderQueryBuilder.executed + .pipe(takeUntil(this.onDestroy$)) + .subscribe((newNodePaging: NodePaging) => { + this.update.emit(newNodePaging); + }); + } + + ngOnChanges(changes: SimpleChanges) { + if (changes['currentFolderNodeId'] && changes['currentFolderNodeId'].currentValue) { + const currentIdValue = changes['currentFolderNodeId'].currentValue; + const previousIdValue = changes['currentFolderNodeId'].previousValue; + this.searchHeaderQueryBuilder.setCurrentRootFolderId( + currentIdValue, + previousIdValue + ); + + this.isActive = false; + } + + if (changes['maxItems'] || changes['skipCount']) { + let actualMaxItems = this.maxItems; + let actualSkipCount = this.skipCount; + + if (changes['maxItems'] && changes['maxItems'].currentValue) { + actualMaxItems = changes['maxItems'].currentValue; + } + if (changes['skipCount'] && changes['skipCount'].currentValue) { + actualSkipCount = changes['skipCount'].currentValue; + } + + this.searchHeaderQueryBuilder.setupCurrentPagination(actualMaxItems, actualSkipCount); + } + } + + ngOnDestroy() { + this.onDestroy$.next(true); + this.onDestroy$.complete(); + } + + onMenuButtonClick(event: Event) { + event.stopPropagation(); + } + + onMenuClick(event: Event) { + event.stopPropagation(); + } + + onApplyButtonClick() { + this.isActive = true; + this.widgetContainer.applyInnerWidget(); + this.searchHeaderQueryBuilder.setActiveFilter(this.category.columnKey); + this.searchHeaderQueryBuilder.execute(); + } + + onClearButtonClick(event: Event) { + event.stopPropagation(); + this.widgetContainer.resetInnerWidget(); + this.isActive = false; + this.searchHeaderQueryBuilder.removeActiveFilter(this.category.columnKey); + if (this.searchHeaderQueryBuilder.isNoFilterActive()) { + this.clear.emit(); + } else { + this.searchHeaderQueryBuilder.execute(); + } + } +} diff --git a/lib/content-services/src/lib/search/components/search-radio/search-radio.component.ts b/lib/content-services/src/lib/search/components/search-radio/search-radio.component.ts index 18146ae9fb..b143546388 100644 --- a/lib/content-services/src/lib/search/components/search-radio/search-radio.component.ts +++ b/lib/content-services/src/lib/search/components/search-radio/search-radio.component.ts @@ -89,4 +89,11 @@ export class SearchRadioComponent implements SearchWidget, OnInit { changeHandler(event: MatRadioChange) { this.setValue(event.value); } + + reset() { + const initialValue = this.getSelectedValue(); + if (initialValue !== null) { + this.setValue(initialValue); + } + } } diff --git a/lib/content-services/src/lib/search/components/search-slider/search-slider.component.html b/lib/content-services/src/lib/search/components/search-slider/search-slider.component.html index 38a86c9c06..3c74f81dc7 100644 --- a/lib/content-services/src/lib/search/components/search-slider/search-slider.component.html +++ b/lib/content-services/src/lib/search/components/search-slider/search-slider.component.html @@ -8,7 +8,7 @@ data-automation-id="slider-range"> -
+
diff --git a/lib/content-services/src/lib/search/components/search-sorting-picker/search-sorting-picker.component.ts b/lib/content-services/src/lib/search/components/search-sorting-picker/search-sorting-picker.component.ts index 34e28137c4..532f157c11 100644 --- a/lib/content-services/src/lib/search/components/search-sorting-picker/search-sorting-picker.component.ts +++ b/lib/content-services/src/lib/search/components/search-sorting-picker/search-sorting-picker.component.ts @@ -15,9 +15,10 @@ * limitations under the License. */ -import { Component, OnInit, ViewEncapsulation } from '@angular/core'; +import { Component, OnInit, ViewEncapsulation, Inject } from '@angular/core'; import { SearchQueryBuilderService } from '../../search-query-builder.service'; import { SearchSortingDefinition } from '../../search-sorting-definition.interface'; +import { SEARCH_QUERY_SERVICE_TOKEN } from '../../search-query-service.token'; @Component({ selector: 'adf-search-sorting-picker', @@ -32,7 +33,7 @@ export class SearchSortingPickerComponent implements OnInit { value: string; ascending: boolean; - constructor(private queryBuilder: SearchQueryBuilderService) {} + constructor(@Inject(SEARCH_QUERY_SERVICE_TOKEN) private queryBuilder: SearchQueryBuilderService) {} ngOnInit() { this.options = this.queryBuilder.getSortingOptions(); diff --git a/lib/content-services/src/lib/search/components/search-widget-container/search-widget-container.component.ts b/lib/content-services/src/lib/search/components/search-widget-container/search-widget-container.component.ts index 3ace735e27..d9ebd80f19 100644 --- a/lib/content-services/src/lib/search/components/search-widget-container/search-widget-container.component.ts +++ b/lib/content-services/src/lib/search/components/search-widget-container/search-widget-container.component.ts @@ -15,9 +15,10 @@ * limitations under the License. */ -import { Component, Input, ViewChild, ViewContainerRef, OnInit, OnDestroy, ComponentRef, ComponentFactoryResolver } from '@angular/core'; -import { SearchQueryBuilderService } from '../../search-query-builder.service'; +import { Component, Input, ViewChild, ViewContainerRef, OnInit, OnDestroy, ComponentRef, ComponentFactoryResolver, Inject } from '@angular/core'; import { SearchFilterService } from '../search-filter/search-filter.service'; +import { BaseQueryBuilderService } from '../../base-query-builder.service'; +import { SEARCH_QUERY_SERVICE_TOKEN } from '../../search-query-service.token'; @Component({ selector: 'adf-search-widget-container', @@ -44,7 +45,7 @@ export class SearchWidgetContainerComponent implements OnInit, OnDestroy { constructor( private searchFilterService: SearchFilterService, - private queryBuilder: SearchQueryBuilderService, + @Inject(SEARCH_QUERY_SERVICE_TOKEN) private queryBuilder: BaseQueryBuilderService, private componentFactoryResolver: ComponentFactoryResolver) { } @@ -75,4 +76,15 @@ export class SearchWidgetContainerComponent implements OnInit, OnDestroy { } } + applyInnerWidget() { + if (this.selector === 'date-range' && this.componentRef && this.componentRef.instance) { + this.componentRef.instance.applyCurrentForm(); + } + } + + resetInnerWidget() { + if (this.componentRef && this.componentRef.instance) { + this.componentRef.instance.reset(); + } + } } diff --git a/lib/content-services/src/lib/search/public-api.ts b/lib/content-services/src/lib/search/public-api.ts index 3b3fa1770a..f62498360b 100644 --- a/lib/content-services/src/lib/search/public-api.ts +++ b/lib/content-services/src/lib/search/public-api.ts @@ -25,6 +25,8 @@ export * from './search-widget.interface'; export * from './search-configuration.interface'; export * from './search-query-builder.service'; export * from './search-range.interface'; +export * from './search-query-service.token'; +export * from './search-header-query-builder.service'; export * from './components/search.component'; export * from './components/search-control.component'; diff --git a/lib/content-services/src/lib/search/search-category.interface.ts b/lib/content-services/src/lib/search/search-category.interface.ts index cd6975bf0c..4c54c46f76 100644 --- a/lib/content-services/src/lib/search/search-category.interface.ts +++ b/lib/content-services/src/lib/search/search-category.interface.ts @@ -20,6 +20,7 @@ import { SearchWidgetSettings } from './search-widget-settings.interface'; export interface SearchCategory { id: string; name: string; + columnKey?: string; enabled: boolean; expanded: boolean; component: { diff --git a/lib/content-services/src/lib/search/search-header-query-builder.service.spec.ts b/lib/content-services/src/lib/search/search-header-query-builder.service.spec.ts new file mode 100644 index 0000000000..62a9dee851 --- /dev/null +++ b/lib/content-services/src/lib/search/search-header-query-builder.service.spec.ts @@ -0,0 +1,178 @@ +/*! + * @license + * Copyright 2019 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 { SearchConfiguration } from './search-configuration.interface'; +import { AppConfigService } from '@alfresco/adf-core'; +import { SearchHeaderQueryBuilderService } from './search-header-query-builder.service'; + +describe('SearchHeaderQueryBuilder', () => { + + const buildConfig = (searchSettings): AppConfigService => { + const config = new AppConfigService(null); + config.config['search-headers'] = searchSettings; + return config; + }; + + it('should load the configuration from app config', () => { + const config: SearchConfiguration = { + categories: [ + { id: 'cat1', enabled: true }, + { id: 'cat2', enabled: true } + ], + filterQueries: [{ query: 'query1' }, { query: 'query2' }] + }; + + const builder = new SearchHeaderQueryBuilderService( + buildConfig(config), + null, + null + ); + + builder.categories = []; + builder.filterQueries = []; + + expect(builder.categories.length).toBe(0); + expect(builder.filterQueries.length).toBe(0); + + builder.setUpConfiguration(); + + expect(builder.categories.length).toBe(2); + expect(builder.filterQueries.length).toBe(2); + }); + + it('should return the category assigned to a column key', () => { + const config: SearchConfiguration = { + categories: [ + { id: 'cat1', columnKey: 'fake-key-1', enabled: true }, + { id: 'cat2', columnKey: 'fake-key-2', enabled: true } + ], + filterQueries: [{ query: 'query1' }, { query: 'query2' }] + }; + + const service = new SearchHeaderQueryBuilderService( + buildConfig(config), + null, + null + ); + + const category = service.getCategoryForColumn('fake-key-1'); + expect(category).not.toBeNull(); + expect(category).not.toBeUndefined(); + expect(category.columnKey).toBe('fake-key-1'); + }); + + it('should have empty user query by default', () => { + const builder = new SearchHeaderQueryBuilderService( + buildConfig({}), + null, + null + ); + expect(builder.userQuery).toBe(''); + }); + + it('should add the extra filter for the parent node', () => { + const config: SearchConfiguration = { + categories: [ + { id: 'cat1', enabled: true }, + { id: 'cat2', enabled: true } + ], + filterQueries: [{ query: 'query1' }, { query: 'query2' }] + }; + + const expectedResult = [ + { query: 'query1' }, + { query: 'query2' }, + { query: 'PARENT:"workspace://SpacesStore/fake-node-id"' } + ]; + + const searchHeaderService = new SearchHeaderQueryBuilderService( + buildConfig(config), + null, + null + ); + + searchHeaderService.setCurrentRootFolderId('fake-node-id', undefined); + + expect(searchHeaderService.filterQueries).toEqual(expectedResult, 'Filters are not as expected'); + }); + + it('should not add again the parent filter if that node is already added', () => { + + const expectedResult = [ + { query: 'query1' }, + { query: 'query2' }, + { query: 'PARENT:"workspace://SpacesStore/fake-node-id' } + ]; + + const config: SearchConfiguration = { + categories: [ + { id: 'cat1', enabled: true }, + { id: 'cat2', enabled: true } + ], + filterQueries: expectedResult + }; + + const searchHeaderService = new SearchHeaderQueryBuilderService( + buildConfig(config), + null, + null + ); + + searchHeaderService.setCurrentRootFolderId('fake-node-id', undefined); + + expect(searchHeaderService.filterQueries).toEqual( + expectedResult, + 'Filters are not as expected' + ); + }); + + it('should replace the new query filter for the old parent node with the new one', () => { + const expectedResult = [ + { query: 'query1' }, + { query: 'query2' }, + { query: 'PARENT:"workspace://SpacesStore/fake-next-node-id"' } + ]; + + const config: SearchConfiguration = { + categories: [ + { id: 'cat1', enabled: true }, + { id: 'cat2', enabled: true } + ], + filterQueries: [ + { query: 'query1' }, + { query: 'query2' }, + { query: 'PARENT:"workspace://SpacesStore/fake-node-id' } + ] + }; + + const searchHeaderService = new SearchHeaderQueryBuilderService( + buildConfig(config), + null, + null + ); + + searchHeaderService.setCurrentRootFolderId( + 'fake-next-node-id', + 'fake-node-id' + ); + + expect(searchHeaderService.filterQueries).toEqual( + expectedResult, + 'Filters are not as expected' + ); + }); +}); diff --git a/lib/content-services/src/lib/search/search-header-query-builder.service.ts b/lib/content-services/src/lib/search/search-header-query-builder.service.ts new file mode 100644 index 0000000000..46e3bc5b54 --- /dev/null +++ b/lib/content-services/src/lib/search/search-header-query-builder.service.ts @@ -0,0 +1,116 @@ +/*! + * @license + * Copyright 2019 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 { AlfrescoApiService, AppConfigService, NodesApiService } from '@alfresco/adf-core'; +import { SearchConfiguration } from './search-configuration.interface'; +import { BaseQueryBuilderService } from './base-query-builder.service'; +import { SearchCategory } from './search-category.interface'; +import { MinimalNode } from '@alfresco/js-api'; + +@Injectable({ + providedIn: 'root' +}) +export class SearchHeaderQueryBuilderService extends BaseQueryBuilderService { + + private customSources = ['-trashcan-', '-sharedlinks-', '-sites-', '-mysites-', '-favorites-', '-recent-', '-my-']; + + activeFilters: string[] = []; + currentParentFolderID: string; + + constructor(appConfig: AppConfigService, alfrescoApiService: AlfrescoApiService, private nodeApiService: NodesApiService) { + super(appConfig, alfrescoApiService); + } + + public isFilterServiceActive(): boolean { + return true; + } + + loadConfiguration(): SearchConfiguration { + return this.appConfig.get('search-headers'); + } + + setupCurrentPagination(maxItems: number, skipCount: number) { + if (!this.paging || + (this.paging && + this.paging.maxItems !== maxItems || this.paging.skipCount !== skipCount)) { + this.paging = { maxItems, skipCount }; + this.execute(); + } + } + + setActiveFilter(columnActivated: string) { + this.activeFilters.push(columnActivated); + } + + isNoFilterActive(): boolean { + return this.activeFilters.length === 0; + } + + removeActiveFilter(columnRemoved: string) { + const removeIndex = this.activeFilters.findIndex((column) => column === columnRemoved); + this.activeFilters.splice(removeIndex, 1); + } + + getCategoryForColumn(columnKey: string): SearchCategory { + let foundCategory = null; + if (this.categories !== null) { + foundCategory = this.categories.find( + category => category.columnKey === columnKey + ); + } + return foundCategory; + } + + setCurrentRootFolderId(currentFolderId: string, previousFolderId: string) { + if (this.customSources.includes(currentFolderId)) { + if (currentFolderId !== this.currentParentFolderID) { + this.nodeApiService.getNode(currentFolderId).subscribe((nodeEntity: MinimalNode) => { + this.updateCurrentParentFilter(nodeEntity.id, previousFolderId); + }); + } + } else { + this.updateCurrentParentFilter(currentFolderId, previousFolderId); + } + } + + private updateCurrentParentFilter(currentFolderId: string, previousFolderId: string) { + const alreadyAddedFilter = this.filterQueries.find(filterQueries => + filterQueries.query.includes(currentFolderId) + ); + + if (!alreadyAddedFilter) { + this.removeOldFolderFiltering(previousFolderId, this.currentParentFolderID); + this.currentParentFolderID = currentFolderId; + this.filterQueries.push({ + query: `PARENT:"workspace://SpacesStore/${currentFolderId}"` + }); + } + } + + private removeOldFolderFiltering(previousFolderId: string, actualSetFolderId: string) { + if (previousFolderId || actualSetFolderId) { + const folderIdToRetrieve = previousFolderId ? previousFolderId : actualSetFolderId; + const oldFilterIndex = this.filterQueries.findIndex(filterQueries => + filterQueries.query.includes(folderIdToRetrieve) + ); + if (oldFilterIndex) { + this.filterQueries.splice(oldFilterIndex, 1); + } + } + } +} diff --git a/lib/content-services/src/lib/search/search-query-builder.service.spec.ts b/lib/content-services/src/lib/search/search-query-builder.service.spec.ts index ad63444bee..4c09fa064b 100644 --- a/lib/content-services/src/lib/search/search-query-builder.service.spec.ts +++ b/lib/content-services/src/lib/search/search-query-builder.service.spec.ts @@ -47,7 +47,7 @@ describe('SearchQueryBuilder', () => { expect(builder.categories.length).toBe(0); expect(builder.filterQueries.length).toBe(0); - builder.resetToDefaults(); + builder.setUpConfiguration(); expect(builder.categories.length).toBe(2); expect(builder.filterQueries.length).toBe(2); diff --git a/lib/content-services/src/lib/search/search-query-builder.service.ts b/lib/content-services/src/lib/search/search-query-builder.service.ts index 872847c66c..640cdc8176 100644 --- a/lib/content-services/src/lib/search/search-query-builder.service.ts +++ b/lib/content-services/src/lib/search/search-query-builder.service.ts @@ -16,417 +16,24 @@ */ import { Injectable } from '@angular/core'; -import { Subject } from 'rxjs'; import { AlfrescoApiService, AppConfigService } from '@alfresco/adf-core'; -import { - QueryBody, - RequestFacetFields, - RequestFacetField, - RequestSortDefinitionInner, - ResultSetPaging, - RequestHighlight -} 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'; -import { SearchSortingDefinition } from './search-sorting-definition.interface'; -import { FacetField } from './facet-field.interface'; -import { FacetFieldBucket } from './facet-field-bucket.interface'; +import { BaseQueryBuilderService } from './base-query-builder.service'; @Injectable({ providedIn: 'root' }) -export class SearchQueryBuilderService { +export class SearchQueryBuilderService extends BaseQueryBuilderService { - private _userQuery = ''; - - updated = new Subject(); - executed = new Subject(); - error = new Subject(); - - categories: Array = []; - queryFragments: { [id: string]: string } = {}; - filterQueries: FilterQuery[] = []; - paging: { maxItems?: number; skipCount?: number } = null; - sorting: Array = []; - - protected userFacetBuckets: { [key: string]: Array } = {}; - - get userQuery(): string { - return this._userQuery; - } - - set userQuery(value: string) { - value = (value || '').trim(); - this._userQuery = value ? `(${value})` : ''; - } - - config: SearchConfiguration = { - categories: [] - }; - - // TODO: to be supported in future iterations - ranges: { [id: string]: SearchRange } = {}; - - constructor(private appConfig: AppConfigService, private alfrescoApiService: AlfrescoApiService) { - this.resetToDefaults(); - } - - /** - * Resets the query to the defaults specified in the app config. - */ - resetToDefaults() { - const template = this.appConfig.get('search'); - if (template) { - this.config = JSON.parse(JSON.stringify(template)); - this.categories = (this.config.categories || []).filter((category) => category.enabled); - this.filterQueries = this.config.filterQueries || []; - this.userFacetBuckets = {}; - if (this.config.sorting) { - this.sorting = this.config.sorting.defaults || []; - } - } - } - - /** - * Adds a facet bucket to a field. - * @param field The target field - * @param bucket Bucket to add - */ - addUserFacetBucket(field: FacetField, bucket: FacetFieldBucket) { - if (field && field.field && bucket) { - const buckets = this.userFacetBuckets[field.field] || []; - const existing = buckets.find((facetBucket) => facetBucket.label === bucket.label); - if (!existing) { - buckets.push(bucket); - } - this.userFacetBuckets[field.field] = buckets; - } - } - - /** - * Gets the buckets currently added to a field - * @param field The target fields - * @returns Bucket array - */ - getUserFacetBuckets(field: string) { - return this.userFacetBuckets[field] || []; - } - - /** - * Removes an existing bucket from a field. - * @param field The target field - * @param bucket Bucket to remove - */ - removeUserFacetBucket(field: FacetField, bucket: FacetFieldBucket) { - if (field && field.field && bucket) { - const buckets = this.userFacetBuckets[field.field] || []; - this.userFacetBuckets[field.field] = buckets - .filter((facetBucket) => facetBucket.label !== bucket.label); - } - } - - /** - * Adds a filter query to the current query. - * @param query Query string to add - */ - addFilterQuery(query: string): void { - if (query) { - const existing = this.filterQueries.find((filterQuery) => filterQuery.query === query); - if (!existing) { - this.filterQueries.push({ query: query }); - } - } - } - - /** - * Removes an existing filter query. - * @param query The query to remove - */ - removeFilterQuery(query: string): void { - if (query) { - this.filterQueries = this.filterQueries - .filter((filterQuery) => filterQuery.query !== query); - } - } - - /** - * Gets a facet query by label. - * @param label Label of the query - * @returns Facet query data - */ - getFacetQuery(label: string): FacetQuery { - if (label && this.hasFacetQueries) { - const result = this.config.facetQueries.queries.find((query) => query.label === label); - if (result) { - return { ...result }; - } - } - return null; - } - - /** - * Gets a facet field by label. - * @param label Label of the facet field - * @returns Facet field data - */ - getFacetField(label: string): FacetField { - if (label) { - const fields = this.config.facetFields.fields || []; - const result = fields.find((field) => field.label === label); - if (result) { - result.label = this.getSupportedLabel(result.label); - return { ...result }; - } - } - return null; - } - - /** - * Builds the current query and triggers the `updated` event. - */ - update(): void { - const query = this.buildQuery(); - this.updated.next(query); - } - - /** - * Builds and executes the current query. - * @returns Nothing - */ - async execute() { - try { - const query = this.buildQuery(); - if (query) { - const resultSetPaging: ResultSetPaging = await this.alfrescoApiService.searchApi.search(query); - this.executed.next(resultSetPaging); - } - } catch (error) { - this.error.next(error); - - this.executed.next({ - list: { - pagination: { - totalItems: 0 - }, - entries: [] - } - }); - } - } - - /** - * Builds the current query. - * @returns The finished query - */ - buildQuery(): QueryBody { - const query = this.getFinalQuery(); - - const include = this.config.include || []; - if (include.length === 0) { - include.push('path', 'allowableOperations'); - } - - if (query) { - const result: QueryBody = { - query: { - query: query, - language: 'afts' - }, - include: include, - paging: this.paging, - fields: this.config.fields, - filterQueries: this.filterQueries, - facetQueries: this.facetQueries, - facetIntervals: this.facetIntervals, - facetFields: this.facetFields, - sort: this.sort, - highlight: this.highlight - }; - - result['facetFormat'] = 'V2'; - return result; - } - - return null; - } - - /** - * Gets the primary sorting definition. - * @returns The primary sorting definition - */ - getPrimarySorting(): SearchSortingDefinition { - if (this.sorting && this.sorting.length > 0) { - return this.sorting[0]; - } - return null; - } - - /** - * Gets all pre-configured sorting options that users can choose from. - * @returns Pre-configured sorting options - */ - getSortingOptions(): SearchSortingDefinition[] { - if (this.config && this.config.sorting) { - return this.config.sorting.options || []; - } - return []; - } - - /** - * Gets the query group. - * @param query Target query - * @returns Query group - */ - getQueryGroup(query) { - return query.group || this.config.facetQueries.label || 'Facet Queries'; - } - - /** - * Checks if FacetQueries has been defined - * @returns True if defined, false otherwise - */ - get hasFacetQueries(): boolean { - if (this.config - && this.config.facetQueries - && this.config.facetQueries.queries - && this.config.facetQueries.queries.length > 0) { - return true; - } + public isFilterServiceActive(): boolean { return false; } - /** - * Checks if FacetIntervals has been defined - * @returns True if defined, false otherwise - */ - get hasFacetIntervals(): boolean { - if (this.config - && this.config.facetIntervals - && this.config.facetIntervals.intervals - && this.config.facetIntervals.intervals.length > 0) { - return true; - } - return false; + constructor(appConfig: AppConfigService, alfrescoApiService: AlfrescoApiService) { + super(appConfig, alfrescoApiService); } - get hasFacetHighlight(): boolean { - return this.config && this.config.highlight ? true : false; - } - - protected get sort(): RequestSortDefinitionInner[] { - return this.sorting.map((def) => { - return new RequestSortDefinitionInner({ - type: def.type, - field: def.field, - ascending: def.ascending - }); - }); - } - - protected get facetQueries(): FacetQuery[] { - if (this.hasFacetQueries) { - return this.config.facetQueries.queries.map((query) => { - query.group = this.getQueryGroup(query); - return { ...query }; - }); - } - - return null; - } - - protected get facetIntervals(): any { - if (this.hasFacetIntervals) { - const configIntervals = this.config.facetIntervals; - - return { - intervals: configIntervals.intervals.map((interval) => { - label: this.getSupportedLabel(interval.label), - field: interval.field, - sets: interval.sets.map((set) => { - label: this.getSupportedLabel(set.label), - start: set.start, - end: set.end, - startInclusive: set.startInclusive, - endInclusive: set.endInclusive - }) - }) - }; - } - - return null; - } - - protected get highlight(): RequestHighlight { - return this.hasFacetHighlight ? this.config.highlight : null; - } - - protected getFinalQuery(): string { - let query = ''; - - this.categories.forEach((facet) => { - const customQuery = this.queryFragments[facet.id]; - if (customQuery) { - if (query.length > 0) { - query += ' AND '; - } - query += `(${customQuery})`; - } - }); - - let result = [this.userQuery, query] - .filter((entry) => entry) - .join(' AND '); - - if (this.userFacetBuckets) { - Object.keys(this.userFacetBuckets).forEach((key) => { - const subQuery = (this.userFacetBuckets[key] || []) - .filter((bucket) => bucket.filterQuery) - .map((bucket) => bucket.filterQuery) - .join(' OR '); - if (subQuery) { - if (result.length > 0) { - result += ' AND '; - } - result += `(${subQuery})`; - } - }); - } - - return result; - } - - protected get facetFields(): RequestFacetFields { - const facetFields = this.config.facetFields && this.config.facetFields.fields; - - if (facetFields && facetFields.length > 0) { - return { - facets: facetFields.map((facet) => { - field: facet.field, - mincount: facet.mincount, - label: this.getSupportedLabel(facet.label), - limit: facet.limit, - offset: facet.offset, - prefix: facet.prefix - }) - }; - } - - return null; - } - - /** - * Encloses a label name with double quotes if it contains whitespace characters. - * @param configLabel Original label text - * @returns Label, possibly with quotes if it contains spaces - */ - getSupportedLabel(configLabel: string): string { - const spaceInsideLabelIndex = configLabel.search(/\s/g); - if (spaceInsideLabelIndex > -1) { - return `"${configLabel}"`; - } - return configLabel; + public loadConfiguration(): SearchConfiguration { + return this.appConfig.get('search'); } } diff --git a/lib/content-services/src/lib/search/search-query-service.token.ts b/lib/content-services/src/lib/search/search-query-service.token.ts new file mode 100644 index 0000000000..13a2584d81 --- /dev/null +++ b/lib/content-services/src/lib/search/search-query-service.token.ts @@ -0,0 +1,21 @@ +/*! + * @license + * Copyright 2019 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 { InjectionToken } from '@angular/core'; +import { BaseQueryBuilderService } from './base-query-builder.service'; + +export const SEARCH_QUERY_SERVICE_TOKEN = new InjectionToken('QueryService'); diff --git a/lib/content-services/src/lib/search/search-widget.interface.ts b/lib/content-services/src/lib/search/search-widget.interface.ts index 4415d979ca..883a4249e1 100644 --- a/lib/content-services/src/lib/search/search-widget.interface.ts +++ b/lib/content-services/src/lib/search/search-widget.interface.ts @@ -22,4 +22,5 @@ export interface SearchWidget { id: string; settings?: SearchWidgetSettings; context?: SearchQueryBuilderService; + reset(); } diff --git a/lib/content-services/src/lib/search/search.module.ts b/lib/content-services/src/lib/search/search.module.ts index 8bb1c18413..b7044cda3d 100644 --- a/lib/content-services/src/lib/search/search.module.ts +++ b/lib/content-services/src/lib/search/search.module.ts @@ -35,6 +35,9 @@ import { SearchNumberRangeComponent } from './components/search-number-range/sea import { SearchCheckListComponent } from './components/search-check-list/search-check-list.component'; import { SearchDateRangeComponent } from './components/search-date-range/search-date-range.component'; import { SearchSortingPickerComponent } from './components/search-sorting-picker/search-sorting-picker.component'; +import { SearchHeaderComponent } from './components/search-header/search-header.component'; +import { SEARCH_QUERY_SERVICE_TOKEN } from './search-query-service.token'; +import { SearchQueryBuilderService } from './search-query-builder.service'; @NgModule({ imports: [ @@ -57,7 +60,8 @@ import { SearchSortingPickerComponent } from './components/search-sorting-picker SearchNumberRangeComponent, SearchCheckListComponent, SearchDateRangeComponent, - SearchSortingPickerComponent + SearchSortingPickerComponent, + SearchHeaderComponent ], exports: [ SearchComponent, @@ -72,7 +76,8 @@ import { SearchSortingPickerComponent } from './components/search-sorting-picker SearchNumberRangeComponent, SearchCheckListComponent, SearchDateRangeComponent, - SearchSortingPickerComponent + SearchSortingPickerComponent, + SearchHeaderComponent ], entryComponents: [ SearchWidgetContainerComponent, @@ -82,6 +87,9 @@ import { SearchSortingPickerComponent } from './components/search-sorting-picker SearchNumberRangeComponent, SearchCheckListComponent, SearchDateRangeComponent + ], + providers: [ + { provide: SEARCH_QUERY_SERVICE_TOKEN, useClass: SearchQueryBuilderService } ] }) export class SearchModule {} diff --git a/lib/content-services/src/lib/styles/_index.scss b/lib/content-services/src/lib/styles/_index.scss index 907b4c534c..072808b855 100644 --- a/lib/content-services/src/lib/styles/_index.scss +++ b/lib/content-services/src/lib/styles/_index.scss @@ -13,6 +13,7 @@ @import '../search/components/search-sorting-picker/search-sorting-picker.component'; @import '../search/components/search-filter/search-filter.component'; @import '../search/components/search-chip-list/search-chip-list.component'; +@import '../search/components/search-header/search-header.component'; @import '../dialogs/folder.dialog'; @@ -39,6 +40,7 @@ @include adf-search-control-theme($theme); @include adf-search-autocomplete-theme($theme); @include adf-search-sorting-picker-theme($theme); + @include adf-filter-menu-theme($theme); @include adf-dialog-theme($theme); @include adf-content-node-selector-dialog-theme($theme); @include adf-content-metadata-module-theme($theme); diff --git a/lib/core/datatable/components/datatable/datatable.component.html b/lib/core/datatable/components/datatable/datatable.component.html index e99cbb91b9..89bcb7836f 100644 --- a/lib/core/datatable/components/datatable/datatable.component.html +++ b/lib/core/datatable/components/datatable/datatable.component.html @@ -4,11 +4,12 @@ [class.adf-datatable-card]="display === 'gallery'" [class.adf-datatable-list]="display === 'list'" [class.adf-sticky-header]="isStickyHeaderEnabled()" - [class.adf-datatable--empty]="!isHeaderVisible()"> -
+ [class.adf-datatable--empty]="(isEmpty() && !isHeaderVisible()) || loading" + [class.adf-datatable--empty--header-visible]="isEmpty() && isHeaderVisible()"> +
@@ -29,19 +30,19 @@ (click)="onColumnHeaderClick(col)" (keyup.enter)="onColumnHeaderClick(col)" role="columnheader" - [attr.tabindex]="showHeader ? 0 : null" + [attr.tabindex]="isHeaderVisible() ? 0 : null" [attr.aria-sort]="col.sortable ? (getAriaSort(col) | translate) : null" adf-drop-zone dropTarget="header" [dropColumn]="col"> {{ col.title | translate}} {{ getSortLiveAnnouncement(col) | translate: { string: col.title | translate } }} - +
{{ 'ADF-DATATABLE.ACCESSIBILITY.ACTIONS' | translate }}
- + { expect(element.querySelector('.adf-datatable-list')).not.toBeNull(); }); - it('should hide the header if showHeader is false', () => { + describe('Header modes', () => { + const newData = new ObjectDataTableAdapter( [ { name: '1' }, @@ -168,65 +169,113 @@ describe('DataTable', () => { ], [new ObjectDataColumn({ key: 'name' })] ); + const emptyData = new ObjectDataTableAdapter(); - dataTable.showHeader = false; - dataTable.loading = false; - dataTable.ngOnChanges({ - data: new SimpleChange(null, newData, false) + it('should show the header if showHeader is `Data` and there is data', () => { + + dataTable.showHeader = ShowHeaderMode.Data; + dataTable.loading = false; + dataTable.ngOnChanges({ + data: new SimpleChange(null, newData, false) + }); + + fixture.detectChanges(); + + expect(element.querySelector('.adf-datatable-header')).toBeDefined(); }); - fixture.detectChanges(); + it('should hide the header if showHeader is `Data` and there is no data', () => { - expect(element.querySelector('.adf-datatable-header')).toBe(null); - }); + dataTable.showHeader = ShowHeaderMode.Data; + dataTable.loading = false; + dataTable.ngOnChanges({ + data: new SimpleChange(null, emptyData, false) + }); - it('should hide the header if there are no elements inside', () => { - const newData = new ObjectDataTableAdapter( - ); + fixture.detectChanges(); - dataTable.ngOnChanges({ - data: new SimpleChange(null, newData, false) + expect(element.querySelector('.adf-datatable-header')).toBeNull(); }); - fixture.detectChanges(); + it('should always show the header if showHeader is `Always`', () => { - expect(element.querySelector('.adf-datatable-header')).toBe(null); - }); + dataTable.showHeader = ShowHeaderMode.Always; + dataTable.loading = false; - it('should hide the header if noPermission is true', () => { - const newData = new ObjectDataTableAdapter( - ); + dataTable.ngOnChanges({ + data: new SimpleChange(null, newData, false) + }); + fixture.detectChanges(); + expect(element.querySelector('.adf-datatable-header')).toBeDefined(); - dataTable.noPermission = true; - dataTable.loading = false; - - dataTable.ngOnChanges({ - data: new SimpleChange(null, newData, false) + dataTable.ngOnChanges({ + data: new SimpleChange(null, emptyData, false) + }); + fixture.detectChanges(); + expect(element.querySelector('.adf-datatable-header')).toBeDefined(); }); - fixture.detectChanges(); + it('should never show the header if showHeader is `Never`', () => { - expect(element.querySelector('.adf-datatable-header')).toBe(null); - }); + dataTable.showHeader = ShowHeaderMode.Never; + dataTable.loading = false; + dataTable.ngOnChanges({ + data: new SimpleChange(null, newData, false) + }); - it('should show the header if showHeader is true', () => { - const newData = new ObjectDataTableAdapter( - [ - { name: '1' }, - { name: '2' } - ], - [new ObjectDataColumn({ key: 'name' })] - ); - dataTable.showHeader = true; - dataTable.loading = false; + fixture.detectChanges(); - dataTable.ngOnChanges({ - data: new SimpleChange(null, newData, false) + expect(element.querySelector('.adf-datatable-header')).toBeNull(); + + dataTable.ngOnChanges({ + data: new SimpleChange(null, emptyData, false) + }); + + fixture.detectChanges(); + + expect(element.querySelector('.adf-datatable-header')).toBeNull(); }); - fixture.detectChanges(); + it('should never show the header if noPermission is true', () => { - expect(element.querySelector('.adf-datatable-header')).toBeDefined(); + dataTable.loading = false; + dataTable.noPermission = true; + dataTable.ngOnChanges({ + data: new SimpleChange(null, emptyData, false) + }); + + dataTable.showHeader = ShowHeaderMode.Data; + fixture.detectChanges(); + expect(element.querySelector('.adf-datatable-header')).toBeNull(); + + dataTable.showHeader = ShowHeaderMode.Always; + fixture.detectChanges(); + expect(element.querySelector('.adf-datatable-header')).toBeNull(); + + dataTable.showHeader = ShowHeaderMode.Never; + fixture.detectChanges(); + expect(element.querySelector('.adf-datatable-header')).toBeNull(); + }); + + it('should never show the header if loading is true', () => { + + dataTable.loading = true; + dataTable.ngOnChanges({ + data: new SimpleChange(null, emptyData, false) + }); + + dataTable.showHeader = ShowHeaderMode.Data; + fixture.detectChanges(); + expect(element.querySelector('.adf-datatable-header')).toBeNull(); + + dataTable.showHeader = ShowHeaderMode.Always; + fixture.detectChanges(); + expect(element.querySelector('.adf-datatable-header')).toBeNull(); + + dataTable.showHeader = ShowHeaderMode.Never; + fixture.detectChanges(); + expect(element.querySelector('.adf-datatable-header')).toBeNull(); + }); }); it('should emit "sorting-changed" DOM event', (done) => { @@ -1394,7 +1443,7 @@ describe('Accesibility', () => { expect(document.activeElement.getAttribute('data-automation-id')).toBe('datatable-row-0'); }); - it('should select header row when `showHeader` is true', () => { + it('should select header row when `showHeader` is `Always`', () => { const event = new KeyboardEvent('keyup', { code: 'ArrowUp', key: 'ArrowUp', @@ -1408,7 +1457,7 @@ describe('Accesibility', () => { [new ObjectDataColumn({ key: 'name' })] ); - dataTable.showHeader = true; + dataTable.showHeader = ShowHeaderMode.Always; dataTable.ngOnChanges({ rows: new SimpleChange(null, dataRows, false) @@ -1426,7 +1475,7 @@ describe('Accesibility', () => { expect(document.activeElement.getAttribute('data-automation-id')).toBe('datatable-row-header'); }); - it('should not select header row when `showHeader` is false', () => { + it('should not select header row when `showHeader` is `Never`', () => { const event = new KeyboardEvent('keyup', { code: 'ArrowUp', key: 'ArrowUp', @@ -1440,7 +1489,7 @@ describe('Accesibility', () => { [new ObjectDataColumn({ key: 'name' })] ); - dataTable.showHeader = false; + dataTable.showHeader = ShowHeaderMode.Never; dataTable.ngOnChanges({ rows: new SimpleChange(null, dataRows, false) @@ -1459,7 +1508,7 @@ describe('Accesibility', () => { }); it('should remove cell focus when [focus] is set to false', () => { - dataTable.showHeader = false; + dataTable.showHeader = ShowHeaderMode.Never; const dataRows = [ { name: 'name1' } ]; dataTable.data = new ObjectDataTableAdapter([], @@ -1478,7 +1527,7 @@ describe('Accesibility', () => { }); it('should allow element focus when [focus] is set to true', () => { - dataTable.showHeader = false; + dataTable.showHeader = ShowHeaderMode.Never; const dataRows = [ { name: 'name1' } ]; dataTable.data = new ObjectDataTableAdapter([], diff --git a/lib/core/datatable/components/datatable/datatable.component.ts b/lib/core/datatable/components/datatable/datatable.component.ts index 4b3f0d61d3..11b122bcc3 100644 --- a/lib/core/datatable/components/datatable/datatable.component.ts +++ b/lib/core/datatable/components/datatable/datatable.component.ts @@ -43,6 +43,12 @@ export enum DisplayMode { Gallery = 'gallery' } +export enum ShowHeaderMode { + Never = 'never', + Always = 'always', + Data = 'data' +} + @Component({ selector: 'adf-datatable', styleUrls: ['./datatable.component.scss'], @@ -119,7 +125,7 @@ export class DataTableComponent implements AfterContentInit, OnChanges, DoCheck, /** Toggles the header. */ @Input() - showHeader: boolean = true; + showHeader: string = ShowHeaderMode.Data; /** Toggles the sticky header mode. */ @Input() @@ -175,9 +181,7 @@ export class DataTableComponent implements AfterContentInit, OnChanges, DoCheck, @Input() allowFiltering: boolean = false; - @ContentChild(TemplateRef) - filterTemplateRef: TemplateRef; - + headerFilterTemplate: TemplateRef; noContentTemplate: TemplateRef; noPermissionTemplate: TemplateRef; loadingTemplate: TemplateRef; @@ -738,7 +742,16 @@ export class DataTableComponent implements AfterContentInit, OnChanges, DoCheck, } isHeaderVisible() { - return !this.loading && !this.isEmpty() && !this.noPermission; + let headerVisibility: boolean; + + if (this.showHeader === ShowHeaderMode.Data) { + headerVisibility = !this.loading && !this.noPermission && !this.isEmpty(); + } else if (this.showHeader === ShowHeaderMode.Always) { + headerVisibility = !this.loading && !this.noPermission; + } else if (this.showHeader === ShowHeaderMode.Never) { + headerVisibility = false; + } + return headerVisibility; } isStickyHeaderEnabled() { diff --git a/lib/core/datatable/datatable.module.ts b/lib/core/datatable/datatable.module.ts index 7372c449c7..6a9c927cf7 100644 --- a/lib/core/datatable/datatable.module.ts +++ b/lib/core/datatable/datatable.module.ts @@ -38,9 +38,11 @@ import { LocationCellComponent } from './components/location-cell/location-cell. import { LoadingContentTemplateDirective } from './directives/loading-template.directive'; import { NoContentTemplateDirective } from './directives/no-content-template.directive'; import { NoPermissionTemplateDirective } from './directives/no-permission-template.directive'; +import { HeaderFilterTemplateDirective } from './directives/header-filter-template.directive'; import { CustomEmptyContentTemplateDirective } from './directives/custom-empty-content-template.directive'; import { CustomLoadingContentTemplateDirective } from './directives/custom-loading-template.directive'; import { CustomNoPermissionTemplateDirective } from './directives/custom-no-permission-template.directive'; +import { CustomHeaderFilterTemplateDirective } from './directives/custom-header-filter-template.directive'; import { JsonCellComponent } from './components/json-cell/json-cell.component'; import { ClipboardModule } from '../clipboard/clipboard.module'; import { DropZoneDirective } from './directives/drop-zone.directive'; @@ -73,9 +75,11 @@ import { DataColumnModule } from '../data-column/data-column.module'; NoContentTemplateDirective, NoPermissionTemplateDirective, LoadingContentTemplateDirective, + HeaderFilterTemplateDirective, CustomEmptyContentTemplateDirective, CustomLoadingContentTemplateDirective, CustomNoPermissionTemplateDirective, + CustomHeaderFilterTemplateDirective, DropZoneDirective ], exports: [ @@ -93,9 +97,11 @@ import { DataColumnModule } from '../data-column/data-column.module'; NoContentTemplateDirective, NoPermissionTemplateDirective, LoadingContentTemplateDirective, + HeaderFilterTemplateDirective, CustomEmptyContentTemplateDirective, CustomLoadingContentTemplateDirective, CustomNoPermissionTemplateDirective, + CustomHeaderFilterTemplateDirective, DropZoneDirective ] diff --git a/lib/content-services/src/lib/document-list/components/filter-menu/filter-menu.component.ts b/lib/core/datatable/directives/custom-header-filter-template.directive.ts similarity index 64% rename from lib/content-services/src/lib/document-list/components/filter-menu/filter-menu.component.ts rename to lib/core/datatable/directives/custom-header-filter-template.directive.ts index 1624476db1..09101347cd 100644 --- a/lib/content-services/src/lib/document-list/components/filter-menu/filter-menu.component.ts +++ b/lib/core/datatable/directives/custom-header-filter-template.directive.ts @@ -15,19 +15,14 @@ * limitations under the License. */ -import { Component, Input } from '@angular/core'; -import { DataColumn } from '@alfresco/adf-core'; +import { Directive, ContentChild, TemplateRef } from '@angular/core'; -@Component({ - selector: 'adf-filter-menu', - templateUrl: './filter-menu.component.html' +@Directive({ + selector: 'adf-custom-header-filter-template' }) -export class FilterMenuComponent { +export class CustomHeaderFilterTemplateDirective { - @Input() - col: DataColumn; + @ContentChild(TemplateRef) + template: any; - onMenuButtonClick(event: Event) { - event.stopPropagation(); - } } diff --git a/lib/core/datatable/directives/header-filter-template.directive.spec.ts b/lib/core/datatable/directives/header-filter-template.directive.spec.ts new file mode 100644 index 0000000000..eba4e229fb --- /dev/null +++ b/lib/core/datatable/directives/header-filter-template.directive.spec.ts @@ -0,0 +1,54 @@ +/*! + * @license + * Copyright 2019 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 { TestBed, ComponentFixture } from '@angular/core/testing'; +import { DataTableComponent } from '../components/datatable/datatable.component'; +import { HeaderFilterTemplateDirective } from './header-filter-template.directive'; +import { setupTestBed } from '../../testing/setup-test-bed'; +import { CoreTestingModule } from '../../testing/core.testing.module'; +import { TranslateModule } from '@ngx-translate/core'; + +describe('HeaderFilterTemplateDirective', () => { + + let fixture: ComponentFixture; + let dataTable: DataTableComponent; + let directive: HeaderFilterTemplateDirective; + + setupTestBed({ + imports: [ + TranslateModule.forRoot(), + CoreTestingModule + ] + }); + + beforeEach(() => { + fixture = TestBed.createComponent(DataTableComponent); + dataTable = fixture.componentInstance; + directive = new HeaderFilterTemplateDirective(dataTable); + }); + + afterEach(() => { + fixture.destroy(); + }); + + it('applies template to the datatable', () => { + const template: any = 'test template'; + directive.template = template; + directive.ngAfterContentInit(); + expect(dataTable.headerFilterTemplate).toBe(template); + }); +}); diff --git a/lib/core/datatable/directives/header-filter-template.directive.ts b/lib/core/datatable/directives/header-filter-template.directive.ts new file mode 100644 index 0000000000..b7720cd073 --- /dev/null +++ b/lib/core/datatable/directives/header-filter-template.directive.ts @@ -0,0 +1,37 @@ +/*! + * @license + * Copyright 2019 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 { AfterContentInit, ContentChild, Directive, TemplateRef } from '@angular/core'; +import { DataTableComponent } from '../components/datatable/datatable.component'; + +@Directive({ + selector: 'adf-header-filter-template' +}) +export class HeaderFilterTemplateDirective implements AfterContentInit { + + @ContentChild(TemplateRef) + template: any; + + constructor(private dataTable: DataTableComponent) { + } + + ngAfterContentInit() { + if (this.dataTable) { + this.dataTable.headerFilterTemplate = this.template; + } + } +} diff --git a/lib/core/datatable/public-api.ts b/lib/core/datatable/public-api.ts index 3be952d601..5d71788825 100644 --- a/lib/core/datatable/public-api.ts +++ b/lib/core/datatable/public-api.ts @@ -41,8 +41,10 @@ export * from './data/data-table.schema'; export * from './directives/loading-template.directive'; export * from './directives/no-content-template.directive'; export * from './directives/no-permission-template.directive'; +export * from './directives/header-filter-template.directive'; export * from './directives/custom-empty-content-template.directive'; export * from './directives/custom-loading-template.directive'; export * from './directives/custom-no-permission-template.directive'; +export * from './directives/custom-header-filter-template.directive'; export * from './datatable.module';