Skip to content

Instantly share code, notes, and snippets.

@Bludwarf
Last active May 25, 2021 08:32
Show Gist options
  • Save Bludwarf/17a20ebe5e38aa34e8bfe5a6752e50ea to your computer and use it in GitHub Desktop.
Save Bludwarf/17a20ebe5e38aa34e8bfe5a6752e50ea to your computer and use it in GitHub Desktop.
Angular Material Tree Dynamic based on https://stackblitz.com/edit/material-tree-dynamic
import {Injectable} from '@angular/core';
import {FlatTreeControl} from '@angular/cdk/tree';
import {CollectionViewer, SelectionChange} from '@angular/cdk/collections';
import {BehaviorSubject, merge, Observable} from 'rxjs';
import {map} from 'rxjs/operators';
/** Flat node with expandable and level information */
export class DynamicFlatNode<T> {
constructor(public item: T, public level: number = 1, public hasChildren: boolean = false, public isLoading: boolean = false) {}
}
/**
* Database for dynamic data. When expanding a node in the tree, the data source will need to fetch
* the descendants data from the database.
*/
export abstract class DynamicDatabase<T> {
/** Initial data from database */
initialData(): DynamicFlatNode<T>[] {
return this.getRootLevelItems().map(item => new DynamicFlatNode<T>(item, 0, true));
}
abstract getRootLevelItems(): T[];
abstract getChildren(item: T): Promise<T[] | undefined>;
abstract hasChildren(item: T): Promise<boolean>;
}
/**
* File database, it can build a tree structured Json object from string.
* Each node in Json object represents a file or a directory. For a file, it has filename and type.
* For a directory, it has filename and children (a list of files or directories).
* The input will be a json object string, and the output is a list of `FileNode` with nested
* structure.
*/
@Injectable()
export class DynamicDataSource<T> {
dataChange: BehaviorSubject<DynamicFlatNode<T>[]> = new BehaviorSubject<DynamicFlatNode<T>[]>([]);
/** children cache */
children = new Map<T, T[]>();
get data(): DynamicFlatNode<T>[] { return this.dataChange.value; }
set data(value: DynamicFlatNode<T>[]) {
this.treeControl.dataNodes = value;
this.dataChange.next(value);
}
constructor(private treeControl: DynamicFlatTreeControl<T>,
private database: DynamicDatabase<T>) {}
connect(collectionViewer: CollectionViewer): Observable<DynamicFlatNode<T>[]> {
this.treeControl.expansionModel.changed.subscribe(change => {
if ((change as SelectionChange<DynamicFlatNode<T>>).added ||
(change as SelectionChange<DynamicFlatNode<T>>).removed) {
this.handleTreeControl(change as SelectionChange<DynamicFlatNode<T>>);
}
});
return merge(collectionViewer.viewChange, this.dataChange).pipe(map(() => this.data));
}
/** Handle expand/collapse behaviors */
handleTreeControl(change: SelectionChange<DynamicFlatNode<T>>) {
if (change.added) {
change.added.forEach((node) => this.toggleNode(node, true));
}
if (change.removed) {
change.removed.reverse().forEach((node) => this.toggleNode(node, false));
}
}
/**
* Toggle the node, remove from display list
*/
async toggleNode(node: DynamicFlatNode<T>, expand: boolean) {
let children: T[];
if (this.children.has(node.item)) {
children = this.children.get(node.item);
} else {
node.isLoading = true;
children = await this.database.getChildren(node.item);
}
const index = this.data.indexOf(node);
if (!children || index < 0) { // If no children, or cannot find the node, no op
node.isLoading = false;
return;
}
if (expand) {
const nodesPromises: Promise<DynamicFlatNode<T>>[] = children.map(async item =>
new DynamicFlatNode<T>(item, node.level + 1, await this.database.hasChildren(item)));
const nodes = await Promise.all(nodesPromises);
this.data.splice(index + 1, 0, ...nodes);
this.children.set(node.item, children);
} else {
const count = this.countInvisibleDescendants(node);
this.data.splice(index + 1, count);
this.children.delete(node.item);
}
// notify the change
this.dataChange.next(this.data);
node.isLoading = false;
}
countInvisibleDescendants(node: DynamicFlatNode<T>): number {
let count = 0;
if (!this.treeControl.isExpanded(node)) {
this.treeControl.getDescendants(node).map(child => {
count += 1 + this.countInvisibleDescendants(child);
});
}
return count;
}
}
export class DynamicFlatTreeControl<T> extends FlatTreeControl<DynamicFlatNode<T>> {
constructor() {
super(
(node: DynamicFlatNode<T>) => node.level, // getLevel
(node: DynamicFlatNode<T>) => node.hasChildren); // isExpandable
}
}
.example-tree-progress-bar {
margin-left: 30px;
}
.mat-tree-node {
padding: 0;
white-space: nowrap;
}
<h1>https://material-tree-dynamic.stackblitz.io</h1>
<mat-tree [dataSource]="dataSource" [treeControl]="treeControl">
<mat-tree-node *matTreeNodeDef="let node" matTreeNodePadding>
<button mat-icon-button disabled></button>
{{node.item}}
</mat-tree-node>
<mat-tree-node *matTreeNodeDef="let node; when: hasChildren" matTreeNodePadding>
<button mat-icon-button
[attr.aria-label]="'toggle ' + node.filename" matTreeNodeToggle>
<mat-icon>
{{treeControl.isExpanded(node) ? 'expand_more' : 'chevron_right'}}
</mat-icon>
</button>
{{node.item}}
<mat-progress-bar *ngIf="node.isLoading"
mode="indeterminate"
class="example-tree-progress-bar"></mat-progress-bar>
</mat-tree-node>
</mat-tree>
import {Component} from '@angular/core';
import {DynamicDatabase, DynamicDataSource, DynamicFlatNode, DynamicFlatTreeControl} from '../common/dynamic-flat-tree';
/**
* Database for dynamic data. When expanding a node in the tree, the data source will need to fetch
* the descendants data from the database.
*/
export class GamesDatabase extends DynamicDatabase<string> {
dataMap = new Map<string, string[]>([
['Simulation', ['Factorio', 'Oxygen not included']],
['Indie', [`Don't Starve`, 'Terraria', 'Starbound', 'Dungeon of the Endless']],
['Action', ['Overcooked']],
['Strategy', ['Rise to ruins']],
['RPG', ['Magicka']],
['Magicka', ['Magicka 1', 'Magicka 2']],
[`Don't Starve`, ['Region of Giants', 'Together', 'Shipwrecked']]
]);
rootLevelNodes = ['Simulation', 'Indie', 'Action', 'Strategy', 'RPG'];
getRootLevelItems(): string[] {
return this.rootLevelNodes;
}
getChildren(item: string): Promise<string[] | undefined> {
return new Promise(resolve => {
setTimeout(() => {
return resolve(this.dataMap.get(item));
}, 1000);
});
}
async hasChildren(item: string): Promise<boolean> {
return this.dataMap.has(item);
}
}
/**
* @title Tree with dynamic data
*/
@Component({
selector: 'app-test-flat',
templateUrl: 'flat.component.html',
styleUrls: ['flat.component.css'],
providers: [GamesDatabase]
})
export class FlatComponent {
treeControl: DynamicFlatTreeControl<string>;
dataSource: DynamicDataSource<string>;
constructor(database: GamesDatabase) {
this.treeControl = new DynamicFlatTreeControl<string>();
this.dataSource = new DynamicDataSource(this.treeControl, database);
this.dataSource.data = database.initialData();
}
hasChildren = (_: number, nodeData: DynamicFlatNode<string>) => nodeData.hasChildren;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment