From 52520bb61ee9991c749d034178fcaa4677d1ab67 Mon Sep 17 00:00:00 2001 From: MichalKinas <113341662+MichalKinas@users.noreply.github.com> Date: Wed, 1 Feb 2023 15:42:40 +0100 Subject: [PATCH] [ACS-4364] Move tree component and categories service to ADF (#8156) * [ACS-4364] Add tree component and categories service * [ACS-4364] Add tree component to public api * [ACS-4364] Refine tree unit tests * [ACS-4364] Intergrate adding and deleting category * [ACS-4364] Restyle load more button in tree component * [ACS-4364] Missing semicolon * [ACS-4364] Fix code styling * [ACS-4364] Add docs for tree component and category service * [ACS-4364] CR fixes * [ACS-4364] Hide header row when displayName is not provided * [ACS-4364] Docs fixes * [ACS-4364] Add helper methods, code cleanup, unit tests for new methods * [ACS-4364] Add missing semicolon --- cspell.json | 1 + .../components/tree.component.md | 109 ++++++++ .../category-tree-datasource.service.md | 26 ++ .../services/category.service.md | 43 +++ docs/docassets/images/tree.png | Bin 0 -> 117638 bytes .../src/lib/category/index.ts | 18 ++ .../category/mock/category-mock.service.ts | 38 +++ .../models/category-node.interface.ts | 22 ++ .../src/lib/category/public-api.ts | 20 ++ .../category-tree-datasource.service.spec.ts | 72 +++++ .../category-tree-datasource.service.ts | 65 +++++ .../services/category.service.spec.ts | 74 +++++ .../lib/category/services/category.service.ts | 77 ++++++ .../src/lib/content.module.ts | 3 + lib/content-services/src/lib/i18n/en.json | 3 + .../lib/tree/components/tree.component.html | 117 ++++++++ .../lib/tree/components/tree.component.scss | 98 +++++++ .../tree/components/tree.component.spec.ts | 260 ++++++++++++++++++ .../src/lib/tree/components/tree.component.ts | 254 +++++++++++++++++ lib/content-services/src/lib/tree/index.ts | 18 ++ .../src/lib/tree/mock/tree-node.mock.ts | 198 +++++++++++++ .../tree/mock/tree-service.service.mock.ts | 33 +++ .../lib/tree/models/tree-node.interface.ts | 31 +++ .../tree/models/tree-response.interface.ts | 24 ++ .../src/lib/tree/public-api.ts | 22 ++ .../lib/tree/services/tree.service.spec.ts | 148 ++++++++++ .../src/lib/tree/services/tree.service.ts | 148 ++++++++++ .../src/lib/tree/tree.module.ts | 40 +++ lib/content-services/src/public-api.ts | 2 + 29 files changed, 1964 insertions(+) create mode 100644 docs/content-services/components/tree.component.md create mode 100644 docs/content-services/services/category-tree-datasource.service.md create mode 100644 docs/content-services/services/category.service.md create mode 100644 docs/docassets/images/tree.png create mode 100644 lib/content-services/src/lib/category/index.ts create mode 100644 lib/content-services/src/lib/category/mock/category-mock.service.ts create mode 100644 lib/content-services/src/lib/category/models/category-node.interface.ts create mode 100644 lib/content-services/src/lib/category/public-api.ts create mode 100644 lib/content-services/src/lib/category/services/category-tree-datasource.service.spec.ts create mode 100644 lib/content-services/src/lib/category/services/category-tree-datasource.service.ts create mode 100644 lib/content-services/src/lib/category/services/category.service.spec.ts create mode 100644 lib/content-services/src/lib/category/services/category.service.ts create mode 100644 lib/content-services/src/lib/tree/components/tree.component.html create mode 100644 lib/content-services/src/lib/tree/components/tree.component.scss create mode 100644 lib/content-services/src/lib/tree/components/tree.component.spec.ts create mode 100644 lib/content-services/src/lib/tree/components/tree.component.ts create mode 100644 lib/content-services/src/lib/tree/index.ts create mode 100644 lib/content-services/src/lib/tree/mock/tree-node.mock.ts create mode 100644 lib/content-services/src/lib/tree/mock/tree-service.service.mock.ts create mode 100644 lib/content-services/src/lib/tree/models/tree-node.interface.ts create mode 100644 lib/content-services/src/lib/tree/models/tree-response.interface.ts create mode 100644 lib/content-services/src/lib/tree/public-api.ts create mode 100644 lib/content-services/src/lib/tree/services/tree.service.spec.ts create mode 100644 lib/content-services/src/lib/tree/services/tree.service.ts create mode 100644 lib/content-services/src/lib/tree/tree.module.ts diff --git a/cspell.json b/cspell.json index cb2afaae1d..d15ae332c6 100644 --- a/cspell.json +++ b/cspell.json @@ -27,6 +27,7 @@ "CSRF", "datacolumn", "datarow", + "Datasource", "datatable", "dateitem", "datepicker", diff --git a/docs/content-services/components/tree.component.md b/docs/content-services/components/tree.component.md new file mode 100644 index 0000000000..d49ddeb2cb --- /dev/null +++ b/docs/content-services/components/tree.component.md @@ -0,0 +1,109 @@ +--- +Title: Tree component +Added: v6.0.0.0 +Status: Active +Last reviewed: 2023-01-25 +--- + +# [Tree component](../../../lib/content-services/src/lib/tree/components/tree.component.ts "Defined in tree.component.ts") + +Shows the nodes in tree structure, each node containing children is collapsible/expandable. Can be integrated with any datasource extending [Tree service](../../../lib/content-services//src/lib/tree/services/tree.service.ts). + +![Tree component screenshot](../../docassets/images/tree.png) + +## Basic Usage + +```html + + +``` + +## Class members + +### Properties + +| Name | Type | Default value | Description | +| ---- | ---- | ------------- | ----------- | +| emptyContentTemplate | `TemplateRef` | | Template that will be rendered when no nodes are loaded. | +| nodeActionsMenuTemplate | `TemplateRef` | | Template that will be rendered when context menu for given node is opened. | +| stickyHeader | `boolean` | false | If set to true header will be sticky. | +| selectableNodes | `boolean` | false | If set to true nodes will be selectable. | +| displayName | `string` | | Display name for tree title. | +| loadMoreSuffix | `string` | | Suffix added to `Load more` string inside load more node. | +| expandIcon | `string` | `chevron_right` | Icon shown when node is collapsed. | +| collapseIcon | `string` | `expand_more` | Icon showed when node is expanded. | + + +### Events + +| Name | Type | Description | +| ---- | ---- | ----------- | +| paginationChanged | [`EventEmitter`](https://angular.io/api/core/EventEmitter)`` | Emitted when during loading additional nodes pagination changes. | + +## Details + +### Defining your own custom datasource + +First of all create custom node interface extending [`TreeNode`](../../../lib/content-services/src/lib/tree/models/tree-node.interface.ts) interface or use [`TreeNode`](../../../lib/content-services/src/lib/tree/models/tree-node.interface.ts) when none extra properties are required. + +```ts +export interface CustomNode extends TreeNode +``` + +Next create custom datasource service extending [`TreeService`](../../../lib/content-services/src/lib/tree/services/tree.service.ts). Datasource service must implement `getSubNodes` method. It has to be able to provide both root level nodes as well as subnodes. If there are more subnodes to load for a given node it should add node with `LoadMoreNode` node type. Example of custom datasource service can be found in [`Category tree datasource service`](../services/category-tree-datasource.service.md). + +```ts +@Injectable({...}) +export class CustomTreeDatasourceService extends TreeService { + ... + public getSubNodes(parentNodeId: string, skipCount?: number, maxItems?: number): Observable> { + ... +} +``` + +Final step is to provide your custom datasource service as tree service in component using `TreeComponent`. + +```ts +providers: [ + { + provide: TreeService, + useClass: CustomTreeDatasourceService, + }, +] +``` + +### Enabling nodes selection and listening to selection changes + +First step is to provide necessary input value. +```html + + +``` + +Next inside your component get the `TreeComponent` + +```ts +@ViewChild(TreeComponent) +public treeComponent: TreeComponent; +``` + +and listen to selection changes. + +```ts +this.treeComponent.treeNodesSelection.changed.subscribe( + (selectionChange: SelectionChange) => { + this.onTreeSelectionChange(selectionChange); + } +); +``` diff --git a/docs/content-services/services/category-tree-datasource.service.md b/docs/content-services/services/category-tree-datasource.service.md new file mode 100644 index 0000000000..1b6dd60ada --- /dev/null +++ b/docs/content-services/services/category-tree-datasource.service.md @@ -0,0 +1,26 @@ +--- +Title: Category tree datasource service +Added: v6.0.0.0 +Status: Active +Last reviewed: 2023-01-25 +--- + +# [Category tree datasource service](../../../lib/content-services/src/lib/category/services/category-tree-datasource.service.ts "Defined in category-tree-datasource.service.ts") + +Datasource service for category tree. + +## Class members + +### Methods + +- **getSubNodes**(parentNodeId: `string`, skipCount?: `number`, maxItems?: `number`): [`Observable`](http://reactivex.io/documentation/observable.html)`<`[`TreeResponse`](../../../lib/content-services/src/lib/tree/models/tree-response.interface.ts)`>`
+ Gets categories as nodes for category tree. + - _parentNodeId:_ `string` - Identifier of a parent category + - _skipCount:_ `number` - Number of top categories to skip + - _maxItems:_ `number` - Maximum number of subcategories returned from Observable + - **Returns** [`Observable`](http://reactivex.io/documentation/observable.html)`<`[`TreeResponse`](../../../lib/content-services/src/lib/tree/models/tree-response.interface.ts)`>` - TreeResponse object containing pagination object and list on nodes + +## Details + +Category tree datasource service acts as datasource for tree component utilizing category service. See the +[Tree component](../../../lib/content-services/src/lib/tree/components/tree.component.ts) and [Tree service](../../../lib/content-services/src/lib/tree/services/tree.service.ts) to get more details on how datasource is used. diff --git a/docs/content-services/services/category.service.md b/docs/content-services/services/category.service.md new file mode 100644 index 0000000000..4b00b98058 --- /dev/null +++ b/docs/content-services/services/category.service.md @@ -0,0 +1,43 @@ +--- +Title: Category service +Added: v6.0.0.0 +Status: Active +Last reviewed: 2023-01-25 +--- + +# [Category service](../../../lib/content-services/src/lib/category/services/category.service.ts "Defined in category.service.ts") + +Manages categories in Content Services. + +## Class members + +### Methods + +- **getSubcategories**(parentCategoryId: `string`, skipCount?: `number`, maxItems?: `number`): [`Observable`](http://reactivex.io/documentation/observable.html)`<`[`CategoryPaging`](https://github.com/Alfresco/alfresco-js-api/blob/master/src/api/content-rest-api/docs/CategoryPaging.md)`>`
+ Gets subcategories of a given parent category. + - _parentCategoryId:_ `string` - Identifier of a parent category + - _skipCount:_ `number` - Number of top categories to skip + - _maxItems:_ `number` - Maximum number of subcategories returned from Observable + - **Returns** [`Observable`](http://reactivex.io/documentation/observable.html)`<`[`CategoryPaging`](https://github.com/Alfresco/alfresco-js-api/blob/master/src/api/content-rest-api/docs/CategoryPaging.md)`>` - CategoryPaging object (defined in JS-API) with category paging list +- **createSubcategory**(parentCategoryId: `string`, payload: [`CategoryBody`](https://github.com/Alfresco/alfresco-js-api/blob/master/src/api/content-rest-api/docs/CategoryBody.md)): [`Observable`](http://reactivex.io/documentation/observable.html)`<`[`CategoryEntry`](https://github.com/Alfresco/alfresco-js-api/blob/master/src/api/content-rest-api/docs/CategoryEntry.md)`>`
+ Creates subcategory under category with provided categoryId. + - _parentCategoryId:_ `string` - Identifier of a parent category + - _payload:_ [`CategoryBody`](https://github.com/Alfresco/alfresco-js-api/blob/master/src/api/content-rest-api/docs/CategoryBody.md) - Created category body + - **Returns** [`Observable`](http://reactivex.io/documentation/observable.html)`<`[`CategoryEntry`](https://github.com/Alfresco/alfresco-js-api/blob/master/src/api/content-rest-api/docs/CategoryEntry.md)`>` - CategoryEntry object (defined in JS-API) containing the category +- **updateCategory**(categoryId: `string`, payload: [`CategoryBody`](https://github.com/Alfresco/alfresco-js-api/blob/master/src/api/content-rest-api/docs/CategoryBody.md)): [`Observable`](http://reactivex.io/documentation/observable.html)`<`[`CategoryEntry`](https://github.com/Alfresco/alfresco-js-api/blob/master/src/api/content-rest-api/docs/CategoryEntry.md)`>`
+ Updates category. + - _categoryId:_ `string` - Identifier of a category + - _payload:_ [`CategoryBody`](https://github.com/Alfresco/alfresco-js-api/blob/master/src/api/content-rest-api/docs/CategoryBody.md) - Created category body + - **Returns** [`Observable`](http://reactivex.io/documentation/observable.html)`<`[`CategoryEntry`](https://github.com/Alfresco/alfresco-js-api/blob/master/src/api/content-rest-api/docs/CategoryEntry.md)`>` - CategoryEntry object (defined in JS-API) containing the category +- **deleteCategory**(categoryId: `string`): [`Observable`](http://reactivex.io/documentation/observable.html)``
+ Deletes category. + - _categoryId:_ `string` - Identifier of a category + - **Returns** [`Observable`](http://reactivex.io/documentation/observable.html)`` - Null object when the operation completes + +## Details + +See the +[Categories API](https://github.com/Alfresco/alfresco-js-api/blob/master/src/api/content-rest-api/docs/CategoriesApi.md) +in the Alfresco JS API for more information about the types returned by [Category +service](category.service.md) methods and for the implementation of the REST API the service is +based on. diff --git a/docs/docassets/images/tree.png b/docs/docassets/images/tree.png new file mode 100644 index 0000000000000000000000000000000000000000..22c6f2e67d2f7d6125bd122f6cd678b817e761bb GIT binary patch literal 117638 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 literal 0 HcmV?d00001 diff --git a/lib/content-services/src/lib/category/index.ts b/lib/content-services/src/lib/category/index.ts new file mode 100644 index 0000000000..a7e30cc675 --- /dev/null +++ b/lib/content-services/src/lib/category/index.ts @@ -0,0 +1,18 @@ +/*! + * @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. + */ + +export * from './public-api'; diff --git a/lib/content-services/src/lib/category/mock/category-mock.service.ts b/lib/content-services/src/lib/category/mock/category-mock.service.ts new file mode 100644 index 0000000000..d1649341eb --- /dev/null +++ b/lib/content-services/src/lib/category/mock/category-mock.service.ts @@ -0,0 +1,38 @@ +/*! + * @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 { CategoryEntry, CategoryPaging } from '@alfresco/js-api'; +import { Observable, of } from 'rxjs'; + +@Injectable({ providedIn: 'root' }) +export class CategoryServiceMock { + + public getSubcategories(parentNodeId: string, skipCount?: number, maxItems?: number): Observable { + return parentNodeId ? of(this.getChildrenLevelResponse(skipCount, maxItems)) : of(this.getRootLevelResponse(skipCount, maxItems)); + } + + private getRootLevelResponse(skipCount?: number, maxItems?: number): CategoryPaging { + const rootCategoryEntry: CategoryEntry = {entry: {id: 'testId', name: 'testNode', parentId: '-root-', hasChildren: true}}; + return {list: {pagination: {skipCount, maxItems, hasMoreItems: false}, entries: [rootCategoryEntry]}}; + } + + private getChildrenLevelResponse(skipCount?: number, maxItems?: number): CategoryPaging { + const childCategoryEntry: CategoryEntry = {entry: {id: 'childId', name: 'childNode', parentId: 'testId', hasChildren: false}}; + return {list: {pagination: {skipCount, maxItems, hasMoreItems: true}, entries: [childCategoryEntry]}}; + } +} diff --git a/lib/content-services/src/lib/category/models/category-node.interface.ts b/lib/content-services/src/lib/category/models/category-node.interface.ts new file mode 100644 index 0000000000..c681c3f520 --- /dev/null +++ b/lib/content-services/src/lib/category/models/category-node.interface.ts @@ -0,0 +1,22 @@ +/*! + * @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 { TreeNode } from '../../tree/models/tree-node.interface'; + +export interface CategoryNode extends TreeNode { + description?: string; +} diff --git a/lib/content-services/src/lib/category/public-api.ts b/lib/content-services/src/lib/category/public-api.ts new file mode 100644 index 0000000000..593e34355a --- /dev/null +++ b/lib/content-services/src/lib/category/public-api.ts @@ -0,0 +1,20 @@ +/*! + * @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. + */ + +export * from './services/category.service'; +export * from './services/category-tree-datasource.service'; +export * from './models/category-node.interface'; diff --git a/lib/content-services/src/lib/category/services/category-tree-datasource.service.spec.ts b/lib/content-services/src/lib/category/services/category-tree-datasource.service.spec.ts new file mode 100644 index 0000000000..f264c0fa01 --- /dev/null +++ b/lib/content-services/src/lib/category/services/category-tree-datasource.service.spec.ts @@ -0,0 +1,72 @@ +/*! + * @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 { CoreTestingModule } from '@alfresco/adf-core'; +import { fakeAsync, TestBed } from '@angular/core/testing'; +import { CategoryService } from '../services/category.service'; +import { CategoryTreeDatasourceService } from './category-tree-datasource.service'; +import { CategoryServiceMock } from '../mock/category-mock.service'; +import { TreeNodeType, TreeResponse } from '../../tree'; +import { CategoryNode } from '../models/category-node.interface'; + +describe('CategoryTreeDatasourceService', () => { + let categoryTreeDatasourceService: CategoryTreeDatasourceService; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ + CoreTestingModule + ], + providers: [ + { provide: CategoryService, useClass: CategoryServiceMock } + ] + }); + + categoryTreeDatasourceService = TestBed.inject(CategoryTreeDatasourceService); + }); + + it('should get root level categories', fakeAsync(() => { + spyOn(categoryTreeDatasourceService, 'getParentNode').and.returnValue(undefined); + categoryTreeDatasourceService.getSubNodes(null, 0 , 100).subscribe((treeResponse: TreeResponse) => { + expect(treeResponse.entries.length).toBe(1); + expect(treeResponse.entries[0].level).toBe(0); + expect(treeResponse.entries[0].nodeType).toBe(TreeNodeType.RegularNode); + }); + })); + + it('should get child level categories and add loadMore node when there are more children to load', fakeAsync(() => { + const parentNode: CategoryNode = { + id: 'testId', + nodeName: 'testNode', + parentId: '-root-', + hasChildren: true, + level: 0, + isLoading: false, + nodeType: TreeNodeType.RegularNode + }; + spyOn(categoryTreeDatasourceService, 'getParentNode').and.returnValue(parentNode); + categoryTreeDatasourceService.getSubNodes(parentNode.id, 0 , 100).subscribe((treeResponse: TreeResponse) => { + expect(treeResponse.entries.length).toBe(2); + expect(treeResponse.entries[0].parentId).toBe(parentNode.id); + expect(treeResponse.entries[0].level).toBe(1); + expect(treeResponse.entries[0].nodeType).toBe(TreeNodeType.RegularNode); + expect(treeResponse.entries[1].id).toBe('loadMore'); + expect(treeResponse.entries[1].parentId).toBe(parentNode.id); + expect(treeResponse.entries[1].nodeType).toBe(TreeNodeType.LoadMoreNode); + }); + })); +}); diff --git a/lib/content-services/src/lib/category/services/category-tree-datasource.service.ts b/lib/content-services/src/lib/category/services/category-tree-datasource.service.ts new file mode 100644 index 0000000000..b7d00e45c9 --- /dev/null +++ b/lib/content-services/src/lib/category/services/category-tree-datasource.service.ts @@ -0,0 +1,65 @@ +/*! + * @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 { TreeNodeType } from '../../tree/models/tree-node.interface'; +import { TreeResponse } from '../../tree/models/tree-response.interface'; +import { TreeService } from '../../tree/services/tree.service'; +import { CategoryNode } from '../models/category-node.interface'; +import { CategoryService } from './category.service'; +import { CategoryEntry, CategoryPaging } from '@alfresco/js-api'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; + +@Injectable({ providedIn: 'root' }) +export class CategoryTreeDatasourceService extends TreeService { + + constructor(private categoryService: CategoryService) { + super(); + } + + public getSubNodes(parentNodeId: string, skipCount?: number, maxItems?: number): Observable> { + return this.categoryService.getSubcategories(parentNodeId, skipCount, maxItems).pipe(map((response: CategoryPaging) => { + const parentNode: CategoryNode = this.getParentNode(parentNodeId); + const nodesList: CategoryNode[] = response.list.entries.map((entry: CategoryEntry) => { + return { + id: entry.entry.id, + nodeName: entry.entry.name, + parentId: entry.entry.parentId, + hasChildren: entry.entry.hasChildren, + level: parentNode ? parentNode.level + 1 : 0, + isLoading: false, + nodeType: TreeNodeType.RegularNode + }; + }); + if (response.list.pagination.hasMoreItems && parentNode) { + const loadMoreNode: CategoryNode = { + id: 'loadMore', + nodeName: '', + parentId: parentNode.id, + hasChildren: false, + level: parentNode.level + 1, + isLoading: false, + nodeType: TreeNodeType.LoadMoreNode + }; + nodesList.push(loadMoreNode); + } + const treeResponse: TreeResponse = {entries: nodesList, pagination: response.list.pagination}; + return treeResponse; + })); + } +} diff --git a/lib/content-services/src/lib/category/services/category.service.spec.ts b/lib/content-services/src/lib/category/services/category.service.spec.ts new file mode 100644 index 0000000000..073f234664 --- /dev/null +++ b/lib/content-services/src/lib/category/services/category.service.spec.ts @@ -0,0 +1,74 @@ +/*! + * @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 { CoreTestingModule } from '@alfresco/adf-core'; +import { CategoryBody, CategoryEntry, CategoryPaging } from '@alfresco/js-api'; +import { fakeAsync, TestBed } from '@angular/core/testing'; +import { CategoryService } from './category.service'; + +describe('CategoryService', () => { + let categoryService: CategoryService; + const fakeParentCategoryId = 'testParentId'; + const fakeCategoriesResponse: CategoryPaging = { list: { pagination: {}, entries: [] }}; + const fakeCategoryEntry: CategoryEntry = { entry: { id: 'testId', name: 'testName' }}; + const fakeCategoryBody: CategoryBody = { name: 'updatedName' }; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ + CoreTestingModule + ] + }); + + categoryService = TestBed.inject(CategoryService); + }); + + it('should fetch categories with provided parentId', fakeAsync(() => { + const getSpy = spyOn(categoryService.categoriesApi, 'getSubcategories').and.returnValue(Promise.resolve(fakeCategoriesResponse)); + categoryService.getSubcategories(fakeParentCategoryId, 0, 100).subscribe(() => { + expect(getSpy).toHaveBeenCalledOnceWith(fakeParentCategoryId, {skipCount: 0, maxItems: 100}); + }); + })); + + it('should fetch root level categories when parentId not provided', fakeAsync(() => { + const getSpy = spyOn(categoryService.categoriesApi, 'getSubcategories').and.returnValue(Promise.resolve(fakeCategoriesResponse)); + categoryService.getSubcategories(null, 0, 100).subscribe(() => { + expect(getSpy).toHaveBeenCalledOnceWith('-root-', {skipCount: 0, maxItems: 100}); + }); + })); + + it('should create subcategory', fakeAsync(() => { + const createSpy = spyOn(categoryService.categoriesApi, 'createSubcategory').and.returnValue(Promise.resolve(fakeCategoryEntry)); + categoryService.createSubcategory(fakeParentCategoryId, fakeCategoryEntry.entry).subscribe(() => { + expect(createSpy).toHaveBeenCalledOnceWith(fakeParentCategoryId, [fakeCategoryEntry.entry], {}); + }); + })); + + it('should update category', fakeAsync(() => { + const updateSpy = spyOn(categoryService.categoriesApi, 'updateCategory').and.returnValue(Promise.resolve(fakeCategoryEntry)); + categoryService.updateCategory(fakeParentCategoryId, fakeCategoryBody).subscribe(() => { + expect(updateSpy).toHaveBeenCalledOnceWith(fakeParentCategoryId, fakeCategoryBody, {}); + }); + })); + + it('should delete category', fakeAsync(() => { + const deleteSpy = spyOn(categoryService.categoriesApi, 'deleteCategory').and.returnValue(Promise.resolve()); + categoryService.deleteCategory(fakeParentCategoryId).subscribe(() => { + expect(deleteSpy).toHaveBeenCalledOnceWith(fakeParentCategoryId); + }); + })); +}); diff --git a/lib/content-services/src/lib/category/services/category.service.ts b/lib/content-services/src/lib/category/services/category.service.ts new file mode 100644 index 0000000000..b6941d0acb --- /dev/null +++ b/lib/content-services/src/lib/category/services/category.service.ts @@ -0,0 +1,77 @@ +/*! + * @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 } from '@alfresco/adf-core'; +import { CategoriesApi, CategoryBody, CategoryEntry, CategoryPaging } from '@alfresco/js-api'; +import { from, Observable } from 'rxjs'; + +@Injectable({ providedIn: 'root' }) +export class CategoryService { + private _categoriesApi: CategoriesApi; + + get categoriesApi(): CategoriesApi { + this._categoriesApi = this._categoriesApi ?? new CategoriesApi(this.apiService.getInstance()); + return this._categoriesApi; + } + + constructor(private apiService: AlfrescoApiService) {} + + /** + * Get subcategories of a given parent category + * + * @param parentCategoryId The identifier of a parent category. + * @param skipCount Number of top categories to skip. + * @param maxItems Maximum number of subcategories returned from Observable. + * @return Observable + */ + getSubcategories(parentCategoryId: string, skipCount?: number, maxItems?: number): Observable { + return from(this.categoriesApi.getSubcategories(parentCategoryId ?? '-root-', {skipCount, maxItems})); + } + + /** + * Creates subcategory under category with provided categoryId + * + * @param parentCategoryId The identifier of a parent category. + * @param payload Created category body + * @return Observable + */ + createSubcategory(parentCategoryId: string, payload: CategoryBody): Observable { + return from(this.categoriesApi.createSubcategory(parentCategoryId, [payload], {})); + } + + /** + * Updates category + * + * @param categoryId The identifier of a category. + * @param payload Updated category body + * @return Observable + */ + updateCategory(categoryId: string, payload: CategoryBody): Observable { + return from(this.categoriesApi.updateCategory(categoryId, payload, {})); + } + + /** + * Deletes category + * + * @param categoryId The identifier of a category. + * @return Observable + */ + deleteCategory(categoryId: string): Observable { + return from(this.categoriesApi.deleteCategory(categoryId)); + } +} diff --git a/lib/content-services/src/lib/content.module.ts b/lib/content-services/src/lib/content.module.ts index ec72e84d11..1afd54bd92 100644 --- a/lib/content-services/src/lib/content.module.ts +++ b/lib/content-services/src/lib/content.module.ts @@ -46,6 +46,7 @@ import { versionCompatibilityFactory } from './version-compatibility/version-com import { VersionCompatibilityService } from './version-compatibility/version-compatibility.service'; import { ContentPipeModule } from './pipes/content-pipe.module'; import { NodeCommentsModule } from './node-comments/node-comments.module'; +import { TreeModule } from './tree/tree.module'; import { SearchTextModule } from './search-text/search-text-input.module'; @NgModule({ @@ -77,6 +78,7 @@ import { SearchTextModule } from './search-text/search-text-input.module'; AspectListModule, VersionCompatibilityModule, NodeCommentsModule, + TreeModule, SearchTextModule ], providers: [ @@ -112,6 +114,7 @@ import { SearchTextModule } from './search-text/search-text-input.module'; ContentTypeModule, VersionCompatibilityModule, NodeCommentsModule, + TreeModule, SearchTextModule ] }) diff --git a/lib/content-services/src/lib/i18n/en.json b/lib/content-services/src/lib/i18n/en.json index ba996c3ef0..382dbd9301 100644 --- a/lib/content-services/src/lib/i18n/en.json +++ b/lib/content-services/src/lib/i18n/en.json @@ -465,6 +465,9 @@ "ARIA_LABEL": "Toggle {{ name }}" } }, + "ADF-TREE": { + "LOAD-MORE-BUTTON": "Load more {{ name }}" + }, "LIBRARY": { "DIALOG": { "CREATE_TITLE": "Create Library", diff --git a/lib/content-services/src/lib/tree/components/tree.component.html b/lib/content-services/src/lib/tree/components/tree.component.html new file mode 100644 index 0000000000..22b8ad1414 --- /dev/null +++ b/lib/content-services/src/lib/tree/components/tree.component.html @@ -0,0 +1,117 @@ + + +
+
+ + {{ displayName | translate }} + +
+
+ + +
+ +
+
+ + {{ 'ADF-TREE.LOAD-MORE-BUTTON' | translate: { name: loadMoreSuffix } }} + +
+
+ +
+ +
+ + + + + + + + +
+ + {{ node.nodeName }} + +
+
+ + + + + +
+
+
+
+
+ + + + + + + +
+ + +
+
diff --git a/lib/content-services/src/lib/tree/components/tree.component.scss b/lib/content-services/src/lib/tree/components/tree.component.scss new file mode 100644 index 0000000000..3114dc7ce0 --- /dev/null +++ b/lib/content-services/src/lib/tree/components/tree.component.scss @@ -0,0 +1,98 @@ +$tree-row-height: 56px !default; +$tree-header-font-size: 12px !default; + +.adf-tree-sticky-header { + display: flex; + flex-direction: column; + height: 100%; + + mat-tree { + overflow-y: scroll; + } +} + +.adf-tree-row, +.adf-tree-load-more-row { + transition: all 0.3s ease; + display: flex; + align-items: center; + padding-left: 15px; + padding-right: 15px; + transition-property: background-color; + border-bottom: 1px solid var(--theme-border-color); + min-height: $tree-row-height; + cursor: pointer; + user-select: none; + + .adf-tree-expand-collapse-container { + min-width: 55px; + } + + &:hover { + background-color: var(--theme-bg-hover-color); + + .adf-tree-actions { + display: flex; + } + } + + &:focus { + background-color: var(--theme-bg-hover-color); + outline-offset: -1px; + outline: 1px solid var(--theme-accent-color-a200); + } + + .adf-tree-expand-collapse-button, + .adf-tree-load-more-button { + display: flex; + align-items: center; + justify-content: center; + margin-left: 15px; + } + + .adf-tree-cell { + color: var(--theme-text-fg-color); + width: 100%; + + .adf-tree-cell-value { + display: block; + padding: 10px; + word-break: break-word; + } + } +} + +.adf-tree-header { + display: flex; + width: fit-content; + min-width: 100%; + box-sizing: border-box; + + .adf-tree-cell-header { + cursor: pointer; + position: relative; + vertical-align: bottom; + text-overflow: ellipsis; + font-weight: bold; + line-height: 24px; + letter-spacing: 0; + min-height: $tree-row-height !important; + font-size: $tree-header-font-size; + color: var(--theme-text-fg-color); + box-sizing: border-box; + padding-top: 12px !important; + + &:focus { + outline-offset: -1px; + outline: 1px solid var(--theme-accent-color-a200); + } + } +} + +.adf-tree-loading-spinner-container { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; +} diff --git a/lib/content-services/src/lib/tree/components/tree.component.spec.ts b/lib/content-services/src/lib/tree/components/tree.component.spec.ts new file mode 100644 index 0000000000..3af05e54a3 --- /dev/null +++ b/lib/content-services/src/lib/tree/components/tree.component.spec.ts @@ -0,0 +1,260 @@ +/*! + * @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 { TreeComponent } from './tree.component'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { CoreTestingModule, UserPreferencesService } from '@alfresco/adf-core'; +import { MatTreeModule } from '@angular/material/tree'; +import { TreeNode, TreeNodeType } from '../models/tree-node.interface'; +import { singleNode, treeNodesChildrenMockExpanded, treeNodesMock, treeNodesMockExpanded } from '../mock/tree-node.mock'; +import { of } from 'rxjs'; +import { TreeService } from '../services/tree.service'; +import { TreeServiceMock } from '../mock/tree-service.service.mock'; +import { By } from '@angular/platform-browser'; +import { SelectionChange } from '@angular/cdk/collections'; + +describe('TreeComponent', () => { + let fixture: ComponentFixture>; + let component: TreeComponent; + let userPreferenceService: UserPreferencesService; + + const getDisplayNameValue = (nodeId: string) => + fixture.nativeElement.querySelector(`.mat-tree-node[data-automation-id="node_${nodeId}"] .adf-tree-cell-value`).innerText.trim(); + + const getNodePadding = (nodeId: string) => { + const element = fixture.nativeElement.querySelector(`.mat-tree-node[data-automation-id="node_${nodeId}"]`); + return parseInt(window.getComputedStyle(element).paddingLeft, 10); + }; + + const getNodeSpinner = (nodeId: string) => fixture.nativeElement.querySelector(`.mat-tree-node[data-automation-id="node_${nodeId}"] .mat-progress-spinner`); + + const getExpandCollapseBtn = (nodeId: string) => fixture.nativeElement.querySelector(`.mat-tree-node[data-automation-id="node_${nodeId}"] .adf-icon`); + + const tickCheckbox = (index: number) => { + const nodeCheckboxes = fixture.debugElement.queryAll(By.css('mat-checkbox')); + nodeCheckboxes[index].nativeElement.dispatchEvent(new Event('change')); + }; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ + CoreTestingModule, + MatTreeModule + ], + declarations: [ + TreeComponent + ], + providers: [ + { provide: TreeService, useClass: TreeServiceMock } + ] + }); + + fixture = TestBed.createComponent(TreeComponent); + component = fixture.componentInstance; + userPreferenceService = TestBed.inject(UserPreferencesService); + }); + + afterEach(() => { + fixture.destroy(); + }); + + it('should refresh the tree on component initialization', () => { + const refreshSpy = spyOn(component, 'refreshTree'); + fixture.detectChanges(); + expect(refreshSpy).toHaveBeenCalled(); + }); + + it('should show a header title showing displayName property value', () => { + spyOn(component, 'isEmpty').and.returnValue(false); + component.displayName = 'test'; + fixture.detectChanges(); + const treeHeaderDisplayName = fixture.nativeElement.querySelector(`[data-automation-id="tree-header-display-name"]`); + expect(treeHeaderDisplayName.innerText.trim()).toBe('test'); + }); + + it('should show a list of nodes', () => { + component.refreshTree(); + fixture.detectChanges(); + const displayNameCellValueNode1 = getDisplayNameValue('testId1'); + const displayNameCellValueNode2 = getDisplayNameValue('testId2'); + expect(displayNameCellValueNode1).toBe('testName1'); + expect(displayNameCellValueNode2).toBe('testName2'); + }); + + it('should pad the tree according to the level of the node', () => { + component.treeService.treeNodes = Array.from(treeNodesChildrenMockExpanded); + fixture.detectChanges(); + const nodeLevel0Padding = getNodePadding('testId1'); + const nodeLevel0Padding2 = getNodePadding('testId2'); + const nodeLevel1Padding = getNodePadding('testId3'); + expect(nodeLevel0Padding).toEqual(nodeLevel0Padding2); + expect(nodeLevel1Padding).toBeGreaterThan(nodeLevel0Padding); + }); + + it('should show a spinner for nodes that are loading subnodes', () => { + component.treeService.treeNodes = Array.from(treeNodesChildrenMockExpanded); + fixture.detectChanges(); + component.treeService.treeControl.dataNodes[0].isLoading = true; + fixture.detectChanges(); + const nodeSpinner = getNodeSpinner('testId1'); + expect(nodeSpinner).not.toBeNull(); + }); + + it('should show a spinner while the tree is loading', () => { + fixture.detectChanges(); + component.loadingRoot$ = of(true); + fixture.detectChanges(); + const matSpinnerElement = fixture.nativeElement.querySelector('.adf-tree-loading-spinner-container .mat-progress-spinner'); + expect(matSpinnerElement).not.toBeNull(); + }); + + it('should show provided expand/collapse icons', () => { + component.treeService.treeNodes = Array.from(treeNodesMockExpanded); + component.expandIcon = 'folder'; + component.collapseIcon = 'chevron_left'; + component.treeService.collapseNode(component.treeService.treeNodes[0]); + fixture.detectChanges(); + let nodeIcons: any = fixture.debugElement.queryAll(By.css('.adf-icon')); + expect(nodeIcons[0].nativeElement.innerText).toContain('folder'); + spyOn(component.treeService.treeControl, 'isExpanded').and.returnValue(true); + fixture.detectChanges(); + nodeIcons = fixture.debugElement.queryAll(By.css('.adf-icon')); + expect(nodeIcons[0].nativeElement.innerText).toContain('chevron_left'); + }); + + it('should emit pagination when nodes are loaded', (done) => { + component.treeService.treeNodes = Array.from(treeNodesMockExpanded); + component.paginationChanged.subscribe((pagination) => { + expect(pagination.skipCount).toBe(0); + expect(pagination.maxItems).toBe(userPreferenceService.paginationSize); + done(); + }); + component.expandCollapseNode(component.treeService.treeNodes[0]); + }); + + it('when node has more items to load loadMore node should appear', () => { + component.treeService.treeNodes = Array.from(treeNodesMockExpanded); + fixture.detectChanges(); + const loadMoreNode = fixture.nativeElement.querySelector('.adf-tree-load-more-row'); + expect(loadMoreNode).not.toBeNull(); + }); + + it('should clear the selection and load root nodes on refresh', () => { + const selectionSpy = spyOn(component.treeNodesSelection, 'clear'); + const getNodesSpy = spyOn(component.treeService, 'getSubNodes').and.callThrough(); + component.refreshTree(0, 25); + expect(selectionSpy).toHaveBeenCalled(); + expect(getNodesSpy).toHaveBeenCalledWith('-root-', 0, 25); + }); + + it('should call correct server method on collapsing node', () => { + component.refreshTree(); + fixture.detectChanges(); + const collapseSpy = spyOn(component.treeService, 'collapseNode'); + spyOn(component.treeService.treeControl, 'isExpanded').and.returnValue(true); + getExpandCollapseBtn(component.treeService.treeNodes[0].id).dispatchEvent(new Event('click')); + expect(collapseSpy).toHaveBeenCalledWith(component.treeService.treeNodes[0]); + }); + + it('should call correct server method on expanding node', () => { + component.refreshTree(); + fixture.detectChanges(); + const collapseSpy = spyOn(component.treeService, 'expandNode'); + spyOn(component.treeService.treeControl, 'isExpanded').and.returnValue(false); + getExpandCollapseBtn(component.treeService.treeNodes[0].id).dispatchEvent(new Event('click')); + expect(collapseSpy).toHaveBeenCalledWith(component.treeService.treeNodes[0], treeNodesMockExpanded); + }); + + it('should load more subnodes and remove load more button when load more button is clicked', () => { + component.refreshTree(); + fixture.detectChanges(); + spyOn(component.treeService, 'getSubNodes').and.returnValue(of({pagination: {}, entries: Array.from(singleNode)})); + const loadMoreBtn = fixture.debugElement.query(By.css('.adf-tree-load-more-button adf-icon')).nativeElement; + const appendSpy = spyOn(component.treeService, 'appendNodes').and.callThrough(); + loadMoreBtn.dispatchEvent(new Event('click')); + fixture.whenStable(); + fixture.detectChanges(); + const loadMoreNodes = component.treeService.treeNodes.find((node: TreeNode) => node.nodeType === TreeNodeType.LoadMoreNode); + expect(appendSpy).toHaveBeenCalledWith(component.treeService.treeNodes[0], Array.from(singleNode)); + expect(loadMoreNodes).toBeUndefined(); + }); + + it('selection should be disabled by default, no checkboxes should be displayed', () => { + component.refreshTree(); + fixture.detectChanges(); + const nodeCheckboxes = fixture.debugElement.queryAll(By.css('mat-checkbox')); + expect(nodeCheckboxes.length).toEqual(0); + expect(component.selectableNodes).toEqual(false); + }); + + describe('Tree nodes selection tests', () => { + beforeEach(() => { + component.selectableNodes = true; + }); + + it('should display checkboxes when selection is enabled', () => { + component.refreshTree(); + fixture.detectChanges(); + const nodeCheckboxes = fixture.debugElement.queryAll(By.css('mat-checkbox')); + const selectableNodes = component.treeService.treeNodes.filter((node: TreeNode) => node.nodeType !== TreeNodeType.LoadMoreNode); + expect(nodeCheckboxes.length).toEqual(selectableNodes.length); + }); + + it('should update selection when leaf node is selected', () => { + spyOn(component.treeService, 'getSubNodes').and.returnValue(of({ pagination: {}, entries: Array.from(treeNodesMock) })); + fixture.detectChanges(); + tickCheckbox(0); + expect(component.treeNodesSelection.isSelected(component.treeService.treeNodes[0])).toBeTrue(); + }); + + it('should update selection of each child node when parent node is selected and deselected', () => { + spyOn(component.treeService, 'getSubNodes').and.returnValue(of({ pagination: {}, entries: Array.from(treeNodesChildrenMockExpanded) })); + fixture.detectChanges(); + tickCheckbox(0); + expect(component.treeNodesSelection.isSelected(component.treeService.treeNodes[0])).toBeTrue(); + expect(component.descendantsAllSelected(component.treeService.treeNodes[0])).toBeTrue(); + + tickCheckbox(0); + expect(component.treeNodesSelection.isSelected(component.treeService.treeNodes[0])).toBeFalse(); + expect(component.descendantsPartiallySelected(component.treeService.treeNodes[0])).toBeFalse(); + }); + + it('parent node should have intermediate state when not all subnodes are selected', () => { + spyOn(component.treeService, 'getSubNodes').and.returnValue(of({ pagination: {}, entries: Array.from(treeNodesChildrenMockExpanded) })); + fixture.detectChanges(); + tickCheckbox(0); + tickCheckbox(2); + expect(component.descendantsPartiallySelected(component.treeService.treeNodes[0])).toBeTrue(); + expect(component.descendantsPartiallySelected(component.treeService.treeNodes[1])).toBeTrue(); + }); + + it('should select loaded nodes when parent node is selected', (done) => { + component.refreshTree(); + fixture.detectChanges(); + tickCheckbox(0); + spyOn(component.treeService, 'getSubNodes').and.returnValue(of({ pagination: {}, entries: Array.from(singleNode) })); + component.treeNodesSelection.changed.subscribe((selectionChange: SelectionChange) => { + expect(selectionChange.added.length).toEqual(1); + expect(selectionChange.added[0].id).toEqual(singleNode[0].id); + expect(component.treeNodesSelection.isSelected(singleNode[0])); + done(); + }); + component.loadMoreSubnodes(component.treeService.treeNodes.find((node: TreeNode) => node.nodeType === TreeNodeType.LoadMoreNode)); + fixture.detectChanges(); + }); + }); +}); diff --git a/lib/content-services/src/lib/tree/components/tree.component.ts b/lib/content-services/src/lib/tree/components/tree.component.ts new file mode 100644 index 0000000000..a71ee14003 --- /dev/null +++ b/lib/content-services/src/lib/tree/components/tree.component.ts @@ -0,0 +1,254 @@ +/*! + * @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, EventEmitter, HostBinding, Input, OnInit, Output, QueryList, TemplateRef, ViewChildren, ViewEncapsulation } from '@angular/core'; +import { BehaviorSubject, Observable } from 'rxjs'; +import { TreeNode, TreeNodeType } from '../models/tree-node.interface'; +import { TreeService } from '../services/tree.service'; +import { PaginationModel, UserPreferencesService } from '@alfresco/adf-core'; +import { SelectionChange, SelectionModel } from '@angular/cdk/collections'; +import { TreeResponse } from '../models/tree-response.interface'; +import { MatCheckbox } from '@angular/material/checkbox'; + +@Component({ + selector: 'adf-tree', + templateUrl: './tree.component.html', + styleUrls: ['./tree.component.scss'], + host: { class: 'adf-tree' }, + encapsulation: ViewEncapsulation.None +}) +export class TreeComponent implements OnInit { + + /** TemplateRef to provide empty template when no nodes are loaded */ + @Input() + public emptyContentTemplate: TemplateRef; + + /** TemplateRef to provide context menu items for context menu displayed on each row*/ + @Input() + public nodeActionsMenuTemplate: TemplateRef; + + /** Variable defining if tree header should be sticky. By default set to false */ + @Input() + @HostBinding('class.adf-tree-sticky-header') + public stickyHeader: boolean = false; + + /** Variable defining if tree nodes should be selectable. By default set to false */ + @Input() + public selectableNodes: boolean = false; + + /** Tree display name */ + @Input() + public displayName: string; + + /** Load more suffix for load more button */ + @Input() + public loadMoreSuffix: string; + + /** Icon shown when node has children and is collapsed. By default set to chevron_right */ + @Input() + public expandIcon: string = 'chevron_right'; + + /** Icon shown when node is expanded. By default set to expand_more */ + @Input() + public collapseIcon: string = 'expand_more'; + + /** Emitted when pagination has been changed */ + @Output() + public paginationChanged: EventEmitter = new EventEmitter(); + + @ViewChildren(MatCheckbox) + public nodeCheckboxes: QueryList; + + private loadingRootSource = new BehaviorSubject(false); + public loadingRoot$: Observable; + public treeNodesSelection = new SelectionModel(true, [], true, (node1: T, node2: T) => node1.id === node2.id); + + constructor(public treeService: TreeService, + private userPreferenceService: UserPreferencesService) {} + + ngOnInit(): void { + this.loadingRoot$ = this.loadingRootSource.asObservable(); + this.refreshTree(0, this.userPreferenceService.paginationSize); + this.treeNodesSelection.changed.subscribe((selectionChange: SelectionChange) => { + this.onTreeSelectionChange(selectionChange); + }); + } + + /** + * Checks if node is LoadMoreNode node + * + * @param node node to be checked + * @returns boolean + */ + public isLoadMoreNode(_: number, node: T): boolean { + return node.nodeType === TreeNodeType.LoadMoreNode; + } + + /** + * Checks if tree is empty + * + * @returns boolean + */ + public isEmpty(): boolean { + return this.treeService.isEmpty(); + } + + /** + * Returns action icon based on expanded/collapsed node state. + * + * @param node node to be checked + * @returns collapse or expand icon + */ + public expandCollapseIconValue(node: T): string { + return this.treeService.treeControl.isExpanded(node) ? this.collapseIcon : this.expandIcon; + } + + /** + * Refreshes the tree, root nodes are reloaded, tree selection is cleared. + * + * @param skipCount Number of root nodes to skip. + * @param maxItems Maximum number of nodes returned from Observable. + */ + public refreshTree(skipCount?: number, maxItems?: number): void { + this.loadingRootSource.next(true); + this.treeNodesSelection.clear(); + this.treeService.getSubNodes('-root-', skipCount, maxItems).subscribe((response: TreeResponse) => { + this.treeService.treeNodes = response.entries; + this.treeNodesSelection.deselect(...response.entries); + this.paginationChanged.emit(response.pagination); + this.loadingRootSource.next(false); + }); + } + + /** + * Collapses or expanding the node based on its current state + * + * @param node node to be collapsed/expanded + */ + public expandCollapseNode(node: T): void { + if (this.treeService.treeControl.isExpanded(node)) { + this.treeService.collapseNode(node); + } else { + node.isLoading = true; + this.treeService.getSubNodes(node.id, 0, this.userPreferenceService.paginationSize).subscribe((response: TreeResponse) => { + this.treeService.expandNode(node, response.entries); + this.paginationChanged.emit(response.pagination); + node.isLoading = false; + if (this.treeNodesSelection.isSelected(node)) { + //timeout used to update nodeCheckboxes query list after new nodes are added so they can be selected + setTimeout(() => { + this.treeNodesSelection.select(...response.entries); + }); + } + }); + } + } + + /** + * Loads more subnode for a given parent node + * + * @param node parent node + */ + public loadMoreSubnodes(node: T): void { + node.isLoading = true; + const parentNode: T = this.treeService.getParentNode(node.parentId); + this.treeService.removeNode(node); + const loadedChildren: number = this.treeService.getChildren(parentNode).length; + this.treeService.getSubNodes(parentNode.id, loadedChildren, this.userPreferenceService.paginationSize).subscribe((response: TreeResponse) => { + this.treeService.appendNodes(parentNode, response.entries); + this.paginationChanged.emit(response.pagination); + node.isLoading = false; + if (this.treeNodesSelection.isSelected(parentNode)) { + //timeout used to update nodeCheckboxes query list after new nodes are added so they can be selected + setTimeout(() => { + this.treeNodesSelection.select(...response.entries); + }); + } + }); + } + + /** + * When node is selected it selects all its descendants + * + * @param node selected node + */ + public onNodeSelected(node: T): void { + this.treeNodesSelection.toggle(node); + const descendants: T[] = this.treeService.treeControl.getDescendants(node).filter(this.isRegularNode); + if (descendants.length > 0) { + this.treeNodesSelection.isSelected(node) ? this.treeNodesSelection.select(...descendants) : this.treeNodesSelection.deselect(...descendants); + } + this.checkParentsSelection(node); + } + + /** + * Checks if all descendants of a node are selected + * + * @param node selected node + * @returns boolean + */ + public descendantsAllSelected(node: T): boolean { + const descendants: T[] = this.treeService.treeControl.getDescendants(node).filter(this.isRegularNode); + return descendants.length > 0 && descendants.every((descendant: T) => this.treeNodesSelection.isSelected(descendant)); + } + + /** + * Checks if some descendants of a node are selected + * + * @param node selected node + * @returns boolean + */ + public descendantsPartiallySelected(node: T): boolean { + const descendants: T[] = this.treeService.treeControl.getDescendants(node).filter(this.isRegularNode); + return descendants.length > 0 && !this.descendantsAllSelected(node) && descendants.some((descendant: T) => this.treeNodesSelection.isSelected(descendant)); + } + + private checkParentsSelection(node: T): void { + let parent: T = this.treeService.getParentNode(node.parentId); + while(parent) { + this.checkRootNodeSelection(parent); + parent = this.treeService.getParentNode(parent.parentId); + } + } + + private checkRootNodeSelection(node: T): void { + const nodeSelected: boolean = this.treeNodesSelection.isSelected(node); + const descAllSelected = this.descendantsAllSelected(node); + if (nodeSelected && !descAllSelected) { + this.treeNodesSelection.deselect(node); + } else if (!nodeSelected && descAllSelected) { + this.treeNodesSelection.select(node); + } + } + + private onTreeSelectionChange(selectionChange: SelectionChange): void { + selectionChange.removed.forEach((unselectedNode: T) => { + if (this.isRegularNode(unselectedNode)) { + this.nodeCheckboxes.find((checkbox: MatCheckbox) => checkbox.id === unselectedNode.id).checked = false; + } + }); + selectionChange.added.forEach((selectedNode: T) => { + if (this.isRegularNode(selectedNode)) { + this.nodeCheckboxes.find((checkbox: MatCheckbox) => checkbox.id === selectedNode.id).checked = true; + } + }); + } + + private isRegularNode(node: T): boolean { + return node.nodeType !== TreeNodeType.LoadMoreNode; + } +} diff --git a/lib/content-services/src/lib/tree/index.ts b/lib/content-services/src/lib/tree/index.ts new file mode 100644 index 0000000000..a7e30cc675 --- /dev/null +++ b/lib/content-services/src/lib/tree/index.ts @@ -0,0 +1,18 @@ +/*! + * @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. + */ + +export * from './public-api'; diff --git a/lib/content-services/src/lib/tree/mock/tree-node.mock.ts b/lib/content-services/src/lib/tree/mock/tree-node.mock.ts new file mode 100644 index 0000000000..1cfeb2d386 --- /dev/null +++ b/lib/content-services/src/lib/tree/mock/tree-node.mock.ts @@ -0,0 +1,198 @@ +/*! + * @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 { TreeNode, TreeNodeType } from '../models/tree-node.interface'; + +export const treeNodesMock: TreeNode[] = [ + { + id: 'testId1', + nodeName: 'testName1', + parentId: '-root-', + level: 0, + hasChildren: true, + isLoading: false, + nodeType: TreeNodeType.RegularNode + }, + { + id: 'testId2', + nodeName: 'testName2', + parentId: '-root-', + level: 0, + hasChildren: true, + isLoading: false, + nodeType: TreeNodeType.RegularNode + } +]; + +export const treeNodesNoChildrenMock: TreeNode[] = [ + { + id: 'testId1', + nodeName: 'testName1', + parentId: '-root-', + level: 0, + hasChildren: false, + isLoading: false, + nodeType: TreeNodeType.RegularNode + }, + { + id: 'testId2', + nodeName: 'testName2', + parentId: '-root-', + level: 0, + hasChildren: false, + isLoading: false, + nodeType: TreeNodeType.RegularNode + } +]; + +export const treeNodesChildrenMock: TreeNode[] = [ + { + id: 'testId3', + nodeName: 'testName3', + parentId: 'testId1', + level: 1, + hasChildren: true, + isLoading: false, + nodeType: TreeNodeType.RegularNode + }, + { + id: 'testId4', + nodeName: 'testName4', + parentId: 'testId1', + level: 1, + hasChildren: true, + isLoading: false, + nodeType: TreeNodeType.RegularNode + } +]; + +export const treeNodesChildrenMockExpanded: TreeNode[] = [ + { + id: 'testId1', + nodeName: 'testName1', + parentId: '-root-', + level: 0, + hasChildren: true, + isLoading: false, + nodeType: TreeNodeType.RegularNode + }, + { + id: 'testId3', + nodeName: 'testName3', + parentId: 'testId1', + level: 1, + hasChildren: true, + isLoading: false, + nodeType: TreeNodeType.RegularNode + }, + { + id: 'testId5', + nodeName: 'testName5', + parentId: 'testId3', + level: 2, + hasChildren: false, + isLoading: false, + nodeType: TreeNodeType.RegularNode + }, + { + id: 'testId6', + nodeName: 'testName6', + parentId: 'testId3', + level: 2, + hasChildren: false, + isLoading: false, + nodeType: TreeNodeType.RegularNode + }, + { + id: 'testId4', + nodeName: 'testName4', + parentId: 'testId1', + level: 1, + hasChildren: true, + isLoading: false, + nodeType: TreeNodeType.RegularNode + }, + { + id: 'testId2', + nodeName: 'testName2', + parentId: '-root-', + level: 0, + hasChildren: true, + isLoading: false, + nodeType: TreeNodeType.RegularNode + } +]; + +export const treeNodesMockExpanded: TreeNode[] = [ + { + id: 'testId1', + nodeName: 'testName1', + parentId: '-root-', + level: 0, + hasChildren: true, + isLoading: false, + nodeType: TreeNodeType.RegularNode + }, + { + id: 'testId3', + nodeName: 'testName3', + parentId: 'testId1', + level: 1, + hasChildren: true, + isLoading: false, + nodeType: TreeNodeType.RegularNode + }, + { + id: 'testId4', + nodeName: 'testName4', + parentId: 'testId1', + level: 1, + hasChildren: true, + isLoading: false, + nodeType: TreeNodeType.RegularNode + }, + { + id: 'loadMore', + nodeName: '', + parentId: 'testId1', + level: 1, + hasChildren: false, + isLoading: false, + nodeType: TreeNodeType.LoadMoreNode + }, + { + id: 'testId2', + nodeName: 'testName2', + parentId: '-root-', + level: 0, + hasChildren: true, + isLoading: false, + nodeType: TreeNodeType.RegularNode + } +]; + +export const singleNode: TreeNode[] = [ + { + id: 'testId10', + nodeName: 'testName10', + parentId: 'testId1', + level: 1, + hasChildren: false, + isLoading: false, + nodeType: TreeNodeType.RegularNode + } +]; diff --git a/lib/content-services/src/lib/tree/mock/tree-service.service.mock.ts b/lib/content-services/src/lib/tree/mock/tree-service.service.mock.ts new file mode 100644 index 0000000000..760f9b39d0 --- /dev/null +++ b/lib/content-services/src/lib/tree/mock/tree-service.service.mock.ts @@ -0,0 +1,33 @@ +/*! + * @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 { Observable, of } from 'rxjs'; +import { TreeNode } from '../models/tree-node.interface'; +import { TreeResponse } from '../models/tree-response.interface'; +import { TreeService } from '../services/tree.service'; +import { treeNodesMockExpanded } from './tree-node.mock'; + +@Injectable({ providedIn: 'root' }) +export class TreeServiceMock extends TreeService { + public getSubNodes(parentNodeId: string, skipCount?: number, maxItems?: number): Observable> { + if (parentNodeId) { + return of({pagination: {skipCount, maxItems}, entries: Array.from(treeNodesMockExpanded)}); + } + return of(); + } +} diff --git a/lib/content-services/src/lib/tree/models/tree-node.interface.ts b/lib/content-services/src/lib/tree/models/tree-node.interface.ts new file mode 100644 index 0000000000..354715fa85 --- /dev/null +++ b/lib/content-services/src/lib/tree/models/tree-node.interface.ts @@ -0,0 +1,31 @@ +/*! + * @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. + */ + +export enum TreeNodeType { + RegularNode, + LoadMoreNode +} + +export interface TreeNode { + id: string; + nodeName: string; + parentId: string; + level: number; + nodeType: TreeNodeType; + hasChildren: boolean; + isLoading: boolean; +} diff --git a/lib/content-services/src/lib/tree/models/tree-response.interface.ts b/lib/content-services/src/lib/tree/models/tree-response.interface.ts new file mode 100644 index 0000000000..ecdf3a4ec2 --- /dev/null +++ b/lib/content-services/src/lib/tree/models/tree-response.interface.ts @@ -0,0 +1,24 @@ +/*! + * @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 { TreeNode } from './tree-node.interface'; +import { PaginationModel } from '@alfresco/adf-core'; + +export interface TreeResponse { + pagination: PaginationModel; + entries: T[]; +} diff --git a/lib/content-services/src/lib/tree/public-api.ts b/lib/content-services/src/lib/tree/public-api.ts new file mode 100644 index 0000000000..7e0084fc4b --- /dev/null +++ b/lib/content-services/src/lib/tree/public-api.ts @@ -0,0 +1,22 @@ +/*! + * @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. + */ + +export * from './tree.module'; +export * from './models/tree-response.interface'; +export * from './models/tree-node.interface'; +export * from './services/tree.service'; +export * from './components/tree.component'; diff --git a/lib/content-services/src/lib/tree/services/tree.service.spec.ts b/lib/content-services/src/lib/tree/services/tree.service.spec.ts new file mode 100644 index 0000000000..47a73a96ff --- /dev/null +++ b/lib/content-services/src/lib/tree/services/tree.service.spec.ts @@ -0,0 +1,148 @@ +/*! + * @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 { TreeService } from './tree.service'; +import { TestBed } from '@angular/core/testing'; +import { CoreTestingModule } from '@alfresco/adf-core'; +import { TreeNode } from '../models/tree-node.interface'; +import { + treeNodesMock, + treeNodesChildrenMock, + treeNodesMockExpanded, + treeNodesChildrenMockExpanded, + treeNodesNoChildrenMock, + singleNode +} from '../mock/tree-node.mock'; + +describe('TreeService', () => { + let service: TreeService; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ + CoreTestingModule + ] + }); + service = TestBed.inject(TreeService); + }); + + it('should emit tree nodes when new are set', () => { + const nodesSourceSpy = spyOn(service.treeNodesSource, 'next'); + service.treeNodes = Array.from(treeNodesMock); + expect(nodesSourceSpy).toHaveBeenCalledWith(treeNodesMock); + }); + + it('should expand node containing children', () => { + const treeNodesMockCopy = Array.from(treeNodesMock); + service.treeNodes = treeNodesMockCopy; + const nodesSourceSpy = spyOn(service.treeNodesSource, 'next'); + service.expandNode(treeNodesMockCopy[0], Array.from(treeNodesChildrenMock)); + expect(nodesSourceSpy).toHaveBeenCalled(); + expect(service.treeNodes.length).toEqual(treeNodesMockCopy.length); + }); + + it('should collapse node containing children', () => { + const treeNodesMockExpandedCopy = Array.from(treeNodesMockExpanded); + service.treeNodes = treeNodesMockExpandedCopy; + const nodesSourceSpy = spyOn(service.treeNodesSource, 'next'); + service.collapseNode(treeNodesMockExpandedCopy[0]); + expect(nodesSourceSpy).toHaveBeenCalled(); + expect(service.treeNodes.length).toEqual(treeNodesMock.length); + }); + + it('should collapse node with more levels', () => { + service.treeNodes = Array.from(treeNodesChildrenMockExpanded); + const nodesSourceSpy = spyOn(service.treeNodesSource, 'next'); + service.collapseNode(Array.from(treeNodesChildrenMockExpanded)[0]); + expect(nodesSourceSpy).toHaveBeenCalledOnceWith(treeNodesMock); + expect(service.treeNodes.length).toEqual(treeNodesMock.length); + }); + + it('should not expand node without children', () => { + service.treeNodes = Array.from(treeNodesNoChildrenMock); + const nodesSourceSpy = spyOn(service.treeNodesSource, 'next'); + service.expandNode(Array.from(treeNodesNoChildrenMock)[0], []); + expect(nodesSourceSpy).not.toHaveBeenCalled(); + expect(service.treeNodes.length).toEqual(treeNodesNoChildrenMock.length); + }); + + it('should not collapse node without children', () => { + service.treeNodes = Array.from(treeNodesNoChildrenMock); + const nodesSourceSpy = spyOn(service.treeNodesSource, 'next'); + service.collapseNode(Array.from(treeNodesNoChildrenMock)[0]); + expect(nodesSourceSpy).not.toHaveBeenCalled(); + expect(service.treeNodes.length).toEqual(treeNodesNoChildrenMock.length); + }); + + it('should not collapse node without children', () => { + service.treeNodes = Array.from(treeNodesNoChildrenMock); + const nodesSourceSpy = spyOn(service.treeNodesSource, 'next'); + service.collapseNode(Array.from(treeNodesNoChildrenMock)[0]); + expect(nodesSourceSpy).not.toHaveBeenCalled(); + expect(service.treeNodes.length).toEqual(treeNodesNoChildrenMock.length); + }); + + it('should append new child node as the last child of parent node', () => { + service.treeNodes = Array.from(treeNodesMockExpanded); + const nodesSourceSpy = spyOn(service.treeNodesSource, 'next'); + service.appendNodes(Array.from(treeNodesMockExpanded)[0], singleNode); + expect(nodesSourceSpy).toHaveBeenCalled(); + expect(service.treeNodes[4].id).toEqual(singleNode[0].id); + }); + + it('should return parent of given node', () => { + service.treeNodes = treeNodesMockExpanded; + const parentNode: TreeNode = service.getParentNode(treeNodesMockExpanded[1].parentId); + expect(parentNode.id).toEqual(treeNodesMockExpanded[0].id); + }); + + it('should return undefined when node has no parent', () => { + service.treeNodes = treeNodesMockExpanded; + const parentNode: TreeNode = service.getParentNode(treeNodesMockExpanded[0].parentId); + expect(parentNode).toBeUndefined(); + }); + + it('should return true if tree is empty', () => { + service.treeNodes = []; + expect(service.isEmpty()).toBeTrue(); + }); + + it('should return false if tree is not empty', () => { + service.treeNodes = treeNodesMock; + expect(service.isEmpty()).toBeFalse(); + }); + + it('should be able to remove node', () => { + service.treeNodes = Array.from(treeNodesMock); + const removedNodeId = service.treeNodes[0].id; + service.removeNode(service.treeNodes[0]); + expect(service.treeNodes.length).toEqual(1); + expect(service.treeNodes[0].id).not.toEqual(removedNodeId); + }); + + it('should return node children', () => { + service.treeNodes = Array.from(treeNodesMockExpanded); + const children = service.getChildren(service.treeNodes[0]); + expect(children.length).toEqual(3); + }); + + it('should return empty array for node without children', () => { + service.treeNodes = Array.from(treeNodesMock); + const children = service.getChildren(service.treeNodes[0]); + expect(children.length).toEqual(0); + }); +}); diff --git a/lib/content-services/src/lib/tree/services/tree.service.ts b/lib/content-services/src/lib/tree/services/tree.service.ts new file mode 100644 index 0000000000..ac83026edd --- /dev/null +++ b/lib/content-services/src/lib/tree/services/tree.service.ts @@ -0,0 +1,148 @@ +/*! + * @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 { DataSource } from '@angular/cdk/collections'; +import { FlatTreeControl } from '@angular/cdk/tree'; +import { BehaviorSubject, Observable } from 'rxjs'; +import { TreeNode } from '../models/tree-node.interface'; +import { TreeResponse } from '../models/tree-response.interface'; + +@Injectable({ providedIn: 'root' }) +export abstract class TreeService extends DataSource { + public readonly treeControl: FlatTreeControl; + public treeNodesSource = new BehaviorSubject([]); + + get treeNodes(): T[] { + return this.treeControl.dataNodes; + } + + set treeNodes(nodes: T[]) { + this.treeControl.dataNodes = nodes; + this.treeNodesSource.next(nodes); + } + + constructor() { + super(); + this.treeControl = new FlatTreeControl(node => node.level, node => node.hasChildren); + this.treeNodes = []; + } + + public abstract getSubNodes(parentNodeId: string, skipCount?: number, maxItems?: number): Observable>; + + /** + * Expands node applying subnodes to it. + * + * @param nodeToExpand Node to be expanded + * @param subNodes List of nodes that will be added as children of expanded node + */ + public expandNode(nodeToExpand: T, subNodes: T[]): void { + if (nodeToExpand != null && subNodes != null && nodeToExpand.hasChildren) { + const index: number = this.treeNodes.indexOf(nodeToExpand); + this.treeNodes.splice(index + 1, 0, ...subNodes); + nodeToExpand.isLoading = false; + this.treeNodesSource.next(this.treeNodes); + } + } + + /** + * Collapses a node removing all children from it. + * + * @param nodeToCollapse Node to be collapsed + */ + public collapseNode(nodeToCollapse: T): void { + if (nodeToCollapse != null && nodeToCollapse.hasChildren) { + const children: T[] = this.treeNodes.filter((node: T) => nodeToCollapse.id === node.parentId); + children.forEach((child: T) => { + this.collapseInnerNode(child); + }); + this.treeNodesSource.next(this.treeNodes); + } + } + + /** + * Append more child nodes to already expanded parent node + * + * @param nodeToAppend Expanded parent node + * @param subNodes List of nodes that will be added as children of expanded node + */ + public appendNodes(nodeToAppend: T, subNodes: T[]): void { + if (nodeToAppend != null && subNodes != null) { + const lastChild: T = this.treeNodes.filter((treeNode: T) => nodeToAppend.id === treeNode.parentId).pop(); + const index: number = this.treeNodes.indexOf(lastChild); + const children: number = this.treeControl.getDescendants(lastChild).length; + this.treeNodes.splice(index + children + 1, 0, ...subNodes); + nodeToAppend.isLoading = false; + this.treeNodesSource.next(this.treeNodes); + } + } + + /** + * Removes provided node from the tree + * + * @param node Node to be removed + */ + public removeNode(node: T): void { + this.treeNodes.splice(this.treeNodes.indexOf(node), 1); + } + + /** + * Gets children of the node + * + * @param parentNode Parent node + * + * @returns children of parent node + */ + public getChildren(parentNode: T): T[] { + return this.treeNodes.filter((treeNode: T) => treeNode.parentId === parentNode.id); + } + + /** + * Checks if tree is empty + * + * @returns boolean + */ + public isEmpty(): boolean { + return !this.treeNodes.length; + } + + /** + * Gets parent node of given node. If node with parentNodeId is not found it returns undefined. + * + * @param parentNodeId Id of a parent node to be found + * @returns parent node or undefined when not found + */ + public getParentNode(parentNodeId: string): T | undefined { + return this.treeNodes.find((treeNode: T) => treeNode.id === parentNodeId); + } + + public connect(): Observable { + return this.treeNodesSource.asObservable(); + } + + public disconnect(): void {} + + private collapseInnerNode(nodeToCollapse: T): void { + const index: number = this.treeNodes.indexOf(nodeToCollapse); + this.treeNodes.splice(index, 1); + if (nodeToCollapse.hasChildren) { + this.treeNodes + .filter((node: T) => nodeToCollapse.id === node.parentId) + .forEach((child: T) => this.collapseInnerNode(child)); + } + } +} diff --git a/lib/content-services/src/lib/tree/tree.module.ts b/lib/content-services/src/lib/tree/tree.module.ts new file mode 100644 index 0000000000..329f061e0f --- /dev/null +++ b/lib/content-services/src/lib/tree/tree.module.ts @@ -0,0 +1,40 @@ +/*! + * @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 { CoreModule } from '@alfresco/adf-core'; +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { TranslateModule } from '@ngx-translate/core'; +import { MaterialModule } from '../material.module'; +import { TreeComponent } from './components/tree.component'; + +@NgModule({ + imports: [ + CommonModule, + CoreModule, + MaterialModule, + TranslateModule + ], + declarations: [ + TreeComponent + ], + exports: [ + TreeComponent + ] +}) +export class TreeModule { +} diff --git a/lib/content-services/src/public-api.ts b/lib/content-services/src/public-api.ts index bb1b1a86a5..f9dc600202 100644 --- a/lib/content-services/src/public-api.ts +++ b/lib/content-services/src/public-api.ts @@ -41,6 +41,8 @@ export * from './lib/interfaces/index'; export * from './lib/version-compatibility/index'; export * from './lib/pipes/index'; export * from './lib/common/index'; +export * from './lib/tree/index'; +export * from './lib/category/index'; export * from './lib/search-text/index'; export * from './lib/content.module';