Master-Details view using Knockout and ASP.NET MVC

With the introduction of ASP.NET MVC, the traditional WebForm controls and eventing mechanism has been replaced with the more stateless, HTTP based leaner MVC pattern of application development. However, change in paradigms often don’t imply change in requirements. Developing Master-Details type of functionality probably ranks as the top-most project types that are implemented in any LoB application.

Today we explore how to do this in ASP.NET MVC using KnockoutJS to give our application a fast responsive feeling.

The Master Details Use Case

We will pick a simple master-detail style use case where we have a User (master) with multiple addresses (details) and we need to create an AddressBook application.

The application will have the following


1. It will have the default scaffolding for doing CRUD operations on Users and Address.

2. It will have a dedicated ‘AddressBook’ page for showing the master details relationship. This page will show the list of all Users in the system and on selecting a User, it should show the addresses associated with the user in an adjacent panel.

With that laid out, let’s start building our application.

Building the ASP.NET MVC Master Details Application

The project Layout

Step 1: We start off with an ASP.NET MVC 4 project using the Internet Template.

Step 2: Next we update the jQuery, jQuery UI and Knockout packages from Nuget

PM> update-package jquery -version 1.9.1
PM> update-package jquery.validation
PM> update-package jquery.ui.combined
PM> update-package knockoutjs

Notice the version number for jquery. This is because jQuery 2.0 is now available in Microsoft’s CDN however it is incompatible with Microsoft’s Unobtrusive Validator plugin for jQuery. As a result you cannot install jQuery 2.0 as of now! This should be fixed pretty quickly.

Also install the Knockout.Mapping plugin using the following command

PM> install-package knockout.Mapping

We’ll be using the Mapping plugin to map our server side data into Knockout View Models.

The Model

Today we are focusing on the UI implementation, so we’ll simply add models classes to the Model folder and build an EF DBContext out of it instead of an elaborate repository pattern implementation.

The Model is simple, it has a User object that contains a list of Addresses.
Models\User.cs
public class User
{
public int Id { get; set; }
public string Name { get; set; }
public string Email { get; set; }
public List<Address> Addresses { get; set; }
}

Models\Address.cs
public class Address
{
public int Id { get; set; }
public string Street { get; set; }
public string House { get; set; }
public string City { get; set; }
public string State { get; set; }
public string Country { get; set; }
public string Zip { get; set; }
public int UserId { get; set; }
[ForeignKey("UserId")]
public User ParentUser { get; set; }
}

Scaffolding the Controllers

With the model laid out, we’ll build the application and scaffold a controller and the default views for the User entity. Right click on the Controller folder and Select ‘New Controller’ to bring up the wizard.

user-controller

Select the Option to use an MVC Controller with read/write actions and view, using EF. This will scaffold the controller and the views. We repeat the same for the Address entity and call it AddressController. So we now have two Controllers that can take care of the CRUD operations for User and Address Entities. Time to create an empty controller called AddressBook controller. This will contain the action methods that we call using AJAX to fetch data for the Master/Details view.

empty-address-book-controller

We can go ahead and add some test data using the generated UI.

We add Two Users and two addresses for each. The User Index looks like this

new-users

The default Address Index is as follows

new-addresses

Though fully functional, this is not how the end users would like to see data represented. Moreover when the data volumes are higher and things are not so nicely ordered, finding all addresses for a particular user via the above screen will be rather painful.

To fix this, we will build the Address book page that will show a List of Users and on selecting a particular user, it will show all the addresses associated with that user!

Let’s prepare the AddressBookController to return data that our UI will need. We have the default Get method for Index. We add a Post Method that returns JsonResult.

In the method, we use MasterDetailsViewsContext to load Users and Include the Addresses while loading.

Word of Caution: In a real world app we should be paginating and getting only a small set of Data.

public class AddressBookController : Controller
{
private MasterDetailsViewsContext db = new MasterDetailsViewsContext();
public ActionResult Index()
{
  return View();
}
[HttpPost]
public JsonResult Index(int? var)
{
  List<User> users = db.Users.Include("Addresses").ToList();
  return Json(new { Users = users });
}
}

  
As we can see, the list of users returned is wrapped in an anonymous object and used to create the JsonResult that is sent back to the client.

However, we have one minor issue here. Remember our Address Entity has a ‘ParentUser’ entity. This causes a circular reference and crashes the Json Serializer. To fix it, we’ll simple tag the ParentUser property such that it’s not used for serialization. This is done as follows

[ForeignKey("UserId")]
[ScriptIgnore]
public User ParentUser { get; set; }

The ViewModel and the View

Now if we run the application, we will be able add users and addresses, however we don’t have one place to view Users with their associated addresses. This is what we are going to build.

Step 1: Since we created an empty controller for AddressBook, there are no views associated. So let’s add an ‘AddressBook’ folder under ‘View’ in our project. To the ‘View’ folder, add an Index page. Use the ‘New View’ dialog to create the page.

addressbook-index

Step 2: Our first step is to use the data coming from server to build a Knockout ViewModel and start rendering the UI accordingly. To do that, let’s start with the Knockout ViewModel.

Let’s add a JavaScript file called addressbook-vm.js. We start off with the following ViewModel

/// <reference path="_references.js" />
var viewModel = function (data)
{
this.SelectedUser = ko.observable({
  Addresses: ko.observableArray([]),
  Name: ko.observable("")
});
this.Users = ko.observableArray(data);
}

It has two simple elements,

- SelectedUser is the user whose address details we want to see. Initially it is empty.

- Users is the list of Users in our database.

To populate the ViewModel, we need to do an Ajax call to get the data from the Server. From a best-practices point of view, we’ll write the client side logic in a new JavaScript file called addressbook-client.js. But first let’s bind our ViewModel to the view.

Step 3: The AddressBook/Index

The Index page primarily is split into Left panel and Right panel. The Left panel will list all the users whereas the right panel will have the addresses.

The markup for the Left Panel is as follows

<div class="left-aligned-section">
<ul data-bind="foreach: Users()">
  <li>
   <div class="user-item-border">
    <div>
     <label data-bind="text: Name"></label>
    </div>
    <div>
     <label data-bind="text: Email"></label>
    </div>
    <div>
     <a href="#" class="show-details">Show Details</a>
    </div>
   </div>
  </li>
</ul>
</div>

  
As we can see, we have used KO foreach binding to bind the <ul> to the list of Users() in our ViewModel. As a result, KO will treat everything in the <li> as a template and will repeat it. We simply bind the Name and Email of the user to two labels first. The third element is an anchor tag to which we’ll attach a click event handler, and the click even handler will set the current user as the selected user.

Next we see the markup of the Right pane. This will display all the addresses for that particular user.
We have used the KO ‘with’ binding to scope the binding. Everything inside the div that is bound to the ‘SelectedUser’ assumes this new scope. So data-bind to the Name field actually looks for the Name in the SelectedUser ViewModel.

Rest of the binding is pretty similar, we have a <ul> that’s bound the Addresses for the selected user.

<div class="right-aligned-section" data-bind="with: SelectedUser">
<div class="address-header">
  <div class="left-aligned-div"><strong>Address for&nbsp;</strong></div>
  <div class="left-aligned-div" data-bind="text: Name"></div>
</div>
<ul data-bind="foreach: Addresses">
  <li>
   <div class="address-item-border">
    <div>
     <div class="address-label">Street: </div>
     <div style="font-weight: bold" data-bind="text: Street"></div>
    </div>
    <div>
     <div class="address-label">House: </div>
     <div style="font-weight: bold" data-bind="text: House"></div>
    </div>
    <div>
     <div class="address-label">City: </div>
     <div style="font-weight: bold" data-bind="text: City"></div>
    </div>
    <div>
     <div class="address-label">State: </div>
     <div style="font-weight: bold" data-bind="text: State"></div>
    </div>
    <div>
     <div class="address-label">Country: </div>
     <div style="font-weight: bold" data-bind="text: Country"></div>
    </div>
    <div>
     <div class="address-label">Zip: </div>
     <div style="font-weight: bold" data-bind="text: Zip"></div>
    </div>
   </div>
  </li>
</ul>
</div>

Now that our ViewModel and Databinding is all set, we have to actually get the data and assign it to the ViewModel. Let’s see the code in our addressbook-client.js.

Once the document load is completed, we POST a query to the AddressBook’s Index action. This returns us a JSON object with one property called Users. Once data is returned, we use the Knockout Mapping Plugin to convert the JSON object into a KO ViewModel. In our case, we get an array of Objects that have ko.observable properties. Knockout by default converts everything it finds in the JSON array to either ko.observable or if it’s an array, into a ko.observableArray. This Auto Mapping plugin helps us avoid the manual task of looping through each object received from the server and converting it into a ko view model object.

In our case, the data.Users array (which contains each user along with each of their addresses) is first mapped and then passed into the ‘constructor’ of our ViewModel.

vm = new viewModel(userslist);

Once the ViewModel is ready, we use ko.applyBindings(…) to do the actual DataBind.
Since on load nothing will be selected, we hide the right pane.

NOTE: Remember, when you return JSON as an array, it is considered a ‘valid’ JavaScript. This can potentially lead to script hacks. Hence it is always advisable to return JSON arrays wrapped in an object.

/// <reference path="addressbook-vm.js" />
$(document).ready(function ()
{
$.ajax({
  url: "/AddressBook/Index/",
  type: "POST",
  data: {},
  success: function (data)
  {
   var userslist = ko.mapping.fromJS(data.Users);
   vm = new viewModel(userslist);
   ko.applyBindings(vm);
  }
});
$(".right-aligned-section").hide();
});

$(document).delegate(".show-details", "click", function ()
{
  $(".right-aligned-section").fadeIn();
  var user = ko.dataFor(this);
  vm.SelectedUser(user);
});

  
Now that the data has been bound, the anchor tag that said ‘Show Details’, it’s ‘click’ event is bound via the jQuery delegate. We add a little bit of style by fading in the Right hand pane. And then use ko.dataFor(this) to extract the user for which select was clicked. We assign this user as the Selected User. This kicks in the databinding for the right hand side pane and it updates it with all the Addresses for the selected user.

Our final view looks like this.

final-view

Download the Entire Source Code here (Github)

2 comments:

  1. Suprotim, I really appreciate this article you put together as it should answer a whole lot of questions for me. But, I find that when I run the AddressBook/Index view, it doesn't seem to call the [HttpPost] Index function in the controller. I copied the source files, including the scripts, from your source file. I verified that the $(document).ready() function activated, but nothing comes back and when I set breakpoints on the controller code, nothing breaks. I am using VS2013 Express. Any ideas?

    ReplyDelete
  2. when i run it (with my own data) i get "Error: The argument passed when initializing an observable array must be an array, or null, or undefined." and "ReferenceError: vm is not defined"

    ReplyDelete