Click here to Skip to main content
14,297,239 members

KnockoutJS Nested Arrays in MVC

Rate this:
5.00 (8 votes)
Please Sign up or sign in to vote.
5.00 (8 votes)
18 Apr 2015CPOL
How to work with KnockoutJS arrays (simple and nested) in ASP.NET MVC

Introduction

KnockoutJS is a very useful two way data binding library. There are some great articles on it on Code Project and the Knockout website itself has some very good tutorials and examples. If you are working with lists and arrays, you may find my article on searching filtering and sorting knockout lists useful. 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 a 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.

Image 1

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 of whom 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.

public class SalesPerson
   {
       public string FirstName { get; set; }
       public string LastName { get; set; }
       public List<region> Regions {get; set;}
       public List<customer> Customers {get; set;}

       public SalesPerson()
       {
       Regions = new List<region>();
       Customers = new List<customer>();
       }
   }

   public class Region
   {
       public int ID { get; set;}
       public string SortOrder { get; set; }
       public string Name { get; set; }
   }

   public class Customer
   {
       public int ID { get; set; }
       public string SortOrder { get; set; }
       public string Name { get; set; }
       public List<order> Orders { get; set; }

       public Customer()
       {
           Orders = new List<order>();
       }
   }

   public class Order
   {
       public int ID { get; set; }
       public string Date { get; set; }
       public string Value { get; set; }
   }

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.

public ActionResult Index()
{
    return View();
}

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.

public JsonResult SaveModel(SalesPerson SalesPerson)
{
    // the JSON Knockout Model string sent in, maps directly to the "SalesPerson"
    // model defined in SharedModel.cs
    var s = SalesPerson; // we can work with the Data model here - save to
                         // database / update, etc.
    return null;
}

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:

public ActionResult Index()
{
    // create the model
    SalesPerson aalesPersonModel = new SalesPerson
    return View(salesPersonModel);
}

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:

@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 let's discuss that first.

var Order = function {
    var self = this;
        self.ID = ko.observable();
        self.Date = ko.observable();
        self.Value = ko.observable();
    });
}

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:

var Order = function (data) {
    var self = this;
    if (data != null) {
        ko.mapping.fromJS(data, {}, self); 
    } else {
        self.ID = ko.observable();
        self.Date = ko.observable().extend({
            required: true
        });
        self.Value = ko.observable().extend({
            required: true
        });
    }
    self.Value.extend({
        required: {
            message: '* Value needed'
        }
    });
}

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.

var Customer = function (data) {
    var self = this;
    if (data != null) {
        ko.mapping.fromJS(data, { Orders: orderMapping }, self);
    } else {
        self.ID = ko.observable();
        self.SortOrder = ko.observable();
        self.Name = ko.observable().extend({
            required: true
        });
        self.Orders = ko.observable(); // array of Orders
        self.OrdersTotal = ko.computed(function () {
            return self.FirstName() + " " + self.LastName();
        }, self);
    }

The "orderMapping" simply tells Knockout how to unwrap any data it finds for the "Orders" sub-array using the "Order" object:

var orderMapping = {
    create: function (options) {
        return new Order(options.data);
    }
};

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

self.Name.extend({
    required: {
        message: '* Name needed'
    }
});

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 don't have to worry about sending in the selected-index of an item to delete/insert/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.

self.removeOrder = function (Order) {
    self.Orders.remove(Order);
}

This method takes care of pushing a new item onto the array. note in particular that we don't create an anonymous object, instead we specifically declare the type of object we require.

self.addOrder = function () {
    self.Orders.push(new Order({
        ID: null,
        Date: "",
        Value: ""
    }));
}

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 "[]"

self.addCustomer = function () {
    self.Customers.push(new Customer({
        ID: null,
        Name: "",
        Orders: []
    }));
}

Finally, for initialization, 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:

// load data into model
self.loadInlineData = function () {
    ko.mapping.fromJS(modeldata, { Regions: regionMapping, Customers: customerMapping }, self);
}

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

Sales person

        First name:
        <input data-bind="value:FirstName" />

        Last name:
        <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

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

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" ... it's 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.

<div data-bind="foreach:Customers">
    <div class="Customerbox">
        Customer:
        <input data-bind="value:Name" /> <a href="#" data-bind="click: $parent.removeCustomer">x</a>
        <span style="float:right">
            Index: <span data-bind="text:$index"></span>
        </span>
        <a href="#" data-bind="click: addOrder">Order +</a>
        <br />
        <div data-bind="foreach:Orders">
            ---- Order date:
            <input data-bind="value:Date" />Value:
            <input data-bind="value:Value" /> <a href="#" data-bind="click: $parent.removeOrder">x</a>
            <br />
        </div> <!-- foreach Orders -->
    </div>
</div> <!-- foreach Customer -->

Sortable Plugin

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

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

By simply replacing the "for each" array data-bin attribute with "sortable", our array object magically becomes drag-drop sortable. Look at the following animated GIF for an example.

Image 2

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.

self.saveDataToServer = function (){
    var DataToSend = ko.mapping.toJSON(self);
    $.ajax({
        type: 'post',
        url: '/home/SaveModel',
        contentType: 'application/json',
        data: DataToSend,
        success: function (dataReceived) {
            alert('sent: ' + dataReceived)
        },
        fail: function (dataReceived) {
            alert('fail: ' + dataReceived)
        }
        });
};

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:

public JsonResult SaveModel(SalesPerson SalesPerson)
{
    // the JSON Knockout Model string sent in, maps directly
    // to the "SalesPerson" model defined in SharedModel.cs
    var s = SalesPerson; // we can work with the Data
                         // model here - save to database / update, etc.
    return null;
}

That's it - download the attached code to see further detail and experiment. I hope it is useful to some!

History

  • 18th April, 2015 - Version 1 published

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)

Share

About the Author

AJSON
Engineer
United Kingdom United Kingdom
Allen is a consulting architect with a background in enterprise systems. His current obsessions are IoT, Big Data and Machine Learning. When not chained to his desk he can be found fixing broken things, playing music very badly or trying to shape things out of wood. He runs his own company specializing in systems architecture and scaling for big data and is involved in a number of technology startups.

Comments and Discussions

 
-- There are no messages in this forum --
Article
Posted 18 Apr 2015

Stats

23.9K views
543 downloads
17 bookmarked