Skip to content

Commit c80cb86

Browse files
committed
feat(cdk/tree): add complex redux-like demo
1 parent 120e579 commit c80cb86

File tree

3 files changed

+342
-0
lines changed

3 files changed

+342
-0
lines changed
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
cdk-tree-node {
2+
display: flex;
3+
align-items: center;
4+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
<mat-spinner *ngIf="areRootsLoading | async; else treeTemplate"></mat-spinner>
2+
3+
<ng-template #treeTemplate>
4+
<cdk-tree
5+
#tree
6+
[dataSource]="roots"
7+
[childrenAccessor]="getChildren"
8+
[trackBy]="trackBy"
9+
[expansionKey]="expansionKey">
10+
<cdk-tree-node
11+
*cdkTreeNodeDef="let node"
12+
cdkTreeNodePadding
13+
[isExpandable]="node.isExpandable()"
14+
(expandedChange)="onExpand(node, $event)">
15+
<!-- Spinner when node is loading children; this replaces the expand button. -->
16+
<mat-spinner *ngIf="node.areChildrenLoading()" mode="indeterminate"></mat-spinner>
17+
18+
<button
19+
*ngIf="!node.areChildrenLoading() && node.isExpandable()"
20+
mat-icon-button
21+
cdkTreeNodeToggle
22+
[attr.aria-label]="'Toggle ' + node.raw.name">
23+
<mat-icon class="mat-icon-rtl-mirror">
24+
{{tree.isExpanded(node) ? 'expand_more' : 'chevron_right'}}
25+
</mat-icon>
26+
</button>
27+
28+
<!-- Spacer for leaf nodes -->
29+
<div *ngIf="node.isLeaf()" class="toggle-spacer"></div>
30+
31+
<span>{{node.raw.name}}</span>
32+
</cdk-tree-node>
33+
</cdk-tree>
34+
</ng-template>
Lines changed: 304 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,304 @@
1+
import {CdkTreeModule} from '@angular/cdk/tree';
2+
import {CommonModule} from '@angular/common';
3+
import {Component, OnInit} from '@angular/core';
4+
import {MatButtonModule} from '@angular/material/button';
5+
import {MatIconModule} from '@angular/material/icon';
6+
import {MatProgressSpinnerModule} from '@angular/material/progress-spinner';
7+
import {BehaviorSubject, Observable, combineLatest, of as observableOf} from 'rxjs';
8+
import {delay, map, shareReplay} from 'rxjs/operators';
9+
10+
interface BackendData {
11+
id: string;
12+
name: string;
13+
parent?: string;
14+
children?: string[];
15+
}
16+
17+
const TREE_DATA: Map<string, BackendData> = new Map(
18+
[
19+
{
20+
id: '1',
21+
name: 'Fruit',
22+
children: ['1-1', '1-2', '1-3'],
23+
},
24+
{id: '1-1', name: 'Apple', parent: '1'},
25+
{id: '1-2', name: 'Banana', parent: '1'},
26+
{id: '1-3', name: 'Fruit Loops', parent: '1'},
27+
{
28+
id: '2',
29+
name: 'Vegetables',
30+
children: ['2-1', '2-2'],
31+
},
32+
{
33+
id: '2-1',
34+
name: 'Green',
35+
parent: '2',
36+
children: ['2-1-1', '2-1-2'],
37+
},
38+
{
39+
id: '2-2',
40+
name: 'Orange',
41+
parent: '2',
42+
children: ['2-2-1', '2-2-2'],
43+
},
44+
{id: '2-1-1', name: 'Broccoli', parent: '2-1'},
45+
{id: '2-1-2', name: 'Brussel sprouts', parent: '2-1'},
46+
{id: '2-2-1', name: 'Pumpkins', parent: '2-2'},
47+
{id: '2-2-2', name: 'Carrots', parent: '2-2'},
48+
].map(datum => [datum.id, datum]),
49+
);
50+
51+
class FakeDataBackend {
52+
private getRandomDelayTime() {
53+
// anywhere from 100 to 500ms.
54+
return Math.floor(Math.random() * 400) + 100;
55+
}
56+
57+
getChildren(id: string): Observable<BackendData[]> {
58+
// first, find the specified ID in our tree
59+
const item = TREE_DATA.get(id);
60+
const children = item?.children ?? [];
61+
62+
return observableOf(children.map(childId => TREE_DATA.get(childId)!)).pipe(
63+
delay(this.getRandomDelayTime()),
64+
);
65+
}
66+
67+
getRoots(): Observable<BackendData[]> {
68+
return observableOf([...TREE_DATA.values()].filter(datum => !datum.parent)).pipe(
69+
delay(this.getRandomDelayTime()),
70+
);
71+
}
72+
}
73+
74+
type LoadingState = 'INIT' | 'LOADING' | 'LOADED';
75+
76+
interface RawData {
77+
id: string;
78+
name: string;
79+
parentId?: string;
80+
childrenIds?: string[];
81+
childrenLoading: LoadingState;
82+
}
83+
84+
class TransformedData {
85+
constructor(public raw: RawData) {}
86+
87+
areChildrenLoading() {
88+
return this.raw.childrenLoading === 'LOADING';
89+
}
90+
91+
isExpandable() {
92+
return (
93+
(this.raw.childrenLoading === 'INIT' || this.raw.childrenLoading === 'LOADED') &&
94+
!!this.raw.childrenIds?.length
95+
);
96+
}
97+
98+
isLeaf() {
99+
return !this.isExpandable() && !this.areChildrenLoading();
100+
}
101+
}
102+
103+
interface State {
104+
rootIds: string[];
105+
rootsLoading: LoadingState;
106+
allData: Map<string, RawData>;
107+
dataLoading: Map<string, LoadingState>;
108+
}
109+
110+
type ObservedValueOf<T> = T extends Observable<infer U> ? U : never;
111+
112+
type ObservedValuesOf<T extends ReadonlyArray<Observable<unknown>>> = {
113+
[K in keyof T]: ObservedValueOf<T[K]>;
114+
};
115+
116+
type TransformFn<T extends ReadonlyArray<Observable<unknown>>, U> = (
117+
...args: [...ObservedValuesOf<T>, State]
118+
) => U;
119+
120+
class ComplexDataStore {
121+
private readonly backend = new FakeDataBackend();
122+
123+
private state = new BehaviorSubject<State>({
124+
rootIds: [],
125+
rootsLoading: 'INIT',
126+
allData: new Map(),
127+
dataLoading: new Map(),
128+
});
129+
130+
private readonly rootIds = this.select(state => state.rootIds);
131+
private readonly allData = this.select(state => state.allData);
132+
private readonly loadingData = this.select(state => state.dataLoading);
133+
private readonly rootsLoadingState = this.select(state => state.rootsLoading);
134+
readonly areRootsLoading = this.select(
135+
this.rootIds,
136+
this.loadingData,
137+
this.rootsLoadingState,
138+
(rootIds, loading, rootsLoading) =>
139+
rootsLoading !== 'LOADED' || rootIds.some(id => loading.get(id) !== 'LOADED'),
140+
);
141+
readonly roots = this.select(
142+
this.areRootsLoading,
143+
this.rootIds,
144+
this.allData,
145+
(rootsLoading, rootIds, data) => {
146+
if (rootsLoading) {
147+
return [];
148+
}
149+
return this.getDataByIds(rootIds, data);
150+
},
151+
);
152+
153+
getChildren(parentId: string) {
154+
return this.select(this.allData, this.loadingData, (data, loading) => {
155+
const parentData = data.get(parentId);
156+
if (parentData?.childrenLoading !== 'LOADED') {
157+
return [];
158+
}
159+
const childIds = parentData.childrenIds ?? [];
160+
if (childIds.some(id => loading.get(id) !== 'LOADED')) {
161+
return [];
162+
}
163+
return this.getDataByIds(childIds, data);
164+
});
165+
}
166+
167+
loadRoots() {
168+
this.setRootsLoading();
169+
this.backend.getRoots().subscribe(roots => {
170+
this.setRoots(roots);
171+
});
172+
}
173+
174+
loadChildren(parentId: string) {
175+
this.setChildrenLoading(parentId);
176+
this.backend.getChildren(parentId).subscribe(children => {
177+
this.addLoadedData(parentId, children);
178+
});
179+
}
180+
181+
private setRootsLoading() {
182+
this.state.next({
183+
...this.state.value,
184+
rootsLoading: 'LOADING',
185+
});
186+
}
187+
188+
private setRoots(roots: BackendData[]) {
189+
const currentState = this.state.value;
190+
191+
this.state.next({
192+
...currentState,
193+
rootIds: roots.map(root => root.id),
194+
rootsLoading: 'LOADED',
195+
...this.addData(currentState, roots),
196+
});
197+
}
198+
199+
private setChildrenLoading(parentId: string) {
200+
const currentState = this.state.value;
201+
const parentData = currentState.allData.get(parentId);
202+
203+
this.state.next({
204+
...currentState,
205+
dataLoading: new Map([
206+
...currentState.dataLoading,
207+
...(parentData?.childrenIds?.map(childId => [childId, 'LOADING'] as const) ?? []),
208+
]),
209+
});
210+
}
211+
212+
private addLoadedData(parentId: string, childData: BackendData[]) {
213+
const currentState = this.state.value;
214+
215+
this.state.next({
216+
...currentState,
217+
...this.addData(currentState, childData, parentId),
218+
});
219+
}
220+
221+
private addData(
222+
{allData, dataLoading}: State,
223+
data: BackendData[],
224+
parentId?: string,
225+
): Pick<State, 'allData' | 'dataLoading'> {
226+
const parentData = parentId && allData.get(parentId);
227+
const allChildren = data.flatMap(data => data.children ?? []);
228+
return {
229+
allData: new Map([
230+
...allData,
231+
...data.map(datum => {
232+
return [
233+
datum.id,
234+
{
235+
id: datum.id,
236+
name: datum.name,
237+
parentId,
238+
childrenIds: datum.children,
239+
childrenLoading: 'INIT',
240+
},
241+
] as const;
242+
}),
243+
...(parentData ? ([[parentId, {...parentData, childrenLoading: 'LOADED'}]] as const) : []),
244+
]),
245+
dataLoading: new Map([
246+
...dataLoading,
247+
...data.map(datum => [datum.id, 'LOADED'] as const),
248+
...allChildren.map(childId => [childId, 'INIT'] as const),
249+
]),
250+
};
251+
}
252+
253+
private getDataByIds(ids: string[], data: State['allData']) {
254+
return ids
255+
.map(id => data.get(id))
256+
.filter(<T>(item: T | undefined): item is T => !!item)
257+
.map(data => new TransformedData(data));
258+
}
259+
260+
private select<T extends ReadonlyArray<Observable<unknown>>, U>(
261+
...sourcesAndTransform: [...T, TransformFn<T, U>]
262+
) {
263+
const sources = sourcesAndTransform.slice(0, -1) as unknown as T;
264+
const transformFn = sourcesAndTransform[sourcesAndTransform.length - 1] as TransformFn<T, U>;
265+
266+
return combineLatest([...sources, this.state]).pipe(
267+
map(args => transformFn(...(args as [...ObservedValuesOf<T>, State]))),
268+
shareReplay({refCount: true, bufferSize: 1}),
269+
);
270+
}
271+
}
272+
273+
/**
274+
* @title Complex example making use of the redux pattern.
275+
*/
276+
@Component({
277+
selector: 'cdk-tree-complex-example',
278+
templateUrl: 'cdk-tree-complex-example.html',
279+
styleUrls: ['cdk-tree-complex-example.css'],
280+
standalone: true,
281+
imports: [CdkTreeModule, MatButtonModule, MatIconModule, CommonModule, MatProgressSpinnerModule],
282+
})
283+
export class CdkTreeComplexExample implements OnInit {
284+
private readonly dataStore = new ComplexDataStore();
285+
286+
areRootsLoading = this.dataStore.areRootsLoading;
287+
roots = this.dataStore.roots;
288+
289+
getChildren = (node: TransformedData) => this.dataStore.getChildren(node.raw.id);
290+
trackBy = (index: number, node: TransformedData) => this.expansionKey(node);
291+
expansionKey = (node: TransformedData) => node.raw.id;
292+
293+
ngOnInit() {
294+
this.dataStore.loadRoots();
295+
}
296+
297+
onExpand(node: TransformedData, expanded: boolean) {
298+
console.log('onExpand', node.raw.id);
299+
if (expanded) {
300+
// Only perform a load on expansion.
301+
this.dataStore.loadChildren(node.raw.id);
302+
}
303+
}
304+
}

0 commit comments

Comments
 (0)