ASP.NET MVC4 and Single Page Application

A new Nuget package is provided by Microsoft for developing Single Page Application.

What is Single Page Application? Traditionally, the website/web applications have been using multiple pages to provide the intended functionalities. For e.g. an e-commerce web application might have Catalog, Cart and Payment pages.  This model of using multiple pages results in common problems of page reloads and UI flickering on navigation between pages. To address these problems, a single web page can be enhanced using AJAX and DOM manipulation to provide a similar user experience as in desktop applications.

How to decide between developing typical web applications vs Single Page applications?

Some points to consider are:

  1. Minimize page reloads and flickering on page navigation.
  2. To provide look and feel as well as user experience similar to desktop applications.

To read more about Single Page Application read the MSDN article Single-page Interface and AJAX Patterns.

Let see how to get started with ASP.NET MVC 4 SPA template.

Single Page Application template is not provided through MVC 4 beta installer but through Nuget package. See details on NuGet pacakge at http://nuget.org/packages/SinglePageApplication.

To install the template, I have opened a VS2010 solution (yuk, you need to open a solution before installing the package, otherwise Package Manger console throws “Install-Package : The current environment doesn’t have a solution open.”) and run command Install-Package SinglePageApplication

On creating a new ASP.NET MVC 4 web application project, you will get the following dialog to select the template to be used.

image

 

Major differences as compared to typical ASP.NET MVC project.

  1. New Javascript libraries to support SPA. knockout-2.0.0.js, upshot.js, nav.js, native.history.js and modernizer development version from Microsoft.
  2. The controller should derive from DataController which derives from ApiController.

After creating the project, to add functionalities you have to do following steps:

1. Add a model class

Here is the TodoItem model class.

using System;

using System.Collections.Generic;

using System.ComponentModel.DataAnnotations;

using System.Linq;

 

namespace MvcApplication7.Models

{   

 

    public class TodoItem

    {

        public int TodoItemId { get; set; }

        [Required]

        public string Title { get; set; }

        public bool IsDone { get; set; }

    }

}

2. Add controller by invoking add controller action on the Controllers folder. It opens a dialog, select in Template “Single Page Application with read/write actions and views, using Entity Framework”, select the Model class and select the context class or to create new context class select the option “<New data context…>”.

image

 

On adding controller, Visual Studio will generate code for Controller, Views, and JavaScript for this model.

The generated controller class looks like this:

using System.Linq;

using System.Web.Http;

using System.Web.Http.Data.EntityFramework;

 

namespace MvcApplication7.Controllers

{

    public partial class MvcApplication7Controller : DbDataController<MvcApplication7.Models.MvcApplication7Context>

    {

        public IQueryable<MvcApplication7.Models.TodoItem> GetTodoItems() {

            return DbContext.TodoItems.OrderBy(t => t.TodoItemId);

        }

 

        public void InsertTodoItem(MvcApplication7.Models.TodoItem entity) {

            InsertEntity(entity);

        }

 

        public void UpdateTodoItem(MvcApplication7.Models.TodoItem entity) {

            UpdateEntity(entity);

        }

 

        public void DeleteTodoItem(MvcApplication7.Models.TodoItem entity) {

            DeleteEntity(entity);

        }

    }

}

Under Views, Tasks folder is added with Index.cshtml, _Editor.cshtml, _Grid.cshtml and _Paging.cshtml views in it.

Index.cshtml

@{

    ViewBag.Title = "TodoItems";

    Layout = "~/Views/Shared/_SpaLayout.cshtml";

}

 

<div data-bind="visible: editingTodoItem">

    @Html.Partial("_Editor")

</div>

 

<div data-bind="visible: !editingTodoItem()">

    @Html.Partial("_Grid")

</div>

 

<div class="message-info message-success" data-bind="flash: { text: successMessage, duration: 5000 }"></div>

<div class="message-info message-error" data-bind="flash: { text: errorMessage, duration: 20000 }"></div>

 

 

<script type="text/javascript" src="@Url.Content("~/Scripts/TodoItemsViewModel.js")"></script>

 

<script type="text/javascript">

    $(function () {

        upshot.metadata(@(Html.Metadata<MvcApplication7.Controllers.MvcApplication7Controller>()));

 

        var viewModel = new MyApp.TodoItemsViewModel({

            serviceUrl: "@Url.Content("~/api/MvcApplication7")"

        });

        ko.applyBindings(viewModel);

    });

</script>

_Editor.cshtml

<h2>Edit TodoItem</h2>

 

<form data-bind="with: editingTodoItem, validate: validationConfig">

    <p data-bind="visible: TodoItemId">

        TodoItemId:

        <b data-bind="text: TodoItemId"></b>

    </p>

    <p>

        Title:

        <input name="Title" data-bind="value: Title, autovalidate: true" />

        <span class="error" data-bind="text: Title.ValidationError"></span>

    </p>

    <p>

        IsDone:

        <input name="IsDone" type="checkbox" data-bind="checked: IsDone, autovalidate: true" />

        <span class="error" data-bind="text: IsDone.ValidationError"></span>

    </p>

    <p>

        <button type="submit" data-bind="enable: CanSave">Save</button>

        <a href="#" data-bind="visible: TodoItemId, click: $parent.deleteTodoItem">Delete</a>

        <a href="#" data-bind="click: $parent.showGrid">Back to grid</a>

        <a href="#" data-bind="visible: IsUpdated, click: $parent.revertEdit">Undo changes</a>

    </p>

</form>

 

_Grid.cshtml

<h2>TodoItems (<span data-bind="text: paging.totalItems"></span>)</h2>

 

<div data-bind="visible: paging.totalItems() > 0">

    @Html.Partial("_Paging")

 

    <table>

        <thead>

            <tr>

                <th>Title</th>

                <th>IsDone</th>

            </tr>

        </thead>

        <tbody data-bind="foreach: todoItems">

            <tr data-bind="click: $parent.editTodoItem" style="cursor: pointer">

                <td data-bind="text: Title"></td>

                <td data-bind="text: IsDone"></td>

            </tr>

        </tbody>

    </table>

</div>

 

<p><button data-bind="click: createTodoItem">Create TodoItem</button></p>

 

_Paging.cshtml

<p data-bind="with: paging">

    Page <b data-bind="text: pageIndex"></b> 

    of <b data-bind="text: totalPages"></b>

    | Show <select data-bind="options: [5, 10, 20, 50], value: pageSize"></select> per page

 

    <a href="#" data-bind="click: moveFirst, visible: canMovePrev">&laquo; First</a>

    <a href="#" data-bind="click: movePrev, visible: canMovePrev">&laquo; Prev</a>

    <a href="#" data-bind="click: moveNext, visible: canMoveNext">Next &raquo;</a>

    <a href="#" data-bind="click: moveLast, visible: canMoveNext">Last &raquo;</a>

</p>

Under Scripts folder, TodoItemsViewModel.js is added.

TodoItemsViewModel.js

/// <reference path="_references.js" />

 

(function (window, undefined) {

    // Define the "MyApp" namespace

    var MyApp = window.MyApp = window.MyApp || {};

 

    // TodoItem class

    var entityType = "TodoItem:#MvcApplication7.Models";

    MyApp.TodoItem = function (data) {

        var self = this;

 

        // Underlying data

        self.TodoItemId = ko.observable(data.TodoItemId);

        self.Title = ko.observable(data.Title);

        self.IsDone = ko.observable(data.IsDone);

        upshot.addEntityProperties(self, entityType);

    }

 

    // TodoItemsViewModel class

    MyApp.TodoItemsViewModel = function (options) {

        var self = this;

 

        // Private properties

        var dataSourceOptions = {

            providerParameters: { url: options.serviceUrl, operationName: "GetTodoItems" },

            entityType: entityType,

            bufferChanges: true,

            mapping: MyApp.TodoItem

        };

        var gridDataSource = new upshot.RemoteDataSource(dataSourceOptions);

        var editorDataSource = new upshot.RemoteDataSource(dataSourceOptions);

 

        // Data

        self.todoItems = gridDataSource.getEntities();

        self.editingTodoItem = editorDataSource.getFirstEntity();

        self.successMessage = ko.observable().extend({ notify: "always" });

        self.errorMessage = ko.observable().extend({ notify: "always" });

        self.paging = new upshot.PagingModel(gridDataSource, {

            onPageChange: function (pageIndex, pageSize) {

                self.nav.navigate({ page: pageIndex, pageSize: pageSize });

            }

        });

        self.validationConfig = $.extend({

            resetFormOnChange: self.editingTodoItem,

            submitHandler: function () { self.saveEdit() }

        }, editorDataSource.getEntityValidationRules());

 

        // Client-side navigation

        self.nav = new NavHistory({

            params: { edit: null, page: 1, pageSize: 10 },

            onNavigate: function (navEntry, navInfo) {

                self.paging.moveTo(navEntry.params.page, navEntry.params.pageSize);

 

                // Wipe out any old data so that it is not displayed in the UI while new data is being loaded 

                editorDataSource.revertChanges();

                editorDataSource.reset();

 

                if (navEntry.params.edit) {

                    

                    if (navEntry.params.edit == "new") {

                        // Create and begin editing a new todoItem instance

                        editorDataSource.getEntities().push(new MyApp.TodoItem({}));

                    } else {

                        // Load and begin editing an existing todoItem instance

                        editorDataSource.setFilter({ property: "TodoItemId", value: Number(navEntry.params.edit) }).refresh();

                    }

                } else {

                    // Not editing, so load the requested page of data to display in the grid

                    gridDataSource.refresh();

                }

            }

        }).initialize({ linkToUrl: true });

 

        // Public operations

        self.saveEdit = function () {

            editorDataSource.commitChanges(function () {

                self.successMessage("Saved TodoItem").errorMessage("");

                self.showGrid();

            });

        }

        self.revertEdit = function () { editorDataSource.revertChanges() }

        self.editTodoItem = function (todoItem) { self.nav.navigate({ edit: todoItem.TodoItemId() }) }

        self.showGrid = function () { self.nav.navigate({ edit: null }) }

        self.createTodoItem = function () { self.nav.navigate({ edit: "new" }) }

        self.deleteTodoItem = function (todoItem) {

            editorDataSource.deleteEntity(todoItem);

            editorDataSource.commitChanges(function () {

                self.successMessage("Deleted TodoItem").errorMessage("");

                self.showGrid();

            });

        };

 

        // Error handling

        var handleServerError = function (httpStatus, message) {

            if (httpStatus === 200) {

                // Application domain error (e.g., validation error)

                self.errorMessage(message).successMessage("");

            } else {

                // System error (e.g., unhandled exception)

                self.errorMessage("Server error: HTTP status code: " + httpStatus + ", message: " + message).successMessage("");

            }

        }

        

        gridDataSource.bind({ refreshError: handleServerError, commitError: handleServerError });

        editorDataSource.bind({ refreshError: handleServerError, commitError: handleServerError });

    }

})(window);

 

Advertisements