Using the knockout mapping plugin ( http://knockoutjs.com/documentation/plugins-mapping.html ) can you map a deeply hierachical object?
If I have an object with mult
Something like this (Live copy on js fiddle):
CSS:
.left {
float: left;
}
.clear {
clear: both;
}
HTML:
<p>Current:
<a href="#" data-bind="visible: (stack.length > 0), text: selectedNode().name, click: selectParentNode"></a>
<span data-bind="visible: (stack.length <= 0), text: selectedNode().name"></span>
</p>
<p class="left">Children: </p>
<ul class="left" data-bind="template: {name: 'childList', foreach: selectedNode().children}"></ul>
<script type="text/html" id="childList">
<li data-bind="click: function(){nodeViewModel.selectChildNode($data)}">
<a href="#">A${name}</a>
</li>
</script>
<br /><br />
<ul class="clear" data-bind="template: {name: 'backBtn'}"></ul>
<script type="text/html" id="backBtn">
<a href="#" data-bind="visible: $data.selectedNode().back, click: function() { nodeViewModel.selectBackNode($data.selectedNode().back) }">Back</a>
</script>
JavaScript:
var node = function(config, parent) {
this.parent = parent;
var _this = this;
var mappingOptions = {
children: {
create: function(args) {
return new node(args.data, _this);
}
}
};
ko.mapping.fromJS(config, mappingOptions, this);
};
var myModel = {
node: {
name: "Root",
children: [
{
name: "Child 1",
back: 1,
children: [
{
name: "Child 1_1",
back: 1,
children: [
{
name: "Child 1_1_1",
back: 4,
children: [
]},
{
name: "Child 1_1_2",
back: 2,
children: [
]},
{
name: "Child 1_1_3",
back: 1,
children: [
]}
]}
]},
{
name: "Child 2",
back: 1,
children: [
{
name: "Child 2_1",
back: 1,
children: [
]},
{
name: "Child 2_2",
back: 1,
children: [
]}
]}
]
}
};
var viewModel = {
nodeData: new node(myModel.node, undefined),
selectedNode: ko.observable(myModel.node),
stack: [],
selectBackNode: function(numBack) {
if (this.stack.length >= numBack) {
for (var i = 0; i < numBack - 1; i++) {
this.stack.pop();
}
}
else {
for (var i = 0; i < this.stack.length; i++) {
this.stack.pop();
}
}
this.selectNode( this.stack.pop() );
},
selectParentNode: function() {
if (this.stack.length > 0) {
this.selectNode( this.stack.pop() );
}
},
selectChildNode: function(node) {
this.stack.push(this.selectedNode());
this.selectNode(node);
},
selectNode: function(node) {
this.selectedNode(node);
}
};
window.nodeViewModel = viewModel;
ko.applyBindings(viewModel);
This sample just maps an infinitely nested set of JSON data, and I can say from actually using this exact code in application that is works great.
Some of the extra functions like
selectBackNode and selectParentNode
allow you to move back up the tree.
While navigating the example the parent label becomes a link to allow for going up one level, and some of the leaf nodes have a back button that allows them to move back up the tree by a given number of levels.
--EDIT--
If your leaf nodes don't have a children array you might get a problem where additional data is introduced that doesn't exist in the model.
From my experience, I would say that it shouldn't have any problems.
I would use the following line -
var grahamModel = ko.mapping.fromJS(data);
Then set a breakpoint on the next line at look at the generated object in your debugger (chrome or FF+Firebug works best). This way you will know if ko.mapping will generate a viewmodel that meets your needs.
Normally, it generates an object where only the end points (variables with values) are ko.observables. Any of the other data times that you can use for navigation through the data, like ... children: [...
are shown as ordinary javaScript objects.
I used the approach in this answer to create a hierarchy of checkboxes where nodes with children are collapsible and when you check/uncheck the parent its descendants get checked/unchecked.
View Model
var Category = function(data, parent) {
var self = this;
self.name = data.name;
self.id = data.id;
self.parent = parent;
self.categoryChecked = ko.observable(false);
ko.mapping.fromJS(data, self.map, self);
};
// This will add a "map" to our category view model
Category.prototype.map = {
'sub_categories' : {
create: function(options){
var category = new Category(options.data, options.parent);
category.parent.categoryChecked.subscribe(function(value){
category.categoryChecked(value);
});
return category;
}
}
};
HTML (view)
<div data-role="panel" id="left-panel" data-position="left" data-position-fixed="false" data-theme="b">
<div data-role="collapsible-set" data-bind="template: {name: 'category_collapsible', foreach: sub_categories}" data-mini="true" id="categories" data-iscroll> </div>
</div><!-- END left panel -->
<script type="text/html" id="category_collapsible">
<div class="category_collapsible" data-mini="true" data-content-theme="b" data-inset="true" data-iconpos="right">
<h3>
<input data-role="none" data-them="b" data-bind='checked: categoryChecked, jqmChecked: true, attr: {id: "category_checkbox_"+id}' class="chk_category" type="checkbox" />
<label data-bind='attr: {for: "category_checkbox_"+id}'><span data-bind="text: name"> </span></label>
</h3>
<ul data-role="listview" data-bind="template: {name: 'category_list', foreach: sub_categories}">
</ul>
</div>
</script><!-- END category_collapsible template -->
<script type="text/html" id="category_list">
<!-- ko if: sub_categories().length==0 -->
<li data-theme="c">
<input data-role="none" data-theme="c" data-bind='checked: categoryChecked, jqmChecked: true, attr: {id: "category_checkbox_"+id}' class="chk_category" type="checkbox"/>
<label data-corners="false" data-bind='attr: {for: "category_checkbox_"+id}'>
<span data-bind="text: name"> </span>
</label>
</li>
<!-- /ko -->
<!-- ko if: sub_categories().length>0 -->
<li data-theme="c" data-bind="template: {name: 'category_collapsible', data: $data}"></li>
<!-- /ko -->
</script>
If you don't want the nested mappingOptions (creating a ko map object for each node level), you can take advantage of the fact the ko mapping options for create give you access to the parent object. Something like this:
function Folder(parent,data) {
var self = this;
self.parent = parent;
ko.mapping.fromJS(data, self.map, self);
}
Folder.prototype.map = {
'folders': {
create: function(options) {
var folder = new Folder(options.parent,options.data);
return folder;
}
}
}
var data = { name:"root", folders: [ {name:"child", folders: [] } ] };
var root = new Folder(null, data);
That way you only have 1 copy of the map, in your class prototype (or could be any function). If you want Folder.parent to be an observable as well, you could do data.parent = parent;
inside the map function and not pass as a parameter to your Folder constructor, or do that inside the folder constructor instead of self.parent = parent;