ASP.NET MVC 5 with Bootstrap and Knockout.js (2015)
Part II. Working with Data
Chapter 7. Server-Side ViewModels
Chapter 3 introduced client-side ViewModels that are used to perform data bindings with Knockout. I would consider them identical in purpose, but they live at a different level in the lifecycle of a web request.
A server-side ViewModel is generated from a data model. The ViewModel is then bound to a view. In Chapter 5 and Chapter 6 when the AuthorsController was scaffolded from the Author model, the Author model is also being used as a ViewModel for the various views in the CRUD operation.
In Chapter 6, client-side ViewModels were created that accepted the ViewModel from the Razor view (the data model), and they were bound to the view via Knockout. The major difference between client-side and server-side ViewModels is that server-side ViewModels are static. Once the web request has been returned from the server to the client, the server-side ViewModel will never change, whereas the client-side ViewModel is dynamic and responds to user interactions on the web page.
Why Create Server-Side ViewModels?
This is an important question. As you will see very soon, when I create an Authors ViewModel, it will be nearly identical to the Authors model, so why should we create them?
In Chapter 5, when the AuthorsController was first scaffolded from the Author model, the list of authors was serialized to JSON and provided to the client-side ViewModel. If you view the source of the authors index page, you will notice the list of books for each author was also serialized. I consider this a mistake because unnecessary data was transferred to the client, and this data was then unnecessarily bound to the ViewModel.
The definition of a ViewModel is to bind data from a model so that it can be accessed easily by a view. Returning the unneeded and unused array of books breaks this definition. This, of course, is required by the data model and Entity Framework to create inter-relationships.
It is also quite common for data models to contain nonpublic data. For example, an Authors table may often contain contact information that should not be displayed publicly, but be available for internal use. These fields would exist in the data model, but they would not exist in the ViewModel.
The concept of a server-side ViewModel could have existed in Chapter 3 with the Person model that demonstrated how Knockout ViewModels accepted input. In this case, the Person is a not a data model, but rather it is a ViewModel used for the Advanced view in the HomeController.
Chapter 5 also introduced two additional ViewModels that at the time were placed in the Models directory: QueryOptions and SortOrder. These also do not correspond to data models; they are used by the View and the Controller to communicate information back and forth.
With a good understanding of server-side ViewModels, it is a good time to create a new ViewModels folder in the root of the project. The Person, QueryOptions, and SortOrder classes should then be relocated to this folder.
ViewModels Namespace
When you relocate the preceding three classes, it is a good idea to adjust the namespaces. Because these classes were created inside the Models directory, their namespace is BootstrapIntroduction.Models. An updated namespace would be to change it to BootstrapIntroduction.ViewModels. Once you update the namespace in the class, you’ll need to update any reference to it and include the newly named namespace, for example the HomeController, the HtmlHelperExtension, the Advanced.cshtml, etc.
It’s not necessary to update the namespace; however, if your project continues to grow and you create a Person data model, you would receive an error because a Person class would already exist in that namespace.
Going forward, Controllers will now be responsible for converting Models to ViewModels for output, and vice versa for input (as shown in Figure 7-1). The remainder of this chapter will update the AuthorsController to demonstrate this.
Figure 7-1. Server-side ViewModels
The Authors ViewModel
Example 7-1 creates a new class called AuthorViewModel inside the newly created ViewModels directory. Even though the class is contained within the ViewModels folder, I do like post-fixing ViewModel in the name because it helps to easily distinguish the ViewModel from the data model.
Example 7-1. AuthorViewModel
using Newtonsoft.Json;
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Web;
namespace BootstrapIntroduction.ViewModels
{
public class AuthorViewModel
{
[JsonProperty(PropertyName="id")]
public int Id { get; set; }
[Required]
[JsonProperty(PropertyName = "firstName")]
public string FirstName { get; set; }
[Required]
[JsonProperty(PropertyName = "lastName")]
public string LastName { get; set; }
[JsonProperty(PropertyName = "biography")]
public string Biography { get; set; }
}
}
The AuthorViewModel is nearly identical to the Author data model. It contains the JsonProperty annotations because this model will now be serialized for the JavaScript ViewModels. It also contains the validation annotations because, as will be demonstrated in a later section, the add and edit forms will be using this model in the form. Adding the data validation will allow the form to ensure the appropriate fields are populated.
Updating the Authors Listing
Updating the list of authors to use the new AuthorViewModel requires two changes:
1. Update the Index.cshtml to be bound to a list of AuthorViewModels instead of Author model.
2. Update the AuthorsController to convert the list of Author models to a list of AuthorViewModels.
Updating the authors Index.cshtml requires changes to the first two lines of the entire view, as shown in Example 7-2.
Example 7-2. Changing to AuthorViewModel
@using BootstrapIntroduction.ViewModels
@model IEnumerable<AuthorViewModel>
Converting the list of models to ViewModels is equally as easy because I am going to leverage a new third-party library called Automapper. Automapper is a library that lets you define a map from the source (Author data model) to a destination (AuthorViewModel). It will automatically go through each record in the list, and all properties that are named the same will be copied from the source to the destination.
Automapper can also be customized to map properties that don’t match in name with a little bit of configuration. That’s not necessary at this time, however, because the naming conventions between the Author model and AuthorViewModel are identical.
To install Automapper, right-click the project and select Manage NuGet Packages. With the Online option selected on the left, search for Automapper. Click the Install button on the first result.
Using Automapper in code requires two things. The first is to define the mapping that identifies the source class and the destination class. The second is to run the map. Example 7-3 shows an updated Index function inside the AuthorsController.
Example 7-3. Updated AuthorsController Index
public ActionResult Index([Form] QueryOptions queryOptions)
{
var start = (queryOptions.CurrentPage - 1) * queryOptions.PageSize;
var authors = db.Authors.
OrderBy(queryOptions.Sort).
Skip(start).
Take(queryOptions.PageSize);
queryOptions.TotalPages =
(int)Math.Ceiling((double)db.Authors.Count() / queryOptions.PageSize);
ViewBag.QueryOptions = queryOptions;
AutoMapper.Mapper.CreateMap<Author, AuthorViewModel>();
return View(AutoMapper.Mapper.Map<List<Author>,
List<AuthorViewModel>>(authors.ToList()));
}
The two Automapper lines appear almost identical; the first one calls a CreateMap function, and the second calls the Map function. The second line also defines the source and destination slightly differently. When the Automapper map is defined, it only takes the class names; however, if you want to convert an entire collection of those models, you must indicate that when calling the Map function.
The listing of authors is now data bound to a ViewModel instead of a data model.
Updating the Add/Edit Form
Updating the add and edit authors form involves the same two things as updating the index. The authors Form.cshtml view needs to be data bound to the AuthorViewModel (as shown in Example 7-4).
Example 7-4. Updated Authors Form
@model BootstrapIntroduction.ViewModels.AuthorViewModel
The AuthorsController then needs to be updated to convert the data. The Index function only has to convert from the data model to the ViewModel; for the add and edit form, it also needs to convert from the ViewModel to the data model. Example 7-5 contains updates to both of the Createand both of the Edit functions.
Example 7-5. Updated AuthorsController
// GET: Authors/Create
public ActionResult Create()
{
return View("Form", new AuthorViewModel());
}
// POST: Authors/Create
[HttpPost]
[ValidateAntiForgeryToken]
public ActionResult Create([Bind(Include = "Id,FirstName,LastName,Biography")]
AuthorViewModel author)
{
if (ModelState.IsValid)
{
AutoMapper.Mapper.CreateMap<AuthorViewModel, Author>();
db.Authors.Add(AutoMapper.Mapper.Map<AuthorViewModel, Author>(author));
db.SaveChanges();
return RedirectToAction("Index");
}
return View(author);
}
// GET: Authors/Edit/5
public ActionResult Edit(int? id)
{
if (id == null)
{
return new HttpStatusCodeResult(HttpStatusCode.BadRequest);
}
Author author = db.Authors.Find(id);
if (author == null)
{
return HttpNotFound();
}
AutoMapper.Mapper.CreateMap<Author, AuthorViewModel>();
return View("Form", AutoMapper.Mapper.Map<Author, AuthorViewModel>(author));
}
// POST: Authors/Edit/5
[HttpPost]
[ValidateAntiForgeryToken]
public ActionResult Edit([Bind(Include = "Id,FirstName,LastName,Biography")]
AuthorViewModel author)
{
if (ModelState.IsValid)
{
AutoMapper.Mapper.CreateMap<AuthorViewModel, Author>();
db.Entry(AutoMapper.Mapper.Map<AuthorViewModel, Author>(author)).State
= EntityState.Modified;
db.SaveChanges();
return RedirectToAction("Index");
}
return View("Form", author);
}
The first Create function was updated to create a new AuthorViewModel instead of the previous Author data model.
The second Create function (that is called when the form is posted) was updated to implement the Automapper. This time the source is the AuthorViewModel and the destination is the Author data model.
The first Edit function was updated to be similar to the Index function. It uses Automapper to convert from the data model to the ViewModel. This will allow the form to be prepopulated with the existing author data from the database.
The second Edit function (also called when the form is posted) was updated just like the second Create function to perform the conversion from the AuthorViewModel to the Author data model. This allows the updated Author to be saved to the database.
Updating the Delete Modal
You guessed it! Updating the deletion of an author requires the same two updates. First, the Delete.cshtml file needs to be updated to the AuthorViewModel as shown in Example 7-6.
Example 7-6. Updated Delete Author view
@model BootstrapIntroduction.ViewModels.AuthorViewModel
The Delete function in the AuthorsController needs to be updated just like the first Edit function. It creates an Automapper from the data model to the ViewModel as shown in Example 7-7.
Example 7-7. Updated Delete AuthorsController
public ActionResult Delete(int? id)
{
if (id == null)
{
return new HttpStatusCodeResult(HttpStatusCode.BadRequest);
}
Author author = db.Authors.Find(id);
if (author == null)
{
return HttpNotFound();
}
AutoMapper.Mapper.CreateMap<Author, AuthorViewModel>();
return View(AutoMapper.Mapper.Map<Author, AuthorViewModel>(author));
}
The second Delete function requires no updates because it doesn’t accept the entire Author model as input when the delete is confirmed; it simply accepts the id. The id is used to fetch the author and delete it from the database. It also does not return the Author model (because it was just deleted).
Summary
Implementing server-side ViewModels can appear as duplicated code to the data model, and I sometimes feel this way. However, as soon as you have a single property or relationship that is not required by the View, ViewModels become almost mandatory.
The next chapter will introduce Web API, for which ViewModels are a prerequisite because models are often the view in the Web API. Data models that contain a circular relationship (an author can have many books and a book has one author is a circular relationship) cannot be used in the return.