Skip to content

Commit 04d1588

Browse files
authored
feat(cdk/tree): support optional trackBy in FlatTreeControl (#18708)
This is similar to trackBy in *ngFor and is important for those who use the redux pattern (i.e. NGRX) since node references are frequently replaced with modifies instances from the Store (as opposed to changing the state within the node objects themselves)
1 parent 4e6083b commit 04d1588

File tree

5 files changed

+75
-28
lines changed

5 files changed

+75
-28
lines changed

src/cdk/tree/control/base-tree-control.ts

Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {Observable} from 'rxjs';
1010
import {TreeControl} from './tree-control';
1111

1212
/** Base tree control. It has basic toggle/expand/collapse operations on a single data node. */
13-
export abstract class BaseTreeControl<T> implements TreeControl<T> {
13+
export abstract class BaseTreeControl<T, K = T> implements TreeControl<T, K> {
1414

1515
/** Gets a list of descendent data nodes of a subtree rooted at given data node recursively. */
1616
abstract getDescendants(dataNode: T): T[];
@@ -22,7 +22,15 @@ export abstract class BaseTreeControl<T> implements TreeControl<T> {
2222
dataNodes: T[];
2323

2424
/** A selection model with multi-selection to track expansion status. */
25-
expansionModel: SelectionModel<T> = new SelectionModel<T>(true);
25+
expansionModel: SelectionModel<K> = new SelectionModel<K>(true);
26+
27+
/**
28+
* Returns the identifier by which a dataNode should be tracked, should its
29+
* reference change.
30+
*
31+
* Similar to trackBy for *ngFor
32+
*/
33+
trackBy?: (dataNode: T) => K;
2634

2735
/** Get depth of a given data node, return the level number. This is for flat tree node. */
2836
getLevel: (dataNode: T) => number;
@@ -38,29 +46,29 @@ export abstract class BaseTreeControl<T> implements TreeControl<T> {
3846

3947
/** Toggles one single data node's expanded/collapsed state. */
4048
toggle(dataNode: T): void {
41-
this.expansionModel.toggle(dataNode);
49+
this.expansionModel.toggle(this._trackByValue(dataNode));
4250
}
4351

4452
/** Expands one single data node. */
4553
expand(dataNode: T): void {
46-
this.expansionModel.select(dataNode);
54+
this.expansionModel.select(this._trackByValue(dataNode));
4755
}
4856

4957
/** Collapses one single data node. */
5058
collapse(dataNode: T): void {
51-
this.expansionModel.deselect(dataNode);
59+
this.expansionModel.deselect(this._trackByValue(dataNode));
5260
}
5361

5462
/** Whether a given data node is expanded or not. Returns true if the data node is expanded. */
5563
isExpanded(dataNode: T): boolean {
56-
return this.expansionModel.isSelected(dataNode);
64+
return this.expansionModel.isSelected(this._trackByValue(dataNode));
5765
}
5866

5967
/** Toggles a subtree rooted at `node` recursively. */
6068
toggleDescendants(dataNode: T): void {
61-
this.expansionModel.isSelected(dataNode)
62-
? this.collapseDescendants(dataNode)
63-
: this.expandDescendants(dataNode);
69+
this.expansionModel.isSelected(this._trackByValue(dataNode)) ?
70+
this.collapseDescendants(dataNode) :
71+
this.expandDescendants(dataNode);
6472
}
6573

6674
/** Collapse all dataNodes in the tree. */
@@ -72,13 +80,17 @@ export abstract class BaseTreeControl<T> implements TreeControl<T> {
7280
expandDescendants(dataNode: T): void {
7381
let toBeProcessed = [dataNode];
7482
toBeProcessed.push(...this.getDescendants(dataNode));
75-
this.expansionModel.select(...toBeProcessed);
83+
this.expansionModel.select(...toBeProcessed.map(value => this._trackByValue(value)));
7684
}
7785

7886
/** Collapses a subtree rooted at given data node recursively. */
7987
collapseDescendants(dataNode: T): void {
8088
let toBeProcessed = [dataNode];
8189
toBeProcessed.push(...this.getDescendants(dataNode));
82-
this.expansionModel.deselect(...toBeProcessed);
90+
this.expansionModel.deselect(...toBeProcessed.map(value => this._trackByValue(value)));
91+
}
92+
93+
protected _trackByValue(value: T|K): K {
94+
return this.trackBy ? this.trackBy(value as T) : value as K;
8395
}
8496
}

src/cdk/tree/control/flat-tree-control.spec.ts

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,16 @@
11
import {FlatTreeControl} from './flat-tree-control';
22

33
describe('CdkFlatTreeControl', () => {
4-
let treeControl: FlatTreeControl<TestData>;
54
let getLevel = (node: TestData) => node.level;
65
let isExpandable = (node: TestData) => node.children && node.children.length > 0;
76

8-
beforeEach(() => {
9-
treeControl = new FlatTreeControl<TestData>(getLevel, isExpandable);
10-
});
11-
127
describe('base tree control actions', () => {
8+
let treeControl: FlatTreeControl<TestData>;
9+
10+
beforeEach(() => {
11+
treeControl = new FlatTreeControl<TestData>(getLevel, isExpandable);
12+
});
13+
1314
it('should be able to expand and collapse dataNodes', () => {
1415
const nodes = generateData(10, 4);
1516
const secondNode = nodes[1];
@@ -139,6 +140,23 @@ describe('CdkFlatTreeControl', () => {
139140
.toBe(totalNumber, `Expect ${totalNumber} expanded nodes`);
140141
});
141142
});
143+
144+
it('maintains node expansion state based on trackBy function, if provided', () => {
145+
const treeControl = new FlatTreeControl<TestData, string>(getLevel, isExpandable);
146+
147+
const nodes = generateData(2, 2);
148+
const secondNode = nodes[1];
149+
treeControl.dataNodes = nodes;
150+
treeControl.trackBy = (node: TestData) => `${node.a} ${node.b} ${node.c}`;
151+
152+
treeControl.expand(secondNode);
153+
expect(treeControl.isExpanded(secondNode)).toBeTruthy('Expect second node to be expanded');
154+
155+
// Replace the second node with a brand new instance with same hash
156+
nodes[1] = new TestData(
157+
secondNode.a, secondNode.b, secondNode.c, secondNode.level, secondNode.children);
158+
expect(treeControl.isExpanded(nodes[1])).toBeTruthy('Expect second node to still be expanded');
159+
});
142160
});
143161

144162
export class TestData {

src/cdk/tree/control/flat-tree-control.ts

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,23 @@
88

99
import {BaseTreeControl} from './base-tree-control';
1010

11+
/** Optional set of configuration that can be provided to the FlatTreeControl. */
12+
export interface FlatTreeControlOptions<T, K> {
13+
trackBy?: (dataNode: T) => K;
14+
}
15+
1116
/** Flat tree control. Able to expand/collapse a subtree recursively for flattened tree. */
12-
export class FlatTreeControl<T> extends BaseTreeControl<T> {
17+
export class FlatTreeControl<T, K = T> extends BaseTreeControl<T, K> {
1318

1419
/** Construct with flat tree data node functions getLevel and isExpandable. */
15-
constructor(public getLevel: (dataNode: T) => number,
16-
public isExpandable: (dataNode: T) => boolean) {
20+
constructor(
21+
public getLevel: (dataNode: T) => number, public isExpandable: (dataNode: T) => boolean,
22+
public options?: FlatTreeControlOptions<T, K>) {
1723
super();
24+
25+
if (this.options) {
26+
this.trackBy = this.options.trackBy;
27+
}
1828
}
1929

2030
/**
@@ -48,6 +58,6 @@ export class FlatTreeControl<T> extends BaseTreeControl<T> {
4858
* data nodes of the tree.
4959
*/
5060
expandAll(): void {
51-
this.expansionModel.select(...this.dataNodes);
61+
this.expansionModel.select(...this.dataNodes.map(node => this._trackByValue(node)));
5262
}
5363
}

src/cdk/tree/control/tree-control.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,12 @@ import {Observable} from 'rxjs';
1313
* The CDKTree will use this TreeControl to expand/collapse a node.
1414
* User can also use it outside the `<cdk-tree>` to control the expansion status of the tree.
1515
*/
16-
export interface TreeControl<T> {
16+
export interface TreeControl<T, K = T> {
1717
/** The saved tree nodes data for `expandAll` action. */
1818
dataNodes: T[];
1919

2020
/** The expansion model */
21-
expansionModel: SelectionModel<T>;
21+
expansionModel: SelectionModel<K>;
2222

2323
/** Whether the data node is expanded or collapsed. Return true if it's expanded. */
2424
isExpanded(dataNode: T): boolean;

tools/public_api_guard/cdk/tree.d.ts

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
1-
export declare abstract class BaseTreeControl<T> implements TreeControl<T> {
1+
export declare abstract class BaseTreeControl<T, K = T> implements TreeControl<T, K> {
22
dataNodes: T[];
3-
expansionModel: SelectionModel<T>;
3+
expansionModel: SelectionModel<K>;
44
getChildren: (dataNode: T) => (Observable<T[]> | T[] | undefined | null);
55
getLevel: (dataNode: T) => number;
66
isExpandable: (dataNode: T) => boolean;
7+
trackBy?: (dataNode: T) => K;
8+
protected _trackByValue(value: T | K): K;
79
collapse(dataNode: T): void;
810
collapseAll(): void;
911
collapseDescendants(dataNode: T): void;
@@ -136,14 +138,19 @@ export declare class CdkTreeNodeToggle<T> {
136138
static ɵfac: i0.ɵɵFactoryDef<CdkTreeNodeToggle<any>, never>;
137139
}
138140

139-
export declare class FlatTreeControl<T> extends BaseTreeControl<T> {
141+
export declare class FlatTreeControl<T, K = T> extends BaseTreeControl<T, K> {
140142
getLevel: (dataNode: T) => number;
141143
isExpandable: (dataNode: T) => boolean;
142-
constructor(getLevel: (dataNode: T) => number, isExpandable: (dataNode: T) => boolean);
144+
options?: FlatTreeControlOptions<T, K> | undefined;
145+
constructor(getLevel: (dataNode: T) => number, isExpandable: (dataNode: T) => boolean, options?: FlatTreeControlOptions<T, K> | undefined);
143146
expandAll(): void;
144147
getDescendants(dataNode: T): T[];
145148
}
146149

150+
export interface FlatTreeControlOptions<T, K> {
151+
trackBy?: (dataNode: T) => K;
152+
}
153+
147154
export declare function getTreeControlFunctionsMissingError(): Error;
148155

149156
export declare function getTreeControlMissingError(): Error;
@@ -162,9 +169,9 @@ export declare class NestedTreeControl<T> extends BaseTreeControl<T> {
162169
getDescendants(dataNode: T): T[];
163170
}
164171

165-
export interface TreeControl<T> {
172+
export interface TreeControl<T, K = T> {
166173
dataNodes: T[];
167-
expansionModel: SelectionModel<T>;
174+
expansionModel: SelectionModel<K>;
168175
readonly getChildren: (dataNode: T) => Observable<T[]> | T[] | undefined | null;
169176
readonly getLevel: (dataNode: T) => number;
170177
readonly isExpandable: (dataNode: T) => boolean;

0 commit comments

Comments
 (0)