|
| 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