📜 ⬆️ ⬇️

Knockoutjs Grow a tree


Judging by the frequency of appearance of articles, KnockoutJS is gaining popularity in Habré. I will make my contribution too. I want to highlight the topic of non-standard HTML controls and the "tree" in particular. The tree here is understood as an analogue of the TreeView control. The article assumes that the reader is already familiar with KnockoutJS at a basic level. The publication can be considered as a study guide for KnockoutJS. On the other hand, I hope that experienced KnockoutJS users will be able to learn something new.

There are many libraries written to display TreeView. A for the use of third-party libraries with KnockoutJS traditionally created the appropriate binding (binding) for KnockoutJS. Unfortunately, often the TreeView libraries are huge, contain an excess of functionality, it is often necessary to adapt your data model to the library. If you want to use TreeView in conjunction with KnockoutJS, the programmer searches for the ideal library and bindings. Not always, under the found library, there is a ready-made binding, so you have to create your own binding, and here the most interesting part begins - the study of the inside of the library, which is not always pleasant. And you just want to work ... Here an alternative approach is proposed - to make a TreeView right on KnockoutJS.

To begin with, we will build an abstract tree without data binding. Binding to the real data you can do yourself when you "grow" your trees. Either this problem will be solved by my way of reusing the KnockoutJS code, which I will show at the end.

... and not a tree, but a list.


Traditionally, HTML trees are built using a set of nested, unordered lists (UL tag) and CSS styles. Those. To build a tree, you need to generate approximately the following HTML markup:
<ul> <li> 1 <ul> <li> 3</li> </ul> </li> <li> 2 </li> <ul> 

')
The appearance of the nodes is configured using css classes.
The display model (ViewModel), obviously, can be constructed from two objects - TreeViewNode and TreeView for starters like this:
 function TreeViewNode(caption,children){ this.caption = caption; this.children = children; } function TreeView(children){ this.children = children; }} 

Here there is a temptation to do only one TreeViewNode, since the TreeView differs only in the absence of the caption field. However, this should not be rushed, because later these objects will have much more differences.
Model-bound markup will use a recursive pattern:
 <div data-bind='with: tree'> <ul data-bind='template: {name:"treeNode", foreach: children}'> </ul> </div> <script id='treeNode' type='text/html'> <li> <span data-bind='text:caption'></span> <ul data-bind='template: {name:"treeNode", foreach: children}'> </ul> </li> </script> 


Of course, we need to fill our model with data and initialize the binding:
 var vm = { tree: new TreeView([ new TreeViewNode('Node 1',[ new TreeViewNode('Node 3') ]), new TreeViewNode('Node 2') ]) }; ko.applyBindings(vm); 


Here's what we got:
image
JSFiddle for experiments.

For now, this is not like a TreeView. You must assign CSS classes and add the appropriate styles.

Reflection of the situation.


Classes should reflect the position and state of the tree node. The state of the node should be reflected in the model, so we move on to the model.
The node can be expanded or collapsed. One state from another can be obtained by a simple operation, but for convenience, we define both:

 function TreeViewNode(caption,children){ ... this.isOpen = ko.observable(); this.isClosed = ko.computed(function(){ return !this.isOpen(); },this); ... } 

I made the properties .isOpen, .isClosed observable, because they depend on each other, and changing them will automatically lead to changes in the DOM. By changing these properties, we will expand / collapse tree nodes.
I made the .isClosed property read-only, in order not to produce extra code and not to introduce an extra cyclic dependency here, although it can be “fixed”. Thus, only the .isOpen property can be directly changed.

To display correctly, it is also important for us to know whether the node has children (can it be opened) and whether the node is the last in its level so as not to draw a line from it to the next node.
 function TreeViewNode(caption,children){ ... this.children = children||[]; this.isLeaf = !this.children.length; this.isLast = false; ... } 

Since the node may not have children, which means that the children property is either empty (null or undefined), or is an array of zero length, I added unambiguity to this question — I initialized the property with an empty array if children were not passed to us.

As for the isLast property, there are two options for approaching its implementation. It can be made by ko.computed with the transfer to the node of the link to its parent, or it can be made so that the parent calculates the isLast properties of its children. I chose the second approach, because with him so far less code. The first approach can also be useful, since the link to the node's parent is useful in many scenarios. However, it will be easy to move from one to another.
So, we add the processing of the isLast property:

 function setIsLast(children){ for(var i=0,l=children.length;i<l;i++){ children[i].isLast = (i==(l-1)); } } function TreeViewNode(caption,children){ ... setIsLast(this.children); ... } function TreeView(children){ ... setIsLast(this.children); ... } 


Now we add bindings for the corresponding classes in the node template:
 ... <li data-bind='css:{closed:isClosed,open:isOpen, leaf: isLeaf, last: isLast}'> ... </li> ... 


It remains to add the ability to collapse and expand nodes:
 function TreeViewNode(caption,children){ var self = this; ... this.toggleOpen = function(){ self.isOpen(!self.isOpen()); }; ... } 

And add a binding:
 ... <li data-bind='css:{closed:isClosed,open:isOpen, leaf: isLeaf, last: isLast}, click: toggleOpen, clickBubble: false'> ... </li> ... 

clickBubble: false needed so that the event does not pop up to the parents and does not affect them.

Op-pa new style.


You can go to CSS. I did not invent my CSS, but simply simplified styles from another library.

I quote them below:
Styles
 .tree li, .tree ins{ background-image:url("http://habrastorage.org/storage2/0eb/507/98d/0eb50798dca00f5cc8e153e6da9a87f9.png"); background-repeat:no-repeat; background-color:transparent; } .tree li { background-position:-90px 0; background-repeat:repeat-y; } .tree li { display:block; min-height:18px; line-height:18px; white-space:nowrap; margin-left:18px; min-width:18px; } .tree ul, .tree li { display:block; margin:0 0 0 0; padding:0 0 0 0; list-style-type:none; } .tree li { display:block; min-height:18px; line-height:18px; white-space:nowrap; margin-left:18px; min-width:18px; } .tree > ul > li { margin-left:0px; } .tree li.last { background:transparent; } .tree .open > ins { background-position:-72px 0;} .tree .closed > ins { background-position:-54px 0;} .tree .leaf > ins { background-position:-36px 0;} .tree ins { display:inline-block; text-decoration:none; width:18px; height:18px; margin:0 0 0 0; padding:0; } li.open > ul { display:block; } li.closed > ul { display:none; } 



Full JavaScript
 function setIsLast(children){ for(var i=0,l=children.length;i<l;i++){ children[i].isLast = (i==(l-1)); } } function TreeViewNode(caption,children){ var self = this; this.caption = caption; this.children = children||[]; this.isOpen = ko.observable(); this.isClosed = ko.computed(function(){ return !this.isOpen(); },this); this.isLeaf = !this.children.length; this.isLast = false; setIsLast(this.children); this.toggleOpen = function(){ self.isOpen(!self.isOpen()); }; } function TreeView(children){ this.children = children; setIsLast(this.children); } var vm = { tree: new TreeView([ new TreeViewNode('Node 1',[ new TreeViewNode('Node 3') ]), new TreeViewNode('Node 2') ]) }; ko.applyBindings(vm); 



Full HTML
 <div class='tree' data-bind='with: tree'> <ul data-bind='template: {name:"treeNode", foreach: children}'> </ul> </div> <script id='treeNode' type='text/html'> <li data-bind='css:{closed:isClosed,open:isOpen, leaf: isLeaf, last: isLast}, click: toggleOpen, clickBubble: false'> <ins></ins> <span data-bind='text:caption'></span> <ul data-bind='template: {name:"treeNode", foreach: children}'> </ul> </li> </script> 



It turned out like this:

JSFiddle for experiments.

Not very beautiful, but already quite functional. Now it’s not difficult to add an icon or a checkbox to each node. You can make nodes selectable. But first of all I would like to make the code more universal so that the TreeView can be easily used with an arbitrary hierarchical data structure. Moreover, it is necessary that changes in the data are automatically reflected in the tree model, and further in the DOM.

In FP, this is called a “map”.


To begin with, we introduce an auxiliary function that will associate an array of node models with the data array.
 function dataToNodes(dataArray){ var res = []; for(var i=0,l=dataArray.length;i<l;i++){ res.push(new TreeViewNode(dataArray[i])); } return res; } 

TreeViewNode now takes as input not a label for the node and "children", but some abstract data. Obviously, the inscription and the "children" he must himself extract from the data. And first, suppose that the data is not completely abstract, and this is an object in which the signature is stored in the caption property, and “children” in the children property, which is an array.

 function TreeViewNode(data){ ... this.data = data; this.caption = data.caption; if(data.children) this.children = dataToNodes(data.children); else this.children = []; ... } 


This is not a map that is a map in OP.


However, it is easy for us to abandon the rigid binding to the names of properties. Let a certain map object be passed to us, in which the corresponding properties are indicated:

 function TreeViewNode(data){ ... this.data = data; var captionProp = (map && map.caption)||'caption', childrenProp = (map && map.children)||'children'; this.caption = data[captionProp]; if(data[childrenProp]) this.children = dataToNodes(data[childrenProp]); else this.children = []; ... } 


And even better, to be able to determine the compliance of properties dynamically, based on the type of the data object:
 function TreeViewNode(data){ ... this.data = data; var map = (typeof propMap == 'function') ? propMap(data):propMap, captionProp = (map && map.caption)||'caption', childrenProp = (map && map.children)||'children'; ... } 


You can now hide the TreeViewNode declaration and support functions inside the TreeView model declaration, since the TreeViewNode instances should no longer be created by the user on their own.
Full JavaScript
 function TreeView(data, propMap){ this.data = data; this.children = dataToNodes(data); setIsLast(this.children); function dataToNodes(dataArray){ var res = []; for(var i=0,l=dataArray.length;i<l;i++){ res.push(new TreeViewNode(dataArray[i])); } return res; } function setIsLast(children){ for(var i=0,l=children.length;i<l;i++){ children[i].isLast = (i==(l-1)); } } function TreeViewNode(data){ var self = this; this.data = data; var map = (typeof propMap == 'function') ? propMap(data):propMap; captionProp = (map && map.caption)||'caption', childrenProp = (map && map.children)||'children'; this.caption = data[captionProp]; if(data[childrenProp]) this.children = dataToNodes(data[childrenProp]); else this.children = []; this.isOpen = ko.observable(); this.isClosed = ko.computed(function(){ return !this.isOpen(); },this); this.isLeaf = !this.children.length; this.isLast = false; setIsLast(this.children); this.toggleOpen = function(){ self.isOpen(!self.isOpen()); }; } } var vm = { data: [ { name:'Node 1', list: [{ name: 'Node 3' }] }, { name:'Node2', list: [{ name: 'Node 6', list: [{ name: 'Node 5' }] }] } ] }; vm.tree = new TreeView(vm.data,{caption:'name',children:'list'}); ko.applyBindings(vm); 


JSFiddle for experiments.

The magic begins here.


Now we need to fulfill the last requirement - automatic reflection of data changes in the “tree” model. We use the “magic” of KnockoutJS with the help of ko.observable, ko.computed, ko.observableArray . To do this, we just need to make the children property computable. And also change the code for other properties that depend on it:
  function TreeViewNode(data){ ... if(data[childrenProp]) this.children = ko.computed(function(){ return dataToNodes(ko.utils.unwrapObservable(data[childrenProp])); }); else this.children = null; ... this.isLeaf = ko.computed(function(){ return !(this.children && this.children().length); },this); this.isLast = ko.observable(false); if(this.children){ setIsLast(this.children()); this.children.subscribe(function(newVal){ setIsLast(newVal); }); } ... 

The ko.utils.unwrapObservable function returns the current value of the observed object, or, if it is not an observed object, the same value that was passed to it on input. Using ko.utils.unwrapObservable inside ko.computed will automatically create a dependency and .children will be automatically updated if we used the observed value as data. On the other hand, you can simply use the JS array, then there will be no automatic change tracking.
We do the same for TreeView
 function TreeView(data, propMap){ ... this.children = ko.computed(function(){ return dataToNodes(ko.utils.unwrapObservable(data)); }); setIsLast(this.children()); this.children.subscribe(function(newVal){ setIsLast(newVal); }); ... 

Now changes in data will be automatically reflected in the model of the "tree" and then automatically in the DOM.
You can experiment with JSFiddle .
There is only one unpleasant problem - adding nodes collapses our tree. This happens because every time we update, we re-create the TreeViewNode models. A smarter approach is needed - create models only for new data, and for old ones, use old ones. This can be implemented in two ways:
  1. Save the link to TreeViewNode in the data;
  2. When updating the list of nodes, look for the TreeViewNode in the old list.

I will show the first way, because it is shorter. However, it has a limitation - if you want to use the same object for different nodes of the tree, this method will not work. More precisely, it will lead to unexpected effects. But if you have only one tree node for each data object, then everything will be fine.
So:
  function TreeViewNode(data){ ... data._treeviewNode = this; //        ... } function dataToNodes(dataArray,old){ var res = []; for(var i=0,l=dataArray.length;i<l;i++){ res.push(dataArray[i]._treeviewNode || new TreeViewNode(dataArray[i])); //        } return res; } 


A choice is always a pleasure.


Our "tree" is practically grown. For complete happiness, we lack only the ability to select individual nodes of the "tree".
 function TreeView(data, propMap){ var treeView = this; //    TreeView this.selectedNode = ko.observable(); //   ... function TreeViewNode(data){ ... this.isSelected = ko.computed(function(){ //      return (treeView.selectedNode()===this) },this); this.toggleSelection = function(){ //     if(this.isSelected()) treeView.selectedNode(null); else treeView.selectedNode(this); } } } 

You also need to add a template:
 ... <span class='caption' data-bind='text:caption, css: {selected:isSelected},click:toggleSelection, clickBubble: false'></span> ... 


Now we can build a full-fledged editor of the "tree".
Full JavaScript
 function TreeView(data, propMap){ var treeView = this; this.data = data; this.selectedNode = ko.observable(); this.children = ko.computed(function(){ return dataToNodes(ko.utils.unwrapObservable(data)); }); setIsLast(this.children()); this.children.subscribe(function(newVal){ setIsLast(newVal); }); function dataToNodes(dataArray,old){ var res = []; for(var i=0,l=dataArray.length;i<l;i++){ res.push(dataArray[i]._treeviewNode || new TreeViewNode(dataArray[i])); } return res; } function setIsLast(children){ for(var i=0,l=children.length;i<l;i++){ children[i].isLast(i==(l-1)); } } function TreeViewNode(data){ var self = this; this.data = data; data._treeviewNode = this; var map = (typeof propMap == 'function') ? propMap(data):propMap; captionProp = (map && map.caption)||'caption', childrenProp = (map && map.children)||'children'; this.caption = data[captionProp]; if(data[childrenProp]) this.children = ko.computed(function(){ return dataToNodes(ko.utils.unwrapObservable(data[childrenProp])); }); else this.children = null; this.isOpen = ko.observable(); this.isClosed = ko.computed(function(){ return !this.isOpen(); },this); this.isLeaf = ko.computed(function(){ return !(this.children && this.children().length); },this); this.isLast = ko.observable(false); if(this.children){ setIsLast(this.children()); this.children.subscribe(function(newVal){ setIsLast(newVal); }); } this.toggleOpen = function(){ self.isOpen(!self.isOpen()); }; this.isSelected = ko.computed(function(){ return (treeView.selectedNode()===this) },this); this.toggleSelection = function(){ if(this.isSelected()) treeView.selectedNode(null); else treeView.selectedNode(this); } } } function SomeObject(col){ this.name = ko.observable('New SomeObject'); this.list = ko.observableArray(); this.collection = col; } var vm = { data:ko.observableArray(), AddRootNode: function(){ this.data.push(new SomeObject(this.data)); }, AddChildNode: function(){ var data = this.tree.selectedNode().data; data.list.push(new SomeObject(data.list)); }, RemoveNode:function(){ var data = this.tree.selectedNode().data; this.tree.selectedNode(null); data.collection.remove(data); } }; vm.tree = new TreeView(vm.data,{caption:'name',children:'list'}); ko.applyBindings(vm); 


Full HTML
 <button data-bind='click:AddRootNode'>Add New Root Node</button> <button data-bind='click:AddChildNode,visible: tree.selectedNode'>Add Child toSelected Node</button> <button data-bind='click:RemoveNode,visible: tree.selectedNode'>Delete Selected Node</button> <div class='tree' data-bind='with: tree'> <ul data-bind='template: {name:"treeNode", foreach: children}'> </ul> </div> <div data-bind='with: tree.selectedNode'> <!-- ko with: data --> <input data-bind='value:name'> <!-- /ko --> </div> <script id='treeNode' type='text/html'> <li data-bind='css:{closed:isClosed,open:isOpen, leaf: isLeaf, last: isLast}, click: toggleOpen, clickBubble: false'> <ins></ins> <span class='caption' data-bind='text:caption, css: {selected:isSelected},click:toggleSelection, clickBubble: false'></span> <ul data-bind='template: {name:"treeNode", foreach: children}'> </ul> </li> </script> 


Full CSS
 .tree li, .tree ins{ background-image:url("http://habrastorage.org/storage2/0eb/507/98d/0eb50798dca00f5cc8e153e6da9a87f9.png"); background-repeat:no-repeat; background-color:transparent; } .tree li { background-position:-90px 0; background-repeat:repeat-y; } .tree li { display:block; min-height:18px; line-height:18px; white-space:nowrap; margin-left:18px; min-width:18px; } .tree ul, .tree li { display:block; margin:0 0 0 0; padding:0 0 0 0; list-style-type:none; } .tree li { display:block; min-height:18px; line-height:18px; white-space:nowrap; margin-left:18px; min-width:18px; } .tree > ul > li { margin-left:0px; } .tree li.last { background:transparent; } .tree .open > ins { background-position:-72px 0;} .tree .closed > ins { background-position:-54px 0;} .tree .leaf > ins { background-position:-36px 0;} .tree ins { display:inline-block; text-decoration:none; width:18px; height:18px; margin:0 0 0 0; padding:0; } li.open > ul { display:block; } li.closed > ul { display:none; } .selected {background-color: #ccc; } span.caption {cursor: pointer} 



JSFiddle for experiments.

Results Hidden PR. Distribution of elephants.


So, we got a fully functional implementation of the TreeView adapted for working with Knockout, which takes a little more than 60 lines of code in JavaScript, is understandable, can be easily expanded with new functions, easily adapts to the data model. Now consider the possible reuse scenarios:
  1. Copy & Paste TreeView functions your JS code or putting it into a separate file. Insert styles in your styles, or import styles as a separate file. Insert and adaptation of templates. This script is similar to the code snippet usage scenario.
  2. Make your own binding.
  3. Use my knockout-component library to convert a set of pattern + model into a binding.


I used the third method. Now the insertion of the tree is reduced to this HTML code:
 <div data-bind='kc.treeView: {data:data,map:{caption:"name",children:"list"}},kc.assign:tree'></div> 

How I did it - a topic for a separate article. And look at the previous example, but using the TreeView as a component you can here JSFiddle
The component itself is available on GitHub .

Source: https://habr.com/ru/post/165565/


All Articles