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 0000000000..22c6f2e67d Binary files /dev/null and b/docs/docassets/images/tree.png differ 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';