-
Notifications
You must be signed in to change notification settings - Fork 89
/
ivh-treeview.js
425 lines (390 loc) · 12.8 KB
/
ivh-treeview.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
/**
* The `ivh-treeview` directive
*
* A filterable tree view with checkbox support.
*
* Example:
*
* ```
* <div
* ivh-treeview="myHierarchicalData">
* ivh-treeview-filter="myFilterText">
* </div>
* ```
*
* @package ivh.treeview
* @copyright 2014 iVantage Health Analytics, Inc.
*/
angular.module('ivh.treeview').directive('ivhTreeview', ['ivhTreeviewMgr', function(ivhTreeviewMgr) {
'use strict';
return {
restrict: 'A',
transclude: true,
scope: {
// The tree data store
root: '=ivhTreeview',
// Specific config options
childrenAttribute: '=ivhTreeviewChildrenAttribute',
defaultSelectedState: '=ivhTreeviewDefaultSelectedState',
disableCheckboxSelectionPropagation: '=ivhTreeviewDisableCheckboxSelectionPropagation',
expandToDepth: '=ivhTreeviewExpandToDepth',
idAttribute: '=ivhTreeviewIdAttribute',
indeterminateAttribute: '=ivhTreeviewIndeterminateAttribute',
expandedAttribute: '=ivhTreeviewExpandedAttribute',
labelAttribute: '=ivhTreeviewLabelAttribute',
nodeTpl: '=ivhTreeviewNodeTpl',
selectedAttribute: '=ivhTreeviewSelectedAttribute',
onCbChange: '&ivhTreeviewOnCbChange',
onToggle: '&ivhTreeviewOnToggle',
twistieCollapsedTpl: '=ivhTreeviewTwistieCollapsedTpl',
twistieExpandedTpl: '=ivhTreeviewTwistieExpandedTpl',
twistieLeafTpl: '=ivhTreeviewTwistieLeafTpl',
useCheckboxes: '=ivhTreeviewUseCheckboxes',
validate: '=ivhTreeviewValidate',
visibleAttribute: '=ivhTreeviewVisibleAttribute',
// Generic options object
userOptions: '=ivhTreeviewOptions',
// The filter
filter: '=ivhTreeviewFilter'
},
controllerAs: 'trvw',
controller: ['$scope', '$element', '$attrs', '$transclude', 'ivhTreeviewOptions', 'filterFilter', function($scope, $element, $attrs, $transclude, ivhTreeviewOptions, filterFilter) {
var ng = angular
, trvw = this;
// Merge any locally set options with those registered with hte
// ivhTreeviewOptions provider
var localOpts = ng.extend({}, ivhTreeviewOptions(), $scope.userOptions);
// Two-way bound attributes (=) can be copied over directly if they're
// non-empty
ng.forEach([
'childrenAttribute',
'defaultSelectedState',
'disableCheckboxSelectionPropagation',
'expandToDepth',
'idAttribute',
'indeterminateAttribute',
'expandedAttribute',
'labelAttribute',
'nodeTpl',
'selectedAttribute',
'twistieCollapsedTpl',
'twistieExpandedTpl',
'twistieLeafTpl',
'useCheckboxes',
'validate',
'visibleAttribute'
], function(attr) {
if(ng.isDefined($scope[attr])) {
localOpts[attr] = $scope[attr];
}
});
// Attrs with the `&` prefix will yield a defined scope entity even if
// no value is specified. We must check to make sure the attribute string
// is non-empty before copying over the scope value.
var normedAttr = function(attrKey) {
return 'ivhTreeview' +
attrKey.charAt(0).toUpperCase() +
attrKey.slice(1);
};
ng.forEach([
'onCbChange',
'onToggle'
], function(attr) {
if($attrs[normedAttr(attr)]) {
localOpts[attr] = $scope[attr];
}
});
// Treat the transcluded content (if there is any) as our node template
var transcludedScope;
$transclude(function(clone, scope) {
var transcludedNodeTpl = '';
angular.forEach(clone, function(c) {
transcludedNodeTpl += (c.innerHTML || '').trim();
});
if(transcludedNodeTpl.length) {
transcludedScope = scope;
localOpts.nodeTpl = transcludedNodeTpl;
}
});
/**
* Get the merged global and local options
*
* @return {Object} the merged options
*/
trvw.opts = function() {
return localOpts;
};
// If we didn't provide twistie templates we'll be doing a fair bit of
// extra checks for no reason. Let's just inform down stream directives
// whether or not they need to worry about twistie non-global templates.
var userOpts = $scope.userOptions || {};
/**
* Whether or not we have local twistie templates
*
* @private
*/
trvw.hasLocalTwistieTpls = !!(
userOpts.twistieCollapsedTpl ||
userOpts.twistieExpandedTpl ||
userOpts.twistieLeafTpl ||
$scope.twistieCollapsedTpl ||
$scope.twistieExpandedTpl ||
$scope.twistieLeafTpl);
/**
* Get the child nodes for `node`
*
* Abstracts away the need to know the actual label attribute in
* templates.
*
* @param {Object} node a tree node
* @return {Array} the child nodes
*/
trvw.children = function(node) {
var children = node[localOpts.childrenAttribute];
return ng.isArray(children) ? children : [];
};
/**
* Get the label for `node`
*
* Abstracts away the need to know the actual label attribute in
* templates.
*
* @param {Object} node A tree node
* @return {String} The node label
*/
trvw.label = function(node) {
return node[localOpts.labelAttribute];
};
/**
* Returns `true` if this treeview has a filter
*
* @return {Boolean} Whether on not we have a filter
* @private
*/
trvw.hasFilter = function() {
return ng.isDefined($scope.filter);
};
/**
* Get the treeview filter
*
* @return {String} The filter string
* @private
*/
trvw.getFilter = function() {
return $scope.filter || '';
};
/**
* Returns `true` if current filter should hide `node`, false otherwise
*
* @todo Note that for object and function filters each node gets hit with
* `isVisible` N-times where N is its depth in the tree. We may be able to
* optimize `isVisible` in this case by:
*
* - On first call to `isVisible` in a given digest cycle walk the tree to
* build a flat array of nodes.
* - Run the array of nodes through the filter.
* - Build a map (`id`/$scopeId --> true) for the nodes that survive the
* filter
* - On subsequent calls to `isVisible` just lookup the node id in our
* map.
* - Clean the map with a $timeout (?)
*
* In theory the result of a call to `isVisible` could change during a
* digest cycle as scope variables are updated... I think calls would
* happen bottom up (i.e. from "leaf" to "root") so that might not
* actually be an issue. Need to investigate if this ends up feeling for
* large/deep trees.
*
* @param {Object} node A tree node
* @return {Boolean} Whether or not `node` is filtered out
*/
trvw.isVisible = function(node) {
var filter = trvw.getFilter();
// Quick shortcut
if(!filter || filterFilter([node], filter).length) {
return true;
}
// If we have an object or function filter we have to check children
// separately
if(typeof filter === 'object' || typeof filter === 'function') {
var children = trvw.children(node);
// If any child is visible then so is this node
for(var ix = children.length; ix--;) {
if(trvw.isVisible(children[ix])) {
return true;
}
}
}
return false;
};
/**
* Returns `true` if we should use checkboxes, false otherwise
*
* @return {Boolean} Whether or not to use checkboxes
*/
trvw.useCheckboxes = function() {
return localOpts.useCheckboxes;
};
/**
* Select or deselect `node`
*
* Updates parent and child nodes appropriately, `isSelected` defaults to
* `true`.
*
* @param {Object} node The node to select or deselect
* @param {Boolean} isSelected Defaults to `true`
*/
trvw.select = function(node, isSelected) {
ivhTreeviewMgr.select($scope.root, node, localOpts, isSelected);
trvw.onCbChange(node, isSelected);
};
/**
* Get the selected state of `node`
*
* @param {Object} node The node to get the selected state of
* @return {Boolean} `true` if `node` is selected
*/
trvw.isSelected = function(node) {
return node[localOpts.selectedAttribute];
};
/**
* Toggle the selected state of `node`
*
* Updates parent and child node selected states appropriately.
*
* @param {Object} node The node to update
*/
trvw.toggleSelected = function(node) {
var isSelected = !node[localOpts.selectedAttribute];
trvw.select(node, isSelected);
};
/**
* Expand or collapse a given node
*
* `isExpanded` is optional and defaults to `true`.
*
* @param {Object} node The node to expand/collapse
* @param {Boolean} isExpanded Whether to expand (`true`) or collapse
*/
trvw.expand = function(node, isExpanded) {
ivhTreeviewMgr.expand($scope.root, node, localOpts, isExpanded);
};
/**
* Get the expanded state of a given node
*
* @param {Object} node The node to check the expanded state of
* @return {Boolean}
*/
trvw.isExpanded = function(node) {
return node[localOpts.expandedAttribute];
};
/**
* Toggle the expanded state of a given node
*
* @param {Object} node The node to toggle
*/
trvw.toggleExpanded = function(node) {
trvw.expand(node, !trvw.isExpanded(node));
};
/**
* Whether or not nodes at `depth` should be expanded by default
*
* Use -1 to fully expand the tree by default.
*
* @param {Integer} depth The depth to expand to
* @return {Boolean} Whether or not nodes at `depth` should be expanded
* @private
*/
trvw.isInitiallyExpanded = function(depth) {
var expandTo = localOpts.expandToDepth === -1 ?
Infinity : localOpts.expandToDepth;
return depth < expandTo;
};
/**
* Returns `true` if `node` is a leaf node
*
* @param {Object} node The node to check
* @return {Boolean} `true` if `node` is a leaf
*/
trvw.isLeaf = function(node) {
return trvw.children(node).length === 0;
};
/**
* Get the tree node template
*
* @return {String} The node template
* @private
*/
trvw.getNodeTpl = function() {
return localOpts.nodeTpl;
};
/**
* Get the root of the tree
*
* Mostly a helper for custom templates
*
* @return {Object|Array} The tree root
* @private
*/
trvw.root = function() {
return $scope.root;
};
/**
* Call the registered toggle handler
*
* Handler will get a reference to `node` and the root of the tree.
*
* @param {Object} node Tree node to pass to the handler
* @private
*/
trvw.onToggle = function(node) {
if(localOpts.onToggle) {
var locals = {
ivhNode: node,
ivhIsExpanded: trvw.isExpanded(node),
ivhTree: $scope.root
};
localOpts.onToggle(locals);
}
};
/**
* Call the registered selection change handler
*
* Handler will get a reference to `node`, the new selected state of
* `node, and the root of the tree.
*
* @param {Object} node Tree node to pass to the handler
* @param {Boolean} isSelected Selected state for `node`
* @private
*/
trvw.onCbChange = function(node, isSelected) {
if(localOpts.onCbChange) {
var locals = {
ivhNode: node,
ivhIsSelected: isSelected,
ivhTree: $scope.root
};
localOpts.onCbChange(locals);
}
};
}],
link: function(scope, element, attrs) {
var opts = scope.trvw.opts();
// Allow opt-in validate on startup
if(opts.validate) {
ivhTreeviewMgr.validate(scope.root, opts);
}
},
template: [
'<ul class="ivh-treeview">',
'<li ng-repeat="child in root | ivhTreeviewAsArray"',
'ng-hide="trvw.hasFilter() && !trvw.isVisible(child)"',
'class="ivh-treeview-node"',
'ng-class="{\'ivh-treeview-node-collapsed\': !trvw.isExpanded(child) && !trvw.isLeaf(child)}"',
'ivh-treeview-node="child"',
'ivh-treeview-depth="0">',
'</li>',
'</ul>'
].join('\n')
};
}]);