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".
The following code sets up this simple model server-side.
- @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.
- 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();
- 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 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.
- 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 dont 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 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,
-
- 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>
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.
- <div data-bind="foreach:Customers">
- <div class="Customerbox">
- Customer:
- <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.
- <div data-bind="sortable:Regions">
- <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.
- 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)
- {
-
-
- var s = SalesPerson;
-
- return null;
- }
Thats it - download the attached code to see further detail and experiment.