Skip to content

Commit aec7125

Browse files
committed
feat: Add Neo.data.TreeStore (#9406)
- Implemented TreeStore extending Neo.data.Store for hierarchical data. - Added internal maps for O(1) child lookups. - Added allRecordsMap to ensure get(key) works for hidden nodes. - Overridden add() to compute and append only the visible nodes to the flat items array. - Implemented expand(), collapse(), and toggle() using Collection.splice for high-performance virtual scrolling updates. - Added singleExpand config support.
1 parent 625d177 commit aec7125

1 file changed

Lines changed: 269 additions & 0 deletions

File tree

src/data/TreeStore.mjs

Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
1+
import RecordFactory from './RecordFactory.mjs';
2+
import Store from './Store.mjs';
3+
import TreeModel from './TreeModel.mjs';
4+
5+
/**
6+
* @class Neo.data.TreeStore
7+
* @extends Neo.data.Store
8+
*
9+
* @summary A specialized store to manage hierarchical data for Tree Grids.
10+
*
11+
* TreeStore provides a flat `items` array to the UI (for virtual scrolling) while
12+
* maintaining the full hierarchical data structure internally using `#childrenMap`.
13+
* Expanding or collapsing nodes mathematically injects or removes rows from the
14+
* flattened array, ensuring O(1) rendering performance regardless of tree depth.
15+
*/
16+
class TreeStore extends Store {
17+
static config = {
18+
/**
19+
* @member {String} className='Neo.data.TreeStore'
20+
* @protected
21+
*/
22+
className: 'Neo.data.TreeStore',
23+
/**
24+
* @member {String} ntype='tree-store'
25+
* @protected
26+
*/
27+
ntype: 'tree-store',
28+
/**
29+
* @member {Neo.data.Model} model=TreeModel
30+
*/
31+
model: TreeModel,
32+
/**
33+
* If true, expanding a node will automatically collapse its siblings.
34+
* @member {Boolean} singleExpand=false
35+
*/
36+
singleExpand: false
37+
}
38+
39+
/**
40+
* Map containing all nodes (visible or hidden) keyed by their keyProperty.
41+
* @member {Map} #allRecordsMap
42+
* @private
43+
*/
44+
#allRecordsMap = new Map()
45+
46+
/**
47+
* Map containing arrays of child nodes, keyed by their parentId (or 'root').
48+
* @member {Map} #childrenMap
49+
* @private
50+
*/
51+
#childrenMap = new Map()
52+
53+
/**
54+
* Overrides Store:add() to intercept data ingestion.
55+
* It populates the internal hierarchical maps and calculates the initial
56+
* flattened array of visible nodes, passing ONLY the visible nodes to `super.add()`.
57+
*
58+
* @param {Array|Object} item
59+
* @param {Boolean} [init=this.autoInitRecords]
60+
* @returns {Number|Object[]|Neo.data.Model[]}
61+
*/
62+
add(item, init=this.autoInitRecords) {
63+
let me = this,
64+
items = Array.isArray(item) ? item : [item];
65+
66+
if (items.length === 0) {
67+
return items;
68+
}
69+
70+
// 1. Ingest all data into maps
71+
items.forEach(data => {
72+
let key = me.getKey(data),
73+
parentId = data.parentId || 'root';
74+
75+
me.#allRecordsMap.set(key, data);
76+
77+
if (!me.#childrenMap.has(parentId)) {
78+
me.#childrenMap.set(parentId, []);
79+
}
80+
me.#childrenMap.get(parentId).push(data);
81+
});
82+
83+
// 2. Identify root nodes
84+
// A node is a root if its parentId is 'root' or its parent does not exist in the dataset.
85+
let roots = me.#childrenMap.get('root') || [];
86+
87+
items.forEach(data => {
88+
let parentId = data.parentId;
89+
if (parentId && parentId !== 'root' && !me.#allRecordsMap.has(parentId)) {
90+
roots.push(data);
91+
// Re-parent to 'root' internally to heal the disconnected branch
92+
data.parentId = 'root';
93+
94+
let siblings = me.#childrenMap.get(parentId);
95+
if (siblings) {
96+
let idx = siblings.indexOf(data);
97+
if (idx > -1) {
98+
siblings.splice(idx, 1);
99+
}
100+
}
101+
if (!me.#childrenMap.has('root')) {
102+
me.#childrenMap.set('root', []);
103+
}
104+
if (!me.#childrenMap.get('root').includes(data)) {
105+
me.#childrenMap.get('root').push(data);
106+
}
107+
}
108+
});
109+
110+
// 3. Compute flat visible list
111+
let visibleItems = [];
112+
roots.forEach(root => {
113+
me.collectVisibleDescendants(root, visibleItems);
114+
});
115+
116+
// 4. Delegate to super.add but ONLY for the visible items
117+
// The hidden items remain in #allRecordsMap as raw data (Turbo Mode) until accessed via get()
118+
return super.add(visibleItems, init);
119+
}
120+
121+
/**
122+
* Recursively collects a node and all of its visible descendants into a flat array.
123+
* @param {Object|Neo.data.Record} node
124+
* @param {Array} resultArr
125+
* @protected
126+
*/
127+
collectVisibleDescendants(node, resultArr) {
128+
resultArr.push(node);
129+
130+
if (node.collapsed === false) {
131+
let key = this.getKey(node),
132+
children = this.#childrenMap.get(key) || [];
133+
134+
children.forEach(child => {
135+
this.collectVisibleDescendants(child, resultArr);
136+
});
137+
}
138+
}
139+
140+
/**
141+
* Collapses a node, removing its visible descendants from the flat grid view.
142+
* @param {String|Number} nodeId
143+
*/
144+
collapse(nodeId) {
145+
let me = this,
146+
node = me.get(nodeId);
147+
148+
if (!node || node.collapsed || node.isLeaf) {
149+
return;
150+
}
151+
152+
node.collapsed = true;
153+
154+
me.onRecordChange({
155+
fields: [{name: 'collapsed', oldValue: false, value: true}],
156+
model : me.model,
157+
record: node
158+
});
159+
160+
// Find how many visible descendants to remove
161+
let visibleDescendants = [],
162+
children = me.#childrenMap.get(nodeId) || [];
163+
164+
children.forEach(child => me.collectVisibleDescendants(child, visibleDescendants));
165+
166+
if (visibleDescendants.length > 0) {
167+
let parentIndex = me.indexOf(node);
168+
if (parentIndex > -1) {
169+
// Pass the array of items to remove so `Collection.splice` removes them by key
170+
me.splice(null, visibleDescendants);
171+
}
172+
}
173+
}
174+
175+
/**
176+
* Expands a node, injecting its children into the flat grid view.
177+
* @param {String|Number} nodeId
178+
*/
179+
expand(nodeId) {
180+
let me = this,
181+
node = me.get(nodeId);
182+
183+
if (!node || node.collapsed === false || node.isLeaf) {
184+
return;
185+
}
186+
187+
if (me.singleExpand && node.parentId) {
188+
let siblings = me.#childrenMap.get(node.parentId) || [];
189+
siblings.forEach(sibling => {
190+
if (sibling !== node && sibling.collapsed === false) {
191+
me.collapse(me.getKey(sibling));
192+
}
193+
});
194+
}
195+
196+
node.collapsed = false;
197+
198+
me.onRecordChange({
199+
fields: [{name: 'collapsed', oldValue: true, value: false}],
200+
model : me.model,
201+
record: node
202+
});
203+
204+
let children = me.#childrenMap.get(nodeId) || [];
205+
206+
if (children.length > 0) {
207+
let visibleDescendants = [];
208+
children.forEach(child => me.collectVisibleDescendants(child, visibleDescendants));
209+
210+
let parentIndex = me.indexOf(node);
211+
if (parentIndex > -1) {
212+
me.splice(parentIndex + 1, 0, visibleDescendants);
213+
}
214+
} else {
215+
// Note: Async fetching for missing children will be implemented here later (#9413).
216+
}
217+
}
218+
219+
/**
220+
* Overrides Store:get() to ensure records can be retrieved even if they are hidden
221+
* (not in the active flattened view).
222+
* @param {Number|String} key
223+
* @returns {Object|null}
224+
*/
225+
get(key) {
226+
let me = this,
227+
item = super.get(key); // Check the standard visible map first (fastest)
228+
229+
if (!item && me.#allRecordsMap.has(key)) {
230+
// Fallback to the full tree map for hidden nodes
231+
item = me.#allRecordsMap.get(key);
232+
233+
// Handle Turbo mode lazy instantiation just like Store.get
234+
if (item && !RecordFactory.isRecord(item)) {
235+
item = RecordFactory.createRecord(me.model, item);
236+
me.#allRecordsMap.set(key, item);
237+
238+
// Update in childrenMap to keep references consistent
239+
let parentId = item.parentId || 'root',
240+
siblings = me.#childrenMap.get(parentId);
241+
242+
if (siblings) {
243+
let idx = siblings.findIndex(s => me.getKey(s) === key);
244+
if (idx > -1) {
245+
siblings[idx] = item;
246+
}
247+
}
248+
}
249+
}
250+
return item || null;
251+
}
252+
253+
/**
254+
* Toggles the expansion state of a node.
255+
* @param {String|Number} nodeId
256+
*/
257+
toggle(nodeId) {
258+
let node = this.get(nodeId);
259+
if (node) {
260+
if (node.collapsed) {
261+
this.expand(nodeId);
262+
} else {
263+
this.collapse(nodeId);
264+
}
265+
}
266+
}
267+
}
268+
269+
export default Neo.setupClass(TreeStore);

0 commit comments

Comments
 (0)