From b0a46f7eac60a9f332ac9e45b671cc5e9196f558 Mon Sep 17 00:00:00 2001 From: davidcanonieto Date: Fri, 25 Sep 2020 11:08:15 +0200 Subject: [PATCH] [ADF-5219] Refactor Document list Filters (#6092) * First commit * Second commit * Commit 4 * Add unit tests * Fix unit tests * Add documentation for breaking change * Fix rebase * Fix unit test --- .../app/components/files/files.component.html | 21 +- .../app/components/files/files.component.ts | 55 +++-- .../files/filtered-search.component.html | 6 +- .../files/filtered-search.component.ts | 10 +- .../components/document-list.component.md | 24 +- .../components/search-header.component.md | 71 ------ docs/docassets/images/header-filters.png | Bin 0 -> 69288 bytes docs/upgrade-guide/upgrade40-41.md | 62 +++++ .../dropdown-breadcrumb.component.spec.ts | 8 +- .../components/document-list.component.html | 14 +- .../document-list.component.spec.ts | 13 +- .../components/document-list.component.ts | 66 +++-- .../filter-header.component.html | 10 + .../filter-header.component.spec.ts | 151 ++++++++++++ .../filter-header/filter-header.component.ts | 126 ++++++++++ .../lib/document-list/document-list.module.ts | 11 +- .../lib/search/base-query-builder.service.ts | 8 +- .../search-filter-container.component.html | 49 ++++ .../search-filter-container.component.scss} | 0 ...search-filter-container.component.spec.ts} | 211 +++++----------- .../search-filter-container.component.ts | 136 +++++++++++ .../search-header.component.html | 43 ---- .../search-header/search-header.component.ts | 225 ------------------ .../lib/search/filter-search.interface.ts} | 13 +- .../src/lib/search/public-api.ts | 5 +- ...arch-filter-query-builder.service.spec.ts} | 20 +- ...=> search-filter-query-builder.service.ts} | 57 +++-- .../src/lib/search/search.module.ts | 6 +- .../src/lib/styles/_index.scss | 2 +- lib/core/datatable/datatable.module.ts | 3 - lib/core/datatable/public-api.ts | 1 - 31 files changed, 796 insertions(+), 631 deletions(-) delete mode 100644 docs/content-services/components/search-header.component.md create mode 100644 docs/docassets/images/header-filters.png create mode 100644 docs/upgrade-guide/upgrade40-41.md create mode 100644 lib/content-services/src/lib/document-list/components/filter-header/filter-header.component.html create mode 100644 lib/content-services/src/lib/document-list/components/filter-header/filter-header.component.spec.ts create mode 100644 lib/content-services/src/lib/document-list/components/filter-header/filter-header.component.ts create mode 100644 lib/content-services/src/lib/search/components/search-filter-container/search-filter-container.component.html rename lib/content-services/src/lib/search/components/{search-header/search-header.component.scss => search-filter-container/search-filter-container.component.scss} (100%) rename lib/content-services/src/lib/search/components/{search-header/search-header.component.spec.ts => search-filter-container/search-filter-container.component.spec.ts} (57%) create mode 100644 lib/content-services/src/lib/search/components/search-filter-container/search-filter-container.component.ts delete mode 100644 lib/content-services/src/lib/search/components/search-header/search-header.component.html delete mode 100644 lib/content-services/src/lib/search/components/search-header/search-header.component.ts rename lib/{core/datatable/directives/custom-header-filter-template.directive.ts => content-services/src/lib/search/filter-search.interface.ts} (72%) rename lib/content-services/src/lib/search/{search-header-query-builder.service.spec.ts => search-filter-query-builder.service.spec.ts} (88%) rename lib/content-services/src/lib/search/{search-header-query-builder.service.ts => search-filter-query-builder.service.ts} (66%) diff --git a/demo-shell/src/app/components/files/files.component.html b/demo-shell/src/app/components/files/files.component.html index 8db9dd3ecf..db377f7812 100644 --- a/demo-shell/src/app/components/files/files.component.html +++ b/demo-shell/src/app/components/files/files.component.html @@ -222,7 +222,6 @@ [contentActions]="true" [allowDropFiles]="allowDropFiles" [selectionMode]="selectionMode" - [preselectNodes]="selectedNodes" [multiselect]="multiselect" [display]="displayMode" [node]="nodeResult" @@ -232,27 +231,17 @@ [showHeader]="showHeader" [thumbnails]="thumbnails" [stickyHeader]="stickyHeader" + [headerFilters]="headerFilters" + [filterValue]="paramValues" (error)="onNavigationError($event)" (success)="resetError()" (ready)="emitReadyEvent($event)" (preview)="showFile($event)" (folderChange)="onFolderChange($event)" (permissionError)="handlePermissionError($event)" - (name-click)="documentList.onNodeDblClick($event.detail?.node)"> - - - - - - + (name-click)="documentList.onNodeDblClick($event.detail?.node)" + (filterSelection)="onFilterSelected($event)"> +

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 6b4c4afbe2..563704dfa5 100644 --- a/demo-shell/src/app/components/files/files.component.ts +++ b/demo-shell/src/app/components/files/files.component.ts @@ -39,7 +39,8 @@ import { UploadFilesEvent, ConfirmDialogComponent, LibraryDialogComponent, - ContentMetadataService + ContentMetadataService, + FilterSearch } from '@alfresco/adf-content-services'; import { SelectAppsDialogComponent, ProcessFormRenderingService } from '@alfresco/adf-process-services'; @@ -79,9 +80,9 @@ export class FilesComponent implements OnInit, OnChanges, OnDestroy { toolbarColor = 'default'; selectionModes = [ - {value: 'none', viewValue: 'None'}, - {value: 'single', viewValue: 'Single'}, - {value: 'multiple', viewValue: 'Multiple'} + { value: 'none', viewValue: 'None' }, + { value: 'single', viewValue: 'Single' }, + { value: 'multiple', viewValue: 'Multiple' } ]; // The identifier of a node. You can also use one of these well-known aliases: -my- | -shared- | -root- @@ -165,7 +166,7 @@ export class FilesComponent implements OnInit, OnChanges, OnDestroy { navigationRoute = '/files'; @Input() - enableCustomHeaderFilter = false; + headerFilters = false; @Input() paramValues: Map = null; @@ -365,7 +366,7 @@ export class FilesComponent implements OnInit, OnChanges, OnDestroy { getCurrentDocumentListNode(): MinimalNodeEntity[] { if (this.documentList.folderNode) { - return [{entry: this.documentList.folderNode}]; + return [{ entry: this.documentList.folderNode }]; } else { return []; } @@ -464,7 +465,7 @@ export class FilesComponent implements OnInit, OnChanges, OnDestroy { if (this.contentService.hasAllowableOperations(contentEntry, 'update')) { this.dialog.open(VersionManagerDialogAdapterComponent, { - data: {contentEntry: contentEntry, showComments: showComments, allowDownload: allowDownload}, + data: { contentEntry: contentEntry, showComments: showComments, allowDownload: allowDownload }, panelClass: 'adf-version-manager-dialog', width: '630px' }); @@ -677,11 +678,30 @@ export class FilesComponent implements OnInit, OnChanges, OnDestroy { return ''; } - onFilterUpdate(newNodePaging: NodePaging) { - this.nodeResult = newNodePaging; + onFilterSelected(activeFilters: FilterSearch[]) { + if (activeFilters.length) { + this.navigateToFilter(activeFilters); + } else { + this.clearFilterNavigation(); + } } - onAllFilterCleared() { + navigateToFilter(activeFilters: FilterSearch[]) { + const objectFromMap = {}; + activeFilters.forEach((filter: FilterSearch) => { + let paramValue = null; + if (filter.value && filter.value.from && filter.value.to) { + paramValue = `${filter.value.from}||${filter.value.to}`; + } else { + paramValue = filter.value; + } + objectFromMap[filter.key] = paramValue; + }); + + this.router.navigate([], { relativeTo: this.route, queryParams: objectFromMap }); + } + + clearFilterNavigation() { this.documentList.node = null; if (this.currentFolderId === '-my-') { this.router.navigate([this.navigationRoute, '']); @@ -691,21 +711,6 @@ export class FilesComponent implements OnInit, OnChanges, OnDestroy { this.documentList.reload(); } - onFilterSelected(currentActiveFilters: Map) { - const objectFromMap = {}; - currentActiveFilters.forEach((value: any, key) => { - let paramValue = null; - if (value && value.from && value.to) { - paramValue = `${value.from}||${value.to}`; - } else { - paramValue = value; - } - objectFromMap[key] = paramValue; - }); - - this.router.navigate([], { relativeTo: this.route, queryParams: objectFromMap }); - } - setPreselectNodes(nodes: string) { this.selectedNodes = this.getArrayFromString(nodes); 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 index bbf256dfd4..f02c2e95d5 100644 --- a/demo-shell/src/app/components/files/filtered-search.component.html +++ b/demo-shell/src/app/components/files/filtered-search.component.html @@ -4,8 +4,6 @@ [showSettingsPanel]="false" [navigationRoute]="navigationRoute" [currentFolderId]="currentFolderId" - [filterSorting]="filterSorting" - [enableCustomHeaderFilter]="true" - [paramValues]="queryParams" - (sorting-changed)="onSortingChanged($event)"> + [headerFilters]="true" + [paramValues]="queryParams"> diff --git a/demo-shell/src/app/components/files/filtered-search.component.ts b/demo-shell/src/app/components/files/filtered-search.component.ts index c7e6d8c08a..3624e9d438 100644 --- a/demo-shell/src/app/components/files/filtered-search.component.ts +++ b/demo-shell/src/app/components/files/filtered-search.component.ts @@ -16,13 +16,11 @@ */ 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}] + templateUrl: './filtered-search.component.html' }) export class FilteredSearchComponent { @@ -30,7 +28,6 @@ export class FilteredSearchComponent { currentFolderId = '-my-'; queryParams = null; - filterSorting: string = 'name-asc'; constructor(@Optional() private route: ActivatedRoute) { @@ -46,9 +43,4 @@ export class FilteredSearchComponent { }); } } - - onSortingChanged(event) { - this.filterSorting = event.detail.key + '-' + event.detail.direction; - } - } diff --git a/docs/content-services/components/document-list.component.md b/docs/content-services/components/document-list.component.md index 16b81339e1..2757bb536a 100644 --- a/docs/content-services/components/document-list.component.md +++ b/docs/content-services/components/document-list.component.md @@ -32,6 +32,7 @@ Displays the documents from a repository. - [Location Column](#location-column) - [Actions](#actions) - [Navigation mode](#navigation-mode) + - [Header filters](#header-filters) - [Advanced usage and customization](#advanced-usage-and-customization) - [Image Resolver and Row Filter functions](#image-resolver-and-row-filter-functions) - [Custom 'empty folder' template](#custom-empty-folder-template) @@ -56,7 +57,7 @@ Displays the documents from a repository. | Name | Type | Default value | Description | | ---- | ---- | ------------- | ----------- | -| additionalSorting | `string[]` | ['isFolder DESC'] | Defines default sorting. The format is an array of strings `[key direction, otherKey otherDirection]` i.e. `['name desc', 'nodeType asc']` or `['name asc']`. Set this value if you want a base rule to be added to the sorting apart from the one driven by the header. | +| additionalSorting | [`DataSorting`](../../../lib/core/datatable/data/data-sorting.model.ts) | Defines default sorting. The format is an array of strings `[key direction, otherKey otherDirection]` i.e. `['name desc', 'nodeType asc']` or `['name asc']`. Set this value if you want a base rule to be added to the sorting apart from the one driven by the header. | | allowDropFiles | `boolean` | false | When true, this enables you to drop files directly into subfolders shown as items in the list or into another file to trigger updating it's version. When false, the dropped file will be added to the current folder (ie, the one containing all the items shown in the list). See the [Upload directive](../../core/directives/upload.directive.md) for further details about how the file drop is handled. | | contentActions | `boolean` | false | Toggles content actions for each row | | contentActionsPosition | `string` | "right" | Position of the content actions dropdown menu. Can be set to "left" or "right". | @@ -78,13 +79,15 @@ Displays the documents from a repository. | 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 | `string` | | Toggles the header | -| 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. | +| sorting | `string[]` \| [`DataSorting`](../../../lib/core/datatable/data/data-sorting.model.ts) | ['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` | "server" | 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. | | thumbnails | `boolean` | false | Show document thumbnails rather than icons | | where | `string` | | Filters the [`Node`](https://github.com/Alfresco/alfresco-js-api/blob/develop/src/api/content-rest-api/docs/Node.md) list using the _where_ condition of the REST API (for example, isFolder=true). See the REST API documentation for more information. | -| currentFolderId | `string` | | The ID of the folder node to display or a reserved string alias for special sources | +| currentFolderId | `string` | null | The ID of the folder node to display or a reserved string alias for special sources | | rowFilter | [`RowFilter`](../../../lib/content-services/src/lib/document-list/data/row-filter.model.ts) | | Custom function to choose whether to show or hide rows. See the [Row Filter Model](row-filter.model.md) page for more information. | +| headerFilters | `boolean` | false | Toggles the header filters mode. | +| filterValue | any | | Initial value for filter. | ### Events @@ -96,7 +99,8 @@ Displays the documents from a repository. | nodeDblClick | [`EventEmitter`](https://angular.io/api/core/EventEmitter)`<`[`NodeEntityEvent`](../../../lib/content-services/src/lib/document-list/components/node.event.ts)`>` | Emitted when the user double-clicks a list node | | nodeSelected | [`EventEmitter`](https://angular.io/api/core/EventEmitter)`<`[`NodeEntry`](https://github.com/Alfresco/alfresco-js-api/blob/master/src/alfresco-core-rest-api/docs/NodeEntry.md)`[]>` | Emitted when the node selection change | | preview | [`EventEmitter`](https://angular.io/api/core/EventEmitter)`<`[`NodeEntityEvent`](../../../lib/content-services/src/lib/document-list/components/node.event.ts)`>` | Emitted when the user acts upon files with either single or double click (depends on `navigation-mode`). Useful for integration with the [Viewer component](../../core/components/viewer.component.md). | -| ready | [`EventEmitter`](https://angular.io/api/core/EventEmitter)`<`[`NodePaging`](https://github.com/Alfresco/alfresco-js-api/blob/develop/src/api/content-rest-api/docs/NodePaging.md)`>` | Emitted when the Document List has loaded all items and is ready for use | +| ready | [`EventEmitter`](https://angular.io/api/core/EventEmitter)`<`[`NodePaging`](https://github.com/Alfresco/alfresco-js-api/blob/develop/src/api/content-rest-api/docs/NodePaging.md)`>` | Emitted when the Document List has loaded all items and is ready for use. | +| filterSelection | [`EventEmitter`](https://angular.io/api/core/EventEmitter)`<`[`FilterSearch[]`](../../../lib/content-services/src/lib/search/filter-search.interface.ts)`>` | Emitted when a filter value is selected. | ## Details @@ -687,6 +691,18 @@ The following example switches navigation to single clicks: ``` +### Header filters + +You can enable Header filters in your document list simply setting to true its `headerFilters` input. + +```html + + +``` +![Header filters](../../docassets/images/header-filters.png) + ## Advanced usage and customization ### Image Resolver and Row Filter functions diff --git a/docs/content-services/components/search-header.component.md b/docs/content-services/components/search-header.component.md deleted file mode 100644 index d680928c45..0000000000 --- a/docs/content-services/components/search-header.component.md +++ /dev/null @@ -1,71 +0,0 @@ ---- -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) - -## Basic usage - -**app.component.html** - -```html - - - - - - - - -``` - -**app.config.json** - -```json - -``` - -This component is designed to be used as transcluded inside the [document list component](../../content-services/components/document-list.component.md). 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`](../../../lib/core/datatable/data/data-column.model.ts) | | The column the filter will be applied on. | -| currentFolderNodeId | `string` | | The id of the current folder of the document list. | -| maxItems | `number` | | Maximum number of search results to show in a page. | -| skipCount | `number` | | The offset of the start of the page within the results list. | -| sorting | `string` | null | The sorting to apply to the the filter. | -| value | `any` | | (optional) Initial filter value to sort . | - -### Events - -| Name | Type | Description | -| ---- | ---- | ----------- | -| clear | [`EventEmitter`](https://angular.io/api/core/EventEmitter)`` | Emitted when the last of all the filters is cleared. | -| selection | [`EventEmitter`](https://angular.io/api/core/EventEmitter)`>` | Emitted when a filter value is selected | -| update | [`EventEmitter`](https://angular.io/api/core/EventEmitter)`<`[`NodePaging`](https://github.com/Alfresco/alfresco-js-api/blob/develop/src/api/content-rest-api/docs/NodePaging.md)`>` | Emitted when the result of the filter is received from the API. | - -## 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/docassets/images/header-filters.png b/docs/docassets/images/header-filters.png new file mode 100644 index 0000000000000000000000000000000000000000..49de63f4bf4d034dd6d5a03a29c5bfa2e434d091 GIT binary patch literal 69288 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 literal 0 HcmV?d00001 diff --git a/docs/upgrade-guide/upgrade40-41.md b/docs/upgrade-guide/upgrade40-41.md new file mode 100644 index 0000000000..2151ed9bc8 --- /dev/null +++ b/docs/upgrade-guide/upgrade40-41.md @@ -0,0 +1,62 @@ +--- +Title: Upgrading from ADF v4.0 to v4.1 +--- + +# Upgrading from ADF v4.0 to v4.1 + +This guide explains how to upgrade your ADF v4.0 project to work with v4.1. + +Do not skip this task, if you want your application to be updated to a most recent version of ADF. +Upgrades of multiple versions of ADF cannot be done in one step only, but should follow the chain of sequential updates. + +**Note:** the steps described below might involve making changes +to your code. If you are working with a versioning system then you should +commit any changes you are currently working on. If you aren't using versioning +then be sure to make a backup copy of your project before going ahead with the +upgrade. + +## Header Filters for Document List Components + +We released a new feature called Header Filters in ADF 4.0. It would allow users to filter the content of a folder by its columns properties. While this feature was working we noticed it was hard to implement. That is way we came up with a new way of enabling this feature. + +You will need to update your code to overcome this breaking change. + +ADF 4.0 implementation +```html + + + + + + + + +``` +ADF 4.1 implementation +```html + + +``` + +This is all you'll need to set it up in your app. Alternatively, you can also pass an initial value to the filters and listen to filter selection changes. + +```html + + +``` +Notice that for this feature in ADF 4.0 to work you also needed to overwrite the `SearchFilterQueryBuilderService` with `SEARCH_QUERY_SERVICE_TOKEN` at an app level to make it work. That is no longer the case with the new version. The component will handle everything for you. diff --git a/lib/content-services/src/lib/breadcrumb/dropdown-breadcrumb.component.spec.ts b/lib/content-services/src/lib/breadcrumb/dropdown-breadcrumb.component.spec.ts index 591a774fe1..f7349bc697 100644 --- a/lib/content-services/src/lib/breadcrumb/dropdown-breadcrumb.component.spec.ts +++ b/lib/content-services/src/lib/breadcrumb/dropdown-breadcrumb.component.spec.ts @@ -31,7 +31,7 @@ describe('DropdownBreadcrumb', () => { let component: DropdownBreadcrumbComponent; let fixture: ComponentFixture; let documentList: DocumentListComponent; - let documentListService: DocumentListService = jasmine.createSpyObj({'loadFolderByNodeId' : of(''), 'isCustomSourceService': false}); + let documentListService: DocumentListService = jasmine.createSpyObj({ 'loadFolderByNodeId': of(''), 'isCustomSourceService': false }); setupTestBed({ imports: [ @@ -39,7 +39,7 @@ describe('DropdownBreadcrumb', () => { ContentTestingModule ], schemas: [CUSTOM_ELEMENTS_SCHEMA], - providers : [{ provide: DocumentListService, useValue: documentListService }] + providers: [{ provide: DocumentListService, useValue: documentListService }] }); beforeEach(async(() => { @@ -133,7 +133,7 @@ describe('DropdownBreadcrumb', () => { }); }); - it('should update document list when clicking on an option', (done) => { + it('should update document list when clicking on an option', (done) => { component.target = documentList; const fakeNodeWithCreatePermissionInstance = JSON.parse(JSON.stringify(fakeNodeWithCreatePermission)); fakeNodeWithCreatePermissionInstance.path.elements = [{ id: '1', name: 'Stark Industries' }]; @@ -144,7 +144,7 @@ describe('DropdownBreadcrumb', () => { fixture.whenStable().then(() => { clickOnTheFirstOption(); - expect(documentListService.loadFolderByNodeId).toHaveBeenCalledWith('1', documentList.DEFAULT_PAGINATION, undefined, undefined, ['name ASC']); + expect(documentListService.loadFolderByNodeId).toHaveBeenCalledWith('1', documentList.DEFAULT_PAGINATION, undefined, undefined, null); done(); }); }); 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 6864a2d156..8926a9f0a8 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 @@ -25,13 +25,13 @@ (sorting-changed)="onSortingChanged($event)" [class.adf-datatable-gallery-thumbnails]="data.thumbnails"> - - - - - - +
+ + +
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 61f9a5d7d2..42a3d83e80 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 @@ -1456,7 +1456,7 @@ describe('DocumentList', () => { where: undefined, maxItems: 25, skipCount: 0, - orderBy: ['isFolder DESC', 'name asc'], + orderBy: ['isFolder desc', 'name asc'], rootFolderId: 'fake-id' }, ['test-include']); }); @@ -1472,14 +1472,14 @@ describe('DocumentList', () => { where: '(isFolder=true)', maxItems: 25, skipCount: 0, - orderBy: ['isFolder DESC', 'name asc'], + orderBy: ['isFolder desc', 'name asc'], rootFolderId: 'fake-id' }, ['test-include']); }); it('should add orderBy in the server request', () => { documentList.includeFields = ['test-include']; - documentList.sorting = ['size', 'DESC']; + documentList.sorting = ['size', 'desc']; documentList.where = null; documentList.currentFolderId = 'fake-id'; @@ -1489,12 +1489,13 @@ describe('DocumentList', () => { maxItems: 25, skipCount: 0, where: null, - orderBy: ['isFolder DESC', 'size DESC'], + orderBy: ['isFolder desc', 'size desc'], rootFolderId: 'fake-id' }, ['test-include']); }); it('should reset the pagination when enter in a new folder', () => { + documentList.ngOnChanges({ currentFolderId: new SimpleChange(undefined, 'fake-id', true) }); const folder = new FolderNode(); documentList.navigationMode = DocumentListComponent.SINGLE_CLICK_NAVIGATION; documentList.updatePagination({ @@ -1505,7 +1506,7 @@ describe('DocumentList', () => { expect(documentListService.getFolder).toHaveBeenCalledWith(null, Object({ maxItems: 10, skipCount: 10, - orderBy: ['name ASC'], + orderBy: ['isFolder desc', 'name asc'], rootFolderId: 'no-node', where: undefined }), undefined); @@ -1515,7 +1516,7 @@ describe('DocumentList', () => { expect(documentListService.getFolder).toHaveBeenCalledWith(null, Object({ maxItems: 25, skipCount: 0, - orderBy: ['name ASC'], + orderBy: ['isFolder desc', 'name asc'], rootFolderId: 'folder-id', where: undefined }), undefined); 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 d6b50c14c2..14292a6488 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 @@ -41,7 +41,6 @@ import { CustomLoadingContentTemplateDirective, CustomNoPermissionTemplateDirective, CustomEmptyContentTemplateDirective, - CustomHeaderFilterTemplateDirective, RequestPaginationModel, AlfrescoApiService, UserPreferenceValues, @@ -58,6 +57,7 @@ import { ContentActionModel } from './../models/content-action.model'; import { PermissionStyleModel } from './../models/permissions-style.model'; import { NodeEntityEvent, NodeEntryEvent } from './node.event'; import { NavigableComponentInterface } from '../../breadcrumb/navigable-component.interface'; +import { FilterSearch } from './../../search/filter-search.interface'; import { RowFilter } from '../data/row-filter.model'; import { DocumentListService } from '../services/document-list.service'; import { DocumentLoaderNode } from '../models/document-folder.model'; @@ -68,7 +68,7 @@ import { takeUntil } from 'rxjs/operators'; styleUrls: ['./document-list.component.scss'], templateUrl: './document-list.component.html', encapsulation: ViewEncapsulation.None, - host: {class: 'adf-document-list'} + host: { class: 'adf-document-list' } }) export class DocumentListComponent implements OnInit, OnChanges, OnDestroy, AfterContentInit, PaginatedComponent, NavigableComponentInterface { @@ -82,6 +82,11 @@ export class DocumentListComponent implements OnInit, OnChanges, OnDestroy, Afte totalItems: 0 }); + DEFAULT_SORTING: DataSorting[] = [ + new DataSorting('name', 'asc'), + new DataSorting('isFolder', 'desc') + ]; + @ContentChild(DataColumnListComponent) columnList: DataColumnListComponent; @@ -94,9 +99,6 @@ 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[]; @@ -183,14 +185,14 @@ export class DocumentListComponent implements OnInit, OnChanges, OnDestroy, Afte * override the default sorting detected by the component based on columns. */ @Input() - sorting = ['name', 'asc']; + sorting: string[] | DataSorting = ['name', 'asc']; /** Defines default sorting. The format is an array of strings `[key direction, otherKey otherDirection]` * i.e. `['name desc', 'nodeType asc']` or `['name asc']`. Set this value if you want a base * rule to be added to the sorting apart from the one driven by the header. */ @Input() - additionalSorting = ['isFolder DESC']; + additionalSorting: DataSorting = new DataSorting('isFolder', 'desc'); /** Defines sorting mode. Can be either `client` (items in the list * are sorted client-side) or `server` (the ordering supplied by the @@ -255,6 +257,14 @@ export class DocumentListComponent implements OnInit, OnChanges, OnDestroy, Afte @Input() stickyHeader: boolean = false; + /** Toggles the header filters mode. */ + @Input() + headerFilters: boolean = false; + + /** Initial value for filter. */ + @Input() + filterValue: any; + /** The ID of the folder node to display or a reserved string alias for special sources */ @Input() currentFolderId: string = null; @@ -305,6 +315,10 @@ export class DocumentListComponent implements OnInit, OnChanges, OnDestroy, Afte @Output() nodeSelected: EventEmitter = new EventEmitter(); + /** Emitted when a filter value is selected */ + @Output() + filterSelection: EventEmitter = new EventEmitter(); + @ViewChild('dataTable', { static: true }) dataTable: DataTableComponent; @@ -315,13 +329,14 @@ export class DocumentListComponent implements OnInit, OnChanges, OnDestroy, Afte selection = new Array(); $folderNode: Subject = new Subject(); allowFiltering: boolean = true; - orderBy: string[] = ['name ASC']; + orderBy: string[] = null; // @deprecated 3.0.0 folderNode: Node; private _pagination: PaginationModel = this.DEFAULT_PAGINATION; pagination: BehaviorSubject = new BehaviorSubject(this.DEFAULT_PAGINATION); + sortingSubject: BehaviorSubject = new BehaviorSubject(this.DEFAULT_SORTING); private layoutPresets = {}; private rowMenuCache: { [key: string]: ContentActionModel[] } = {}; @@ -367,10 +382,13 @@ export class DocumentListComponent implements OnInit, OnChanges, OnDestroy, Afte private getDefaultSorting(): DataSorting { let defaultSorting: DataSorting; - if (this.sorting) { + if (Array.isArray(this.sorting)) { const [key, direction] = this.sorting; defaultSorting = new DataSorting(key, direction); + } else { + defaultSorting = new DataSorting(this.sorting.key, this.sorting.direction); } + return defaultSorting; } @@ -440,9 +458,11 @@ export class DocumentListComponent implements OnInit, OnChanges, OnDestroy, Afte ngOnChanges(changes: SimpleChanges) { this.resetSelection(); - if (this.sorting) { + if (Array.isArray(this.sorting)) { const [key, direction] = this.sorting; this.orderBy = this.buildOrderByArray(key, direction); + } else { + this.orderBy = this.buildOrderByArray(this.sorting.key, this.sorting.direction); } if (this.data) { @@ -493,6 +513,7 @@ export class DocumentListComponent implements OnInit, OnChanges, OnDestroy, Afte if (this.node) { this.data.loadPage(this.node, this._pagination.merge, null, this.getPreselectNodesBasedOnSelectionMode()); this.onPreselectNodes(); + this.syncPagination(); this.onDataReady(this.node); } else { this.loadFolder(); @@ -586,14 +607,14 @@ export class DocumentListComponent implements OnInit, OnChanges, OnDestroy, Afte if (typeof node === 'string') { this.resetNewFolderPagination(); this.currentFolderId = node; - this.folderChange.emit(new NodeEntryEvent( {id: node})); + this.folderChange.emit(new NodeEntryEvent( { id: node })); this.reload(); return true; } else { if (this.canNavigateFolder(node)) { this.resetNewFolderPagination(); this.currentFolderId = this.getNodeFolderDestinationId(node); - this.folderChange.emit(new NodeEntryEvent( {id: this.currentFolderId})); + this.folderChange.emit(new NodeEntryEvent( { id: this.currentFolderId })); this.reload(); return true; } @@ -690,14 +711,16 @@ export class DocumentListComponent implements OnInit, OnChanges, OnDestroy, Afte } onSortingChanged(event: CustomEvent) { - this.orderBy = this.buildOrderByArray(event.detail.sortingKey, event.detail.direction); + this.orderBy = this.buildOrderByArray(event.detail.key, event.detail.direction); this.reload(); + this.sortingSubject.next([this.additionalSorting, event.detail]); } - private buildOrderByArray(currentKey: string, currentDirection: string ): string[] { - const orderArray = [...this.additionalSorting]; - orderArray.push(''.concat(currentKey, ' ', currentDirection)); - return orderArray; + private buildOrderByArray(currentKey: string, currentDirection: string): string[] { + return [ + `${this.additionalSorting.key} ${this.additionalSorting.direction}`, + `${currentKey} ${currentDirection}` + ]; } /** @@ -874,6 +897,15 @@ export class DocumentListComponent implements OnInit, OnChanges, OnDestroy, Afte this.reload(); } + private syncPagination() { + this.node.list.pagination.maxItems = this._pagination.maxItems; + this.node.list.pagination.skipCount = this._pagination.skipCount; + } + + onFilterSelectionChange(activeFilters: FilterSearch[]) { + this.filterSelection.emit(activeFilters); + } + private resetNewFolderPagination() { this._pagination.skipCount = 0; this._pagination.maxItems = this.maxItems; diff --git a/lib/content-services/src/lib/document-list/components/filter-header/filter-header.component.html b/lib/content-services/src/lib/document-list/components/filter-header/filter-header.component.html new file mode 100644 index 0000000000..79fed603e2 --- /dev/null +++ b/lib/content-services/src/lib/document-list/components/filter-header/filter-header.component.html @@ -0,0 +1,10 @@ +
+ + + + + + +
diff --git a/lib/content-services/src/lib/document-list/components/filter-header/filter-header.component.spec.ts b/lib/content-services/src/lib/document-list/components/filter-header/filter-header.component.spec.ts new file mode 100644 index 0000000000..ab7f774d50 --- /dev/null +++ b/lib/content-services/src/lib/document-list/components/filter-header/filter-header.component.spec.ts @@ -0,0 +1,151 @@ +/*! + * @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, BehaviorSubject } from 'rxjs'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { SearchService, setupTestBed, DataTableComponent, DataSorting } from '@alfresco/adf-core'; +import { ContentTestingModule } from '../../../testing/content.testing.module'; +import { SimpleChange } from '@angular/core'; +import { SearchFilterQueryBuilderService } from './../../../search/search-filter-query-builder.service'; +import { SEARCH_QUERY_SERVICE_TOKEN } from './../../../search/search-query-service.token'; +import { DocumentListComponent } from './../document-list.component'; +import { FilterHeaderComponent } from './filter-header.component'; +import { Pagination } from '@alfresco/js-api'; + +describe('FilterHeaderComponent', () => { + let fixture: ComponentFixture; + let component: FilterHeaderComponent; + let queryBuilder: SearchFilterQueryBuilderService; + + const searchMock: any = { + dataLoaded: new Subject() + }; + + const paginationMock = { maxItems: 10, skipCount: 0 }; + + const documentListMock = { + node: 'my-node', + sorting: ['name', 'asc'], + pagination: new BehaviorSubject(paginationMock), + sortingSubject: new BehaviorSubject([]), + reload: () => jasmine.createSpy('reload') + }; + + setupTestBed({ + imports: [ + TranslateModule.forRoot(), + ContentTestingModule + ], + providers: [ + { provide: SearchService, useValue: searchMock }, + { provide: SEARCH_QUERY_SERVICE_TOKEN, useClass: SearchFilterQueryBuilderService }, + { provide: DocumentListComponent, useValue: documentListMock }, + DataTableComponent + ] + }); + + beforeEach(() => { + fixture = TestBed.createComponent(FilterHeaderComponent); + component = fixture.componentInstance; + queryBuilder = fixture.componentInstance['searchFilterQueryBuilder']; + }); + + afterEach(() => { + fixture.destroy(); + }); + + it('should subscribe to changes in document list pagination', async () => { + const setupCurrentPaginationSpy = spyOn(queryBuilder, 'setupCurrentPagination'); + + const currentFolderNodeIdChange = new SimpleChange('current-node-id', 'next-node-id', true); + component.ngOnChanges({ currentFolderId: currentFolderNodeIdChange }); + fixture.detectChanges(); + await fixture.whenStable(); + + expect(setupCurrentPaginationSpy).toHaveBeenCalled(); + }); + + it('should subscribe to changes in document list sorting', async () => { + const setSortingSpy = spyOn(queryBuilder, 'setSorting'); + + const currentFolderNodeIdChange = new SimpleChange('current-node-id', 'next-node-id', true); + component.ngOnChanges({ currentFolderId: currentFolderNodeIdChange }); + fixture.detectChanges(); + await fixture.whenStable(); + + expect(setSortingSpy).toHaveBeenCalled(); + }); + + it('should reset filters after changing the folder node', async () => { + const resetActiveFiltersSpy = spyOn(queryBuilder, 'resetActiveFilters'); + spyOn(queryBuilder, 'isCustomSourceNode').and.returnValue(false); + + const currentFolderNodeIdChange = new SimpleChange('current-node-id', 'next-node-id', true); + component.ngOnChanges({ currentFolderId: currentFolderNodeIdChange }); + fixture.detectChanges(); + await fixture.whenStable(); + + expect(resetActiveFiltersSpy).toHaveBeenCalled(); + }); + + it('should init filters after changing the folder node', async () => { + const setCurrentRootFolderIdSpy = spyOn(queryBuilder, 'setCurrentRootFolderId'); + spyOn(queryBuilder, 'isCustomSourceNode').and.returnValue(false); + const currentFolderNodeIdChange = new SimpleChange('current-node-id', 'next-node-id', true); + component.ngOnChanges({ currentFolderId: currentFolderNodeIdChange }); + fixture.detectChanges(); + await fixture.whenStable(); + expect(setCurrentRootFolderIdSpy).toHaveBeenCalled(); + }); + + it('should set active filters when an initial value is set', async () => { + spyOn(queryBuilder, 'setCurrentRootFolderId'); + spyOn(queryBuilder, 'isCustomSourceNode').and.returnValue(false); + + fixture.detectChanges(); + await fixture.whenStable(); + expect(queryBuilder.getActiveFilters().length).toBe(0); + + const initialFilterValue = { name: 'pinocchio'}; + component.value = initialFilterValue; + const currentFolderNodeIdChange = new SimpleChange('current-node-id', 'next-node-id', true); + component.ngOnChanges({ currentFolderId: currentFolderNodeIdChange }); + fixture.detectChanges(); + await fixture.whenStable(); + + expect(queryBuilder.getActiveFilters().length).toBe(1); + expect(queryBuilder.getActiveFilters()[0].key).toBe('name'); + expect(queryBuilder.getActiveFilters()[0].value).toBe('pinocchio'); + }); + + it('should emit filterSelection when a filter is changed', async (done) => { + spyOn(queryBuilder, 'getActiveFilters').and.returnValue([{ key: 'name', value: 'pinocchio' }]); + + component.filterSelection.subscribe((selectedFilters) => { + expect(selectedFilters.length).toBe(1); + expect(selectedFilters[0].key).toBe('name'); + expect(selectedFilters[0].value).toBe('pinocchio'); + done(); + }); + + component.onFilterSelectionChange(); + fixture.detectChanges(); + await fixture.whenStable(); + }); + +}); diff --git a/lib/content-services/src/lib/document-list/components/filter-header/filter-header.component.ts b/lib/content-services/src/lib/document-list/components/filter-header/filter-header.component.ts new file mode 100644 index 0000000000..a2a6d07cf7 --- /dev/null +++ b/lib/content-services/src/lib/document-list/components/filter-header/filter-header.component.ts @@ -0,0 +1,126 @@ +/*! + * @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, Inject, OnInit, OnChanges, SimpleChanges, Input, Output, EventEmitter } from '@angular/core'; +import { PaginationModel, DataSorting } from '@alfresco/adf-core'; +import { DocumentListComponent } from '../document-list.component'; +import { SEARCH_QUERY_SERVICE_TOKEN } from '../../../search/search-query-service.token'; +import { SearchFilterQueryBuilderService } from '../../../search/search-filter-query-builder.service'; +import { FilterSearch } from './../../../search/filter-search.interface'; +import { Subject } from 'rxjs'; +import { takeUntil } from 'rxjs/operators'; +import { NodePaging, MinimalNode } from '@alfresco/js-api'; + +@Component({ + selector: 'adf-filter-header', + templateUrl: './filter-header.component.html', + providers: [{ provide: SEARCH_QUERY_SERVICE_TOKEN, useClass: SearchFilterQueryBuilderService}] +}) +export class FilterHeaderComponent implements OnInit, OnChanges { + + /** (optional) Initial filter value to sort . */ + @Input() + value: any = {}; + + /** The id of the current folder of the document list. */ + @Input() + currentFolderId: string; + + /** Emitted when a filter value is selected */ + @Output() + filterSelection: EventEmitter = new EventEmitter(); + + isFilterServiceActive: boolean; + private onDestroy$ = new Subject(); + + constructor(@Inject(DocumentListComponent) private documentList: DocumentListComponent, + @Inject(SEARCH_QUERY_SERVICE_TOKEN) private searchFilterQueryBuilder: SearchFilterQueryBuilderService) { + this.isFilterServiceActive = this.searchFilterQueryBuilder.isFilterServiceActive(); + } + + ngOnInit() { + this.searchFilterQueryBuilder.executed + .pipe(takeUntil(this.onDestroy$)) + .subscribe((newNodePaging: NodePaging) => { + this.documentList.node = newNodePaging; + this.documentList.reload(); + }); + + this.initDataPagination(); + this.initDataSorting(); + } + + ngOnChanges(changes: SimpleChanges) { + if (changes['currentFolderId'] && changes['currentFolderId'].currentValue) { + this.resetFilterHeader(); + this.configureSearchParent(changes['currentFolderId'].currentValue); + } + } + + onFilterSelectionChange() { + this.filterSelection.emit(this.searchFilterQueryBuilder.getActiveFilters()); + if (this.searchFilterQueryBuilder.isNoFilterActive()) { + this.documentList.node = null; + this.documentList.reload(); + } + } + + resetFilterHeader() { + this.searchFilterQueryBuilder.resetActiveFilters(); + } + + initDataPagination() { + this.documentList.pagination + .pipe(takeUntil(this.onDestroy$)) + .subscribe((newPagination: PaginationModel) => { + this.searchFilterQueryBuilder.setupCurrentPagination(newPagination.maxItems, newPagination.skipCount); + }); + } + + initDataSorting() { + this.documentList.sortingSubject + .pipe(takeUntil(this.onDestroy$)) + .subscribe((sorting: DataSorting[]) => { + this.searchFilterQueryBuilder.setSorting(sorting); + }); + } + + private configureSearchParent(currentFolderId: string) { + if (this.searchFilterQueryBuilder.isCustomSourceNode(currentFolderId)) { + this.searchFilterQueryBuilder.getNodeIdForCustomSource(currentFolderId).subscribe((node: MinimalNode) => { + this.initSearchHeader(node.id); + }); + } else { + this.initSearchHeader(currentFolderId); + } + } + + private initSearchHeader(currentFolderId: string) { + this.searchFilterQueryBuilder.setCurrentRootFolderId(currentFolderId); + if (this.value) { + Object.keys(this.value).forEach((columnKey) => { + this.searchFilterQueryBuilder.setActiveFilter(columnKey, this.value[columnKey]); + }); + } + + } + + ngOnDestroy() { + this.onDestroy$.next(true); + this.onDestroy$.complete(); + } +} 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 03f1c84dec..785e352082 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,6 +32,8 @@ 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 { FilterHeaderComponent } from './components/filter-header/filter-header.component'; +import { SearchModule } from './../search/search.module'; @NgModule({ imports: [ @@ -40,7 +42,8 @@ import { NameColumnComponent } from './components/name-column/name-column.compon FlexLayoutModule, MaterialModule, UploadModule, - EditJsonDialogModule + EditJsonDialogModule, + SearchModule ], declarations: [ DocumentListComponent, @@ -50,7 +53,8 @@ import { NameColumnComponent } from './components/name-column/name-column.compon LibraryNameColumnComponent, NameColumnComponent, ContentActionComponent, - ContentActionListComponent + ContentActionListComponent, + FilterHeaderComponent ], exports: [ DocumentListComponent, @@ -60,7 +64,8 @@ import { NameColumnComponent } from './components/name-column/name-column.compon LibraryNameColumnComponent, NameColumnComponent, ContentActionComponent, - ContentActionListComponent + ContentActionListComponent, + FilterHeaderComponent ] }) export class DocumentListModule {} 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 index d5c2a06c79..dcdc57dcc3 100644 --- a/lib/content-services/src/lib/search/base-query-builder.service.ts +++ b/lib/content-services/src/lib/search/base-query-builder.service.ts @@ -46,14 +46,14 @@ export abstract class BaseQueryBuilderService { executed = new Subject(); error = new Subject(); - categories: Array = []; + categories: SearchCategory[] = []; queryFragments: { [id: string]: string } = {}; filterQueries: FilterQuery[] = []; paging: { maxItems?: number; skipCount?: number } = null; - sorting: Array = []; - sortingOptions: Array = []; + sorting: SearchSortingDefinition[] = []; + sortingOptions: SearchSortingDefinition[] = []; - protected userFacetBuckets: { [key: string]: Array } = {}; + protected userFacetBuckets: { [key: string]: FacetFieldBucket[] } = {}; get userQuery(): string { return this._userQuery; diff --git a/lib/content-services/src/lib/search/components/search-filter-container/search-filter-container.component.html b/lib/content-services/src/lib/search/components/search-filter-container/search-filter-container.component.html new file mode 100644 index 0000000000..0095edd4fe --- /dev/null +++ b/lib/content-services/src/lib/search/components/search-filter-container/search-filter-container.component.html @@ -0,0 +1,49 @@ +
+ + + +
+
+
{{ category?.name | 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-filter-container/search-filter-container.component.scss similarity index 100% rename from lib/content-services/src/lib/search/components/search-header/search-header.component.scss rename to lib/content-services/src/lib/search/components/search-filter-container/search-filter-container.component.scss 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-filter-container/search-filter-container.component.spec.ts similarity index 57% rename from lib/content-services/src/lib/search/components/search-header/search-header.component.spec.ts rename to lib/content-services/src/lib/search/components/search-filter-container/search-filter-container.component.spec.ts index 8e1351f239..2775d52956 100644 --- a/lib/content-services/src/lib/search/components/search-header/search-header.component.spec.ts +++ b/lib/content-services/src/lib/search/components/search-filter-container/search-filter-container.component.spec.ts @@ -18,16 +18,16 @@ 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 { SearchFilterQueryBuilderService } from '../../search-filter-query-builder.service'; import { ContentTestingModule } from '../../../testing/content.testing.module'; -import { fakeNodePaging } from '../../../mock'; +import { fakeNodePaging } from './../../../mock/document-list.component.mock'; import { SEARCH_QUERY_SERVICE_TOKEN } from '../../search-query-service.token'; import { By } from '@angular/platform-browser'; -import { SimpleChange } from '@angular/core'; +import { SearchFilterContainerComponent } from './search-filter-container.component'; import { MatMenuTrigger } from '@angular/material/menu'; +import { SearchCategory } from '../../search-category.interface'; -const mockCategory: any = { +const mockCategory: SearchCategory = { 'id': 'queryName', 'name': 'Name', 'columnKey': 'name', @@ -43,10 +43,10 @@ const mockCategory: any = { } }; -describe('SearchHeaderComponent', () => { - let fixture: ComponentFixture; - let component: SearchHeaderComponent; - let queryBuilder: SearchHeaderQueryBuilderService; +describe('SearchFilterContainerComponent', () => { + let fixture: ComponentFixture; + let component: SearchFilterContainerComponent; + let queryBuilder: SearchFilterQueryBuilderService; let alfrescoApiService: AlfrescoApiService; const searchMock: any = { @@ -60,14 +60,14 @@ describe('SearchHeaderComponent', () => { ], providers: [ { provide: SearchService, useValue: searchMock }, - { provide: SEARCH_QUERY_SERVICE_TOKEN, useClass: SearchHeaderQueryBuilderService } + { provide: SEARCH_QUERY_SERVICE_TOKEN, useClass: SearchFilterQueryBuilderService } ] }); beforeEach(() => { - fixture = TestBed.createComponent(SearchHeaderComponent); + fixture = TestBed.createComponent(SearchFilterContainerComponent); component = fixture.componentInstance; - queryBuilder = fixture.componentInstance['searchHeaderQueryBuilder']; + queryBuilder = fixture.componentInstance['searchFilterQueryBuilder']; alfrescoApiService = TestBed.inject(AlfrescoApiService); component.col = {key: '123', type: 'text'}; spyOn(queryBuilder, 'getCategoryForColumn').and.returnValue(mockCategory); @@ -79,17 +79,33 @@ describe('SearchHeaderComponent', () => { }); it('should show the filter when a category is found', async () => { + await fixture.whenStable(); + fixture.detectChanges(); 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 Apply button is clicked', async (done) => { + it('should set new active filter after the Apply button is clicked', async () => { + const menuButton: HTMLButtonElement = fixture.nativeElement.querySelector('#filter-menu-button'); + menuButton.click(); + fixture.detectChanges(); + await fixture.whenStable(); + component.widgetContainer.componentRef.instance.value = 'searchText'; + const applyButton = fixture.debugElement.query(By.css('#apply-filter-button')); + applyButton.triggerEventHandler('click', {}); + fixture.detectChanges(); + await fixture.whenStable(); + expect(queryBuilder.getActiveFilters().length).toBe(1); + expect(queryBuilder.getActiveFilters()[0].key).toBe('name'); + expect(queryBuilder.getActiveFilters()[0].value).toBe('searchText'); + }); + + it('should emit filterChange after the Apply button is clicked', async (done) => { spyOn(alfrescoApiService.searchApi, 'search').and.returnValue(Promise.resolve(fakeNodePaging)); spyOn(queryBuilder, 'buildQuery').and.returnValue({}); - component.update.subscribe((newNodePaging) => { - expect(newNodePaging).toBe(fakeNodePaging); + component.filterChange.subscribe(() => { done(); }); const menuButton: HTMLButtonElement = fixture.nativeElement.querySelector('#filter-menu-button'); @@ -103,11 +119,42 @@ describe('SearchHeaderComponent', () => { await fixture.whenStable(); }); - it('should emit the node paging received from the queryBuilder after the Enter key is pressed', async (done) => { + it('should remove active filter after the Clear button is clicked', async () => { + queryBuilder.setActiveFilter('name', 'searchText'); + const menuButton: HTMLButtonElement = fixture.nativeElement.querySelector('#filter-menu-button'); + menuButton.click(); + fixture.detectChanges(); + await fixture.whenStable(); + component.widgetContainer.componentRef.instance.value = 'searchText'; + const fakeEvent = jasmine.createSpyObj('event', ['stopPropagation']); + const clearButton = fixture.debugElement.query(By.css('#clear-filter-button')); + clearButton.triggerEventHandler('click', fakeEvent); + fixture.detectChanges(); + await fixture.whenStable(); + expect(queryBuilder.getActiveFilters().length).toBe(0); + }); + + it('should emit filterChange after the Clear button is clicked', async (done) => { spyOn(alfrescoApiService.searchApi, 'search').and.returnValue(Promise.resolve(fakeNodePaging)); spyOn(queryBuilder, 'buildQuery').and.returnValue({}); - component.update.subscribe((newNodePaging) => { - expect(newNodePaging).toBe(fakeNodePaging); + component.filterChange.subscribe(() => { + done(); + }); + const menuButton: HTMLButtonElement = fixture.nativeElement.querySelector('#filter-menu-button'); + menuButton.click(); + fixture.detectChanges(); + await fixture.whenStable(); + component.widgetContainer.componentRef.instance.value = 'searchText'; + const fakeEvent = jasmine.createSpyObj('event', ['stopPropagation']); + const clearButton = fixture.debugElement.query(By.css('#clear-filter-button')); + clearButton.triggerEventHandler('click', fakeEvent); + fixture.detectChanges(); + await fixture.whenStable(); + }); + + it('should emit filterChange after the Enter key is pressed', async (done) => { + spyOn(queryBuilder, 'buildQuery').and.returnValue({}); + component.filterChange.subscribe(() => { done(); }); const menuButton: HTMLButtonElement = fixture.nativeElement.querySelector('#filter-menu-button'); @@ -121,134 +168,6 @@ describe('SearchHeaderComponent', () => { 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 execute a new query when a new sorting 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(null, '123-asc', false); - component.ngOnChanges({ 'sorting': 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(); - spyOn(component, 'isActive').and.returnValue(true); - 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(); - await fixture.whenStable(); - }); - - it('should execute the query again if there are more filter actives after a clear', async (done) => { - spyOn(queryBuilder, 'isNoFilterActive').and.returnValue(false); - spyOn(alfrescoApiService.searchApi, 'search').and.returnValue(Promise.resolve(fakeNodePaging)); - spyOn(queryBuilder, 'buildQuery').and.returnValue({}); - spyOn(component, 'isActive').and.returnValue(true); - queryBuilder.queryFragments['fake'] = 'test'; - spyOn(component.widgetContainer, 'resetInnerWidget').and.callThrough(); - const fakeEvent = jasmine.createSpyObj('event', ['stopPropagation']); - const menuButton: HTMLButtonElement = fixture.nativeElement.querySelector('#filter-menu-button'); - component.update.subscribe((newNodePaging) => { - expect(newNodePaging).toBe(fakeNodePaging); - done(); - }); - - menuButton.click(); - fixture.detectChanges(); - await fixture.whenStable(); - const clearButton = fixture.debugElement.query(By.css('#clear-filter-button')); - clearButton.triggerEventHandler('click', fakeEvent); - fixture.detectChanges(); - await fixture.whenStable(); - }); - - it('should emit the clear event when no filter has valued applied', async (done) => { - spyOn(queryBuilder, 'isNoFilterActive').and.returnValue(true); - spyOn(alfrescoApiService.searchApi, 'search').and.returnValue(Promise.resolve(fakeNodePaging)); - spyOn(queryBuilder, 'buildQuery').and.returnValue({}); - spyOn(component, 'isActive').and.returnValue(true); - spyOn(component.widgetContainer, 'resetInnerWidget').and.stub(); - component.widgetContainer.componentRef.instance.value = ''; - 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 applyButton = fixture.debugElement.query(By.css('#apply-filter-button')); - applyButton.triggerEventHandler('click', fakeEvent); - fixture.detectChanges(); - await fixture.whenStable(); - }); - - it('should not emit clear event when currentFolderNodeId changes and no filter was applied', async () => { - const currentFolderNodeIdChange = new SimpleChange('current-node-id', 'next-node-id', true); - spyOn(component, 'isActive').and.returnValue(false); - spyOn(component.clear, 'emit'); - - component.ngOnChanges({ currentFolderNodeId: currentFolderNodeIdChange }); - fixture.detectChanges(); - expect(component.clear.emit).not.toHaveBeenCalled(); - }); - - it('should emit clear event when currentFolderNodeId changes and filter was applied', async () => { - const currentFolderNodeIdChange = new SimpleChange('current-node-id', 'next-node-id', true); - spyOn(component.clear, 'emit'); - spyOn(component, 'isActive').and.returnValue(true); - - component.ngOnChanges({ currentFolderNodeId: currentFolderNodeIdChange }); - fixture.detectChanges(); - expect(component.clear.emit).toHaveBeenCalled(); - }); - describe('Accessibility', () => { it('should set up a focus trap on the filter when the menu is opened', async () => { diff --git a/lib/content-services/src/lib/search/components/search-filter-container/search-filter-container.component.ts b/lib/content-services/src/lib/search/components/search-filter-container/search-filter-container.component.ts new file mode 100644 index 0000000000..43f27d29c3 --- /dev/null +++ b/lib/content-services/src/lib/search/components/search-filter-container/search-filter-container.component.ts @@ -0,0 +1,136 @@ +/*! + * @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, + EventEmitter, + ViewEncapsulation, + ViewChild, + Inject, + OnDestroy, + ElementRef +} from '@angular/core'; +import { ConfigurableFocusTrapFactory, ConfigurableFocusTrap } from '@angular/cdk/a11y'; +import { DataColumn, TranslationService } from '@alfresco/adf-core'; +import { SearchWidgetContainerComponent } from '../search-widget-container/search-widget-container.component'; +import { SearchFilterQueryBuilderService } from '../../search-filter-query-builder.service'; +import { SearchCategory } from '../../search-category.interface'; +import { SEARCH_QUERY_SERVICE_TOKEN } from '../../search-query-service.token'; +import { Subject } from 'rxjs'; +import { MatMenuTrigger } from '@angular/material/menu'; + +@Component({ + selector: 'adf-search-filter-container', + templateUrl: './search-filter-container.component.html', + styleUrls: ['./search-filter-container.component.scss'], + encapsulation: ViewEncapsulation.None +}) +export class SearchFilterContainerComponent implements OnInit, OnDestroy { + + /** The column the filter will be applied on. */ + @Input() + col: DataColumn; + + /** The column the filter will be applied on. */ + @Input() + value: any; + + /** Emitted when a filter value is selected */ + @Output() + filterChange: EventEmitter = new EventEmitter(); + + @ViewChild(SearchWidgetContainerComponent) + widgetContainer: SearchWidgetContainerComponent; + + @ViewChild('filterContainer') + filterContainer: ElementRef; + + category: SearchCategory; + focusTrap: ConfigurableFocusTrap; + initialValue: any; + + private onDestroy$ = new Subject(); + + constructor(@Inject(SEARCH_QUERY_SERVICE_TOKEN) private searchFilterQueryBuilder: SearchFilterQueryBuilderService, + private translationService: TranslationService, + private focusTrapFactory: ConfigurableFocusTrapFactory) { + } + + ngOnInit() { + this.category = this.searchFilterQueryBuilder.getCategoryForColumn(this.col.key); + this.initialValue = this.value && this.value[this.col.key] ? this.value[this.col.key] : undefined; + } + + ngOnDestroy() { + this.onDestroy$.next(true); + this.onDestroy$.complete(); + } + + onKeyPressed(event: KeyboardEvent, menuTrigger: MatMenuTrigger) { + if (event.key === 'Enter' && this.widgetContainer.selector !== 'check-list') { + this.onApply(); + menuTrigger.closeMenu(); + } + } + + onApply() { + if (this.widgetContainer.hasValueSelected()) { + this.searchFilterQueryBuilder.setActiveFilter(this.category.columnKey, this.widgetContainer.getCurrentValue()); + this.filterChange.emit(); + this.widgetContainer.applyInnerWidget(); + } else { + this.resetSearchFilter(); + } + } + + onClearButtonClick(event: Event) { + event.stopPropagation(); + this.resetSearchFilter(); + } + + resetSearchFilter() { + this.widgetContainer.resetInnerWidget(); + this.searchFilterQueryBuilder.removeActiveFilter(this.category.columnKey); + this.filterChange.emit(); + } + + getTooltipTranslation(columnTitle: string): string { + if (!columnTitle) { + columnTitle = 'SEARCH.SEARCH_HEADER.TYPE'; + } + return this.translationService.instant('SEARCH.SEARCH_HEADER.FILTER_BY', { category: this.translationService.instant(columnTitle) }); + } + + isActive(): boolean { + return this.widgetContainer && this.widgetContainer.componentRef && this.widgetContainer.componentRef.instance.isActive; + } + + onMenuOpen() { + if (this.filterContainer && !this.focusTrap) { + this.focusTrap = this.focusTrapFactory.create(this.filterContainer.nativeElement); + this.focusTrap.focusInitialElement(); + } + } + + onClosed() { + this.focusTrap.destroy(); + this.focusTrap = null; + } +} 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 deleted file mode 100644 index 88a400a01b..0000000000 --- a/lib/content-services/src/lib/search/components/search-header/search-header.component.html +++ /dev/null @@ -1,43 +0,0 @@ -
-
- - - -
-
-
{{ category?.name | translate }}
- - -
- - - - -
-
-
-
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 deleted file mode 100644 index 2a866da459..0000000000 --- a/lib/content-services/src/lib/search/components/search-header/search-header.component.ts +++ /dev/null @@ -1,225 +0,0 @@ -/*! - * @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, - ElementRef -} from '@angular/core'; -import { ConfigurableFocusTrapFactory, ConfigurableFocusTrap } from '@angular/cdk/a11y'; -import { DataColumn, TranslationService } from '@alfresco/adf-core'; -import { SearchWidgetContainerComponent } from '../search-widget-container/search-widget-container.component'; -import { SearchHeaderQueryBuilderService } from '../../search-header-query-builder.service'; -import { NodePaging, MinimalNode } 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'; -import { MatMenuTrigger } from '@angular/material/menu'; - -@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 { - - /** The column the filter will be applied on. */ - @Input() - col: DataColumn; - - /** (optional) Initial filter value to sort . */ - @Input() - value: any; - - /** The id of the current folder of the document list. */ - @Input() - currentFolderNodeId: string; - - /** Maximum number of search results to show in a page. */ - @Input() - maxItems: number; - - /** The offset of the start of the page within the results list. */ - @Input() - skipCount: number; - - /** The sorting to apply to the the filter. */ - @Input() - sorting: string = null; - - /** Emitted when the result of the filter is received from the API. */ - @Output() - update: EventEmitter = new EventEmitter(); - - /** Emitted when the last of all the filters is cleared. */ - @Output() - clear: EventEmitter = new EventEmitter(); - - /** Emitted when a filter value is selected */ - @Output() - selection: EventEmitter> = new EventEmitter(); - - @ViewChild(SearchWidgetContainerComponent) - widgetContainer: SearchWidgetContainerComponent; - - @ViewChild('filterContainer') - filterContainer: ElementRef; - - category: SearchCategory; - isFilterServiceActive: boolean; - initialValue: any; - focusTrap: ConfigurableFocusTrap; - - private onDestroy$ = new Subject(); - - constructor(@Inject(SEARCH_QUERY_SERVICE_TOKEN) private searchHeaderQueryBuilder: SearchHeaderQueryBuilderService, - private translationService: TranslationService, - private focusTrapFactory: ConfigurableFocusTrapFactory) { - 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) { - this.clearHeader(); - this.configureSearchParent(changes['currentFolderNodeId'].currentValue); - } - - 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); - } - - if (changes['sorting'] && changes['sorting'].currentValue) { - const [key, value] = changes['sorting'].currentValue.split('-'); - if (key === this.col.key) { - this.searchHeaderQueryBuilder.setSorting(key, value); - } - } - - } - - ngOnDestroy() { - this.onDestroy$.next(true); - this.onDestroy$.complete(); - } - - onKeyPressed(event: KeyboardEvent, menuTrigger: MatMenuTrigger) { - if (event.key === 'Enter' && this.widgetContainer.selector !== 'check-list') { - this.onApply(); - menuTrigger.closeMenu(); - } - } - - onApply() { - if (this.widgetContainer.hasValueSelected()) { - this.widgetContainer.applyInnerWidget(); - this.searchHeaderQueryBuilder.setActiveFilter(this.category.columnKey, this.widgetContainer.getCurrentValue()); - this.selection.emit(this.searchHeaderQueryBuilder.getActiveFilters()); - } else { - this.clearHeader(); - } - } - - onClearButtonClick(event: Event) { - event.stopPropagation(); - this.clearHeader(); - } - - clearHeader() { - if (this.widgetContainer && this.isActive()) { - this.widgetContainer.resetInnerWidget(); - this.searchHeaderQueryBuilder.removeActiveFilter(this.category.columnKey); - this.selection.emit(this.searchHeaderQueryBuilder.getActiveFilters()); - if (this.searchHeaderQueryBuilder.isNoFilterActive()) { - this.clear.emit(); - } - } - } - - getTooltipTranslation(columnTitle: string): string { - if (!columnTitle) { - columnTitle = 'SEARCH.SEARCH_HEADER.TYPE'; - } - return this.translationService.instant('SEARCH.SEARCH_HEADER.FILTER_BY', { category: this.translationService.instant(columnTitle) }); - } - - isActive(): boolean { - return this.widgetContainer && this.widgetContainer.componentRef && this.widgetContainer.componentRef.instance.isActive; - } - - private configureSearchParent(currentFolderNodeId: string) { - if (this.searchHeaderQueryBuilder.isCustomSourceNode(currentFolderNodeId)) { - this.searchHeaderQueryBuilder.getNodeIdForCustomSource(currentFolderNodeId).subscribe((node: MinimalNode) => { - this.initSearchHeader(node.id); - }); - } else { - this.initSearchHeader(currentFolderNodeId); - } - } - - private initSearchHeader(currentFolderId: string) { - this.searchHeaderQueryBuilder.setCurrentRootFolderId(currentFolderId); - if (this.value) { - this.searchHeaderQueryBuilder.setActiveFilter(this.category.columnKey, this.initialValue); - this.initialValue = this.value; - } - } - - onMenuOpen() { - if (this.filterContainer && !this.focusTrap) { - this.focusTrap = this.focusTrapFactory.create(this.filterContainer.nativeElement); - this.focusTrap.focusInitialElement(); - } - } - - onClosed() { - this.focusTrap.destroy(); - this.focusTrap = null; - } -} diff --git a/lib/core/datatable/directives/custom-header-filter-template.directive.ts b/lib/content-services/src/lib/search/filter-search.interface.ts similarity index 72% rename from lib/core/datatable/directives/custom-header-filter-template.directive.ts rename to lib/content-services/src/lib/search/filter-search.interface.ts index 09101347cd..033674c6a2 100644 --- a/lib/core/datatable/directives/custom-header-filter-template.directive.ts +++ b/lib/content-services/src/lib/search/filter-search.interface.ts @@ -15,14 +15,7 @@ * limitations under the License. */ -import { Directive, ContentChild, TemplateRef } from '@angular/core'; - -@Directive({ - selector: 'adf-custom-header-filter-template' -}) -export class CustomHeaderFilterTemplateDirective { - - @ContentChild(TemplateRef) - template: any; - +export interface FilterSearch { + key: string; + value: any; } diff --git a/lib/content-services/src/lib/search/public-api.ts b/lib/content-services/src/lib/search/public-api.ts index e63bce2f15..a3ff78868b 100644 --- a/lib/content-services/src/lib/search/public-api.ts +++ b/lib/content-services/src/lib/search/public-api.ts @@ -19,6 +19,7 @@ export * from './facet-field-bucket.interface'; export * from './facet-field.interface'; export * from './facet-query.interface'; export * from './filter-query.interface'; +export * from './filter-search.interface'; export * from './search-category.interface'; export * from './search-widget-settings.interface'; export * from './search-widget.interface'; @@ -26,7 +27,7 @@ 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 './search-filter-query-builder.service'; export * from './components/search.component'; export * from './components/search-control.component'; @@ -38,7 +39,7 @@ export * from './components/search-chip-list/search-chip-list.component'; export * from './components/search-date-range/search-date-range.component'; export * from './components/search-filter/search-filter.component'; export * from './components/search-filter/search-filter.service'; -export * from './components/search-header/search-header.component'; +export * from './components/search-filter-container/search-filter-container.component'; export * from './components/search-number-range/search-number-range.component'; export * from './components/search-radio/search-radio.component'; export * from './components/search-slider/search-slider.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-filter-query-builder.service.spec.ts similarity index 88% rename from lib/content-services/src/lib/search/search-header-query-builder.service.spec.ts rename to lib/content-services/src/lib/search/search-filter-query-builder.service.spec.ts index c0f2dfdcab..4d8b5e972f 100644 --- a/lib/content-services/src/lib/search/search-header-query-builder.service.spec.ts +++ b/lib/content-services/src/lib/search/search-filter-query-builder.service.spec.ts @@ -17,9 +17,9 @@ import { SearchConfiguration } from './search-configuration.interface'; import { AppConfigService } from '@alfresco/adf-core'; -import { SearchHeaderQueryBuilderService } from './search-header-query-builder.service'; +import { SearchFilterQueryBuilderService } from './search-filter-query-builder.service'; -describe('SearchHeaderQueryBuilder', () => { +describe('SearchFilterQueryBuilderService', () => { const buildConfig = (searchSettings): AppConfigService => { const config = new AppConfigService(null); @@ -36,7 +36,7 @@ describe('SearchHeaderQueryBuilder', () => { filterQueries: [{ query: 'query1' }, { query: 'query2' }] }; - const builder = new SearchHeaderQueryBuilderService( + const builder = new SearchFilterQueryBuilderService( buildConfig(config), null, null @@ -63,7 +63,7 @@ describe('SearchHeaderQueryBuilder', () => { filterQueries: [{ query: 'query1' }, { query: 'query2' }] }; - const service = new SearchHeaderQueryBuilderService( + const service = new SearchFilterQueryBuilderService( buildConfig(config), null, null @@ -76,7 +76,7 @@ describe('SearchHeaderQueryBuilder', () => { }); it('should have empty user query by default', () => { - const builder = new SearchHeaderQueryBuilderService( + const builder = new SearchFilterQueryBuilderService( buildConfig({}), null, null @@ -97,7 +97,7 @@ describe('SearchHeaderQueryBuilder', () => { { query: 'PARENT:"workspace://SpacesStore/fake-node-id"' } ]; - const searchHeaderService = new SearchHeaderQueryBuilderService( + const searchHeaderService = new SearchFilterQueryBuilderService( buildConfig(config), null, null @@ -122,7 +122,7 @@ describe('SearchHeaderQueryBuilder', () => { filterQueries: expectedResult }; - const searchHeaderService = new SearchHeaderQueryBuilderService( + const searchHeaderService = new SearchFilterQueryBuilderService( buildConfig(config), null, null @@ -148,17 +148,17 @@ describe('SearchHeaderQueryBuilder', () => { ] }; - const searchHeaderService = new SearchHeaderQueryBuilderService( + const searchHeaderService = new SearchFilterQueryBuilderService( buildConfig(config), null, null ); - expect(searchHeaderService.activeFilters.size).toBe(0); + expect(searchHeaderService.activeFilters.length).toBe(0); searchHeaderService.setActiveFilter(activeFilter, 'fake-value'); searchHeaderService.setActiveFilter(activeFilter, 'fake-value'); - expect(searchHeaderService.activeFilters.size).toBe(1); + expect(searchHeaderService.activeFilters.length).toBe(1); }); }); diff --git a/lib/content-services/src/lib/search/search-header-query-builder.service.ts b/lib/content-services/src/lib/search/search-filter-query-builder.service.ts similarity index 66% rename from lib/content-services/src/lib/search/search-header-query-builder.service.ts rename to lib/content-services/src/lib/search/search-filter-query-builder.service.ts index 90c7218357..70d4829edf 100644 --- a/lib/content-services/src/lib/search/search-header-query-builder.service.ts +++ b/lib/content-services/src/lib/search/search-filter-query-builder.service.ts @@ -16,7 +16,7 @@ */ import { Injectable } from '@angular/core'; -import { AlfrescoApiService, AppConfigService, NodesApiService } from '@alfresco/adf-core'; +import { AlfrescoApiService, AppConfigService, NodesApiService, DataSorting } from '@alfresco/adf-core'; import { SearchConfiguration } from './search-configuration.interface'; import { BaseQueryBuilderService } from './base-query-builder.service'; import { SearchCategory } from './search-category.interface'; @@ -24,23 +24,26 @@ import { MinimalNode, QueryBody } from '@alfresco/js-api'; import { filter } from 'rxjs/operators'; import { Observable } from 'rxjs'; import { SearchSortingDefinition } from './search-sorting-definition.interface'; +import { FilterSearch } from './filter-search.interface'; @Injectable({ providedIn: 'root' }) -export class SearchHeaderQueryBuilderService extends BaseQueryBuilderService { +export class SearchFilterQueryBuilderService extends BaseQueryBuilderService { private customSources = ['-trashcan-', '-sharedlinks-', '-sites-', '-mysites-', '-favorites-', '-recent-', '-my-']; - activeFilters: Map = new Map(); + activeFilters: FilterSearch[] = []; - constructor(appConfig: AppConfigService, alfrescoApiService: AlfrescoApiService, private nodeApiService: NodesApiService) { + constructor(appConfig: AppConfigService, + alfrescoApiService: AlfrescoApiService, + private nodeApiService: NodesApiService) { super(appConfig, alfrescoApiService); this.updated.pipe( filter((query: QueryBody) => !!query)).subscribe(() => { - this.execute(); - }); + this.execute(); + }); } public isFilterServiceActive(): boolean { @@ -61,35 +64,53 @@ export class SearchHeaderQueryBuilderService extends BaseQueryBuilderService { } setActiveFilter(columnActivated: string, filterValue: string) { - this.activeFilters.set(columnActivated, filterValue); + const filterIndex = this.activeFilters.find((activeFilter) => activeFilter.key === columnActivated); + if (!filterIndex) { + this.activeFilters.push( { + key: columnActivated, + value: filterValue + }); + } + } - getActiveFilters(): Map { + resetActiveFilters() { + this.activeFilters = []; + } + + getActiveFilters(): FilterSearch[] { return this.activeFilters; } isNoFilterActive(): boolean { - return this.activeFilters.size === 0; + return this.activeFilters.length === 0; } removeActiveFilter(columnRemoved: string) { - if (this.activeFilters.get(columnRemoved) !== null) { - this.activeFilters.delete(columnRemoved); + const filterIndex = this.activeFilters.map((activeFilter) => activeFilter.key).indexOf(columnRemoved); + if (filterIndex >= 0) { + this.activeFilters.splice(filterIndex, 1); } } - setSorting(column: string, direction: string) { - const optionAscending = direction.toLocaleLowerCase() === 'asc' ? true : false; - const fieldValue = this.getSortingFieldFromColumnName(column); - const currentSort: SearchSortingDefinition = { key: column, label: 'current', type: 'FIELD', field: fieldValue, ascending: optionAscending}; - this.sorting = [currentSort]; + setSorting(dataSorting: DataSorting[]) { + this.sorting = []; + dataSorting.forEach((columnSorting: DataSorting) => { + const fieldValue = this.getSortingFieldFromColumnName(columnSorting.key); + if (fieldValue) { + const optionAscending = columnSorting.direction.toLocaleLowerCase() === 'asc' ? true : false; + const currentSort: SearchSortingDefinition = { key: columnSorting.key, label: 'current', type: 'FIELD', field: fieldValue, ascending: optionAscending }; + this.sorting.push(currentSort); + } + }); + this.execute(); } private getSortingFieldFromColumnName(columnName: string) { if (this.sortingOptions.length > 0) { const sortOption: SearchSortingDefinition = this.sortingOptions.find((option: SearchSortingDefinition) => option.key === columnName); - return sortOption.field; + return sortOption ? sortOption.field : ''; } return ''; } @@ -116,6 +137,8 @@ export class SearchHeaderQueryBuilderService extends BaseQueryBuilderService { this.filterQueries = [{ query: `PARENT:"workspace://SpacesStore/${currentFolderId}"` }]; + + this.execute(); } isCustomSourceNode(currentNodeId: string): boolean { diff --git a/lib/content-services/src/lib/search/search.module.ts b/lib/content-services/src/lib/search/search.module.ts index 19a1378189..d035731ef1 100644 --- a/lib/content-services/src/lib/search/search.module.ts +++ b/lib/content-services/src/lib/search/search.module.ts @@ -35,9 +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'; +import { SearchFilterContainerComponent } from './components/search-filter-container/search-filter-container.component'; @NgModule({ imports: [ @@ -61,7 +61,7 @@ import { SearchQueryBuilderService } from './search-query-builder.service'; SearchCheckListComponent, SearchDateRangeComponent, SearchSortingPickerComponent, - SearchHeaderComponent + SearchFilterContainerComponent ], exports: [ SearchComponent, @@ -77,7 +77,7 @@ import { SearchQueryBuilderService } from './search-query-builder.service'; SearchCheckListComponent, SearchDateRangeComponent, SearchSortingPickerComponent, - SearchHeaderComponent + SearchFilterContainerComponent ], providers: [ { provide: SEARCH_QUERY_SERVICE_TOKEN, useExisting: SearchQueryBuilderService }, diff --git a/lib/content-services/src/lib/styles/_index.scss b/lib/content-services/src/lib/styles/_index.scss index 072808b855..871593151f 100644 --- a/lib/content-services/src/lib/styles/_index.scss +++ b/lib/content-services/src/lib/styles/_index.scss @@ -13,7 +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 '../search/components/search-filter-container/search-filter-container.component'; @import '../dialogs/folder.dialog'; diff --git a/lib/core/datatable/datatable.module.ts b/lib/core/datatable/datatable.module.ts index 6a9c927cf7..b72dcff565 100644 --- a/lib/core/datatable/datatable.module.ts +++ b/lib/core/datatable/datatable.module.ts @@ -42,7 +42,6 @@ import { HeaderFilterTemplateDirective } from './directives/header-filter-templa 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'; @@ -79,7 +78,6 @@ import { DataColumnModule } from '../data-column/data-column.module'; CustomEmptyContentTemplateDirective, CustomLoadingContentTemplateDirective, CustomNoPermissionTemplateDirective, - CustomHeaderFilterTemplateDirective, DropZoneDirective ], exports: [ @@ -101,7 +99,6 @@ import { DataColumnModule } from '../data-column/data-column.module'; CustomEmptyContentTemplateDirective, CustomLoadingContentTemplateDirective, CustomNoPermissionTemplateDirective, - CustomHeaderFilterTemplateDirective, DropZoneDirective ] diff --git a/lib/core/datatable/public-api.ts b/lib/core/datatable/public-api.ts index 11f722a912..533de02367 100644 --- a/lib/core/datatable/public-api.ts +++ b/lib/core/datatable/public-api.ts @@ -47,6 +47,5 @@ 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';