Client and Server Validation with Web API and Knockout

In this post I’m going to demonstrate how to implement both client and server side validation with ASP.NET Web API and Knockout. In particular I’m going to demonstrate how to convey server side binding and validation errors from the controller to the view model, and how to utilize Knockout client side validation features to display both client and server side errors in the view.

Model

My model comprises of 2 domain entities, Product and ProductCategory, with a one-to-many relationship between them.

using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;

namespace WebAPIKo.Models
{
    public class Product
    {
        [Key]
        [DatabaseGeneratedAttribute(DatabaseGeneratedOption.Identity)]
        public int ProductId { get; set; }

        [Required]
        [StringLength(100)]
        public string Name { get; set; }

        [Required]
        [StringLength(1000)]
        public string Description { get; set; }

        [Required]
        public int ProductCategoryId { get; set; }

        // Navigation properties
        public virtual ProductCategory ProductCategory { get; set; }
    }
}
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;

namespace WebAPIKo.Models
{
    public class ProductCategory
    {
        [Key]
        [DatabaseGeneratedAttribute(DatabaseGeneratedOption.Identity)]
        public int ProductCategoryId { get; set; }

        [Required]
        [StringLength(100)]
        public string Name { get; set; }

        // Navigational Properties
        public virtual ICollection<Product> Products { get; set; }
    }
}

I am using code first Entity Framework as my ORM. Here’s the DbContext containing DbSets for the aforementioned entities –

using System.Data.Entity;

namespace WebAPIKo.Models
{
    public class WebAPIKoDbContext : DbContext
    {
        public virtual DbSet<Product> Products { get; set; }
        public virtual DbSet<ProductCategory> ProductCategories { get; set; }
    }
}

View

My view is a simple “Create Product” form with product name and description text boxes, and a select list for product categories.

create project 2

Here’s the HTML –

<div class="row">
    <div class="col-md-12">
        <div class="panel panel-info" id="add-panel">
            <div class="panel-heading">
                <h2 class="panel-title">Create Product</h2>
            </div>
            <div class="panel-body">
                <form role="form" data-bind="validationOptions: validationOptions">
                    <div class="form-group">
                        <label for="name">Name</label>
                        <input id="name" type="text" class="form-control" data-bind="value: product.name" placeholder="Product Name" />
                    </div>
                    <div class="form-group">
                        <label for="description">Description</label>
                        <input id="description" class="form-control" data-bind="value: product.description" placeholder="Description" />
                    </div>
                    <div class="form-group">
                        <label for="product-category">Product Category</label>
                        <select id="product-category"
                                data-bind="options: productCategories, optionsText: 'name', optionsValue: 'productCategoryId',
                           optionsCaption: '-- Select a Product Category --', value: product.productCategoryId"></select>
                    </div>
                    <button type="button" class="btn btn-primary" data-bind="click: save, enable: canSave">
                        <span class="glyphicon glyphicon-save" aria-hidden="true"></span> Add
                    </button>
                    <ul id="errors" class="error-message" data-bind="foreach: generalErrors">
                        <li><span data-bind="text: $data"></span></li>
                    </ul>
                </form>
            </div>
        </div>
    </div>
</div>

Knockout data-bind attributes are used to declare the binding of the controls to the view model.

Of note are the following –

  • The <form> has a data-bind=”validationOptions: validationOptions” attribute, which applies global validation options to all validatable controls in the form.
  • There is a “errors” <ul> element at the bottom of the form, which has a data-bind=”foreach: generalErrors” attribute.  The <ul> contains a <li> element which in turn contains a <span> with a data-bind=”text: $data” attribute. A <li> will therefore be rendered for each error message in the view model’s generalErrors ko.observableArray property. I am using this to display any errors that are not specific to a field.

Here’s some styles to accompany the form –

<style type="text/css">
    select {
        height: 30px;
        display: block;
    }

    .panel {
        width: 450px;
        margin-top: 15px;
    }

    .panel-heading {
        padding: 10px 20px;
    }

    .panel-body {
        padding: 10px 20px 10px 20px;
    }

    .error-message {
        color: red;
    }

    .error-element {
        border-color: red;
    }

    #errors {
        padding-left: 0;
        padding-top: 5px;
    }

        #errors li {
            list-style-type: none;
        }
</style>

Controller

Before I present the view model I want to discuss Web API controller actions, and in particular the HTTP response header and body that will be returned depending on whether a post/put is successful or not.

With Visual Studio 2013 we have a number of Web API controller scaffolding extensions which provide basic implementation for Web API 2 controllers and actions.

newcontroller

createcontroller2

I have selected the “Web API Controller with actions, using Entity Framework” scaffolder. I am prompted to provide the model class – Product – and the data context class – WebAPIKoDbContext. And a controller is generated with working implementations of for get, post, put, and delete actions.

Here’s the basic implementation for the post action (used for creating new products) –

// POST: api/Product
[ResponseType(typeof(Product))]
public IHttpActionResult PostProduct([FromBody]Product product)
{
    if (!ModelState.IsValid)
    {
        return BadRequest(ModelState);
    }

    db.Products.Add(product);
    db.SaveChanges();

    return CreatedAtRoute("DefaultApi", new { id = product.ProductId }, product);
}

This action uses Web API model binding to bind request parameters, from the body of the request, to an instance of the Product entity.

The Product entity has a number of validation attributes, which are assessed during model binding. Any validation errors are added to the ModelState object, which is an instance of System.Web.Http.ModelBinding.ModelStateDictionary. For each validation error, the property name is added to the ModelState’s Keys collection and the error description is added to the ModelState’s Values collection. Each item in the Values collection is actually an array of strings, thus enabling more than one error description per Key.

If there are model binding or validation errors, the action returns BadRequest(ModelState). This results in a HTTP 400 response being sent to the client, along with the response body containing a JSON serialization of a Message string and the ModelState object. Here are some examples of the response body –

In the first example the model binding was unsuccessful as the parameters in the request body did not match any of the Product properties. The model binder was not able to generate the product object.

{   "Message":"The request is invalid.",
    "ModelState":{
        "product":["Error converting value \"ww\" to type 'WebAPIKo.Models.Product'. Path '', line 1, position 4."]}
}

In this second example, the model binder was able to generate the product object, but the name and description properties failed validation as no values were posted for them, whilst they are required.

{   "Message":"The request is invalid.",
    "ModelState":{
        "product.Name":["The Name field is required."],
        "product.Description":["The Description field is required."]}}

In addition to performing this validation on the server, I will also be performing model validation on the client. Validation is one example of where it is necessary to deviate from the DRY principal. If we were only to validate on the server then the user would have to submit the form and wait for a response before being notified of validation errors. This could be very frustrating for the user, and so it is better to have the browser perform validation whilst the user is interacting with the form. But the client side validation should be an addition to rather than a replacement for the server side validation. Firstly, because we cannot guarantee that the request will be correctly formed, and secondly, because not all validation can easily be performed on the client.

For example, if we have such a business rule stating that product names must be unique, and we have many products then we may need to validate the product against this rule on the server. If the product fails this rule we need to communicate this back to the client, and we can utilize the ModelState object to do this. We can also utilize the ModelState object to convey non-field-specific validation errors as shown below.

ModelState.AddModelError("product.Name", "Product name is already in use");

ModelState.AddModelError("General", "General error 1");
ModelState.AddModelError("General", "General error 2");

And resulting in the following response body –

{   "Message":"The request is invalid.",
    "ModelState":{
        "product.Name":["Product name is already in use"],
        "General":["General error 1","General error 2"]}}

Of note is that because we added 2 model errors with the “General” key, then we have 2 items in the array of error descriptions.

One further thing we need to account for in handling errors, is that it is possible that an error occurs on the server during the processing of the request. For example, there may be a network issue with the application temporarily not being able to connect to the database. In this case a HTTP 500 response will be generated with a response body similar to the following –

{   "Message":"An error has occurred.",
    "ExceptionMessage":"An exception has occurred",
    "ExceptionType":"System.Exception"}

So when we have a validation error or exception then we get a HTTP 400 or 500 response with a response body that always contains a “Message” property, and optionally contains a “ModelState” object. Our client code will therefore need to trap HTTP 400 and 500 responses, and parse the “ModelState” object if one exists. And if not, display the “Message”.

View Model

ko.observable.appendError

Knockout validation, by default, appends validation attributes and an error message span after each input control that is bound to a validateable property. For example, the view incorporates the following input control which is bound to the product.name property –

<input id="name" type="text" class="form-control" data-bind="value: product.name" placeholder="Product Name" />

After the bindings are applied, the following HTML is generated by Knockout –

<input id="name" type="text" class="form-control" data-bind="value: name" placeholder="Product Name" title="This field is required." data-orig-title="">
<span class="error-message" style="display: none;"></span>

When the user interacts with the view, entering or modifying data, the view model’s ko.observable properties are automatically updated. The Knockout validation library extends the ko.observable type to include an error property, and a setError method. Each validateable ko.observable property is validated according to it’s validation rules, and the setError method is used to assign a validation error to the error property. The setError method overwrites the value of the error property, and assigns the ko.observable as being not valid.

The mechanics of displaying client validation errors in the view is therefore available out-of-the-box with Knockout validation. We can also hook into these mechanics in order to display validation errors returned from the server.

The first thing I have done to hook into these mechanics is to extend the ko.observable type to include an appendError function, which appends new errors onto existing ones rather than overwriting them. This is needed because there is the possibility of more than one error per property being returned from the server.

///////////////////////////////////////////////////////////////////
// ko.observable.appendError

ko.observable.fn.appendError = function (error) {
    var thisError = this.error();
    if (thisError) {
        this.setError(thisError += '. ' + error);
    }
    else {
        this.setError(error)
    };
};

ProductCategory and ProductCategoryList

The productCategory and productCategoryList types contain properties and functions for the retrieval of a list of product categories from the server. The product categories are used to populate the form’s product category select control.

///////////////////////////////////////////////////////////////////
// ProductCategory

var productCategory = function (productCategoryId, name) {
    var self = this;

    // Properties
    self.productCategoryId = productCategoryId;
    self.name = name;
}

var productCategoryList = function () {
    var self = this;

    self.productCategories = ko.observableArray([]);

    self.get = function (callBack) {
        $.ajax({
            url: '/api/productCategory',
            type: 'get',
            contentType: 'application/json; charset=utf-8',
            success: function (data) {
                $.each(data, function (key, value) {
                    self.productCategories.push(new productCategory(value.ProductCategoryId, value.Name));
                });

                if (typeof callBack !== "undefined") {
                    callBack();
                };
            }
        });
    }
}

ProductVM

I will be binding an instance of ProductVM to the create product view. It is our view model object.

ProductVM  has a self.product object, which is a wrapper for the name, description, and productCategoryId ko.observable properties. I am using product as a wrapper for these properties as it makes the properties easier to work with as a group.

self.product = {
    name: ko.observable(null),
    description: ko.observable(null),
    productCategoryId: ko.observable(0)
}

ProductVM also has self.productCategories which is a ko.observableArray property for storing product categories. An array of product categories is passed into the ProductVM’s constructor during initialization, and this array is in turn passed into the ko.observableArray’s constructor. self.productCategories is used to populate the view’s product category select list.

self.productCategories = ko.observableArray(productCategories);

Next I have the assignment of Knockout validation validation rules to the ko.observable product properties. And the creation of a validation group, which enables us to display, hide, or remove all validation errors.

self.product.name.extend({
    required: true,
    maxLength: 100
});

self.product.description.extend({
    required: true,
    maxLength: 1000
});

self.product.productCategoryId.extend({
    required: true
});

self.errors = ko.validation.group(self.product);

I then define self.generalErrors which is a ko.observableArray that will store all validation errors that are not specific to one of the validatable properties. This array is bound to the errors list towards the bottom of the view using a foreach binding. As such, when it is populated with one or more validation errors, a list item element is created for each, and the messages are displayed.

self.generalErrors = ko.observableArray([]);

The next thing I do is define some validation options. These options are bound, using a validationOptions binding to the form. They are therefore applicable to all input controls in the form.

self.validationOptions = {
    decorateInputElement: true,
    errorElementClass: 'error-element',
    errorMessageClass: 'error-message'
};

Next we have some properties that manage state. The first of these, self.isSaving, is set to true at the start of a save, and then set to false once the save is complete. The next, self.isValid, is a ko.computed property that is set to true when there are no validation error in the errors group, and false when there are. The third property, self.canSave, is a ko.computed property that is set to true when self.isValid, and !self.isSaving. self.canSave is bound to the enable attribute of the view’s add button.

self.isSaving = ko.observable(false);

self.isValid = ko.computed(function () {
    return self.errors().length == 0;
});

self.canSave = ko.computed(function () {
    return self.isValid() && !self.isSaving();
});

The final thing we have in the ProductVM is the save method.

If there are no validation errors the save method sets self.isSaving to true, which has the effect of disabling the view’s add button. This stops the user from invoking concurrent saves. The save method then uses ko.toJSON to generate a JSON string containing the product properties.

The save method then performs an ajax POST to the /api/products url, passing the JSON string in the request body, and if the ajax request is successful then self.isSaving to false.

However, if the ajax request is not successful, and a HTTP 400 response is returned from the server then the following logic is performed –

  • Check to see if there is a ModelState object in the response body.
  • If there is a ModelState object then loop through the keys, and for each key loop through the errors.
  • For each error, if the key matches a validateable property name then append the error to the property.
  • If the key does not match a property name then push the error to the generalErrors observableArray.
  • If the response body does not contain a ModelState object – which is optional – then push the Message string – which is required – to the generalErrors array.

And if a HTTP 500 response is returned then the following logic is performed –

  • The response body will not contain a ModelState object. Push the Message string to the generalErrors array.
self.save = function () {
    if (self.errors().length == 0) {
        self.isSaving(true);

        var dataObject = ko.toJSON(self.product);

        $.ajax({
            url: '/api/products',
            type: 'post',
            data: dataObject,
            dataType: 'json',
            contentType: 'application/json; charset=utf-8',
            success: function (data) {
                self.isSaving(false);
            },
            statusCode: {
                400: function (data) {
                    if (typeof data.responseJSON.ModelState !== 'undefined') {
                        $.each(data.responseJSON.ModelState, function (key, errors) {
                            $.each(errors, function (index, error) {
                                switch (key) {
                                    case 'Name':
                                        self.product.name.appendError(error);
                                        break;
                                    case 'Description':
                                        self.product.description.appendError(error);
                                        break;
                                    case 'ProductCategory':
                                        self.product.productCategoryId.appendError(error);
                                        break;
                                    default:
                                        self.generalErrors.push(error);
                                        break;
                                };
                            });
                        });
                    }
                    else {
                        self.generalErrors.push(data.responseJSON.Message);
                    };
                },
                500: function (data) {
                    self.generalErrors.push(data.statusText + '. Please try again.');
                }
            }
        });
    }
    else {
        self.errors.showAllMessages(true);
    }
};

Because we are appending and pushing the errors to the validatable ko.observable properties and the generalErrors ko.observableArray, and these are bound to the form, then the validation errors will automatically be displayed in the appropriate places in the view.

This is what we will see.

createformerrors

Kick-off

The final thing I have in the view model is the code to kick it all off –

/////////////////////////////////////////////////////////////////
// Let's kick it all off

var productCategoryList = new productCategoryList();

productCategoryList.get(function () {
    var vm = new productVM(productCategoryList.productCategories());
    ko.applyBindings(vm, $addPanel[0]);
    vm.errors.showAllMessages(false);
});

 

For completeness, here’s the full view model.

<script type="text/javascript">
    ///////////////////////////////////////////////////////////////////
    // ko.observable.appendError

    ko.observable.fn.appendError = function (error) {
        var thisError = this.error();
        if (thisError) {
            this.setError(thisError += '. ' + error);
        }
        else {
            this.setError(error)
        };
    };

    $(function () {
        var $addPanel = $('#add-panel');
        ///////////////////////////////////////////////////////////////////
        // ProductCategory

        var productCategory = function (productCategoryId, name) {
            var self = this;

            // Properties
            self.productCategoryId = productCategoryId;
            self.name = name;
        }

        var productCategoryList = function () {
            var self = this;

            self.productCategories = ko.observableArray([]);

            self.get = function (callBack) {
                $.ajax({
                    url: '/api/productCategory',
                    type: 'get',
                    contentType: 'application/json; charset=utf-8',
                    success: function (data) {
                        $.each(data, function (key, value) {
                            self.productCategories.push(new productCategory(value.ProductCategoryId, value.Name));
                        });

                        if (typeof callBack !== "undefined") {
                            callBack();
                        };
                    }
                });
            }
        }

        ///////////////////////////////////////////////////////////////////
        // ProductVM

        var productVM = function (productCategories) {
            var self = this;

            // Properties

            self.product = {
                name: ko.observable(null),
                description: ko.observable(null),
                productCategoryId: ko.observable(0)
            }

            self.productCategories = ko.observableArray(productCategories);

            // Validation

            self.product.name.extend({
                required: true,
                maxLength: 100
            });

            self.product.description.extend({
                required: true,
                maxLength: 1000
            });

            self.product.productCategoryId.extend({
                required: true
            });

            self.errors = ko.validation.group(self.product);

            self.generalErrors = ko.observableArray([]);

            self.validationOptions = {
                decorateInputElement: true,
                errorElementClass: 'error-element',
                errorMessageClass: 'error-message'
            };

            // State

            self.isSaving = ko.observable(false);

            self.isValid = ko.computed(function () {
                return self.errors().length == 0;
            });

            self.canSave = ko.computed(function () {
                return self.isValid() && !self.isSaving();
            });

            // Methods

            self.save = function () {
                if (self.errors().length == 0) {
                    self.isSaving(true);

                    var dataObject = ko.toJSON(self.product);

                    $.ajax({
                        url: '/api/products',
                        type: 'post',
                        data: dataObject,
                        dataType: 'json',
                        contentType: 'application/json; charset=utf-8',
                        success: function (data) {
                            self.isSaving(false);
                        },
                        statusCode: {
                            400: function (data) {
                                if (typeof data.responseJSON.ModelState !== 'undefined') {
                                    $.each(data.responseJSON.ModelState, function (key, errors) {
                                        $.each(errors, function (index, error) {
                                            switch (key) {
                                                case 'product.Name':
                                                    self.product.name.appendError(error);
                                                    break;
                                                case 'product.Description':
                                                    self.product.description.appendError(error);
                                                    break;
                                                case 'product.ProductCategory':
                                                    self.product.productCategoryId.appendError(error);
                                                    break;
                                                default:
                                                    self.generalErrors.push(error);
                                                    break;
                                            };
                                        });
                                    });
                                }
                                else {
                                    self.generalErrors.push(data.responseJSON.Message);
                                };
                            },
                            500: function (data) {
                                self.generalErrors.push(data.statusText + '. Please try again.');
                            }
                        }
                    });
                }
                else {
                    self.errors.showAllMessages(true);
                }
            };
        }

        /////////////////////////////////////////////////////////////////
        // Let's kick it all off

        var productCategoryList = new productCategoryList();

        productCategoryList.get(function () {
            var vm = new productVM(productCategoryList.productCategories());
            ko.applyBindings(vm, $addPanel[0]);
            vm.errors.showAllMessages(false);
        });
    });
</script>

 

Advertisements

Create, Update, and Delete with KoGrid

In my previous post I demonstrated how to implement server-side paging with KoGrid and Knockout within the context of an ASP.NET web application, and utilizing MVC and WebApi controllers. In this post I’m going to demonstrate how to extend the grid to enable the user to create, update, and delete records.

I’m going to add an ‘action’ column to the grid, with edit and delete buttons for each record. And I’m going to add an add button.

kogrid2

When the user clicks on a record’s edit button, an edit panel is displayed. The save button is initially disabled, and will be enabled if the user changes the details and all fields are valid. When the user clicks on the save button the changes will be saved, the edit panel will be hidden, and the grid will be refreshed.

kogrid3

When the user clicks on the add button, an add panel is displayed. The add button is initially disabled and will be enabled when the user has entered valid values for all fields. When the user clicks on the add button, the new record is added, the add panel is hidden, and the grid is refreshed.

kogrid4

And when the user clicks on a delete button, the record will be deleted and the grid refreshed.

As per my previous post I’m going to present the solution in the following order –

  1. Model
  2. Controller
  3. View
  4. ViewModel

1. Model

Domain Model

The domain model remains unchanged, with a one-to-many relationship between product categories and products.

ProductCategory.cs

using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;

namespace WebAPIKo.Models
{
    public class ProductCategory
    {
        [Key]
        [DatabaseGeneratedAttribute(DatabaseGeneratedOption.Identity)]
        public int ProductCategoryId { get; set; }

        [Required]
        [StringLength(100)]
        public string Name { get; set; }

        // Navigational Properties
        public virtual ICollection<Product> Products { get; set; }
    }
}

Product.cs

using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;

namespace WebAPIKo.Models
{
    public class Product
    {
        [Key]
        [DatabaseGeneratedAttribute(DatabaseGeneratedOption.Identity)]
        public int ProductId { get; set; }

        [Required]
        [StringLength(100)]
        public string Name { get; set; }

        [Required]
        [StringLength(1000)]
        public string Description { get; set; }

        [Required]
        public int ProductCategoryId { get; set; }

        // Navigation properties
        public virtual ProductCategory ProductCategory { get; set; }
    }
}

WebApiDbContext.cs

using System.Data.Entity;

namespace WebAPIKo.Models
{
    public class WebAPIKoDbContext : DbContext
    {
        public virtual DbSet<Product> Products { get; set; }
        public virtual DbSet<ProductCategory> ProductCategories { get; set; }
    }
}

Data Transfer Objects (DTOs)

ProductCategoryDTO.cs

When editing and adding products we need to allow the user to select a product category from a list. We therefore need a ProductCategoryDTO class to transfer product category details to the view model.

namespace WebAPIKo.Models
{
    public class ProductCategoryDTO
    {
        public int ProductCategoryId { get; set; }
        public string Name { get; set; }
    }
}

ProductDTO.cs

I have added ProductCategoryId to the ProductDTO class, as we now need to uniquely identify a product’s category when editing the product.

namespace WebAPIKo.Models
{
    public class ProductDTO
    {
        public int ProductId { get; set; }
        public string Name { get; set; }
        public string Description { get; set; }
        public int ProductCategoryId { get; set; }
        public string ProductCategoryName { get; set; }
    }
}

ProductListDTO.cs

The ProductListDTO class has not changed. The grid still requires a page of products and a count of the total number of products matching the selected filter.

using System.Collections.Generic;

namespace WebAPIKo.Models
{
    public class ProductListDTO
    {
        public int ProductCount { get; set; }
        public List<ProductDTO> PageOfProducts { get; set; }
    }
}

2. Controller

HomeController.cs

The view/view model – ProductKoGrid.cshtml – is served up by the home controller.

using System.Web.Mvc;

namespace WebAPIKo.Controllers
{
    public class HomeController : Controller
    {
        public ActionResult ProductKoGrid()
        {
            return View();
        }
    }
}

ProductController.cs

In addition to GetProducts, the Product controller has the following actions –

  • GetProduct(int id)
    • Queries the domain model for a Product with the specified id.
    • If not found then returns a 404 (not found) response.
    • If found the returns a 200 (OK) response along with a ProductDTO class containing the product details.
  • PutProduct([FromBody]Product product)
    • Updates an existing product.
    • The [FromBody] attribute tells the controller to map the values contained in the request body to a Product object.
    • If the product is not valid a 400 (bad request) response is returned.
    • An attempt is made to save the changes to the product. If successful a 204 (success, no content) response is returned.
    • If a DbUpdateConcurrencyException occurs whilst saving the changes then either a 404 (not found) response is returned if the product does not exist on the database (it may have been deleted by another user), or a 500 (internal server error) is returned if the product does exist.
  • PostProduct([FromBody]Product product)
    • Adds a new product.
    • The [FromBody] attribute tells the controller to map the values contained in the request body to a Product object.
    • If the product is not valid a 400 (bad request) response is returned.
    • An attempt is made to add the product. If successful a 201 (created) response is returned.
  • DeleteProduct(int id)
    • Queries the domain model for a Product with the specified id.
    • If not found then returns a 404 (not found) response.
    • If found then deletes the product and returns a 200 (OK) response.
using System.Data.Entity;
using System.Data.Entity.Infrastructure;
using System.Linq;
using System.Linq.Dynamic;
using System.Net;
using System.Web.Http;
using System.Web.Http.Description;
using WebAPIKo.Models;

namespace WebAPIKo.Controllers
{
    public class ProductsController : ApiController
    {
        private WebAPIKoDbContext db = new WebAPIKoDbContext();

        // GET: api/Product
        public IHttpActionResult GetProducts([FromUri]int page, [FromUri]int pageSize, [FromUri]string filter, [FromUri]string sort)
        {       
            ProductListDTO dto = new ProductListDTO();

            IQueryable<Product> query = db.Products;

            if (filter != null)
            {
                if (filter.Contains(':'))
                {
                    string[] filterArray = filter.Split(':');
                    switch (filterArray[0].ToLower())
                    {
                        case "name":
                            query = query.Where(p => p.Name.Contains(filterArray[1].Trim()));
                            break;
                        case "description":
                            query = query.Where(p => p.Description.Contains(filterArray[1].Trim()));
                            break;
                    }
                }
                else
                {
                    query = query.Where(p => p.Name.Contains(filter.Trim()) || p.Description.Contains(filter.Trim()));
                }
            }

            dto.ProductCount = query.Count();

            query = query.OrderBy(sort == null ? "name asc" : sort).Skip((page - 1) * pageSize).Take(pageSize);

            dto.PageOfProducts = query.Select(p => new ProductDTO()
                {
                    ProductId = p.ProductId,
                    Name = p.Name,
                    Description = p.Description,
                    ProductCategoryId = p.ProductCategoryId,
                    ProductCategoryName = p.ProductCategory.Name
                }
                ).ToList(); 

            return Ok(dto);
        }

        // GET: api/Product/5
        [ResponseType(typeof(Product))]
        public IHttpActionResult GetProduct(int id)
        {
            Product product = db.Products.Find(id);
            if (product == null)
            {
                return NotFound();
            }

            return Ok(new ProductDTO() { ProductId = product.ProductId, Name = product.Name, ProductCategoryId = product.ProductCategoryId });
        }

        // PUT: api/Product/5
        [ResponseType(typeof(void))]
        public IHttpActionResult PutProduct([FromBody]Product product)
        {
            if (!ModelState.IsValid)
            {
                return BadRequest(ModelState);
            }

            db.Entry(product).State = EntityState.Modified;

            try
            {
                db.SaveChanges();
            }
            catch (DbUpdateConcurrencyException)
            {
                if (!ProductExists(product.ProductId))
                {
                    return NotFound();
                }
                else
                {
                    throw;
                }
            }

            return StatusCode(HttpStatusCode.NoContent);
        }

        // POST: api/Product
        [ResponseType(typeof(Product))]
        public IHttpActionResult PostProduct([FromBody]Product product)
        {
            if (!ModelState.IsValid)
            {
                return BadRequest(ModelState);
            }

            db.Products.Add(product);
            db.SaveChanges();

            return CreatedAtRoute("DefaultApi", new { id = product.ProductId }, product);
        }

        // DELETE: api/Product/5
        [ResponseType(typeof(Product))]
        public IHttpActionResult DeleteProduct(int id)
        {
            Product product = db.Products.Find(id);
            if (product == null)
            {
                return NotFound();
            }

            db.Products.Remove(product);
            db.SaveChanges();

            return Ok(product);
        }

        protected override void Dispose(bool disposing)
        {
            if (disposing)
            {
                db.Dispose();
            }
            base.Dispose(disposing);
        }

        private bool ProductExists(int id)
        {
            return db.Products.Count(e => e.ProductId == id) > 0;
        }
    }
}

ProductCategoryController.cs

The ProductCategoryController class is an ApiController, containing a single action – GetProductCategories – which returns a list of ProductCategoryDTO objects.

using System.Collections.Generic;
using System.Linq;
using System.Web.Http;
using WebAPIKo.Models;

namespace WebAPIKo.Controllers
{
    public class ProductCategoryController : ApiController
    {
        private WebAPIKoDbContext db = new WebAPIKoDbContext();

        // GET: api/ProductCategory
        public IHttpActionResult GetProductCategories()
        {
            db.Configuration.ProxyCreationEnabled = false;

            List<ProductCategoryDTO> dto = new List<ProductCategoryDTO>();

            dto = db.ProductCategories.Select(pc =>
                new ProductCategoryDTO()
                {
                    ProductCategoryId = pc.ProductCategoryId,
                    Name = pc.Name
                }
                ).ToList();

            return Ok(dto);
        }

        protected override void Dispose(bool disposing)
        {
            if (disposing)
            {
                db.Dispose();
            }
            base.Dispose(disposing);
        }
    }
}

3. View

HTML

When the add or edit panel is displayed, we need to simulate modality by disabling the rest of the page. This is achieved by displaying an opaque <div> over the whole page with a z-order that is greater than all elements on the page except for the panel.

<div id="disabling-div"></div>

I have added a footer to the list panel. The footer contains an add button, which has it’s click event bound to the view models “get” method.

<div class="row">
    <div class="col-md-12">
        <div class="panel panel-primary list-panel" id="list-panel">
            <div class="panel-heading list-panel-heading">
                <h3 class="panel-title list-panel-title">Products</h3>
                <button type="button" class="btn btn-default btn-md refresh-button" data-bind="click: get">
                    <span class="glyphicon glyphicon-refresh" aria-hidden="true"></span> Refresh
                </button>
            </div>
            <div class="panel-body">
                <div class="gridStyle" data-bind="koGrid: gridOptions"></div>
            </div>
            <div class="panel-footer">
                <button type="button" class="btn btn-primary btn-md" data-bind="click: add">
                    <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add
                </button>
            </div>
            <img src="~/Content/Images/ajax-loader.gif" class="loading-indicator" id="loading-indicator" />
        </div>
    </div>
</div>

I have added 2 separate panels, containing the add and edit forms. It does not matter where on the page these panels are placed as initially they will be hidden and then will be displayed at an absolute position using a SlideDown transition.

Each panel is comprised of the following –

  • A close icon in the panel header, which has it’s click event bound to the respective view model’s cancel() method.
  • A form containing form controls that are bound to the appropriate view model’s properties.
  • The id field is hidden in the add panel, and read-only in the edit.
  • A select control is used to display the product category options. The options are populated from the view model’s productCategories field. We will see in the next section that this field is a ko.observableArray containing productCategory objects. The selected value is bound to the view model’s productCategoryId property.
  • A save button bound to the view model’s cancel() method.
  • The add form has an add button bound to the add view model’s add() method. This button is initially disabled, and will be enabled when the user has completed entering valid values.
  • The edit form has an edit button bound to the edit view model’s update() method. This button is initially disabled and will be enabled when the user has changed at least one value, and all values are valid.
<div class="panel panel-info add-panel" id="add-panel">
    <div class="panel-heading">
        <span class="closeIcon"><span class="glyphicon glyphicon-remove" data-bind="click: cancel"></span></span>
        <h2 class="panel-title">Add New Product</h2>
    </div>
    <div class="panel-body">
        <form id="add-form" role="form">
            <div class="form-group">
                <input id="add-id" type="hidden" class="form-control" data-bind="value: productId" />
            </div>
            <div class="form-group">
                <label for="add-name">Name</label>
                <input id="add-name" type="text" class="form-control" data-bind="value: name" placeholder="Product Name" />
            </div>
            <div class="form-group">
                <label for="add-description">Description</label>
                <input id="add-description" class="form-control" data-bind="value: description" placeholder="Description" />
            </div>
            <div class="form-group">
                <label for="add-product-category">Product Category</label>
                <select id="add-product-category"
                        data-bind="options: productCategories, optionsText: 'name', optionsValue: 'productCategoryId',
                           optionsCaption: '-- Select a Product Category --', value: productCategoryId"></select>
            </div>
            <button type="button" class="btn btn-primary" data-bind="click: add, enable: canSave">
                <span class="glyphicon glyphicon-save" aria-hidden="true"></span> Add
            </button>
            <button type="button" class="btn btn-primary" data-bind="click: cancel">
                <span class="glyphicon glyphicon-remove" aria-hidden="true"></span> Cancel
            </button>
        </form>
    </div>
</div>

<div class="panel panel-info edit-panel" id="edit-panel">
    <div class="panel-heading">
        <span class="closeIcon"><span class="glyphicon glyphicon-remove" data-bind="click: cancel"></span></span>
        <h2 class="panel-title">Edit Product</h2>
    </div>
    <div class="panel-body">
        <form id="edit-form" role="form">
            <div class="form-group">
                <label for="edit-id">Id</label>
                <input id="edit-id" type="hidden" class="form-control" data-bind="value: productId" />
                <p data-bind="text: productId"></p>
            </div>
            <div class="form-group">
                <label for="edit-name">Name</label>
                <input id="edit-name" type="text" class="form-control" data-bind="value: name" placeholder="Product Name" />
            </div>
            <div class="form-group">
                <label for="edit-description">Description</label>
                <input id="edit-description" class="form-control" data-bind="value: description" placeholder="Description" />
            </div>
            <div class="form-group">
                <label for="edit-product-category">Product Category</label>
                <select id="add-product-category"
                        data-bind="options: productCategories, optionsText: 'name', optionsValue: 'productCategoryId',
                           optionsCaption: '-- select a Product Category --', value: productCategoryId"></select>
            </div>
            <button type="button" class="btn btn-primary" data-bind="click: update, enable: canSave">
                <span class="glyphicon glyphicon-save" aria-hidden="true"></span> Save
            </button>
            <button type="button" class="btn btn-primary" data-bind="click: cancel">
                <span class="glyphicon glyphicon-remove" aria-hidden="true"></span> Cancel
            </button>
        </form>
    </div>
</div>

CSS

I have made some small changes to the .gridStyle, kg*, and .listPanel styles, which style the grid and grid panel.

The .addPanel and .editPanel styles, as their names suggest style the add and edit panels. The panels are initially hidden and are positioned towards the top of the page. The z-index is set at 9999. This ensures that they are displayed over the disabling div.

The .closeIcon style is used to position remove glyphicons towards the right hand side of the add and edit panel headers. This simulates a modal close button.

The #disabling-div style positions the disabling div across the whole page, sets it opacity to 50%, and it’s z-index to 1001. The div when visible should then be displayed over all page elements except for the add and edit panels.

<style type="text/css">
    select {
        height: 30px;
        display: block;
    }

    .panel-heading {
        padding: 10px 20px;
    }

    .panel-body {
        padding: 20px 20px 10px 20px;
    }

    .gridStyle {
        border: 1px solid rgb(212,212,212);
        width: 100%;
        height: 300px;
        margin: auto;
    }

    .kgColMenu {
        width: 200px;
    }

        .kgColMenu input[type=text] {
            width: 100%;
        }

    .kgColList {
        padding-left: 25px;
    }

    .kgColListItem label {
        width: 100%;
    }

    .row-button {
        margin-top: 6px;
    }

    .closeIcon {
        position: absolute;
        right: 10px;
        top: 10px;
        font-weight: bold;
        font-family: sans-serif;
        cursor: pointer;
    }

    .add-panel, .edit-panel {
        position: absolute;
        top: 105px;
        width: 400px;
        display: none;
        margin: auto;
        left: 50%;
        margin-left: -200px;
        z-index: 9999;
    }

    .list-panel {
        margin-top: 20px;
    }

        .list-panel .panel-heading {
            overflow: auto;
            padding: 5px 20px;
        }

        .list-panel .panel-title {
            float: left;
            margin-top: 10px;
        }

        .list-panel .panel-footer {
            padding: 5px 20px;
        }

        .list-panel .refresh-button {
            float: right;
        }

    .loading-indicator {
        position: absolute;
        left: 50%;
        top: 50%;
        margin-left: -26px;
        margin-top: -26px;
        display: none;
        z-index: 9999;
    }

    #disabling-div {
        display: none;
        z-index: 1001;
        position: absolute;
        top: 0;
        left: 0;
        width: 100%;
        height: 100%;
        background-color: white;
        opacity: .50;
        filter: alpha(opacity=50);
    }
</style>

4. ViewModel

Notifier

To keep the code modular, I have created a separate view model for each of the add, edit, and list panels. I want to keep these view models independent, with no knowledge of each other.

For example, the edit view model contains an update() method that communicates changes to the controller via an ajax post. After a successful update, we want the product list to be refreshed, and the list view model contains a get() method to achieve this. We could have the edit view model directly call the list view model’s get() method. But the edit view model would now be dependent on the list view model, making changes more difficult to implement.

A simple answer to our problem is provided by Knockout.

ko.subscribable, is the base object that all Knockout observables derive from. It contains a subscribe() method which enables subscribers to register a function to be called when an event occurs. The subscribe() method accepts three parameters –

  • callBack – the function that is called whenever the event notification occurs.
  • target (optional) – defines the value of this in the callback function.
  • event (optional; default is “change”) – the name of the event.

ko.subscribable also contains a notifySubscribers() method which accepts two parameters –

  • valueToNotify (optional) – value to pass through to the subscribers.
  • event – the names of the event.

I have defined a notifier object to be a global instance of ko.subscribable(), and I have defined some event names –

///////////////////////////////////////////////////////////////////
// Notifier
var notifier = new ko.subscribable();
var PRODUCT_UPDATED = "PRODUCT_UPDATED";
var INVOKE_ADD = "INVOKE_ADD";
var INVOKE_EDIT = "INVOKE_EDIT";

And for our example where we want the list view model’s get() method to be called on successful completion of the edit view model’s update() – the list view model registers it’s get() method using the following code –

notifier.subscribe(self.get, null, PRODUCT_UPDATED);

And the edit view model raises the notification on successful completion of the update() method using the following code –

notifier.notifySubscribers(null, PRODUCT_UPDATED);

Transitions

The add and edit panels are initially hidden. When the user chooses to add a new record, or edit an existing record, we need to reveal the relevant panel, whilst displaying the disabling div that obscures the rest of the view.

I have created a transition object to encapsulate this logic. It has 2 methods – show() and hide(), both of which receive a target and an optional callBack function.

///////////////////////////////////////////////////////////////////////////
// Transitions

var transition = {

    duration: 200,

    show: function (target, callBack) {
        if (target.is(':hidden')) {
            $disablingDiv.show();
            target.slideDown(transition.duration, function () {
                target.find('input[type!=hidden]:first').focus();
                if (typeof callBack !== "undefined") {
                    callBack();
                }
            });
        }
        else if (typeof callBack !== "undefined") {
            callBack();
        }
    },

    hide: function (target, callBack) {
        if (target.is(':visible')) {
            $disablingDiv.hide();
            target.slideUp(transition.duration, callBack);
        }
        else if (typeof callBack !== "undefined") {
            callBack();
        }
    }
}

ProductCategory and ProductCategoryList

The productCategoryList object is comprised of a ko.observableArray, and a get() method.

The get() method retrieves product categories via an ajax get call, and for each creates an instance of productCategory and pushes it to the ko.observableArray.

I have added callBack as an optional argument to the get() method, and will be using this to ensure that a complete list of product categories has been populated prior to binding the add and edit view models.

///////////////////////////////////////////////////////////////////
// ProductCategory

var productCategory = function (productCategoryId, name) {
    var self = this;

    // Properties
    self.productCategoryId = productCategoryId;
    self.name = name;
}

var productCategoryList = function () {
    var self = this;

    self.productCategories = ko.observableArray([]);

    self.get = function (callBack) {
        $.ajax({
            url: '/api/productCategory',
            type: 'get',
            contentType: 'application/json; charset=utf-8',
            success: function (data) {
                $.each(data, function (key, value) {
                    self.productCategories.push(new productCategory(value.ProductCategoryId, value.Name));
                });

                if (typeof callBack !== "undefined") {
                    callBack();
                };
            }
        });
    }
}

Product

The product object type is a data container, with ko.observable properties. The list view model has a ko.observableArray containing product objects, which is bound to the grid. Also, as discussed in the next section, the add product and edit product view models are based on the product type.

///////////////////////////////////////////////////////////////////
// Product

var product = function (data) {
    var self = this;

    self.productId = ko.observable(data.productId || 0);
    self.name = ko.observable(data.name || null);
    self.description = ko.observable(data.description || null);
    self.productCategoryId = ko.observable(data.productCategoryId || 0);
    self.productCategoryName = ko.observable(data.productCategoryName || null);
}

ProductVM

The productVM object type is an extension of product, and is comprised of data, configuration properties, and methods shared by the add and edit product view models.

///////////////////////////////////////////////////////////////////
// ProductVM

var productVM = function (productCategories, panel) {
    var self = this;

    // Functional Inheritance

    ko.utils.extend(self, new product({}));

    // Properties

    self.productCategories = ko.observableArray(productCategories);

    // Validation

    self.name.extend({
        required: true,
        maxLength: 100
    });

    self.description.extend({
        required: true,
        maxLength: 1000
    });

    self.productCategoryId.extend({
        required: true
    });

    self.errors = ko.validation.group([self.productId, self.name, self.description, self.productCategoryId]);

    // State

    self.hasChanged = ko.observable(false);
    self.isSaving = ko.observable(false);

    self.isValid = ko.computed(function () {
        return self.errors().length == 0;
    });

    self.canSave = ko.computed(function () {
        return self.isValid() && self.hasChanged() && !self.isSaving();
    });

    // Subscriptions

    self.name.subscribe(function (value) {
        self.hasChanged(true);
    });

    self.description.subscribe(function (value) {
        self.hasChanged(true);
    });

    self.productCategoryId.subscribe(function (value) {
        self.hasChanged(true);
    });

    // Methods

    self.initialize = function (data) {
        if (data != null) {
            self.productId(data.productId() || 0);
            self.name(data.name() || null);
            self.description(data.description() || null);
            self.productCategoryId(data.productCategoryId() || 0);
        }
        else {
            self.errors.showAllMessages(false);
        }

        self.hasChanged(false);

        self.show();
    };

    self.cancel = function () {
        self.hide();
    };

    self.show = function () {
        transition.show(panel);
    }

    self.hide = function () {
        // Hide and tidy up
        transition.hide(panel, function () {
            self.productId(0);
            self.name(null);
            self.description(null);
            self.hasChanged(false);
            self.errors.showAllMessages(false);
        });
    }
}

productVM is comprised of the following –

  • A constructor that takes 2 arguments – productCategories which is a collection of productCategory objects, and panel which is the JQuery reference to either the add or edit panel.
  • A self.productCategories property of type ko.observableArray. The productCategories argument is passed into it’s constructor, and it is used to populate a select list when the view model is bound to the view.
  • Extension calls which add Knockout validation configuration information to the product properties.
  • A Knockout validation group, self.errors, containing the properties that require validation.
  • Properties used for tracking state –
    • self.hasChanged – tracks whether one or more properties have been changed.
    • self.isSaving – tracks whether the view model is currently saving. This is necessary as the save operations are asynchronous.
    • self.isValid – tracks whether the product is valid. This is a computed based on the number of validation errors in the self.errors validation group.
    • self.canSave – tracks whether the product can be saved, and is computed as self.isValid() && self.hasChanged && !self.isSaving.
  • Subscriptions which assign functions that set self.hasChanged(true) in response to a change to one of the product properties.
  • A self.initialize method that initializes the state of the view model, including setting product properties. The reason I have an initialize method to set the state, rather than using the view model’s constructor, is that I want to create a single instance of each the add and edit view models and bind them against the view once. But then I want to reuse these view models for adding and editing products consecutively without having to rebind.
  • A self.cancel() function which is bound to the add and edit panel cancel buttons. The self.cancel function calls the self.hide() function.
  • A self.show() function which utilizes the transition object to display the panel. I will describe how and when this method gets executed when I discuss the productAddVM and productEditVM implementations.
  • A self.hide() function which utilizes the transition object to hide the panel, and includes some tidy-up code in a callBack function.

ProductAddVM

productAddVM is the view model that is bound to the add product panel. It extends productVM, and adds in functionality that is specific to adding products.

///////////////////////////////////////////////////////////////////
// ProductAddVM

var productAddVM = function (productCategories) {
    var self = this;

    var panel = $addPanel;

    // Functional Inheritance

    ko.utils.extend(self, new productVM(productCategories, panel));

    // Subscriptions

    notifier.subscribe(self.initialize, null, INVOKE_ADD);

    // Methods

    self.add = function () {

        if (self.errors().length == 0) {

            self.isSaving(true);

            var dataObject = ko.toJSON(this);

            $.ajax({
                url: productsUri,
                type: 'post',
                data: dataObject,
                dataType: 'json',
                contentType: 'application/json; charset=utf-8',
                success: function (data) {
                    self.isSaving(false);

                    self.hide();

                    notifier.notifySubscribers(null, PRODUCT_UPDATED);
                }
            });
        }
        else {
            self.errors.showAllMessages();
        }
    };
}

productAddVM is comprised of the following –

  • A constructor that takes a productCategories argument, which is then passed through to the base productVM and assigned to self.productCategories. I have taken the decision to retrieve a set of product categories from the server when the page is initialized, and then pass these into the productAddVM when they are constructed. I am assuming that the product categories will not be changed during the life-time of the page.
  • A JQuery reference to the add panel. This is also passed through to the base productVM and used by the show and hide methods.
  • A notifier subscription, which requests the execution of the self.initialize method when the INVOKE_ADD event notification is triggered. The list view model will trigger this event when the user clicks on the grid’s add button.
  • An update() method which utilizes a ajax put to pass the new product details to the server. On successful execution the method hides the panel and triggers the PRODUCT_UPDATED event notification.

ProductEditVM

productEditVM is bound to the edit panel, and also extends productVM. It contains functionality that is specific to editing products.

///////////////////////////////////////////////////////////////////
// ProductEditVM

var productEditVM = function (productCategories) {
    var self = this;

    var panel = $editPanel;

    // Functional Inheritance

    ko.utils.extend(self, new productVM(productCategories, panel));

    // Subscriptions

    notifier.subscribe(self.initialize, null, INVOKE_EDIT);

    // Methods

    self.update = function () {
        if (self.errors().length == 0) {

            self.isSaving(true);

            var dataObject = ko.toJSON(this);

            $.ajax({
                url: productsUri,
                type: 'put',
                data: dataObject,
                dataType: 'json',
                contentType: 'application/json; charset=utf-8',
                success: function (data) {
                    self.isSaving(false);

                    self.hide();

                    notifier.notifySubscribers(null, PRODUCT_UPDATED);
                }
            });
        }
        else {
            self.errors.showAllMessages();
        }
    };
}

It is comprised of the following –

  • A constructor that takes a productCategories argument, which is passed through to the base productVM.
  • A JQuery reference to the edit panel. This is also passed through to the base productVM and used by the show and hide methods.
  • A notifier subscription, which requests the execution of the self.initialize method when the INVOKE_EDIT event notification is triggered. The list view model will trigger this event when the user clicks on the one of grid’s edit buttons.
  • An update() method which utilizes an ajax post to send product updates to the server. On successful execution the method hides the panel and triggers the PRODUCT_UPDATED event notification.

ProductListVM

prodictListVM  is the view model that is bound to the product grid.

///////////////////////////////////////////////////////////////////
// ProductListVM

var productListVM = function () {
    var self = this;

    // Properties
    self.products = ko.observableArray([]);

    self.columnDefs = [
        { field: 'productId', displayName: 'Id', width: 80 },
        { field: 'name', displayName: 'Name', width: 200 },
        { field: 'description', displayName: 'Description' },
        { field: 'productCategoryName', displayName: 'Product Category', width: 200 },
        { field: 'productId', displayName: ' ', cellTemplate: $('#editDeleteCellTemplate').html(), width: 150, sortable: false }
    ];

    self.filterOptions = {
        filterText: ko.observable(""),
        useExternalFilter: false
    };

    self.pagingOptions = {
        currentPage: ko.observable(1),
        pageSizes: ko.observableArray([2, 5, 10, 20, 50]),
        pageSize: ko.observable(2),
        totalServerItems: ko.observable(0)
    };

    self.sortInfo = ko.observable({ column: { 'field': 'name' }, direction: 'asc' });

    self.gridOptions = {
        data: self.products,
        columnDefs: self.columnDefs,
        autogenerateColumns: false,
        showGroupPanel: true,
        canSelectRows: false,
        showFilter: true,
        filterOptions: self.filterOptions,
        enablePaging: true,
        pagingOptions: self.pagingOptions,
        sortInfo: self.sortInfo,
        rowHeight: 35
    };

    // Subscriptions

    self.pagingOptions.pageSize.subscribe(function (data) {
        self.pagingOptions.currentPage(1);
        self.get();
    });

    self.pagingOptions.currentPage.subscribe(function (data) {
        self.get();
    });

    self.sortInfo.subscribe(function (data) {
        self.pagingOptions.currentPage(1);
        self.get();
    });

    notifier.subscribe(self.get, null, PRODUCT_UPDATED);

    // Methods

    self.get = function () {
        $loadingIndicator.show();

        $.ajax({
            url: productsUri,
            type: 'get',
            data: {
                'page': self.pagingOptions.currentPage(),
                'pageSize': self.pagingOptions.pageSize(),
                'filter': self.filterOptions.filterText == undefined ? '' : self.filterOptions.filterText(),
                'sort': self.sortInfo().column.field + ' ' + self.sortInfo().direction
            },
            contentType: 'application/json; charset=utf-8',
            success: function (data) {
                self.pagingOptions.totalServerItems(data.ProductCount);

                var productsArray = [];
                $.each(data.PageOfProducts, function (key, value) {
                    productsArray.push(
                        new product({
                            productId: value.ProductId,
                            name: value.Name,
                            description: value.Description,
                            productCategoryId: value.ProductCategoryId,
                            productCategoryName: value.ProductCategoryName
                        }));
                });
                self.products(productsArray);

                $loadingIndicator.hide();
            }
        });
    };

    self.add = function () {
        notifier.notifySubscribers(null, INVOKE_ADD);
    };

    self.edit = function (item) {
        notifier.notifySubscribers(item, INVOKE_EDIT);
    };

    self.delete = function (item) {

        bootbox.confirm("Are you sure?", function (result) {
            Example.show("Confirm result: " + result);
        });
        if (confirm("Are you sure you want to delete " + item.name() + "?")) {
            var dataObject = JSON.stringify({ id: item.productId() });

            $.ajax({
                url: productsUri + '/' + item.productId(),
                type: 'delete',
                dataType: 'json',
                contentType: 'application/json; charset=utf-8',
                success: function (data) {
                    self.get();
                }
            });
        }
    }
}

In addition to the functionality described in the previous blog post, productListVM is comprised of the following –

  • An additional column definition, which generates the column containing edit and delete buttons for each product. The column is generated based on a editDeleteCellTemplate column template. this template is outlined in the nest section.
  • A notifier subscription, registering the get() method to be called when the PRODUCT_UPDATED event is invoked. This event is invoked by either the add or edit view model when a product is updated. The grid is therefore refreshed in response to a product being updated.
  • An add() method, which invokes the INVOKE_ADD notification. This method is bound to the add buttons click event.
  • An edit() method, which invokes the INVOKE_EDIT notification. This method is bound to the grid’s edit buttons click event, and passes through the view model item specific to the button that was clicked.
  • A delete() method, which asked for conformation, and then uses an ajax delete call to delete the product. On successfull completion the get() method is called and hence the grid is refreshed.

Edit/Delete Cell Template

The following html template is used by the list view model to generate the column containing the edit and delete buttons –

<script type="text/template" id="editDeleteCellTemplate">
    <div>
        <button type="button" class="btn btn-primary btn-xs row-button" data-bind="click: function() { $parent.$userViewModel.edit($parent.entity); }">
            <span class="glyphicon glyphicon-edit" aria-hidden="true"></span> Edit
        </button>
        <button type="button" class="btn btn-danger btn-xs row-button" data-bind="click: function() { $parent.$userViewModel.delete($parent.entity); }">
            <span class="glyphicon glyphicon-minus" aria-hidden="true"></span> Delete
        </button>
    </div>
</script>

Kicking it all off

First thing I do is create an instance of productListVM, and bind this instance to the list panel.

I then create an instance of productCategoryList, and call the get() method, which gets the product categories from the server.

I pass a callBack function to the productCategoryList.get() method, to ensure that the full list is retrieved prior to performing the following –

  • create a new instance of productAddVM, passing the product category list into the constructor, and binding this instance to the add panel.
  • create a new instance of productEditVM, passing the product category list into the constructor, and binding this instance to the edit panel.
  • and finally, call the prroductListVM.get() method to populate the product grid.
/////////////////////////////////////////////////////////////////
// Let's kick it all off

var productListVM = new productListVM();
ko.applyBindings(productListVM, $listPanel[0]);

var productCategoryList = new productCategoryList();

productCategoryList.get(function () {
    ko.applyBindings(new productAddVM(productCategoryList.productCategories()), $addPanel[0]);
    ko.applyBindings(new productEditVM(productCategoryList.productCategories()), $editPanel[0]);
    productListVM.get();
});

For completeness, here’s the full View Model code –

<script type="text/template" id="editDeleteCellTemplate">
<div>
    <button type="button" class="btn btn-primary btn-xs row-button" data-bind="click: function() { $parent.$userViewModel.edit($parent.entity); }">
        <span class="glyphicon glyphicon-edit" aria-hidden="true"></span> Edit
    </button>
    <button type="button" class="btn btn-danger btn-xs row-button" data-bind="click: function() { $parent.$userViewModel.delete($parent.entity); }">
        <span class="glyphicon glyphicon-minus" aria-hidden="true"></span> Delete
    </button>
</div>
</script>

<script type="text/javascript">
$(function () {
    var $listPanel = $('#list-panel');
    var $addPanel = $('#add-panel');
    var $editPanel = $('#edit-panel');

    var $disablingDiv = $('#disabling-div');
    var $loadingIndicator = $('#loading-indicator');

    var productsUri = '/api/products';

    ///////////////////////////////////////////////////////////////////
    // Notifier
    var notifier = new ko.subscribable();
    var PRODUCT_UPDATED = "PRODUCT_UPDATED";
    var INVOKE_ADD = "INVOKE_ADD";
    var INVOKE_EDIT = "INVOKE_EDIT";

    ///////////////////////////////////////////////////////////////////
    // ProductCategory

    var productCategory = function (productCategoryId, name) {
        var self = this;

        // Properties
        self.productCategoryId = productCategoryId;
        self.name = name;
    }

    var productCategoryList = function () {
        var self = this;

        self.productCategories = ko.observableArray([]);

        self.get = function (callBack) {
            $.ajax({
                url: '/api/productCategory',
                type: 'get',
                contentType: 'application/json; charset=utf-8',
                success: function (data) {
                    $.each(data, function (key, value) {
                        self.productCategories.push(new productCategory(value.ProductCategoryId, value.Name));
                    });

                    if (typeof callBack !== "undefined") {
                        callBack();
                    };
                }
            });
        }
    }

    ///////////////////////////////////////////////////////////////////
    // Product

    var product = function (data) {
        var self = this;

        self.productId = ko.observable(data.productId || 0);
        self.name = ko.observable(data.name || null);
        self.description = ko.observable(data.description || null);
        self.productCategoryId = ko.observable(data.productCategoryId || 0);
        self.productCategoryName = ko.observable(data.productCategoryName || null);
    }

    ///////////////////////////////////////////////////////////////////
    // ProductVM, ProductAddVM, and ProductEditVM

    var productVM = function (productCategories, panel) {
        var self = this;

        // Functional Inheritance

        ko.utils.extend(self, new product({}));

        // Properties

        self.productCategories = ko.observableArray(productCategories);

        // Validation

        self.name.extend({
            required: true,
            maxLength: 100
        });

        self.description.extend({
            required: true,
            maxLength: 1000
        });

        self.productCategoryId.extend({
            required: true
        });

        self.errors = ko.validation.group([self.productId, self.name, self.description, self.productCategoryId]);

        // State

        self.hasChanged = ko.observable(false);
        self.isSaving = ko.observable(false);

        self.isValid = ko.computed(function () {
            return self.errors().length == 0;
        });

        self.canSave = ko.computed(function () {
            return self.isValid() && self.hasChanged() && !self.isSaving();
        });

        // Subscriptions

        self.name.subscribe(function (value) {
            self.hasChanged(true);
        });

        self.description.subscribe(function (value) {
            self.hasChanged(true);
        });

        self.productCategoryId.subscribe(function (value) {
            self.hasChanged(true);
        });

        // Methods

        self.initialize = function (data) {
            if (data != null) {
                self.productId(data.productId() || 0);
                self.name(data.name() || null);
                self.description(data.description() || null);
                self.productCategoryId(data.productCategoryId() || 0);
            }
            else {
                self.errors.showAllMessages(false);
            }

            self.hasChanged(false);

            self.show();
        };

        self.cancel = function () {
            self.hide();
        };

        self.show = function () {
            transition.show(panel);
        }

        self.hide = function () {
            // Hide and tidy up
            transition.hide(panel, function () {
                self.productId(0);
                self.name(null);
                self.description(null);
                self.hasChanged(false);
                self.errors.showAllMessages(false);
            });
        }
    }

    var productAddVM = function (productCategories) {
        var self = this;

        var panel = $addPanel;

        // Functional Inheritance

        ko.utils.extend(self, new productVM(productCategories, panel));

        // Subscriptions

        notifier.subscribe(self.initialize, null, INVOKE_ADD);

        // Methods

        self.add = function () {

            if (self.errors().length == 0) {

                self.isSaving(true);

                var dataObject = ko.toJSON(this);

                $.ajax({
                    url: productsUri,
                    type: 'post',
                    data: dataObject,
                    dataType: 'json',
                    contentType: 'application/json; charset=utf-8',
                    success: function (data) {
                        self.isSaving(false);

                        self.hide();

                        notifier.notifySubscribers(null, PRODUCT_UPDATED);
                    }
                });
            }
            else {
                self.errors.showAllMessages();
            }
        };
    }

    var productEditVM = function (productCategories) {
        var self = this;

        var panel = $editPanel;

        // Functional Inheritance

        ko.utils.extend(self, new productVM(productCategories, panel));

        // Subscriptions

        notifier.subscribe(self.initialize, null, INVOKE_EDIT);

        // Methods

        self.update = function () {
            if (self.errors().length == 0) {

                self.isSaving(true);

                var dataObject = ko.toJSON(this);

                $.ajax({
                    url: productsUri,
                    type: 'put',
                    data: dataObject,
                    dataType: 'json',
                    contentType: 'application/json; charset=utf-8',
                    success: function (data) {
                        self.isSaving(false);

                        self.hide();

                        notifier.notifySubscribers(null, PRODUCT_UPDATED);
                    }
                });
            }
            else {
                self.errors.showAllMessages();
            }
        };
    }

    ///////////////////////////////////////////////////////////////////
    // ProductListVM

    var productListVM = function () {
        var self = this;

        // Properties
        self.products = ko.observableArray([]);

        self.columnDefs = [
            { field: 'productId', displayName: 'Id', width: 80 },
            { field: 'name', displayName: 'Name', width: 200 },
            { field: 'description', displayName: 'Description' },
            { field: 'productCategoryName', displayName: 'Product Category', width: 200 },
            { field: 'productId', displayName: ' ', cellTemplate: $('#editDeleteCellTemplate').html(), width: 150, sortable: false }
        ];

        self.filterOptions = {
            filterText: ko.observable(""),
            useExternalFilter: false
        };

        self.pagingOptions = {
            currentPage: ko.observable(1),
            pageSizes: ko.observableArray([2, 5, 10, 20, 50]),
            pageSize: ko.observable(2),
            totalServerItems: ko.observable(0)
        };

        self.sortInfo = ko.observable({ column: { 'field': 'name' }, direction: 'asc' });

        self.gridOptions = {
            data: self.products,
            columnDefs: self.columnDefs,
            autogenerateColumns: false,
            showGroupPanel: true,
            canSelectRows: false,
            showFilter: true,
            filterOptions: self.filterOptions,
            enablePaging: true,
            pagingOptions: self.pagingOptions,
            sortInfo: self.sortInfo,
            rowHeight: 35
        };

        // Subscriptions

        self.pagingOptions.pageSize.subscribe(function (data) {
            self.pagingOptions.currentPage(1);
            self.get();
        });

        self.pagingOptions.currentPage.subscribe(function (data) {
            self.get();
        });

        self.sortInfo.subscribe(function (data) {
            self.pagingOptions.currentPage(1);
            self.get();
        });

        notifier.subscribe(self.get, null, PRODUCT_UPDATED);

        // Methods

        self.get = function () {
            $loadingIndicator.show();

            $.ajax({
                url: productsUri,
                type: 'get',
                data: {
                    'page': self.pagingOptions.currentPage(),
                    'pageSize': self.pagingOptions.pageSize(),
                    'filter': self.filterOptions.filterText == undefined ? '' : self.filterOptions.filterText(),
                    'sort': self.sortInfo().column.field + ' ' + self.sortInfo().direction
                },
                contentType: 'application/json; charset=utf-8',
                success: function (data) {
                    self.pagingOptions.totalServerItems(data.ProductCount);

                    var productsArray = [];
                    $.each(data.PageOfProducts, function (key, value) {
                        productsArray.push(
                            new product({
                                productId: value.ProductId,
                                name: value.Name,
                                description: value.Description,
                                productCategoryId: value.ProductCategoryId,
                                productCategoryName: value.ProductCategoryName
                            }));
                    });
                    self.products(productsArray);

                    $loadingIndicator.hide();
                }
            });
        };

        self.add = function () {
            notifier.notifySubscribers(null, INVOKE_ADD);
        };

        self.edit = function (item) {
            notifier.notifySubscribers(item, INVOKE_EDIT);
        };

        self.delete = function (item) {

            bootbox.confirm("Are you sure?", function (result) {
                Example.show("Confirm result: " + result);
            });
            if (confirm("Are you sure you want to delete " + item.name() + "?")) {
                var dataObject = JSON.stringify({ id: item.productId() });

                $.ajax({
                    url: productsUri + '/' + item.productId(),
                    type: 'delete',
                    dataType: 'json',
                    contentType: 'application/json; charset=utf-8',
                    success: function (data) {
                        self.get();
                    }
                });
            }
        }
    }

    ///////////////////////////////////////////////////////////////////////////
    // Transitions

    var transition = {

        duration: 200,

        show: function (target, callBack) {
            if (target.is(':hidden')) {
                $disablingDiv.show();
                target.slideDown(transition.duration, function () {
                    target.find('input[type!=hidden]:first').focus();
                    if (typeof callBack !== "undefined") {
                        callBack();
                    }
                });
            }
            else if (typeof callBack !== "undefined") {
                callBack();
            }
        },

        hide: function (target, callBack) {
            if (target.is(':visible')) {
                $disablingDiv.hide();
                target.slideUp(transition.duration, callBack);
            }
            else if (typeof callBack !== "undefined") {
                callBack();
            }
        }
    }

    /////////////////////////////////////////////////////////////////
    // Let's kick it all off

    var productListVM = new productListVM();
    ko.applyBindings(productListVM, $listPanel[0]);

    var productCategoryList = new productCategoryList();

    productCategoryList.get(function () {
        ko.applyBindings(new productAddVM(productCategoryList.productCategories()), $addPanel[0]);
        ko.applyBindings(new productEditVM(productCategoryList.productCategories()), $editPanel[0]);
        productListVM.get();
    });
});

</script>

Unit Testing ASP.NET MVC Authorization

To restrict access to an ASP.NET MVC view we restrict access to the controller action that renders the view. We do this by decorating the controller and/or controller action with [Authorize] and [AllowAnonymous] attributes.

Note. We can also apply the Authorize filter globally by adding it to applications GlobalFiltersCollection.

Below is an example of a controller where I have restricted access as follows –

  1. Decorated the controller with the [Authorize] attribute.
  2. Decorated the Index() action with the [AllowAnonymous] attribute, which overrides the controller’s [Authorize] attribute, allowing all users to access the Index view.
  3. Not decorated the Details(int id) action. It therefore inherits the controller’s [Authorize] attribute, allowing only authenticated users to access the Details view.
  4. Decorated both the Create() and Create(FormCollection collection) actions with the [Authorize(Roles = “Admin”, Users = “Ross”)] attribute. This further restricts access to only authenticated users who are either associated with the Admin role or whose user name is Ross.
using System.Web.Mvc;

namespace BikeStore.Controllers
{
    [Authorize]
    public class WidgetController : Controller
    {
        // GET: Widget
        [AllowAnonymous]
        public ActionResult Index()
        {
            return View();
        }

        // GET: Widget/Details/5
        public ActionResult Details(int id)
        {
            return View();
        }

        // GET: Widget/Create
        [Authorize(Roles = "Admin", Users = "Ross")]
        public ActionResult Create()
        {
            return View();
        }

        // POST: Widget/Create
        [HttpPost]
        [Authorize(Roles = "Admin", Users = "Ross")]
        public ActionResult Create(FormCollection collection)
        {
            try
            {
                // TODO: Add insert logic here

                return RedirectToAction("Index");
            }
            catch
            {
                return View();
            }
        }
    }
}

Security is a high priority for virtually all applications. When I deploy code for user acceptance testing I want to do so having fully unit tested the authorization.

When a request enters the MVC pipeline, the authorization filters are applied prior to a controller action being executed. The authorization filters are part of the MVC framework, and as such we do not need to test them. We can assume that they work as described. Instead what we need to do is test that the controller actions have had the authorization attributes correctly applied.

The AuthorizationTest class – shown below – contains a number of helper methods that utilize reflection to validate controller action’s authorization.

The IsAnonymous method validates whether a controller action method allows anonymous access. The method receives an instance of the controller, a method name, and an array of parameter types. The array of parameter types is used to differentiate between overloaded methods – a common practice in MVC. If a method is not overloaded then this parameter can be set to null.

For a controller action to allow anonymous access, either,

  1. the action must be decorated with the [AllowAnonymous] attribute, or
  2. neither the action nor the controller is decorated with the [Authorize] attribute.

Please note that this code assumes that the Authorize filter has not been applied globally.

There are 2 overloads of the IsAuthorized method, which validate whether a controller is authorized. The 1st overload receives an instance of the controller, a method name, and an array of parameter types.

This overload of the method applies the following logic to determine if the controller action is authorized. Either,

  1. the method is decorated with a [Authorize] attribute, or
  2. the controller is decorated with a [Authorize] attribure whilst the method is not decorated with a [AllowAnonymous] attribute.

The second overload of the IsAuthorized method add 2 parameters. An array of role names and an array of user names. This overload validates whether the controller action is authorized – as above – and then validates whether it is authorized for the roles and users.

using System;
using System.Linq;
using System.Reflection;
using System.Web.Mvc;

namespace Framework.Tests.Mvc
{
    public static class AuthorizationTest
    {
        /// <summary>
        /// Check to see if a method allows anonymous access -
        /// 1. A method is anonymous if it is decorated with the AllowAnonymousAttribute attribute.
        /// 2. Or, a method is anonymous if neither the method nor controller are decorated with the AuthorizeAttribute attribute.
        /// </summary>
        /// <param name="controller"></param>
        /// <param name="methodName"></param>
        /// <param name="methodTypes">Optional</param>
        /// <returns>true is method is anonymous</returns>
        public static bool IsAnonymous(Controller controller, string methodName, Type[] methodTypes)
        {
            return GetMethodAttribute<AllowAnonymousAttribute>(controller, methodName, methodTypes) != null ||
                (GetControllerAttribute<AuthorizeAttribute>(controller) == null &&
                    GetMethodAttribute<AuthorizeAttribute>(controller, methodName, methodTypes) == null);

        }

        /// <summary>
        /// Check to see if a method requires authorization -
        /// 1. A method is authorized if it is decorated with the Authorize attribute.
        /// 2. Or, a method is authorized if the controller is decorated with the AuthorizeAttribute attribute, and
        /// the method is not decorated with the AllowAnonymousAttribute attribute.
        /// </summary>
        /// <param name="controller"></param>
        /// <param name="methodName"></param>
        /// <param name="methodTypes">Optional</param>
        /// <returns></returns>
        public static bool IsAuthorized(Controller controller, string methodName, Type[] methodTypes)
        {
            return GetMethodAttribute<AuthorizeAttribute>(controller, methodName, methodTypes) != null ||
                (GetControllerAttribute<AuthorizeAttribute>(controller) != null &&
                    GetMethodAttribute<AllowAnonymousAttribute>(controller, methodName, methodTypes) == null);
        }

        /// <summary>
        /// Check to see if a method requires authorization for the roles and users specified
        /// </summary>
        /// <param name="controller"></param>
        /// <param name="methodName"></param>
        /// <param name="methodTypes">Optional</param>
        /// <param name="roles"></param>
        /// <param name="users"></param>
        /// <returns></returns>
        public static bool IsAuthorized(Controller controller, string methodName, Type[] methodTypes, string[] roles, string[] users)
        {
            if (roles == null && users == null)
                return IsAuthorized(controller, methodName, methodTypes);

            if (!IsAuthorized(controller, methodName, methodTypes))
                return false;

            AuthorizeAttribute controllerAttribute = GetControllerAttribute<AuthorizeAttribute>(controller);
            AuthorizeAttribute methodAttribute = GetMethodAttribute<AuthorizeAttribute>(controller, methodName, methodTypes);

            // Check to see if all roles are authorized
            if (roles != null)
            {
                foreach (string role in roles)
                {
                    string lowerRole = role.ToLower();

                    bool roleIsAuthorized =
                        (controllerAttribute != null ?
                            controllerAttribute.Roles.ToLower().Split(',').Any(r => r == lowerRole) : false) ||
                        (methodAttribute != null ?
                            methodAttribute.Roles.ToLower().Split(',').Any(r => r == lowerRole) : false);

                    if (!roleIsAuthorized)
                        return false;
                }
            }

            // Check to see if all users are authorized
            if (users != null)
            {
                foreach (string user in users)
                {
                    string lowerUser = user.ToLower();

                    bool userIsAuthorized =
                        (controllerAttribute != null ?
                            controllerAttribute.Users.ToLower().Split(',').Any(u => u == lowerUser) : false) ||
                        (methodAttribute != null ?
                            methodAttribute.Users.Split(',').Any(u => u.ToLower() == lowerUser) : false);

                    if (!userIsAuthorized)
                        return false;
                }
            }

            return true;
        }

        private static T GetControllerAttribute<T>(Controller controller) where T : Attribute
        {
            Type type = controller.GetType();
            object[] attributes = type.GetCustomAttributes(typeof(T), true);
            T attribute = attributes.Count() == 0 ? null : (T)attributes[0];
            return attribute;
        }

        private static T GetMethodAttribute<T>(Controller controller, string methodName, Type[] methodTypes) where T : Attribute
        {
            Type type = controller.GetType();
            if (methodTypes == null)
            {
                methodTypes = new Type[0];
            }
            MethodInfo method = type.GetMethod(methodName, methodTypes);
            object[] attributes = method.GetCustomAttributes(typeof(T), true);
            T attribute = attributes.Count() == 0 ? null : (T)attributes[0];
            return attribute;
        }
    }
}

And here are some tests which utilize the AuthorizationTest class to validate whether the WidgetController actions will be correctly authorized based on the authorization attributes –

using BikeStore.Controllers;
using Framework.Tests.Mvc;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using System;
using System.Web.Mvc;

namespace BikeStore.Tests.Controllers
{
    [TestClass]
    public class WidgetControllerTests
    {
        [TestMethod]
        public void Index_IsAnonymous()
        {
            // Arrange
            WidgetController controller = new WidgetController();

            // Assert
            Assert.IsTrue(AuthorizationTest.IsAnonymous(
                controller,
                "Index",
                null));
        }

        [TestMethod]
        public void Details_IsAuthorized()
        {
            // Arrange
            WidgetController controller = new WidgetController();

            // Asset
            Assert.IsTrue(AuthorizationTest.IsAuthorized(
                controller,
                "Details",
                new Type[] { typeof(int) }));
        }

        [TestMethod]
        public void Create_Get_IsAuthorized()
        {
            // Arrange
            WidgetController controller = new WidgetController();

            // Assert
            Assert.IsTrue(AuthorizationTest.IsAuthorized(
                controller,
                "Create",
                null,
                new string[] { "Admin" },
                new string[] { "Ross" } ));
        }

        [TestMethod]
        public void Create_Post_IsAuthorized()
        {
            // Arrange
            WidgetController controller = new WidgetController();

            // Assert
            Assert.IsTrue(AuthorizationTest.IsAuthorized(
                controller,
                "Create",
                new Type[] { typeof(FormCollection) },
                new string[] { "Admin" },
                new string[] { "Ross" } ));
        }
    }
}

Populating Parameter Lists Using MDX

In a previous post I demonstrated how to surface SSAS data in an ASP.NET MVC website with Google charts.

If you have access to SQL Server Reporting Services (SSRS) then this should be your first port of call when considering how to incorporate charts in your website. It’s an enterprise level solution, providing enterprise level features for monitoring and optimizing report delivery. It also provides a comprehensive set of report authoring tools, and gives the report authors detailed control over most aspects of the reports, through the use of report expressions.

However, there a number of significant limitations with SSRS. Report parameters are restricted to Boolean, DateTime, Integer, Float, and Text. And the controls used to render the parameters are quite basic and cannot be customized. Also, interactivity is limited to drill-throughs and drill-downs. Business users are becoming accustomed to and are demanding rich user experiences. SSRS does not really offer the flexibility to meet these demands, hence the need to consider alternative solutions such as Google Charts.

What I want to do is take the next step from the previous post – where I populated Google graphs – by discussing how to populate parameter lists using MDX. These parameter lists can be rendered in an MVC view using one of a multitude of JQuery UI components, with the richness of the UI and interactivity being only limited by the developer ability and imagination.

The first steps is to query the database for the parameter captions and values. The following script does this for the [Data].[Calendar].[Calendar Year] set –

WITH
	MEMBER [Measures].[ParameterCaption] AS [Date].[Calendar].CURRENTMEMBER.MEMBER_CAPTION
	MEMBER [Measures].[ParameterValue] AS [Date].[Calendar].CURRENTMEMBER.UNIQUENAME
SELECT
	{ [Measures].[ParameterCaption], [Measures].[ParameterValue] } ON COLUMNS,
	{ [Date].[Calendar].[Calendar Year] } ON ROWS
FROM
	[Adventure Works]

Returning the following –

paramlist1

I will be using the above query for the rest of this post. However, if we require members from different levels we can additionally return the level ordinal. This can then be used to help us populate a tree-view for example. The following script does this for the [Date].[Calendar] set, and ensures that only the [All Periods], [Calendar Year], and [Month] levels are returned. Please note that I exclude the unwanted levels, rather than build a set using the wanted levels, so that the remaining members will be returned in the correct order –

WITH
	MEMBER [Measures].[ParameterCaption] AS [Date].[Calendar].CURRENTMEMBER.MEMBER_CAPTION
	MEMBER [Measures].[ParameterValue] AS [Date].[Calendar].CURRENTMEMBER.UNIQUENAME
	MEMBER [Measures].[ParameterLevel] AS [Date].[Calendar].CURRENTMEMBER.LEVEL_NUMBER
SELECT
	{	[Measures].[ParameterCaption],
		[Measures].[ParameterValue],
		[Measures].[ParameterLevel] } ON COLUMNS,
	{	[Date].[Calendar].MEMBERS
		- [Date].[Calendar].[Calendar Semester]
		- [Date].[Calendar].[Calendar Quarter]
		- [Date].[Calendar].[Date] } ON ROWS
FROM
	[Adventure Works]

Which returns –

paramlist2

I want to bind the results to a JQuery control in my view. And I will do this by retrieving the parameter data using a Ajax Get call – as I did to populate the charts in the previous post.

I therefore want a Controller action method as follows –

public JsonResult CalendarYears()
{
    Dictionary<string, string> data = ReportData.CalendarYears();
    return Json(data, JsonRequestBehavior.AllowGet);
}

The controller action method calls the ReportData.CalendarYears method to return a dictionary containing the key and value pairs for the parameter. It then serializes the dictionary into a Json response.

The ReportData.CalendarYears method is shown below –

using Microsoft.AnalysisServices.AdomdClient;
using System.Collections.Generic;
using System.Data;

namespace Charts.Reports
{
    public class ReportData
    {
        public static Dictionary<string, string> CalendarYears()
        {
            string command = @" WITH
                                MEMBER [Measures].[ParameterCaption] AS [Date].[Calendar].CURRENTMEMBER.MEMBER_CAPTION
                                MEMBER [Measures].[ParameterValue] AS [Date].[Calendar].CURRENTMEMBER.UNIQUENAME
                            SELECT
                                { [Measures].[ParameterCaption], [Measures].[ParameterValue] } ON COLUMNS,
                                { [Date].[Calendar].[Calendar Year] } ON ROWS
                            FROM
                                [Adventure Works]";

            return GetData(command).ToDictionary("[Measures].[ParameterValue]", "[Measures].[ParameterCaption]");
        }

        private static DataTable GetData(string command)
        {
            DataSet ds = new DataSet();

            using (AdomdConnection conn = new AdomdConnection("Data Source=localhost"))
            {
                conn.Open();
                using (AdomdCommand cmd = new AdomdCommand(command, conn))
                {
                    AdomdDataAdapter adapter = new AdomdDataAdapter(cmd);
                    adapter.Fill(ds);
                }
                conn.Close();
            }

            return ds.Tables[0];
        }
    }
}

The ReportData.CalendarYears method passes the MDX statement to the GetData method, which in turn connects to the database, executes the command, and fills a DataSet with the results. The GetData method then returns the first DataTable in the DataSet.

The ReportData.CalendarYears method converts the DataTable into a Dictionary<string, string> by calling a ToDictionary DataTable extension method. It specifies which columns should be used for the key and value data by specifying the respective column names.

Here’s the ToDictionary Datable extension method –

using System.Collections.Generic;
using System.Data;
using System.Linq;

namespace Charts.Reports
{
    public static class DataTableExtensionMethods
    {

        public static Dictionary<string, string> ToDictionary(this DataTable dt, string keyColumnName, string valueColumnName)
        {
            Dictionary<string, string> dictionary = new Dictionary<string, string>();

            int keyColumn = dt.Columns.IndexOf(keyColumnName);
            int valueColumn = dt.Columns.IndexOf(valueColumnName);

            foreach (DataRow row in dt.Rows)
            {
                dictionary.Add(row[keyColumn].ToString(), row[valueColumn].ToString());
            }

            return dictionary;
        }
    }
}

I’m not going to demonstrate constructing the view. The view will request the parameter list data using an Ajax Get request. And when the user has made their selection, will use Ajax Post requests to get the chart data based on the selected key values. The selected key values will be used to construct a MDX query – in order to filter the results set. Here’s an example of such a query, which has a WHERE clause restricting the results to the years 2012 and 2013 –

SELECT
    NON EMPTY [Product].[Product Categories].[Category] ON ROWS,
    {[Measures].[Internet Sales Amount]} ON COLUMNS
FROM
    [Adventure Works]
WHERE
    {[Date].[Calendar].[Calendar Year].&[2012],[Date].[Calendar].[Calendar Year].&[2013]}

Surfacing SSAS Data With Google Charts

In this post I’m going to demonstrate how to surface SSAS data in an MVC view using ADOMD.NET and Google Charts.

This is what I’m going to create –

charts

1. ASP.NET MVC Project

First step is to create a new ASP.NET MVC Web Application project.

Then add a reference to the ADOMD.NET client components library (microsoft.analysisservices.adomdclient.dll).

Unfortunately, the library is not available through NuGet – don’t ask me why. Instead we need to download and install the SQL Server feature pack from the following link –

https://www.microsoft.com/en-gb/download/details.aspx?id=42295

And then reference the library in the following folder –

C:\Program Files\Microsoft.NET\ADOMD.NET\120\Microsoft.AnalysisServices.AdomdClient.dll

2. ReportData data-access class

Next step is to create a Data folder and then add the following data-access class  –

using Microsoft.AnalysisServices.AdomdClient;
using System.Collections.Generic;
using System.Data;
using System.Linq;

namespace Charts.Data
{
    public class ReportData
    {
        public static List<object> SalesByYear()
        {
            string command = @" SELECT 
                                    NON EMPTY [Date].[Calendar].[Calendar Year] ON ROWS, 
                                    {[Measures].[Reseller Sales Amount], [Measures].[Internet Sales Amount]} ON COLUMNS
                                FROM 
                                    [Adventure Works]";

            return GetData(command);
        }

        public static List<object> InternetSalesByCategory()
        {
            string command = @" SELECT 
                                    NON EMPTY [Product].[Product Categories].[Category] ON ROWS, 
                                    {[Measures].[Internet Sales Amount]} ON COLUMNS
                                FROM 
                                    [Adventure Works]
                                WHERE
                                    [Date].[Calendar].[Calendar Year].&[2013]";

            return GetData(command);
        }

        public static List<object> ResellerSalesByCategory()
        {
            string command = @" SELECT 
                                    NON EMPTY [Product].[Product Categories].[Category] ON ROWS, 
                                    {[Measures].[Reseller Sales Amount]} ON COLUMNS
                                FROM 
                                    [Adventure Works]
                                WHERE
                                    [Date].[Calendar].[Calendar Year].&[2013]";

            return GetData(command);
        }

        private static List<object> GetData(string command)
        {
            DataSet ds = new DataSet();

            using (AdomdConnection conn = new AdomdConnection("Data Source=localhost"))
            {
                conn.Open();
                using (AdomdCommand cmd = new AdomdCommand(command, conn))
                {
                    AdomdDataAdapter adapter = new AdomdDataAdapter(cmd);
                    adapter.Fill(ds);
                }
                conn.Close();
            }

            return ConvertDataTableToObjectList(ds.Tables[0]);
        }

        private static List<object> ConvertDataTableToObjectList(DataTable dt)
        {
            List<object> data = new List<object>();
            int columnCount = dt.Columns.Count;

            string[] columnObject = new string[columnCount];
            for (int i = 0; i < columnCount; i++)
            {
                string name = dt.Columns[i].ColumnName.Replace(".[MEMBER_CAPTION]", "");
                string[] nameParts = name.Split(new char[] { '.' });

                columnObject[i] = nameParts[nameParts.Count() - 1].Replace("[", "").Replace("]", "");
            }
            data.Add(columnObject);

            foreach (DataRow row in dt.Rows)
            {
                data.Add(row.ItemArray);
            }

            return data;
        }
    }
}

There are 3 data-access methods, 1 per chart –

  1. SalesByYear
  2. InternetSalesByCategory
  3. ResellerSalesByCategory

Each of these methods defines an MDX statement, and then passes the statement to the GetData method.

The GetData method opens a connection to the SSAS database, and then executes the MDX statement, utilizing the AdomdDataAdapter.Fill method to populate a DataSet with the results.

The DataSet contains a collection of DataTable objects. The code assumes that the first in the collection is populated with the results from the query. I’m not sure under what circumstances there would be more than one DataTable?

The ConvertDataTableToObjectList method converts the results into a format that Google charts will understand, populating a list of objects with column names and values from the DataTable.

The column names contain dimension, hierarchy, level information. And additionally are surrounded by curly brackets, and may be suffixed with “‘[MEMBER_CAPTION]”. For example, here are the column names from the 1st query –

  • {[Date].[Calendar].[Calendar Year].[MEMBER_CAPTION]}
  • {[Measures].[Reseller Sales Amount]}
  • {[Measures].[Internet Sales Amount]}

The ConvertDataTableToObjectList strips off the superfluous information, leaving the level names –

  • Calendar Year
  • Reseller Sales Amount
  • Internet Sales Amount

3. Home Controller

using Charts.Data;
using System.Collections.Generic;
using System.Web.Mvc;

namespace Charts.Controllers
{
    public class HomeController : Controller
    {
        public ActionResult Index()
        {
            return View();
        }

        public JsonResult SalesByYear()
        {
            List<object> data = ReportData.SalesByYear();
            return Json(data, JsonRequestBehavior.AllowGet);
        }

        public JsonResult InternetSalesByCategory()
        {
            List<object> data = ReportData.InternetSalesByCategory();
            return Json(data, JsonRequestBehavior.AllowGet);
        }

        public JsonResult ResellerSalesByCategory()
        {
            List<object> data = ReportData.ResellerSalesByCategory();
            return Json(data, JsonRequestBehavior.AllowGet);
        }

        public ActionResult About()
        {
            ViewBag.Message = "Your application description page.";

            return View();
        }

        public ActionResult Contact()
        {
            ViewBag.Message = "Your contact page.";

            return View();
        }
    }
}

The following action methods have been added to the Home controller –

  • SalesByYear
  • InternetSalesByCategory
  • ResellerSalesByCategory

Each of the methods gets a List<object> containing the chart data from the appropriate data access method, and then returns this data serialized as JSON.

4.Home Index View

@{
    ViewBag.Title = "Home Page";
}

<h1>Google Charts Demo</h1>

<div class="row">
    <div class="col-md-12">
        <div class="chart"
             data-url="/Home/SalesByYear"
             data-chart-type="Line"
             data-options='{ "title": "Sales by Year", "vAxis": { "format": "$#,###" } }'>
        </div>
    </div>
</div>
<div class="row">
    <div class="col-md-6">
        <div class="chart"
             data-url="/Home/InternetSalesByCategory"
             data-chart-type="Pie"
             data-options='{ "title": "2013 Internet Sales by Category", "is3D": "true" }'>
        </div>
    </div>
    <div class="col-md-6">
        <div class="chart"
             data-url="/Home/ResellerSalesByCategory"
             data-chart-type="Pie"
             data-options='{ "title": "2013 Reseller Sales by Category", "is3D": "true" }'>
        </div>
    </div>
</div>

@section scripts
{
    <script type="text/javascript"
            src="https://www.google.com/jsapi?autoload={
            'modules':[{
              'name':'visualization',
              'version':'1',
              'packages':['corechart']
            }]
          }"></script>

    <script type="text/javascript">
        $().ready(function () {
            google.setOnLoadCallback(drawCharts);
        });

        function drawCharts() {
            var chart = $('div.chart');
            $.each(chart, drawChart);
        }

        function drawChart() {
            var $this = $(this);
            var url = $this.data('url');
            var chartType = $this.data('chart-type');
            var options = $this.data('options');
            var chartArea = $this[0];

            $.get(url)
             .done(function (jsonArray) {
                 var chart;
                 var data = google.visualization.arrayToDataTable(jsonArray);
                 var defaultOptions = {
                     title: 'Chart',
                     chartArea: { left: '100' },
                     height: '400'
                 };
                 $.extend(true, defaultOptions, options);

                 switch (chartType) {
                     case "Pie":
                         chart = new google.visualization.PieChart(chartArea);
                         break;
                     default:
                         chart = new google.visualization.LineChart(chartArea);
                 }

                 chart.draw(data, defaultOptions);
             });
        }
    </script>
}

After the DOM has loaded, the google.setOnLoadCallback(drawCharts) function is called, ensuring that the Google API is fully loaded prior to calling the drawCharts function.

The drawCharts function gets all <div> elements with the “chart” class, and for each calls the drawChart function.

The drawChart function interrogates the target <div> elements data- attributes to get the following information –

  • data-url
    • the controller action to call to get the data for the chart
  • data-chart-type
    • currently either “Pie” or “Line”, but can easily be extended to include other Google chart types.
  • data-options
    • A Json string representing an options object. Used to specify chart specific options.

The drawChart function then makes an Ajax get request to the url, specifying a callback function which receives the JSON result.

The callback function calls the google.visualization.arrayToDataTable function to convert the JSON result to a google.visualization.DataTable object.

It then uses the chart specific options to extend a default set of options, and then initializes either a Pie or Line chart before drawing the chart based on the data and the options.

And bobs your uncle.

 

 

ASP.NET 5 MVC TagHelpers (6.0.0-beta3)

Note. ASP.NET 5 will be released with Visual Studio 2015. Visual Studio 2015 is currently at CTP 6, with a potential Q3 release date. Microsoft is still developing the product, and the fact that TagHelpers are not included in CTP 6, but instead must be installed from Nuget, suggests that they may still be subject to change. Maybe I'm jumping the gun with this post. But I think that TagHelpers are a great step forward from HtmlHelpers in terms of improving the format and usability of ASP.NET MVC views. So I want to share.

I’m starting to port across my BikeStore application to ASP.NET 5. There are lots of new features to digest, and as mentioned in my previous post, I will be outlining the steps required to migrate the application, and sharing some lessons learnt, on this blog.

An ASP.NET 5 web application differs in many ways from previous versions of ASP.NET (there is a good overview of some of the new features here – Introduction to ASP.NET 5). For this reason my starting point for porting across my application is to create a new ASP.NET 5 Preview Starter Web.

NewApp

NewPreview

As mentioned above, the TagHelpers are not included in the CTP 6 version of Visual Studio 2015. This is why when we inspect the out-of-the-box views, we see HtmlHelper method calls rather than TagHelpers.

Before I start migrating across my BikeStore views, I’m going to update the out-of-box views to use TagHelpers. When Visual Studio 2015 is released you will not have to do this, as the out-of-the-box views should have been updated to use TagHelpers. But I hope this post will still be relevant as I will be detailing the TagHelper classes by comparing the before and after views.

The source code for the TagHelpers is in Microsoft.AspNet.Mvc.TagHelpers namespace and can be found on GitHub. It’s well worth looking through the TagHelper concrete classes to learn the rules that each implement. I will be outlining some of these rules below.

I firstly need to download the Microsoft.AspNet.Mvc.TagHelpers pre-release assembly from NuGet. I’m going to do this using the NuGet Package Manager window –

Nuget

Having clicked on Install the dependency is added to Project.json –

projectjson

With ASP.NET 5 it is possible to add dependencies directly to the Project.json file, helped with intellisense support for package names and versions. However, the package manager window is still relevant as it allows searching, gives descriptive information on the packages, and allows for the adding and removing of packages from multiple projects in a solution.

_ViewStart.cshtml

To enable TagHelpers in a view we need to add the @addtaghelper “Microsoft.AspNet.Mvc.TagHelpers” directive to the view. If we want to use TagHelpers in all views, we can add the directive to _ViewStart.

@using BikeStore;
@addtaghelper "Microsoft.AspNet.Mvc.TagHelpers"
@{
    Layout = "/Views/Shared/_Layout.cshtml";
}

 _ChangePasswordPartial.cshtml

HtmlHelpers (Before) –

@using System.Security.Principal
@model BikeStore.Models.ManageUserViewModel

<p>You're logged in as <strong>@User.Identity.GetUserName()</strong>.</p>

@using (Html.BeginForm("Manage", "Account", FormMethod.Post, new { @class = "form-horizontal", role = "form" }))
{
    @Html.AntiForgeryToken()
    <h4>Change Password Form</h4>
    <hr />
    @Html.ValidationSummary(true, "", new { @class = "text-danger" })
    <div class="form-group">
        @Html.LabelFor(m => m.OldPassword, new { @class = "col-md-2 control-label" })
        <div class="col-md-10">
            @Html.PasswordFor(m => m.OldPassword, new { @class = "form-control" })
        </div>
    </div>
    <div class="form-group">
        @Html.LabelFor(m => m.NewPassword, new { @class = "col-md-2 control-label" })
        <div class="col-md-10">
            @Html.PasswordFor(m => m.NewPassword, new { @class = "form-control" })
        </div>
    </div>
    <div class="form-group">
        @Html.LabelFor(m => m.ConfirmPassword, new { @class = "col-md-2 control-label" })
        <div class="col-md-10">
            @Html.PasswordFor(m => m.ConfirmPassword, new { @class = "form-control" })
        </div>
    </div>

    <div class="form-group">
        <div class="col-md-offset-2 col-md-10">
            <input type="submit" value="Change password" class="btn btn-default" />
        </div>
    </div>
}

TagHelpers (After) –

@using System.Security.Principal
@model BikeStore.Models.ManageUserViewModel

<p>You're logged in as <strong>@User.Identity.Name</strong>.</p>

<form asp-action="Manage" asp-controller="Account" method="post" class="form-horizontal" role="form" asp-anti-forgery="true">
    <h4>Change Password Form</h4>
    <hr />
    <div asp-validation-summary="ValidationSummary.All" class="text-danger"></div>
    <div class="form-group">
        <label asp-for="OldPassword" class="col-md-2 control-label"></label>
        <div class="col-md-10">
            <input asp-for="OldPassword" class="form-control" />
        </div>
    </div>
    <div class="form-group">
        <label asp-for="NewPassword" class="col-md-2 control-label"></label>
        <div class="col-md-10">
            <input asp-for="NewPassword" class="form-control" />
        </div>
    </div>
    <div class="form-group">
        <label asp-for="ConfirmPassword" class="col-md-2 control-label"></label>
        <div class="col-md-10">
            <input asp-for="ConfirmPassword" class="form-control" />
        </div>
    </div>

    <div class="form-group">
        <div class="col-md-offset-2 col-md-10">
            <input type="submit" value="Change password" class="btn btn-default" />
        </div>
    </div>
</form>

Comparing the before and after views for _ChangePasswordPartial.cshtml, the first thing to note is that we replace both the Html.BeginForm() and Html.AntiForgeryToken() method calls with a single <form> element.

The <form> element contains a number of TagHelper attributes prefixed with “asp-“. The Microsoft.AspNet.Mvc.TagHelpers namespace contains a number of classes derived from TagHelper. Each of these classes targets a specific HTML element type, and when the view is parsed, each element, along with their respective attributes and inner HTML, will be processed by the relevant TagHelper class, which will interpret the “asp-” attributes and output the relevant HTML. The <form> element is, believe it or not, targeted by the FormTagHelper class, which recognizes the following “asp-” attributes –

  • asp-action
    • The name of the action method
    • e.g. asp-action=”Manage”
  • asp-controller
    • The name of the controller
    • e.g. asp-controller=”Account”
  • asp-anti-forgery
    • Whether an anti-forgery token is generated
    • Valid values are “true” or “false”
    • e.g. asp-anti-forgery=”true”
  • asp-route-
    • Prefix for route attributes.
    • e.g. asp-route-returnurl=@ViewBag.ReturnUrl
    • e.g. asp-route-area=”Admin”

If the user has specified an “action” attribute, but has also specified values for either asp-action, asp-controller, or any asp-route- prefix, then the FormTagHelper class raises an InvalidOperationException.

Otherwise the FormTagHelper class will generate a <form> tag based either on the “action” attribute or a combination of asp-action, asp-controller, and any asp-route- prefixed attributes. The asp-anti-forgery attribute is not required, and will default to false if there is an “action” attribute, or true otherwise.

Back to comparing the views. Html.ValidationSummary() is replaced by a <div> element containing an asp-validation-summary attribute. And any <div> element with this attribute is targeted by the ValidationSummaryTagHelper class.

The ValidationSummaryTagHelper class only recognizes one “asp-” attribute –

  • asp-validation-summary
    • Validation summary rendering mode
    • Valid values are “ValidationSummary.None”, “ValidationSummary.ModelOnly”, or “ValidationSummary.All”
    • e.g. asp-validation-summary=”ValidationSummary.All”

The ValidationSummaryTagHelper will append the validation summary error messages to the inner HTML of the <div> element. So you can add content to the <div> element and it will get passed through to the output. For example –

<div asp-validation-summary="ValidationSummary.All">
	Messages will be appended to this content...
</div>

The Html.LabelFor() method call is replaced by a <label> element containing an asp-for attribute. This is targeted by the LabelTagHelper class.

The LabelTagHelper class only recognizes one “asp-” attribute –

  • asp-for
    • Expression to be evaluated against the Model, targeting a property of the Model
    • Implemented as type ModelExpression, for which the constructor takes a string parameter
    • e.g. asp-for=”UserName”
    • e.g. asp-for=”Product.Description”
    • e.g. asp-for=”Customers[i].FirstName”

The Html.PasswordFor() method is replaced by an <input> element, which is targeted by the InputTagHelper class.

The InputTagHelper class targets all <input> elements, irrespective of type. It recognizes the following “asp-” attributes –

  •  asp-for
    • Expression to be evaluated against the Model, targeting a property of the Model
  • asp-format
    • Composite format to be applied to the evaluation of the asp-for expression
    • e.g. asp-format=”{0:C}”
    • e.g. asp-format=”{0:MM/dd/yy}”

If the <input> element does not have a type attribute specified – as in my example – then the InputTagHelper will determine a default type based on the datatype and data annotation hints of the target. For example, if the target is of type boolean then the InputTagHelper will determine the default type to be “checkbox”. Or if the target is decorated with the [DataType(DataType.Password)] data annotation then the type will be password.

The InputTagHelper will also attempt to determine a format based on the target if asp-format has not been specified.

Login.cshtml

HtmlHelpers (Before) – 

@model BikeStore.Models.LoginViewModel

@{
    ViewBag.Title = "Log in";
}

<h2>@ViewBag.Title.</h2>
<div class="row">
    <div class="col-md-8">
        <section id="loginForm">
            @using (Html.BeginForm("Login", "Account", new { ReturnUrl = ViewBag.ReturnUrl }, FormMethod.Post, new { @class = "form-horizontal", role = "form" }))
            {
                @Html.AntiForgeryToken()
                <h4>Use a local account to log in.</h4>
                <hr />
                @Html.ValidationSummary(true, "", new { @class = "text-danger" })
                <div class="form-group">
                    @Html.LabelFor(m => m.UserName, new { @class = "col-md-2 control-label" })
                    <div class="col-md-10">
                        @Html.TextBoxFor(m => m.UserName, new { @class = "form-control" })
                        @Html.ValidationMessageFor(m => m.UserName, "", new { @class = "text-danger" })
                    </div>
                </div>
                <div class="form-group">
                    @Html.LabelFor(m => m.Password, new { @class = "col-md-2 control-label" })
                    <div class="col-md-10">
                        @Html.PasswordFor(m => m.Password, new { @class = "form-control" })
                        @Html.ValidationMessageFor(m => m.Password, "", new { @class = "text-danger" })
                    </div>
                </div>
                <div class="form-group">
                    <div class="col-md-offset-2 col-md-10">
                        <div class="checkbox">
                            @Html.CheckBoxFor(m => m.RememberMe)
                            @Html.LabelFor(m => m.RememberMe)
                        </div>
                    </div>
                </div>
                <div class="form-group">
                    <div class="col-md-offset-2 col-md-10">
                        <input type="submit" value="Log in" class="btn btn-default" />
                    </div>
                </div>
                <p>
                    @Html.ActionLink("Register", "Register") if you don't have a local account.
                </p>
            }
        </section>
    </div>
</div>
@section Scripts {
    <script src="@Url.Content("~/lib/jquery-validation/jquery.validate.js")"></script>
    <script src="@Url.Content("~/lib/jquery-validation-unobtrusive/jquery.validate.unobtrusive.js")"></script>
}

TagHelpers (After) –

@model BikeStore.Models.LoginViewModel

@{
    ViewBag.Title = "Log in";
}

<h2>@ViewBag.Title.</h2>
<div class="row">
    <div class="col-md-8">
        <section id="loginForm">
            <form asp-action="Login" asp-controller="Account" asp-route-returnurl="@ViewBag.ReturnUrl"
                  method="post" class="form-horizontal" role="form" asp-anti-forgery="true">
                <h4>Use a local account to log in.</h4>
                <hr />
                <div asp-validation-summary="ValidationSummary.All" class = "text-danger"></div>
                <div class="form-group">
                    <label asp-for="UserName" class="col-md-2 control-label"></label>
                    <div class="col-md-10">
                        <input asp-for="UserName" class="form-control" />
                        <span asp-validation-for="UserName" class="text-danger"></span>
                    </div>
                </div>
                <div class="form-group">
                    <label asp-for="Password" class="col-md-2 control-label"></label>
                    <div class="col-md-10">
                        <input asp-for="Password" class="form-control" />
                        <span asp-validation-for="Password" class="text-danger"></span>
                    </div>
                </div>
                <div class="form-group">
                    <div class="col-md-offset-2 col-md-10">
                        <div class="checkbox">
                            <input asp-for="RememberMe" />
                            <label asp-for="RememberMe"></label>
                        </div>
                    </div>
                </div>
                <div class="form-group">
                    <div class="col-md-offset-2 col-md-10">
                        <input type="submit" value="Log in" class="btn btn-default" />
                    </div>
                </div>
                <p>
                    <a asp-action="Register">Register</a> if you don't have a local account.
                </p>
            </form>
        </section>
    </div>
</div>
@section Scripts {
    <script src="@Url.Content("~/lib/jquery-validation/jquery.validate.js")"></script>
    <script src="@Url.Content("~/lib/jquery-validation-unobtrusive/jquery.validate.unobtrusive.js")"></script>
}

As with _ChangePasswordPartial.cshtml, the first thing we need to do when converting the Login.cshtml view is to replace the Html.BeginForm() and Html.AntiForgeryToken() method calls with a <form> element.

The next thing is to replace the Html.ValidationSummary() call with a <div> element. And then replace the Html.LabelFor() and Html.PasswordFor() calls with <input> elements.

The Html.TextBoxFor() and Html.CheckBoxFor() calls are also replaced with <input> elements. And as mentioned above, the InputTagHelper class will determine the types based on the data types of the target properties.

The Html.ValidationMessageFor() calls are replaced with <span> elements containing asp-validation-for attributes. Such elements are targeted by the ValidationMessageTagHelper class.

ValidationMessageTagHelper recognizes the following “asp-” attribute –

  • asp-validation-for
    • Expression to be evaluated against the Model, targeting a property of the Model
    • Implemented as type ModelExpression, for which the constructor takes a string
    • e.g. asp-validation-for=”UserName”

To complete the conversion of the Login.cshtml view, we replace Html.ActionLink() with a <a> element. The AnchorTagHeper class targets <a> elements and recognizes the following “asp-” attributes –

  • asp-action
    • The name of the action method
    • e.g. asp-action=”Register”
  • asp-controller
    • The name of the controller
    • e.g. asp-controller=”Account”
  • asp-fragment
    • URL fragment name, specifying a location in the document
    • e.g. asp-fragment=”section1″
  • asp-host
    • Host name for the URL
    • e.g. asp-host=”bikestore.com”
  • asp-protocol
    • Protocol for the URL
    • “http” or “https”
    • e.g. asp-protocol=”https”
  • asp-route
    • Name of the route
    • e.g. asp-route=”default”
  • asp-route-
    • Prefix for route attributes.
    • e.g. asp-route-returnurl=@ViewBag.ReturnUrl
    • e.g. asp-route-area=”Admin”

If the user has specified a “href” attribute, and has also specified one or more of the “asp-” attributes, then the AnchorTagHelper will raise an InvalidOperationException.

If asp-route is specified then the AnchorTagHelper will generate a anchor based on the route. Otherwise, the anchor will be generated based on the asp-controller and asp-action attributes.

Manage.cshtml

The Manage view does not require updating. It therefore remains as follows –

@{
    ViewBag.Title = "Manage Account";
}

<h2>@ViewBag.Title.</h2>
<p class="text-success">@ViewBag.StatusMessage</p>

<div class="row">
    <div class="col-md-12">
        @await Html.PartialAsync("_ChangePasswordPartial")
    </div>
</div>

@section Scripts {
    <script src="@Url.Content("~/lib/jquery-validation/jquery.validate.js")"></script>
    <script src="@Url.Content("~/lib/jquery-validation-unobtrusive/jquery.validate.unobtrusive.js")"></script>
} 

Register.cshtml

HtmlHelpers (Before) –

@model BikeStore.Models.RegisterViewModel

@{
    ViewBag.Title = "Register";
}

<h2>@ViewBag.Title.</h2>

@using (Html.BeginForm("Register", "Account", FormMethod.Post, new { @class = "form-horizontal", role = "form" }))
{
    @Html.AntiForgeryToken()
    <h4>Create a new account.</h4>
    <hr />
    @Html.ValidationSummary(true, "", new { @class = "text-danger" })
    <div class="form-group">
        @Html.LabelFor(m => m.UserName, new { @class = "col-md-2 control-label" })
        <div class="col-md-10">
            @Html.TextBoxFor(m => m.UserName, new { @class = "form-control" })
        </div>
    </div>
    <div class="form-group">
        @Html.LabelFor(m => m.Password, new { @class = "col-md-2 control-label" })
        <div class="col-md-10">
            @Html.PasswordFor(m => m.Password, new { @class = "form-control" })
        </div>
    </div>
    <div class="form-group">
        @Html.LabelFor(m => m.ConfirmPassword, new { @class = "col-md-2 control-label" })
        <div class="col-md-10">
            @Html.PasswordFor(m => m.ConfirmPassword, new { @class = "form-control" })
        </div>
    </div>
    <div class="form-group">
        <div class="col-md-offset-2 col-md-10">
            <input type="submit" class="btn btn-default" value="Register" />
        </div>
    </div>
}

@section Scripts {
    <script src="@Url.Content("~/lib/jquery-validation/jquery.validate.js")"></script>
    <script src="@Url.Content("~/lib/jquery-validation-unobtrusive/jquery.validate.unobtrusive.js")"></script>
 } 

TagHelpers (After) –

@model BikeStore.Models.RegisterViewModel

@{
    ViewBag.Title = "Register";
}

<h2>@ViewBag.Title.</h2>

<form asp-action="Register" asp-controller="Account" method="post" class="form-horizontal" role="form" asp-anti-forgery="true">
    <h4>Create a new account.</h4>
    <hr />
    <div asp-validation-summary="ValidationSummary.All" class="text-danger"></div>
    <div class="form-group">
        <label asp-for="UserName" class="col-md-2 control-label"></label>
        <div class="col-md-10">
            <input asp-for="UserName" class="form-control" />
        </div>
    </div>
    <div class="form-group">
        <label asp-for="Password" class="col-md-2 control-label"></label>
        <div class="col-md-10">
            <input asp-for="Password" class="form-control" />
        </div>
    </div>
    <div class="form-group">
        <label asp-for="ConfirmPassword" class="col-md-2 control-label"></label>
        <div class="col-md-10">
            <input asp-for="ConfirmPassword" class="form-control" />
        </div>
    </div>
    <div class="form-group">
        <div class="col-md-offset-2 col-md-10">
            <input type="submit" class="btn btn-default" value="Register" />
        </div>
    </div>
</form>

@section Scripts {
    <script src="@Url.Content("~/lib/jquery-validation/jquery.validate.js")"></script>
    <script src="@Url.Content("~/lib/jquery-validation-unobtrusive/jquery.validate.unobtrusive.js")"></script>
 } 

The Html.BeginForm() and Html.AntiForgeryToken() method calls are replaced with a <form> element. The Html.ValidationSummary() call with a <div> element. And the Html.LabelFor(), Html.PasswordFor() and Html.TextBoxFor() calls are replaced with <input> elements.

_Layout.cshtml

HtmlHelpers (Before) –

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>@ViewBag.Title - My ASP.NET Application</title>

    <link rel="stylesheet" href="~/lib/bootstrap/css/bootstrap.css" />
    <link rel="stylesheet" href="~/css/site.css" />
    <link rel="stylesheet" href="~/lib/bootstrap-touch-carousel/css/bootstrap-touch-carousel.css" />
</head>
<body>
    <div class="navbar navbar-inverse navbar-fixed-top">
        <div class="container">
            <div class="navbar-header">
                <button type="button" class="navbar-toggle" data-toggle="collapse" data-target=".navbar-collapse">
                    <span class="icon-bar"></span>
                    <span class="icon-bar"></span>
                    <span class="icon-bar"></span>
                </button>
                @Html.ActionLink("Application name", "Index", "Home", new { area = "" }, new { @class = "navbar-brand" })
            </div>
            <div class="navbar-collapse collapse">
                <ul class="nav navbar-nav">
                    <li>@Html.ActionLink("Home", "Index", "Home")</li>
                    <li>@Html.ActionLink("About", "About", "Home")</li>
                    <li>@Html.ActionLink("Contact", "Contact", "Home")</li>
                </ul>
                @await Html.PartialAsync("_LoginPartial")
            </div>
        </div>
    </div>
    <div class="container body-content">
        @RenderBody()
        <hr />
        <footer>
            <p>&copy; @DateTime.Now.Year - My ASP.NET Application</p>
        </footer>
    </div>
    <script src="~/lib/jquery/jquery.js"></script>
    <script src="~/lib/bootstrap/js/bootstrap.js"></script>
    <script src="~/lib/hammer.js/hammer.js"></script>
    <script src="~/lib/bootstrap-touch-carousel/js/bootstrap-touch-carousel.js"></script>
    @RenderSection("scripts", required: false)
</body>
</html>

TagHelpers (After) –

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>@ViewBag.Title - My ASP.NET Application</title>
    <environment names="Development">
        <link rel="stylesheet" asp-href-include="{lib/**/*.css,css/site.css}" asp-href-exclude="lib/**/*.min.css" />
    </environment>
    <environment names="Staging,Production">
        <link rel="stylesheet" asp-href-include="{lib/**/*.min.css,css/site.css}" />
    </environment>
</head>
<body>
    <div class="navbar navbar-inverse navbar-fixed-top">
        <div class="container">
            <div class="navbar-header">
                <button type="button" class="navbar-toggle" data-toggle="collapse" data-target=".navbar-collapse">
                    <span class="icon-bar"></span>
                    <span class="icon-bar"></span>
                    <span class="icon-bar"></span>
                </button>
                <a asp-action="Index" asp-controller="Home" class="navbar-brand" asp-route-area="">Application name</a>
            </div>
            <div class="navbar-collapse collapse">
                <ul class="nav navbar-nav">
                    <li><a asp-action="Index" asp-controller="Home">Home</a></li>
                    <li><a asp-action="About" asp-controller="Home">About</a></li>
                    <li><a asp-action="Contact" asp-controller="Home">Contact</a></li>
                </ul>
                @await Html.PartialAsync("_LoginPartial")
            </div>
        </div>
    </div>
    <div class="container body-content">
        @RenderBody()
        <hr />
        <footer>
            <p>&copy; @DateTime.Now.Year - My ASP.NET Application</p>
        </footer>
    </div>
    <environment name="Development">
        <script asp-src-include="lib/**/*.js" asp-src-exclude="lib/**/*.min.js"></script>
    </environment>
    <environment names="Staging,Production">
        <script asp-src-include="lib/**/*.min.js"></script>
    </environment>
    @RenderSection("scripts", required: false)
</body>
</html>

The Html.ActionLink() calls have been replaced with <a> elements.

But the most interesting replacements are the stylesheet <link> and javascript <script> elements at the top and bottom of the view respectively. These have been replaced with a combination of <environment>, <link>, and <script> elements.

The EnvironmentTagHelper class introduces the <environment> tag, and enables the conditional rendering of content based on whether the current IHostingEnvironment.EnvironmentName matches one of a comma-delimited list of names.

The EnvironmentTagHelper class recognizes only one element –

  • names
    • A comma delimited list of environment names

Please note that the attribute name is not prefixed with “asp-“. I believe this is because the element is a TagHelpers construct rather than a HTML element. The same applies for the <cache> element which will be discussed later on in this post. Personally, for sake of consistency, I would have chosen to prefix these attribute names with “asp-“.

I have used the elements in the _Layout.cshtml form to include the non-minified versions of the stylesheets and javascript files when we are rendering views in the Development environment, and the minified versions when we are rendering views in the Staging and Production environments.

The LinkTagHelper class targets <link> elements that contain one or more of the “asp-” attributes shown below. It supports file name expansion (globbing) patterns, and fallback paths.

The LinkTagHelper recognizes the following “asp-” attributes –

  • asp-href-include
    • A comma separated list of globbed file patterns of CSS stylesheets to load.
    • e.g. asp-href-include=”css/**/*.css”
  • asp-href-exclude
    • A comma separated list of globbed file patterns of CSS stylesheets to exclude from loading.
    • e.g. asp-href-exclude=”css/**/*.min.css”
  • asp-fallback-href
    • The URL of a CSS stylesheet to fallback to in the case the primary one fails.
  • asp-fallback-href-include
    • A comma separated list of globbed file patterns of CSS stylesheets to fallback to in the case the primary one fails.
  • asp-fallback-href-exclude
    • A comma separated list of globbed file patterns of CSS stylesheets to exclude from the fallback list, in the case the primary one fails.
  • asp-fallback-test-class
    • The class name defined in the stylesheet to use for the fallback test.
  • asp-fallback-test-property
    • The CSS property name to use for the fallback test.
  • asp-fallback-test-value
    • The CSS property value to use for the fallback test.
  • asp-file-version
    • Boolean indicating if file version should be appended to the href urls
    • e.g. asp-file-version=”true”

The ScriptTagHelper targets <script> elements that contain one or more of the “asp-” attributes shown below. It also supports file name expansion (globbing) patterns, and fallback paths.

The  ScriptTagHelper recognizes the following “asp-” attributes –

  • asp-src-include
    • A comma separated list of globbed file patterns of JavaScript scripts to load.
    • e.g. asp-src-include=”js/**/*.js”
  • asp-src-exclude
    • A comma separated list of globbed file patterns of JavaScript scripts to exclude from loading.
    • asp-src-exclude=”*.min.js”
  • asp-fallback-src
    • The URL of a Script tag to fallback to in the case the primary one fails.
  • asp-fallback-src-include
    • A comma separated list of globbed file patterns of JavaScript scripts to fallback to in the case the primary one fails.
  • asp-fallback-src-exclude
    • A comma separated list of globbed file patterns of JavaScript scripts to exclude from the fallback list, in the case the primary one fails.
  • asp-fallback-test
    • The script method defined in the primary script to use for the fallback test.
  • asp-file-version
    • Boolean indicating if the file version should be appended to the src urls
    • e.g. asp-file-version=”false”

_LoginPartial.cshtml

HtmlHelpers (Before) –

@using System.Security.Principal

@if (User.Identity.IsAuthenticated)
{
    using (Html.BeginForm("LogOff", "Account", FormMethod.Post, new { id = "logoutForm", @class = "navbar-right" }))
    {
        @Html.AntiForgeryToken()
        <ul class="nav navbar-nav navbar-right">
            <li>
                @Html.ActionLink("Hello " + User.Identity.GetUserName() + "!", "Manage", "Account", routeValues: null, htmlAttributes: new { title = "Manage" })
            </li>
            <li><a href="javascript:document.getElementById('logoutForm').submit()">Log off</a></li>
        </ul>
    }
}
else
{
    <ul class="nav navbar-nav navbar-right">
        <li>@Html.ActionLink("Register", "Register", "Account", routeValues: null, htmlAttributes: new { id = "registerLink" })</li>
        <li>@Html.ActionLink("Log in", "Login", "Account", routeValues: null, htmlAttributes: new { id = "loginLink" })</li>
    </ul>
}

TagHelpers (After) –

@using System.Security.Principal

@if (User.Identity.IsAuthenticated)
{
    <form asp-action="LogOff" asp-controller="Account" action="post" id="logoutForm" class="navbar-right" asp-anti-forgery="true">
        <ul class="nav navbar-nav navbar-right">
            <li>
                <a asp-action="Manage" asp-controller="Account" title="Manage">Hello @User.Identity.GetUserName() !</a>
            </li>
            <li><a href="javascript:document.getElementById('logoutForm').submit()">Log off</a></li>
        </ul>
    </form>
}
else
{
    <ul class="nav navbar-nav navbar-right">
        <li><a asp-action="Register" asp-controller="Account" id="registerLink">Register</a></li>
        <li><a asp-action="Login" asp-controller="Account" id="loginLink">Log in</a></li>
    </ul>
}

The Html.BeginForm() and Html.AntiForgeryToken() callshave been replaced with a <form> element. And the Html.ActionLink() calls have been replaced with <a> elements. This completes the changes required to convert the out-of-the-box views to use the TagHelper classes.

So far we have used and discussed the following TagHelper classes –

  • AnchorTagHelper
  • EnvironmentTagHelper
  • FormTagHelper
  • InputTagHelper
  • LabelTagHelper
  • LinkTagHelper
  • ScriptTagHelper
  • ValidationMessageTagHelper
  • ValidationSummaryTagHelper

There are a few more, which I’d like to briefly discuss –

  • CacheTagHelper
  • SelectTagHelper
  • OptionTagHelper
  • TextAreaTagHelper

The CacheTagHelper targets <cache> elements, which have been introduced with ASP.NET 5 to enable the developer to mark fragments of a view to be cached in the .NET in-memory cache. 

In prior versions of MVC, it has been easy to cache a view by applying the [OutputCache] attribute to a controller’s action method. But if we wanted to cache only a fragment of the view – i.e. donut hole caching – then we would have to move the fragment to a partial view, and then create an action method decorated with the [OutputCache] attribute to render the partial view. We would then use the Html.Action() method to inject the partial view into the parent.

The CacheTagHelper and <cache> attribute enable us to implement donut caching declaratively, without the need to move the cached fragment to a partial view. This is much easier to implement and modify, and makes the views much more readable.

The CacheTagHelper recognizes the following “asp-” attributes –

  • vary-by
    • A string to vary the cached result by
    • vary-by=“@item.Name
  • vary-by-header
    • Name of a HTTP request header to vary the cached result by
    • e.g. vary-by-header=”User-Agent”
  • vary-by-query
    • A comma-delimited set of query string parameters to vary the cached result by
    • e.g. var-by-query=”page,category”
  • vary-by-route
    • A comma-delimited set of route data parameters to vary the cached result by
    • vary-by-route=category
  • vary-by-cookie
    • A comma-delimited set of cookie names to vary the cached result by
    • e.g. vary-by-cookie=”user”
  • vary-by-user
    • A bool that determines if the cached result is to be varied by the Identity for the logged in
    • e.g. vary-by-user=”true”
  • expires-on
    • The DateTimeOffset (point in time) the cache entry should be evicted
  • expires-after
    • A TimeSpan duration, from the time the cache entry was added, when it should be evicted
    • expires-after=“@TimeSpan.FromSeconds(60)
  • expires-sliding
    • A TimeSpan duration from the last access that the cache entry should be evicted.
  • priority
    • The CachePreservationPriority policy that specifies how items are prioritized for preservation during a memory pressure triggered cleanup.

The CacheTagHelper will generate a key to uniquely identify each fragment, based on the context and the values supplied for the vary-by and vary-by-* attributes. It will expire the cached entry for a fragment based on the expires-* attributes.

The SelectTagHelper class targets <select> elements containing asp-for attributes, and recognizes the following “asp-” attributes –

  • asp-for
    • Expression to be evaluated against the Model, targeting a property of the Model
    • Implemented as type ModelExpression, for which the constructor takes a string
    • e.g. asp-for=”ProductId”
  • asp-items
    • A collection of SelectListItem objects used to populate the <select> element with <option> elements.
    • Type is IEnumerable<SelectListItem>
    • e.g. asp-items=”ProductsList”
    • e.g. asp-items=”ViewBag.Products”

If the asp-for expression is evaluated as a property that implements IEnumerable, and as such may contain multiple values, then the SelectTagHelper will generate a multiselect list.

The SelectTagHelper class appends the generated <option> elements to any <option> elements that the user has provided in the markup. And it will only select generated options based on the asp-for expression. The selection of any user provided options is dealt with by the OptionTagHelper class.

The OptionTagHelper class targets <option> elements that are children of <select> elements that have been targeted by the SelectTagHelper class. It does not recognize any “asp-” attributes. It selects the user defined <option> elements based on the parent <select> element’s asp-for expression.

The TextAreaTagHelper class targets <textarea> elements containing asp-for attributes, which are alternatives to Html.TextAreaFor() method calls.

The TextAreaTagHelper class recognizes only one “asp-” attribute –

  • asp-for
    • Expression to be evaluated against the Model, targeting a property of the Model
    • Implemented as type ModelExpression, for which the constructor takes a string
    • e.g. asp-for=”Description”

Summary

I hope you agree that views containing TagHelpers are better structured and more readable than views containing HtmlHelpers methods. They are closer in format to “raw” Html and as such will make collaboration between developers and designers much easier.

Also, TagHelpers are easier to code than HtmlHelpers. We are no longer working with many overloads of HtmlHelper methods. And we can apply HTML attributes directly to the elements rather than having to construct an anonymous class and escape any attribute names that may collide with C# reserved keywords.

So in my opinion, TagHelpers area a great step forward from HtmlHelpers.

Porting legacy applications to ASP.NET 5

ASP.NET 5 (vNext) will be released with Visual Studio 2015.

Microsoft has embraced open source, and is sharing the development of ASP.NET 5 via GitHub – https://github.com/aspnet. This is a great decision on Microsoft’s part. It’s engaging for the development community, helping to build both knowledge, loyalty, and momentum for the product. It also provides Microsoft with a huge resource of mostly valuable professionals – MVP pun intended – helping test and drive improvements to the features. When the product does finally come to market there will already be a wealth of knowledge and articles on the web ready to go.

Currently at version CTP 6, Visual Studio 2015 and ASP.NET 5 are looking pretty solid so far. Microsoft hasn’t given a specific release date, But my guess is some time in Q3.

I must admit to being quite excited about the new features – I know, I need to get out more. And I look forward to using them in anger. But obviously it would not be wise to commit to upgrading to Visual Studio 2015 and ASP.NET 5, in a production environment, until product development is complete and there is a stable release available. This may be release 1 or possibly SP1 depending on your risk propensity.

You would have to be sure that legacy solutions, built on earlier versions of .NET and Visual Studio, could be easily ported into Visual Studio 2015. OK, you can run versions of .NET and Visual Studio side-by-side in a development environment. But how many versions do you want cluttering up your development servers? Best to have only 1 version, particularly when provisioning new development servers, and when recruiting and training new developers.

Well, the word from Microsoft with regard to porting across legacy applications to Visual Studio 2015 is as follows –

http://www.asp.net/vnext/overview/aspnet-vnext/aspnet-5-overview#legacy

You may be worried that, with the number of changes in ASP.NET 5, you now need to re-write all of your applications. Don’t worry. Applications that you built on earlier versions of ASP.NET will continue to work with the new .NET Framework. You do not need to update or port these applications if you do not need the new features in ASP.NET 5.

Great news if it works. But of course the proof of the pudding is in the eating. I thought I would try it out.

My pet BikeStore solution is comprised of an MVC 5 web application, a number of class libraries, a unit test project, and a WCF service application. So a variety of project types.

Here’s the project in VS 2013, with the web application’s properties displayed –

VS2013

And the project opens in VS 2015 CTP 6, without needing any intervention at all –

VS2015

And yes, I did do a double check as the IDE, and properties windows are almost identical. The second image is definitely 2015.

I am able to compile and run the web application, run the tests, and debug the WCF service classes using the WCF test client.

In comparison, if I create a new ASP.NET 5 Preview application, this is what I get for the project properties –

vNext

It’s very much simplified due in part to some of the new ASP.NET 5 features. And this is a key point. Yes, it looks like we can easily port legacy applications to VS 2015 – although maybe there will be issues with very early versions of .NET and VS. The legacy environment and features are pretty much replicated. But what about the new ASP.NET 5 features? There’s a lot of great stuff, such as the cross-platform runtime, simplified dependency management, and some cracking improvement to MVC. I suspect that to use this features with a legacy application, the solution will need to be built from the ground up, starting with new projects, and then adding the existing classes and dependencies. Whether all the existing functionality will work with the new CLR and new version of MVC – your guess is as good as mine. I will be finding out though as I’m going to be “Re-writing” my BikeStore application to use the new features. I will share my experiences in future blog posts.

Bottom line is that ASP.NET 5 is significantly different from earlier versions.

If you have a legacy application that you are maintaining and will require incremental changes going forward, then by all means open and maintain the solution using VS 2015 – when a stable version is available.

If you are planning on a major change to an existing legacy application, and thus will be making significant modifications and extensions, then it may be worthwhile fully migrating to ASP.NET 5 by starting with a new ASP.NET 5 solution. This will future proof the application, and will make the developers very happy – and happy developers are good developers 😉