mirror of
https://github.com/Alfresco/alfresco-ng2-components.git
synced 2025-05-12 17:04:57 +00:00
[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
This commit is contained in:
parent
afb22bbc02
commit
52520bb61e
@ -27,6 +27,7 @@
|
|||||||
"CSRF",
|
"CSRF",
|
||||||
"datacolumn",
|
"datacolumn",
|
||||||
"datarow",
|
"datarow",
|
||||||
|
"Datasource",
|
||||||
"datatable",
|
"datatable",
|
||||||
"dateitem",
|
"dateitem",
|
||||||
"datepicker",
|
"datepicker",
|
||||||
|
109
docs/content-services/components/tree.component.md
Normal file
109
docs/content-services/components/tree.component.md
Normal file
@ -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).
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## Basic Usage
|
||||||
|
|
||||||
|
```html
|
||||||
|
<adf-tree
|
||||||
|
[displayName]="'Tree display name'"
|
||||||
|
[loadMoreSuffix]="'subnodes'"
|
||||||
|
[emptyContentTemplate]="emptyContentTemplate"
|
||||||
|
[nodeActionsMenuTemplate]="nodeActionsMenuTemplate"
|
||||||
|
(paginationChanged)="onPaginationChanged($event)">
|
||||||
|
</adf-tree>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 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)`<PaginationModel>` | 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<TreeNode> {
|
||||||
|
...
|
||||||
|
public getSubNodes(parentNodeId: string, skipCount?: number, maxItems?: number): Observable<TreeResponse<TreeNode>> {
|
||||||
|
...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
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
|
||||||
|
<adf-tree
|
||||||
|
[displayName]="'Tree display name'"
|
||||||
|
[loadMoreSuffix]="'subnodes'"
|
||||||
|
[selectableNodes]="true"
|
||||||
|
[emptyContentTemplate]="emptyContentTemplate"
|
||||||
|
[nodeActionsMenuTemplate]="nodeActionsMenuTemplate"
|
||||||
|
(paginationChanged)="onPaginationChanged($event)">
|
||||||
|
</adf-tree>
|
||||||
|
```
|
||||||
|
|
||||||
|
Next inside your component get the `TreeComponent`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
@ViewChild(TreeComponent)
|
||||||
|
public treeComponent: TreeComponent<TreeNode>;
|
||||||
|
```
|
||||||
|
|
||||||
|
and listen to selection changes.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
this.treeComponent.treeNodesSelection.changed.subscribe(
|
||||||
|
(selectionChange: SelectionChange<TreeNode>) => {
|
||||||
|
this.onTreeSelectionChange(selectionChange);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
```
|
@ -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<CategoryNode>`](../../../lib/content-services/src/lib/tree/models/tree-response.interface.ts)`>`<br/>
|
||||||
|
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<CategoryNode>`](../../../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.
|
43
docs/content-services/services/category.service.md
Normal file
43
docs/content-services/services/category.service.md
Normal file
@ -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)`>`<br/>
|
||||||
|
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)`>`<br/>
|
||||||
|
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)`>`<br/>
|
||||||
|
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)`<void>`<br/>
|
||||||
|
Deletes category.
|
||||||
|
- _categoryId:_ `string` - Identifier of a category
|
||||||
|
- **Returns** [`Observable`](http://reactivex.io/documentation/observable.html)`<void>` - 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.
|
BIN
docs/docassets/images/tree.png
Normal file
BIN
docs/docassets/images/tree.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 115 KiB |
18
lib/content-services/src/lib/category/index.ts
Normal file
18
lib/content-services/src/lib/category/index.ts
Normal file
@ -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';
|
@ -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<CategoryPaging> {
|
||||||
|
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]}};
|
||||||
|
}
|
||||||
|
}
|
@ -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;
|
||||||
|
}
|
20
lib/content-services/src/lib/category/public-api.ts
Normal file
20
lib/content-services/src/lib/category/public-api.ts
Normal file
@ -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';
|
@ -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<CategoryNode>) => {
|
||||||
|
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<CategoryNode>) => {
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
});
|
@ -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<CategoryNode> {
|
||||||
|
|
||||||
|
constructor(private categoryService: CategoryService) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
public getSubNodes(parentNodeId: string, skipCount?: number, maxItems?: number): Observable<TreeResponse<CategoryNode>> {
|
||||||
|
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<CategoryNode> = {entries: nodesList, pagination: response.list.pagination};
|
||||||
|
return treeResponse;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
@ -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);
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
});
|
@ -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<CategoryPaging>
|
||||||
|
*/
|
||||||
|
getSubcategories(parentCategoryId: string, skipCount?: number, maxItems?: number): Observable<CategoryPaging> {
|
||||||
|
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<CategoryEntry>
|
||||||
|
*/
|
||||||
|
createSubcategory(parentCategoryId: string, payload: CategoryBody): Observable<CategoryEntry> {
|
||||||
|
return from(this.categoriesApi.createSubcategory(parentCategoryId, [payload], {}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates category
|
||||||
|
*
|
||||||
|
* @param categoryId The identifier of a category.
|
||||||
|
* @param payload Updated category body
|
||||||
|
* @return Observable<CategoryEntry>
|
||||||
|
*/
|
||||||
|
updateCategory(categoryId: string, payload: CategoryBody): Observable<CategoryEntry> {
|
||||||
|
return from(this.categoriesApi.updateCategory(categoryId, payload, {}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes category
|
||||||
|
*
|
||||||
|
* @param categoryId The identifier of a category.
|
||||||
|
* @return Observable<void>
|
||||||
|
*/
|
||||||
|
deleteCategory(categoryId: string): Observable<void> {
|
||||||
|
return from(this.categoriesApi.deleteCategory(categoryId));
|
||||||
|
}
|
||||||
|
}
|
@ -46,6 +46,7 @@ import { versionCompatibilityFactory } from './version-compatibility/version-com
|
|||||||
import { VersionCompatibilityService } from './version-compatibility/version-compatibility.service';
|
import { VersionCompatibilityService } from './version-compatibility/version-compatibility.service';
|
||||||
import { ContentPipeModule } from './pipes/content-pipe.module';
|
import { ContentPipeModule } from './pipes/content-pipe.module';
|
||||||
import { NodeCommentsModule } from './node-comments/node-comments.module';
|
import { NodeCommentsModule } from './node-comments/node-comments.module';
|
||||||
|
import { TreeModule } from './tree/tree.module';
|
||||||
import { SearchTextModule } from './search-text/search-text-input.module';
|
import { SearchTextModule } from './search-text/search-text-input.module';
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
@ -77,6 +78,7 @@ import { SearchTextModule } from './search-text/search-text-input.module';
|
|||||||
AspectListModule,
|
AspectListModule,
|
||||||
VersionCompatibilityModule,
|
VersionCompatibilityModule,
|
||||||
NodeCommentsModule,
|
NodeCommentsModule,
|
||||||
|
TreeModule,
|
||||||
SearchTextModule
|
SearchTextModule
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
@ -112,6 +114,7 @@ import { SearchTextModule } from './search-text/search-text-input.module';
|
|||||||
ContentTypeModule,
|
ContentTypeModule,
|
||||||
VersionCompatibilityModule,
|
VersionCompatibilityModule,
|
||||||
NodeCommentsModule,
|
NodeCommentsModule,
|
||||||
|
TreeModule,
|
||||||
SearchTextModule
|
SearchTextModule
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
@ -465,6 +465,9 @@
|
|||||||
"ARIA_LABEL": "Toggle {{ name }}"
|
"ARIA_LABEL": "Toggle {{ name }}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"ADF-TREE": {
|
||||||
|
"LOAD-MORE-BUTTON": "Load more {{ name }}"
|
||||||
|
},
|
||||||
"LIBRARY": {
|
"LIBRARY": {
|
||||||
"DIALOG": {
|
"DIALOG": {
|
||||||
"CREATE_TITLE": "Create Library",
|
"CREATE_TITLE": "Create Library",
|
||||||
|
117
lib/content-services/src/lib/tree/components/tree.component.html
Normal file
117
lib/content-services/src/lib/tree/components/tree.component.html
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
<ng-container *ngIf="(loadingRoot$ | async) === false; else loadingSpinner">
|
||||||
|
<ng-container *ngIf="!this.isEmpty(); else emptyContent">
|
||||||
|
<div class="adf-tree-row adf-tree-header" *ngIf="displayName">
|
||||||
|
<div class="adf-tree-cell adf-tree-cell-header" data-automation-id="tree-header-display-name">
|
||||||
|
<span class="adf-tree-cell-value">
|
||||||
|
{{ displayName | translate }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<mat-tree
|
||||||
|
class="adf-tree-body"
|
||||||
|
[dataSource]="treeService"
|
||||||
|
[treeControl]="treeService.treeControl">
|
||||||
|
<mat-tree-node
|
||||||
|
class="adf-tree-load-more-row"
|
||||||
|
[attr.data-automation-id]="'loadMoreSubnodes_' + node.parentId"
|
||||||
|
*matTreeNodeDef="let node when isLoadMoreNode"
|
||||||
|
matTreeNodePadding>
|
||||||
|
<div class="adf-tree-expand-collapse-container">
|
||||||
|
<button class="adf-tree-load-more-button" mat-icon-button>
|
||||||
|
<mat-progress-spinner
|
||||||
|
color="primary"
|
||||||
|
mode="indeterminate"
|
||||||
|
[diameter]="24"
|
||||||
|
*ngIf="node.isLoading; else loadMoreIcon">
|
||||||
|
</mat-progress-spinner>
|
||||||
|
<ng-template #loadMoreIcon>
|
||||||
|
<adf-icon
|
||||||
|
[value]="'chevron_right'"
|
||||||
|
(click)="loadMoreSubnodes(node)">
|
||||||
|
</adf-icon>
|
||||||
|
</ng-template>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="adf-tree-cell">
|
||||||
|
<span class="adf-tree-cell-value">
|
||||||
|
{{ 'ADF-TREE.LOAD-MORE-BUTTON' | translate: { name: loadMoreSuffix } }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</mat-tree-node>
|
||||||
|
<mat-tree-node
|
||||||
|
class="adf-tree-row"
|
||||||
|
[attr.data-automation-id]="'node_' + node.id"
|
||||||
|
*matTreeNodeDef="let node"
|
||||||
|
matTreeNodePadding>
|
||||||
|
<div class="adf-tree-expand-collapse-container">
|
||||||
|
<button *ngIf="node.hasChildren"
|
||||||
|
class="adf-tree-expand-collapse-button"
|
||||||
|
mat-icon-button
|
||||||
|
matTreeNodeToggle>
|
||||||
|
<mat-progress-spinner
|
||||||
|
color="primary"
|
||||||
|
mode="indeterminate"
|
||||||
|
[diameter]="24"
|
||||||
|
*ngIf="node.isLoading; else expandCollapseIcon">
|
||||||
|
</mat-progress-spinner>
|
||||||
|
<ng-template #expandCollapseIcon>
|
||||||
|
<adf-icon
|
||||||
|
[value]="expandCollapseIconValue(node)"
|
||||||
|
(click)="expandCollapseNode(node)">
|
||||||
|
</adf-icon>
|
||||||
|
</ng-template>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<ng-container *ngIf="selectableNodes">
|
||||||
|
<mat-checkbox *ngIf="node.hasChildren; else noChildrenNodeCheckbox"
|
||||||
|
color="primary"
|
||||||
|
[id]="node.id"
|
||||||
|
[checked]="descendantsAllSelected(node)"
|
||||||
|
[indeterminate]="descendantsPartiallySelected(node)"
|
||||||
|
(change)="onNodeSelected(node)">
|
||||||
|
</mat-checkbox>
|
||||||
|
<ng-template #noChildrenNodeCheckbox>
|
||||||
|
<mat-checkbox
|
||||||
|
color="primary"
|
||||||
|
[id]="node.id"
|
||||||
|
[checked]="treeNodesSelection.isSelected(node)"
|
||||||
|
(change)="onNodeSelected(node)">
|
||||||
|
</mat-checkbox>
|
||||||
|
</ng-template>
|
||||||
|
</ng-container>
|
||||||
|
<div class="adf-tree-cell">
|
||||||
|
<span class="adf-tree-cell-value">
|
||||||
|
{{ node.nodeName }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="adf-tree-actions">
|
||||||
|
<button mat-icon-button
|
||||||
|
[matMenuTriggerFor]="menu"
|
||||||
|
[attr.id]="'action_menu_right_' + node.id">
|
||||||
|
<mat-icon>more_vert</mat-icon>
|
||||||
|
</button>
|
||||||
|
<mat-menu #menu="matMenu">
|
||||||
|
<ng-template
|
||||||
|
[ngTemplateOutlet]="nodeActionsMenuTemplate"
|
||||||
|
[ngTemplateOutletContext]="{ node: node }">
|
||||||
|
</ng-template>
|
||||||
|
</mat-menu>
|
||||||
|
</div>
|
||||||
|
</mat-tree-node>
|
||||||
|
</mat-tree>
|
||||||
|
</ng-container>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-template #emptyContent>
|
||||||
|
<ng-template [ngTemplateOutlet]="emptyContentTemplate">
|
||||||
|
</ng-template>
|
||||||
|
</ng-template>
|
||||||
|
|
||||||
|
<ng-template #loadingSpinner>
|
||||||
|
<div class="adf-tree-loading-spinner-container">
|
||||||
|
<mat-progress-spinner
|
||||||
|
color="primary"
|
||||||
|
mode="indeterminate">
|
||||||
|
</mat-progress-spinner>
|
||||||
|
</div>
|
||||||
|
</ng-template>
|
@ -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;
|
||||||
|
}
|
@ -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<TreeComponent<TreeNode>>;
|
||||||
|
let component: TreeComponent<TreeNode>;
|
||||||
|
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<TreeNode>) => {
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
254
lib/content-services/src/lib/tree/components/tree.component.ts
Normal file
254
lib/content-services/src/lib/tree/components/tree.component.ts
Normal file
@ -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<T extends TreeNode> implements OnInit {
|
||||||
|
|
||||||
|
/** TemplateRef to provide empty template when no nodes are loaded */
|
||||||
|
@Input()
|
||||||
|
public emptyContentTemplate: TemplateRef<any>;
|
||||||
|
|
||||||
|
/** TemplateRef to provide context menu items for context menu displayed on each row*/
|
||||||
|
@Input()
|
||||||
|
public nodeActionsMenuTemplate: TemplateRef<any>;
|
||||||
|
|
||||||
|
/** 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<PaginationModel> = new EventEmitter();
|
||||||
|
|
||||||
|
@ViewChildren(MatCheckbox)
|
||||||
|
public nodeCheckboxes: QueryList<MatCheckbox>;
|
||||||
|
|
||||||
|
private loadingRootSource = new BehaviorSubject<boolean>(false);
|
||||||
|
public loadingRoot$: Observable<boolean>;
|
||||||
|
public treeNodesSelection = new SelectionModel<T>(true, [], true, (node1: T, node2: T) => node1.id === node2.id);
|
||||||
|
|
||||||
|
constructor(public treeService: TreeService<T>,
|
||||||
|
private userPreferenceService: UserPreferencesService) {}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.loadingRoot$ = this.loadingRootSource.asObservable();
|
||||||
|
this.refreshTree(0, this.userPreferenceService.paginationSize);
|
||||||
|
this.treeNodesSelection.changed.subscribe((selectionChange: SelectionChange<T>) => {
|
||||||
|
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<T>) => {
|
||||||
|
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<T>) => {
|
||||||
|
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<T>) => {
|
||||||
|
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<T>): 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;
|
||||||
|
}
|
||||||
|
}
|
18
lib/content-services/src/lib/tree/index.ts
Normal file
18
lib/content-services/src/lib/tree/index.ts
Normal file
@ -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';
|
198
lib/content-services/src/lib/tree/mock/tree-node.mock.ts
Normal file
198
lib/content-services/src/lib/tree/mock/tree-node.mock.ts
Normal file
@ -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
|
||||||
|
}
|
||||||
|
];
|
@ -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<TreeNode> {
|
||||||
|
public getSubNodes(parentNodeId: string, skipCount?: number, maxItems?: number): Observable<TreeResponse<TreeNode>> {
|
||||||
|
if (parentNodeId) {
|
||||||
|
return of({pagination: {skipCount, maxItems}, entries: Array.from(treeNodesMockExpanded)});
|
||||||
|
}
|
||||||
|
return of();
|
||||||
|
}
|
||||||
|
}
|
@ -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;
|
||||||
|
}
|
@ -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<T extends TreeNode> {
|
||||||
|
pagination: PaginationModel;
|
||||||
|
entries: T[];
|
||||||
|
}
|
22
lib/content-services/src/lib/tree/public-api.ts
Normal file
22
lib/content-services/src/lib/tree/public-api.ts
Normal file
@ -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';
|
148
lib/content-services/src/lib/tree/services/tree.service.spec.ts
Normal file
148
lib/content-services/src/lib/tree/services/tree.service.spec.ts
Normal file
@ -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<TreeNode>;
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
148
lib/content-services/src/lib/tree/services/tree.service.ts
Normal file
148
lib/content-services/src/lib/tree/services/tree.service.ts
Normal file
@ -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<T extends TreeNode> extends DataSource<T> {
|
||||||
|
public readonly treeControl: FlatTreeControl<T>;
|
||||||
|
public treeNodesSource = new BehaviorSubject<T[]>([]);
|
||||||
|
|
||||||
|
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<T>(node => node.level, node => node.hasChildren);
|
||||||
|
this.treeNodes = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public abstract getSubNodes(parentNodeId: string, skipCount?: number, maxItems?: number): Observable<TreeResponse<T>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<T[]> {
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
40
lib/content-services/src/lib/tree/tree.module.ts
Normal file
40
lib/content-services/src/lib/tree/tree.module.ts
Normal file
@ -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 {
|
||||||
|
}
|
@ -41,6 +41,8 @@ export * from './lib/interfaces/index';
|
|||||||
export * from './lib/version-compatibility/index';
|
export * from './lib/version-compatibility/index';
|
||||||
export * from './lib/pipes/index';
|
export * from './lib/pipes/index';
|
||||||
export * from './lib/common/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/search-text/index';
|
||||||
|
|
||||||
export * from './lib/content.module';
|
export * from './lib/content.module';
|
||||||
|
Loading…
x
Reference in New Issue
Block a user