Skip to content

feat(tree): support array of data as children in nested tree #10886

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Jun 28, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/cdk/tree/control/base-tree-control.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ export abstract class BaseTreeControl<T> implements TreeControl<T> {
isExpandable: (dataNode: T) => boolean;

/** Gets a stream that emits whenever the given data node's children change. */
getChildren: (dataNode: T) => Observable<T[]>;
getChildren: (dataNode: T) => (Observable<T[]> | T[]);

/** Toggles one single data node's expanded/collapsed state. */
toggle(dataNode: T): void {
Expand Down
89 changes: 89 additions & 0 deletions src/cdk/tree/control/nested-tree-control.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,95 @@ describe('CdkNestedTreeControl', () => {
expect(treeControl.expansionModel.selected.length)
.toBe(totalNumber, `Expect ${totalNumber} expanded nodes`);
});

describe('with children array', () => {
let getStaticChildren = (node: TestData) => node.children;

beforeEach(() => {
treeControl = new NestedTreeControl<TestData>(getStaticChildren);
});

it('should be able to expand and collapse dataNodes', () => {
const nodes = generateData(10, 4);
const node = nodes[1];
const sixthNode = nodes[5];
treeControl.dataNodes = nodes;

treeControl.expand(node);


expect(treeControl.isExpanded(node)).toBeTruthy('Expect second node to be expanded');
expect(treeControl.expansionModel.selected)
.toContain(node, 'Expect second node in expansionModel');
expect(treeControl.expansionModel.selected.length)
.toBe(1, 'Expect only second node in expansionModel');

treeControl.toggle(sixthNode);

expect(treeControl.isExpanded(node)).toBeTruthy('Expect second node to stay expanded');
expect(treeControl.expansionModel.selected)
.toContain(sixthNode, 'Expect sixth node in expansionModel');
expect(treeControl.expansionModel.selected)
.toContain(node, 'Expect second node in expansionModel');
expect(treeControl.expansionModel.selected.length)
.toBe(2, 'Expect two dataNodes in expansionModel');

treeControl.collapse(node);

expect(treeControl.isExpanded(node)).toBeFalsy('Expect second node to be collapsed');
expect(treeControl.expansionModel.selected.length)
.toBe(1, 'Expect one node in expansionModel');
expect(treeControl.isExpanded(sixthNode)).toBeTruthy('Expect sixth node to stay expanded');
expect(treeControl.expansionModel.selected)
.toContain(sixthNode, 'Expect sixth node in expansionModel');
});

it('should toggle descendants correctly', () => {
const numNodes = 10;
const numChildren = 4;
const numGrandChildren = 2;
const nodes = generateData(numNodes, numChildren, numGrandChildren);
treeControl.dataNodes = nodes;

treeControl.expandDescendants(nodes[1]);

const expandedNodesNum = 1 + numChildren + numChildren * numGrandChildren;
expect(treeControl.expansionModel.selected.length)
.toBe(expandedNodesNum, `Expect expanded ${expandedNodesNum} nodes`);

expect(treeControl.isExpanded(nodes[1])).toBeTruthy('Expect second node to be expanded');
for (let i = 0; i < numChildren; i++) {

expect(treeControl.isExpanded(nodes[1].children[i]))
.toBeTruthy(`Expect second node's children to be expanded`);
for (let j = 0; j < numGrandChildren; j++) {
expect(treeControl.isExpanded(nodes[1].children[i].children[j]))
.toBeTruthy(`Expect second node grand children to be expanded`);
}
}
});

it('should be able to expand/collapse all the dataNodes', () => {
const numNodes = 10;
const numChildren = 4;
const numGrandChildren = 2;
const nodes = generateData(numNodes, numChildren, numGrandChildren);
treeControl.dataNodes = nodes;

treeControl.expandDescendants(nodes[1]);

treeControl.collapseAll();

expect(treeControl.expansionModel.selected.length).toBe(0, `Expect no expanded nodes`);

treeControl.expandAll();

const totalNumber = numNodes + (numNodes * numChildren)
+ (numNodes * numChildren * numGrandChildren);
expect(treeControl.expansionModel.selected.length)
.toBe(totalNumber, `Expect ${totalNumber} expanded nodes`);
});
});
});
});

Expand Down
13 changes: 8 additions & 5 deletions src/cdk/tree/control/nested-tree-control.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {BaseTreeControl} from './base-tree-control';
export class NestedTreeControl<T> extends BaseTreeControl<T> {

/** Construct with nested tree function getChildren. */
constructor(public getChildren: (dataNode: T) => Observable<T[]>) {
constructor(public getChildren: (dataNode: T) => (Observable<T[]> | T[])) {
super();
}

Expand Down Expand Up @@ -41,10 +41,13 @@ export class NestedTreeControl<T> extends BaseTreeControl<T> {
/** A helper function to get descendants recursively. */
protected _getDescendants(descendants: T[], dataNode: T): void {
descendants.push(dataNode);
this.getChildren(dataNode).pipe(take(1)).subscribe(children => {
if (children && children.length > 0) {
const childrenNodes = this.getChildren(dataNode);
if (Array.isArray(childrenNodes)) {
childrenNodes.forEach((child: T) => this._getDescendants(descendants, child));
} else if (childrenNodes instanceof Observable) {
childrenNodes.pipe(take(1)).subscribe(children => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since this is being done recursively and childrenNodes can be async, couldn't this potentially lead to some race conditions?

children.forEach((child: T) => this._getDescendants(descendants, child));
}
});
});
}
}
}
2 changes: 1 addition & 1 deletion src/cdk/tree/control/tree-control.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,5 +60,5 @@ export interface TreeControl<T> {
readonly isExpandable: (dataNode: T) => boolean;

/** Gets a stream that emits whenever the given data node's children change. */
readonly getChildren: (dataNode: T) => Observable<T[]>;
readonly getChildren: (dataNode: T) => Observable<T[]> | T[];
}
18 changes: 12 additions & 6 deletions src/cdk/tree/nested-node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
OnDestroy,
QueryList,
} from '@angular/core';
import {Observable} from 'rxjs';
import {takeUntil} from 'rxjs/operators';

import {CdkTree, CdkTreeNode} from './tree';
Expand Down Expand Up @@ -73,11 +74,13 @@ export class CdkNestedTreeNode<T> extends CdkTreeNode<T> implements AfterContent
if (!this._tree.treeControl.getChildren) {
throw getTreeControlFunctionsMissingError();
}
this._tree.treeControl.getChildren(this.data).pipe(takeUntil(this._destroyed))
.subscribe(result => {
this._children = result;
this.updateChildrenNodes();
});
const childrenNodes = this._tree.treeControl.getChildren(this.data);
if (Array.isArray(childrenNodes)) {
this.updateChildrenNodes(childrenNodes as T[]);
} else if (childrenNodes instanceof Observable) {
childrenNodes.pipe(takeUntil(this._destroyed))
.subscribe(result => this.updateChildrenNodes(result));
}
this.nodeOutlet.changes.pipe(takeUntil(this._destroyed))
.subscribe(() => this.updateChildrenNodes());
}
Expand All @@ -88,7 +91,10 @@ export class CdkNestedTreeNode<T> extends CdkTreeNode<T> implements AfterContent
}

/** Add children dataNodes to the NodeOutlet */
protected updateChildrenNodes(): void {
protected updateChildrenNodes(children?: T[]): void {
if (children) {
this._children = children;
}
if (this.nodeOutlet.length && this._children) {
const viewContainer = this.nodeOutlet.first.viewContainer;
this._tree.renderNodeChanges(this._children, this._dataDiffer, viewContainer, this._data);
Expand Down
57 changes: 57 additions & 0 deletions src/cdk/tree/tree.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -478,6 +478,33 @@ describe('CdkTree', () => {
});
});

describe('with static children', () => {
let fixture: ComponentFixture<StaticNestedCdkTreeApp>;
let component: StaticNestedCdkTreeApp;

beforeEach(() => {
configureCdkTreeTestingModule([StaticNestedCdkTreeApp]);
fixture = TestBed.createComponent(StaticNestedCdkTreeApp);

component = fixture.componentInstance;
dataSource = component.dataSource as FakeDataSource;
tree = component.tree;
treeElement = fixture.nativeElement.querySelector('cdk-tree');

fixture.detectChanges();
});

it('with the right data', () => {
expectNestedTreeToMatch(treeElement,
[`topping_1 - cheese_1 + base_1`],
[`topping_2 - cheese_2 + base_2`],
[_, `topping_4 - cheese_4 + base_4`],
[_, _, `topping_5 - cheese_5 + base_5`],
[_, _, `topping_6 - cheese_6 + base_6`],
[`topping_3 - cheese_3 + base_3`]);
});
});

describe('with when node', () => {
let fixture: ComponentFixture<WhenNodeNestedCdkTreeApp>;
let component: WhenNodeNestedCdkTreeApp;
Expand Down Expand Up @@ -1073,6 +1100,36 @@ class NestedCdkTreeApp {
@ViewChild(CdkTree) tree: CdkTree<TestData>;
}

@Component({
template: `
<cdk-tree [dataSource]="dataSource" [treeControl]="treeControl">
<cdk-nested-tree-node *cdkTreeNodeDef="let node" class="customNodeClass">
{{node.pizzaTopping}} - {{node.pizzaCheese}} + {{node.pizzaBase}}
<ng-template cdkTreeNodeOutlet></ng-template>
</cdk-nested-tree-node>
</cdk-tree>
`
})
class StaticNestedCdkTreeApp {
getChildren = (node: TestData) => node.children;

treeControl: TreeControl<TestData> = new NestedTreeControl(this.getChildren);

dataSource: FakeDataSource;

@ViewChild(CdkTree) tree: CdkTree<TestData>;

constructor() {
const dataSource = new FakeDataSource(this.treeControl);
const data = dataSource.data;
const child = dataSource.addChild(data[1], false);
dataSource.addChild(child, false);
dataSource.addChild(child, false);

this.dataSource = dataSource;
}
}

@Component({
template: `
<cdk-tree [dataSource]="dataSource" [treeControl]="treeControl">
Expand Down
18 changes: 12 additions & 6 deletions src/cdk/tree/tree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,6 @@ import {
getTreeNoValidDataSourceError
} from './tree-errors';


/**
* CDK tree component that connects with a data source to retrieve data of type `T` and renders
* dataNodes with hierarchy. Updates the dataNodes when new data is provided by the data source.
Expand Down Expand Up @@ -338,17 +337,24 @@ export class CdkTreeNode<T> implements FocusableOption, OnDestroy {
this._elementRef.nativeElement.focus();
}

private _setRoleFromData(): void {
protected _setRoleFromData(): void {
if (this._tree.treeControl.isExpandable) {
this.role = this._tree.treeControl.isExpandable(this._data) ? 'group' : 'treeitem';
} else {
if (!this._tree.treeControl.getChildren) {
throw getTreeControlFunctionsMissingError();
}
this._tree.treeControl.getChildren(this._data).pipe(takeUntil(this._destroyed))
.subscribe(children => {
this.role = children && children.length ? 'group' : 'treeitem';
});
const childrenNodes = this._tree.treeControl.getChildren(this._data);
if (Array.isArray(childrenNodes)) {
this._setRoleFromChildren(childrenNodes as T[]);
} else if (childrenNodes instanceof Observable) {
childrenNodes.pipe(takeUntil(this._destroyed))
.subscribe(children => this._setRoleFromChildren(children));
}
}
}

protected _setRoleFromChildren(children: T[]) {
this.role = children && children.length ? 'group' : 'treeitem';
}
}
3 changes: 1 addition & 2 deletions src/demo-app/tree/tree-demo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ import {
MatTreeFlattener,
MatTreeNestedDataSource
} from '@angular/material/tree';
import {Observable, of as ofObservable} from 'rxjs';
import {FileDatabase, FileFlatNode, FileNode} from './file-database';


Expand Down Expand Up @@ -69,7 +68,7 @@ export class TreeDemo {

isExpandable = (node: FileFlatNode) => { return node.expandable; };

getChildren = (node: FileNode): Observable<FileNode[]> => { return ofObservable(node.children); };
getChildren = (node: FileNode): FileNode[] => { return node.children; };

hasChild = (_: number, _nodeData: FileFlatNode) => { return _nodeData.expandable; };

Expand Down
24 changes: 17 additions & 7 deletions src/lib/tree/data-source/flat-data-source.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,25 +50,35 @@ export class MatTreeFlattener<T, F> {
constructor(public transformFunction: (node: T, level: number) => F,
public getLevel: (node: F) => number,
public isExpandable: (node: F) => boolean,
public getChildren: (node: T) => Observable<T[]>) {}
public getChildren: (node: T) => Observable<T[]> | T[]) {}

_flattenNode(node: T, level: number,
resultNodes: F[], parentMap: boolean[]): F[] {
const flatNode = this.transformFunction(node, level);
resultNodes.push(flatNode);

if (this.isExpandable(flatNode)) {
this.getChildren(node).pipe(take(1)).subscribe(children => {
children.forEach((child, index) => {
let childParentMap: boolean[] = parentMap.slice();
childParentMap.push(index != children.length - 1);
this._flattenNode(child, level + 1, resultNodes, childParentMap);
const childrenNodes = this.getChildren(node);
if (Array.isArray(childrenNodes)) {
this._flattenChildren(childrenNodes, level, resultNodes, parentMap);
} else {
childrenNodes.pipe(take(1)).subscribe(children => {
this._flattenChildren(children, level, resultNodes, parentMap);
});
});
}
}
return resultNodes;
}

_flattenChildren(children: T[], level: number,
resultNodes: F[], parentMap: boolean[]): void {
children.forEach((child, index) => {
let childParentMap: boolean[] = parentMap.slice();
childParentMap.push(index != children.length - 1);
this._flattenNode(child, level + 1, resultNodes, childParentMap);
});
}

/**
* Flatten a list of node type T to flattened version of node F.
* Please note that type T may be nested, and the length of `structuredData` may be different
Expand Down
6 changes: 4 additions & 2 deletions src/lib/tree/tree.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,10 @@ rendered data (such as expand/collapse) should be propagated through the table's

The `TreeControl` controls the expand/collapse state of tree nodes. Users can expand/collapse a tree
node recursively through tree control. For nested tree node, `getChildren` function need to pass to
the `NestedTreeControl` to make it work recursively. For flattened tree node, `getLevel` and
`isExpandable` functions need to pass to the `FlatTreeControl` to make it work recursively.
the `NestedTreeControl` to make it work recursively. The `getChildren` function may return an
observable of children for a given node, or an array of children.
For flattened tree node, `getLevel` and `isExpandable` functions need to pass to the
`FlatTreeControl` to make it work recursively.

### Toggle

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import {SelectionModel} from '@angular/cdk/collections';
import {FlatTreeControl} from '@angular/cdk/tree';
import {Component, Injectable} from '@angular/core';
import {MatTreeFlatDataSource, MatTreeFlattener} from '@angular/material/tree';
import {BehaviorSubject, Observable, of as observableOf} from 'rxjs';
import {BehaviorSubject} from 'rxjs';

/**
* Node for to-do item
Expand Down Expand Up @@ -146,7 +146,7 @@ export class TreeChecklistExample {

isExpandable = (node: TodoItemFlatNode) => node.expandable;

getChildren = (node: TodoItemNode): Observable<TodoItemNode[]> => observableOf(node.children);
getChildren = (node: TodoItemNode): TodoItemNode[] => node.children;

hasChild = (_: number, _nodeData: TodoItemFlatNode) => _nodeData.expandable;

Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import {NestedTreeControl} from '@angular/cdk/tree';
import {Component, Injectable} from '@angular/core';
import {MatTreeNestedDataSource} from '@angular/material/tree';
import {BehaviorSubject, of as observableOf} from 'rxjs';
import {BehaviorSubject} from 'rxjs';

/**
* Json node data with nested structure. Each node has a filename and a value or a list of children
Expand Down Expand Up @@ -125,5 +125,5 @@ export class TreeNestedOverviewExample {

hasNestedChild = (_: number, nodeData: FileNode) => !nodeData.type;

private _getChildren = (node: FileNode) => observableOf(node.children);
private _getChildren = (node: FileNode) => node.children;
}