Simple Search Form using knockout.js

In this post I continue to explore the knockout.js with ASP.NET MVC 4.

I wish to create a flight search form which looks like this:screenN1

My ViewModel looks like this:

var airport = function(city,code){

       this.city = city;

       this.code = code;

   };

   var airports =  ko.observableArray([

               new airport("New Delhi", "DEL"),

               new airport("Mumbai", "BOM"),

               new airport("Chennai", "MAA"),

               new airport("Kolkata", "CCU"),

               new airport("Bangalore", "BLR")]);


   var flightSearch = {


       isOneWay: ko.observable("oneWay"),


       airports: airports,


       originatingAirport: ko.observable(),

       destinationAirport: ko.observable(),

       departureDate: ko.observable(),

       returnDate: ko.observable()

   };

I have an airport object defined to take two values: city and code. Then there is an array of airports, which is build using ko.observableArray. Then there is flightSearch view model class with properties for selecting between return flights or one way flight booking, list of airports,  origin and destination airports, departure and optional return date.

I want my view logic to hide return date ui when one way flight is selected.

Here is my markup for the same, along with a summary of values selected.

 1:

 2: <div class="row">

 3:     <div class="span8 ">

 4:         <div class="row">

 5:             <div class="span2 control-group">

 6:                 <input type="radio" name="isOneWay" value="return" data-bind="checked: isOneWay" />

 7:                 Return

 8:             </div>

 9:             <div class="span2 control-group">

 10:                 <input type="radio" name="isOneWay" value="oneWay" data-bind="checked: isOneWay"    />

 11:                 Oneway

 12:             </div>

 13:         </div>

 14:         <div class="row">

 15:             <div class="control-group">

 16:                 <label class="control-label">

 17:                     Leaving From :</label>

 18:                 <div class="controls">

 19:                     <select data-bind="options: airports,optionsText: 'city', optionsValue: 'code', value: originatingAirport, optionsCaption: 'Choose...'">

 20:                     </select>

 21:                 </div>

 22:             </div>

 23:         </div>

 24:         <div class="row">

 25:             <div class="control-group">

 26:                 <label class="control-label">

 27:                     Going To :</label>

 28:                 <div class="controls">

 29:                     <select data-bind="options: airports,optionsText: 'city', optionsValue: 'code',value: destinationAirport, optionsCaption: 'Choose...'">

 30:                     </select>

 31:                 </div>

 32:             </div>

 33:         </div>

 34:         <div class="row">

 35:             <div class="control-group">

 36:                 <label class="control-label">

 37:                     Departing :</label>

 38:                 <div class="controls">

 39:                     <input name="departureDate" type="text" data-bind="value : departureDate" />

 40:                 </div>

 41:             </div>

 42:         </div>

 43:         <div class="row">

 44:             <div class="control-group" data-bind="visible: isOneWay() === 'return'" style="display:none">

 45:                 <label class="control-label">

 46:                     Returning :</label>

 47:                 <div class="controls">

 48:                     <input name="returnDate" type="text" data-bind=" value: returnDate" />

 49:                 </div>

 50:             </div>

 51:         </div>

 52:     </div>

 53:     <div class="span3">

 54:         <h3>

 55:             Search Summary:</h3>

 56:         <div data-bind="if: isOneWay() === 'return'">

 57:             Return flights

 58:         </div>

 59:         <div data-bind="ifnot: isOneWay() === 'return'">

 60:             One way flight

 61:         </div>

 62:         <div>

 63:             Starting at: <span data-bind="text: originatingAirport"></span>

 64:         </div>

 65:         <div>

 66:             Going to: <span data-bind="text: destinationAirport"></span>

 67:         </div>

 68:     </div>

 69: </div>

knockout.js binding usages

Value binding

All input and select controls are bound using value binding.

Options, OptionsText, OptionsValue and OptionsCaption bindings

For Origin and Destination Airports, I have used these bindings. OptionsText and OptionsValue is used to bind the options to JavaScript objects.

The code at line no. 19 specifies that ‘city’ property of the object should be used as text and ‘code’ property of the object should be used a value for the options.

if and ifnot binding

In line number 44 if binding was used to control the visibility of div containing the return date input controls. Similarly in line number 59, ifnot  binding is used to control div’s visibility.

text binding

In line number 63 and 66, text binding is used to show the values of selected airports.

Advertisements

Knockout.js

This post is part of series of post on Single Page Application using ASP.NET MVC 4. We will explore the knockout JavaScript library in this post.

What is Knockout.js?

Knockout.js is a JavaScript library for building HTML UI using Model-View-ViewModel (MVVM) pattern. The library is MIT licensed and source code is hosted on GitHub at https://github.com/SteveSanderson/knockout and project web hosted at http://knockoutjs.com/

Salient features of this library are:

  • Declarative bindings. A way to bind UIs to underlying data model using declarative bindings like data-bind=”text: firstName”
  • Dependency tracking. The build in dependency tracking updates the right UI when the underlying data changes by doing dependency check.
  • Templating

What is MVVM?

Model-View-ViewModel is a design pattern for building user interfaces.

  • Model: an application’s persisted data.
  • View: an application’s visible, interactive UI component.
  • ViewModel: In MVVM ViewModel is the an abstract layer to separate View and Model. ViewModel is a code based representation of data and operations on a UI.

Using Knockout.js

ViewModel in Knockout.js is a JavaScript object like:

var contact = {

    firstName : 'John',

    lastName  : 'Clark',

    email     : 'john.clark@company.com'

}

To bind this to UI, data-bind attribute are used like:

Contact's first name is  <span data-bind="text: firstName"> </span> 

 

For binding view to viewmodel, call Knockout.js activateBindings method as given here:

ko.activateBindings(contact);

To automatically update UI when the data model changes, we need to use Knockout’s observable as given below:

var contact = {

    firstName   : ko.observable('John'),

    lastName    : ko.observable('Clark'),

    email       : ko.observable('john.clark@company.com')

};

Built-in bindings:

  1. text : use text binding to display text value
  2. html : use html binding to display HTML
  3. css : use css binding to add or remove CSS classes
  4. style : use style binding to add or remove style values to the DOM node
  5. attr : use attr binding to
  6. visible : use visible binding to control the DOM element’s visibility
  7. foreach : use  foreach to loop around array and create markup on them
  8. if : use if to control a section of markup’s visibility
  9. ifnot : similar to if binding but works if the conditional is false
  10. with : use with in templates for creating new binding context
  11. click : use click binding to add an event handler to the click event to elements like a, input and buttons
  12. event : use event  binding to add an event handler to specified events like mouseover, mouseout, keypress, keydown etc. on the DOM element
  13. submit : use submit binding to add an event handler to the DOM node which would be called when the element value is being submitted like in case of form submit
  14. enable : use enable  binding to enable the DOM element based on the specified boolean expression. This is typically used for DOM elements like input, select, textarea.
  15. disable : use disable binding to disable the DOM element based on the specified boolean expression
  16. value : use value binding to  link the DOM element’s value with a property in view model. Typically used with input, select, textarea
  17. hasfocus : use hasfocus  binding to link a DOM element’s focus state with a property in view model
  18. checked : use checekd  binding with checkbox or radio button to link it with property on view model
  19. options  : use options binding to control the options  of a select control (<select>) 
  20. selectedOptions : use selectedOptions binding to control the elements selection in a multi-select list. Use this with <select> element and options binding
  21. uniqueName :
  22. template : use template  binding to render a templates output to the associated DOM element

In addition to the built-in bindings, knockout.js supports creating custom bindings.

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);