KnockoutJS Nested Arrays

This is the second part in a short series about aspects of KnockoutJS. First part can be viewed from here>>

KnockoutJS is a very useful two way data binding library. There are some great articles on it in code project and the Knockout website itself has some very good tutorials and examples. This article assumes you are familiar with Knockout and need some insight into using arrays with Knockout and passing that array data back to an MVC application.

The article provides simple walk-through instruction on working with arrays in KnockoutJS, and demonstrates how to save KnockoutJS JSON data from the client browser to server using a mapped ViewModel object.

 

Setup

This example uses a simple MVC project with no other dependencies other than KnockoutJS and some supporting libraries. Our example will use a basic model of a sales-person that has many customers each who can have many orders.

Server-side code

The following is the simple model we will use to represent the "Sales person".

Sales person can sell in many regions
Sales person can sell to many customers, customers can have many orders

The following code sets up this simple model server-side.

  1. public class SalesPerson  
  2.    {  
  3.        public string FirstName { getset; }  
  4.        public string LastName { getset; }  
  5.        public List<region> Regions {getset;}  
  6.        public List<customer> Customers {getset;}  
  7.   
  8.        public SalesPerson()  
  9.        {  
  10.        Regions = new List<region>();  
  11.        Customers = new List<customer>();  
  12.        }  
  13.    }  
  14.   
  15.   
  16.    public class Region  
  17.    {  
  18.        public int ID { getset;}  
  19.        public string SortOrder { getset; }  
  20.        public string Name { getset; }  
  21.   
  22.    }  
  23.   
  24.    public class Customer  
  25.    {  
  26.        public int ID { getset; }  
  27.        public string SortOrder { getset; }  
  28.        public string Name { getset; }  
  29.        public List<order> Orders { getset; }  
  30.   
  31.        public Customer()  
  32.        {  
  33.            Orders = new List<order>();  
  34.        }  
  35.    }  
  36.   
  37.   
  38.    public class Order  
  39.    {  
  40.        public int ID { getset; }  
  41.        public string Date { getset; }  
  42.        public string Value { getset; }  
  43.    }  
For this simple example, we are going to do the majority of the work client-side, and setup data client-side - therefore we will keep things simple server-side. We will start with returning a simple view form the main index controller. 
  1. public ActionResult Index()  
  2. {  
  3.     return View();  
  4. }   
when we are finished our work in the client, we will be sending data back to a controller using the model we have just defined. This is done by simply declaring controller method that takes a parameter of the same type as our model, 
  1. public JsonResult SaveModel(SalesPerson SalesPerson)  
  2.         {  
  3.             // the JSON Knockout Model string sent in, maps directly to the "SalesPerson"   
  4.             // model defined in SharedModel.cs  
  5.             var s = SalesPerson; // we can work with the Data model here - save to   
  6.                                  // database / update, etc.              
  7.             return null;  
  8.         }  
As an aside, if we wanted to take a pre-populated model server-side however, we could use the standard model passing workflow that MVC provides us with: 
  1. public ActionResult Index()  
  2. {  
  3.     // create the model  
  4.     SalesPerson aalesPersonModel = new SalesPerson  
  5.     return View(salesPersonModel);  
  6. }  
in the cshtml view we would then take in the model, serialise it to JSON and render it into a client-side JSON variable we would load into our Knockout model: 
  1. @Html.Raw(Json.Encode(Model))  

Client-side code

The first thing we will do client side, is set up a JavaScript file in our MVC project to mirror our server-side model, and give it some functionality.

If we work backwards up the model tree we can see more clearly how things are created.

Customers can have many orders, so lets discuss that first.

  1. var Order = function {  
  2.     var self = this;  
  3.         self.ID = ko.observable();  
  4.         self.Date = ko.observable();  
  5.         self.Value = ko.observable();  
  6.     });  
  7. }  
The above code is a very basic Knockout object model. Is has three fields, ID, Date and Value. To make it more useful, we need to extend it a bit. We will "extend" to tell the observable a particular field/value is required, we will allow the model to take an argument of "data" into which we can pass a pre-populated model, and finally we will tell the model that if "data" is sent in, to "unwrap" it using the Knockout Mapping plugin. As there are no sub-array items in orders, there are no "options" passed to the ko.mapping function "{}"

Here is the updated model: 
  1. var Order = function (data) {  
  2.     var self = this;  
  3.     if (data != null) {  
  4.         ko.mapping.fromJS(data, {}, self);   
  5.     } else {  
  6.         self.ID = ko.observable();  
  7.         self.Date = ko.observable().extend({  
  8.             required: true  
  9.         });  
  10.         self.Value = ko.observable().extend({  
  11.             required: true  
  12.         });  
  13.     }  
  14.     self.Value.extend({  
  15.         required: {  
  16.             message: '* Value needed'  
  17.         }  
  18.     });  
  19. }  
Next up we have the customer model, it follows the same pattern we discussed for the order. The additional thing to note here, is that we tell it *when you encounter an object called "Orders", unwrap it using the "orderMapping" plugin. 
  1. var Customer = function (data) {  
  2.     var self = this;  
  3.     if (data != null) {  
  4.         ko.mapping.fromJS(data, { Orders: orderMapping }, self);  
  5.     } else {  
  6.         self.ID = ko.observable();  
  7.         self.SortOrder = ko.observable();  
  8.         self.Name = ko.observable().extend({  
  9.             required: true  
  10.         });  
  11.         self.Orders = ko.observable(); // array of Orders  
  12.         self.OrdersTotal = ko.computed(function () {  
  13.             return self.FirstName() + " " + self.LastName();  
  14.         }, self);  
  15.     }  
The "orderMapping" simply tells Knockout how to unwrap any data it finds for the "Orders" sub-array using the "Order" object: 
  1. var orderMapping = {  
  2.     create: function (options) {  
  3.         return new Order(options.data);  
  4.     }  
  5. };  
For the customer model, we will extend it differently, saying that it is required, and if no value is provided, to show the error message "* Name needed". 
  1. self.Name.extend({  
  2.     required: {  
  3.         message: '* Name needed'  
  4.     }  
  5. });  
Finally, we add some operation methods to manage the CRUD of Orders.

Knockout maintains an internal index of its array items, therefore when you call an action to do on an array item, it happens in the context of the currently selected item. This means we dont have to worry about sending in the selected-index of an item to delete/inset/update/etc.

This method is called by the "x" beside each existing order record, and when called, deletes the selected item form the array stack. 

  1. self.removeOrder = function (Order) {  
  2.     self.Orders.remove(Order);  
  3. }  
This method takes care of pushing a new item onto the array. note in particular that we dont create an anonymous object, instead we specifically declare the type of object we require. 
  1. self.addOrder = function () {  
  2.     self.Orders.push(new Order({  
  3.         ID: null,  
  4.         Date: "",  
  5.         Value: ""  
  6.     }));  
  7. }   
As we go higher up the Sales person model, and want to create a customer, it has a child object that is an array (unlike the order object which stands on its own). When creating a new customer object we must therefore also initialise the array that will contain any future customer orders. Note the orders being created as an empty array "[]", 
  1. self.addCustomer = function () {  
  2.     self.Customers.push(new Customer({  
  3.         ID: null,  
  4.         Name: "",  
  5.         Orders: []  
  6.     }));  
  7. }  
Finally, for initialisation, we have a method that loads in-line JSON data into the Knockout ViewModel we declared. Note how the mapping works in this case. the function says ... load the object called modelData, and when you encounter an object called "regions", unwrap it through, 
  1. // load data into model  
  2. self.loadInlineData = function () {  
  3.     ko.mapping.fromJS(modeldata, { Regions: regionMapping, Customers: customerMapping }, self);  
  4. }  

Note the options - it says load data from the object modeldata, and when you enter a sub-object called regions, use regionsmapping method to unwrap it. Likewise with customers, use customermapping.

The downloadable code gives further details.

Mark-up

The data binding of Knockout is simple and powerful. By adding attributes to mark-up tags, we bind to the data-model and any data in the model gets rendered in the browser for us.

Sales person (top level details) mark-up

  1. Sales person  
  2.   
  3.         First name:  
  4.         <input data-bind="value:FirstName" />  
  5.           
  6.   
  7.         Last name:  
  8.         <input data-bind="value:LastName" />  

Regions mark-up

The tag control-flow operator "foreach" tells Knockout "for each array item 'region', render the contents of this div container". Note also the data-bind method "$parent.removeRegion" which calls a simple delete method in the model

  1. <div data-bind="foreach:Regions">  
  2. <div class="Regionbox">Region: <input data-bind="value:Name" /> <a data-bind="click: $parent.removeRegion" href="#">x</a>   

Customers mark-up

The customers mark-up carries the same patterns as previous code. What is important to note in this section of code is that there is a "for each" data-bind *within* a "for each" ... its nested. We are therefore saying "render this mark-up for each customer record you find, and for each customer record you find, render each 'customer.order' record you find."

The other new concept in this block of code is the data-bind "$index". This attribute tells knockout to render the "array index" of the current item.

  1. <div data-bind="foreach:Customers">  
  2.     <div class="Customerbox">  
  3.         Customer:  
  4.         <input data-bind="value:Name" /> <a href="#" data-bind="click: $parent.removeCustomer">x</a>  

Sortable plugin

Before we move to the data exchange part of this example, lets look at one more useful plugin when working with Kncokout arrays and lists. Its "Knockout Sortable", provided by the very talented Ryan Niemeyer.

  1. <div data-bind="sortable:Regions">  
  2. <div class="Regionbox">Region: <input data-bind="value:Name" /> <a data-bind="click: $parent.removeRegion" href="#">x</a>  
 

Sending data to MVC server

Sending the datamodel from client to server is achieved using a simple Ajax call. The main trick to serialising data form Knockout is to use the "ToJSON" method. In our case as we have nested array objects we will pass this through the mapping methods. 

  1. self.saveDataToServer = function (){  
  2.     var DataToSend = ko.mapping.toJSON(self);  
  3.     $.ajax({  
  4.         type: 'post',  
  5.         url: '/home/SaveModel',  
  6.         contentType: 'application/json',  
  7.         data: DataToSend,  
  8.         success: function (dataReceived) {  
  9.             alert('sent: ' + dataReceived)  
  10.         },  
  11.         fail: function (dataReceived) {  
  12.             alert('fail: ' + dataReceived)  
  13.         }  
  14.   
  15.   
  16.         });  
  17. };  
As we have our models on both server and client mapped in structure, the JSON is converted by the MVC server and is directly accessible as a server-side data model, 
  1. public JsonResult SaveModel(SalesPerson SalesPerson)  
  2. {  
  3.     // the JSON Knockout Model string sent in, maps directly  
  4.     // to the "SalesPerson" model defined in SharedModel.cs  
  5.     var s = SalesPerson; // we can work with the Data  
  6.                          // model here - save to database / update, etc.  
  7.     return null;  
  8. }  
Thats it - download the attached code to see further detail and experiment.


Similar Articles