jQuery Mobile Cascaded Selects using MVC4 and KnockoutJs

This post documents building cascading select inputs using jQuery and Knockout for the user interface and MVC4 to serve the content.

Step 1

OPEN or CREATE the MvcMobileApp that includes the Chinook Models and Data Access class [chinook-asp-net-mvc-4-web-application]

View

CREATE a view that will be used to render the cascading selects to the browser. In the Solution Explorer, right click on the Views folder and create a new folder names Chinook. Then right click on the new Chinook folder and Add a new View named CascadeSelect.

Controller

CREATE a controller class for the cascading selects. In the Solution Explorer, right click on the Controllers folder and add a new empty MVC controller named ChinookController

Below the public ActionResult Index() method, add these methods as shown below:

  1. CascadeSelect() returns the CascadeSelect View
  2. GetArtists() returns an json array of artists
  3. GetAlbums() returns an json array of albums by artist id
  4. GetTracks() returns an json array of tracks by album id
MvcMobileApp/Controllers/ChinookController.cs
using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;
using System.Web.Mvc;

namespace MvcMobileApp.Controllers
{
    public class ChinookController : Controller
    {
        //
        // GET: /Chinook/

        public ActionResult Index()
        {
            return View();
        }

        public ActionResult CascadeSelect()
        {
            return View();
        }

        public ActionResult GetArtists()
        {
            var artistList = Chinook.GetArtists();
            return this.Json(artistList, JsonRequestBehavior.AllowGet);
        }

        public ActionResult GetAlbums(string id)
        {
            var albumList = Chinook.GetAlbums(Convert.ToInt32(id));
            return this.Json(albumList, JsonRequestBehavior.AllowGet);
        }

        public ActionResult GetTracks(string id)
        {
            var trackList = Chinook.GetTracks(Convert.ToInt32(id));
            return this.Json(trackList, JsonRequestBehavior.AllowGet);
        }

    }
}

EDIT Views/Shared/_Layout.cshtml

  1. move the client script bundle loaders into the document head
  2. add a link to the knockoutjs script in the document head
  3. in the document body, look for the opening div data-role=“page”, and add id=“page” to it as follows:
<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="utf-8" />
        <title>@ViewBag.Title</title>
        <meta name="viewport" content="width=device-width" />
        <link href="~/favicon.ico" rel="shortcut icon" type="image/x-icon" />
        @Styles.Render("~/Content/mobileCss", "~/Content/css")
        @Scripts.Render("~/bundles/modernizr")
        @Scripts.Render("~/bundles/jquery", "~/bundles/jquerymobile")
        @RenderSection("scripts", required: false)
        <script src="~/Scripts/knockout-2.1.0.js" type="text/javascript"></script>
    </head>
    <body>
        <div data-role="page" data-theme="b" id="page">
            <div data-role="header">
                @if (IsSectionDefined("Header")) {
                    @RenderSection("Header")
                } else {
                    <h1>@ViewBag.Title</h1>
                    @Html.Partial("_LoginPartial")
                }
            </div>
            <div data-role="content">
                @RenderBody()
            </div>
        </div>
    </body>
</html>

EDIT MvcMobileApp/Views/Chinook/CascadeSelect.cshtml, add this javascript to the bottom of the view.

<script type="text/javascript">
    //for jQueryMobile use this instead of document ready
    $("#page").live("pageinit", function (event) {

        getArtists();

    });

    var viewModel = function () {
        var self = this;
        self.artists = ko.observableArray([]);
        self.albums = ko.observableArray([]);
        self.tracks = ko.observableArray([]);

        //selected value observable and onchange subscription to fire data lookup for child select
        self.selectedAlbum = ko.observable();
        self.selectedAlbum.subscribe(function (value) {
            if (value) {
                getTracks(value);
            }
        });
        self.selectedArtist = ko.observable();
        self.selectedArtist.subscribe(function (value) {
            if (value) {
                getAlbums(value);
            }
        });
    };
    var viewModel = new viewModel();
    ko.applyBindings(viewModel);

    function getArtists() {
        $.getJSON("/Chinook/GetArtist", null, function (data) {
            viewModel.artists(data);
            // reset jQueryMobile selection label
            $("#select-artist").selectmenu("refresh");
        });
    }

    function getAlbums(artistId) {
        $.getJSON("/Chinook/GetAlbums/" + artistId, null, function (data) {
            viewModel.albums(data);
            // reset jQueryMobile selection label
            $("#select-album").selectmenu("refresh");
        });
    }

    function getTracks(albumId) {
        $.getJSON("/Chinook/GetTracks/" + albumId, null, function (data) {
            viewModel.tracks(data);
            // reset jQueryMobile selection label
            $("#select-track").selectmenu("refresh");
        });
    }
</script>

Insert this data bound markup above the javascript that was just added to CascadeSelect.cshtml

<label for="select-artist" class="ui-hidden-accessible">Artist</label>
<select name="select-artist" id="select-artist"
    data-bind="options: artists,
    optionsText: 'Name',
    optionsValue: 'ArtistId',
    optionsCaption: 'Select Artist',
    value: selectedArtist">
</select>

<div data-bind="visible: albums().length > 0">
    <label for="select-album" class="ui-hidden-accessible">Album</label>
    <select name="select-album" id="select-album"
        data-bind="options: albums,
        optionsText: 'Title',
        optionsValue: 'AlbumId',
        optionsCaption: 'Select Album',
        value: selectedAlbum">
    </select>
</div>

<div data-bind="visible: tracks().length > 0">
    <label for="select-track" class="ui-hidden-accessible">Track</label>
    <select name="select-track" id="select-track"
        data-bind="options: tracks,
        optionsText: 'Name',
        optionsValue: 'TrackId',
        optionsCaption: 'Select Track'">
    </select>
</div>

EDIT Views/Home/Index.cshtml and add this razor html helper code to the bottom that will render a link to the CascadeSelect view from the home view.

@Html.ActionLink("CascadeSelect", "CascadeSelect", "Chinook")

Run

Select F5 or one of the debug options to run the app.

Optimize

It is likely that on page load the first select does not populate before it can be accessed by the user. Therefore, we will use the server side view model data source for the select list when the view is returned to the page on load.

EDIT Controllers/ChinookController.cs

CHANGE

public ActionResult CascadeSelect()
{
    return View();
}

TO

public ActionResult CascadeSelect()
{
    //return model for first select
    var artistList = Chinook.GetArtists();
    return View(artistList);
}

EDIT Views/Chinook/CascadeSelect.cshtml

On the very first line, add

@model List

NEXT, REPLACE

<select name="select-artist" id="select-artist"
    data-bind="options: artists,
    optionsText: 'Name',
    optionsValue: 'ArtistId',
    optionsCaption: 'Select Artist',
    value: selectedArtist">
</select>

WITH

<select name="select-artist" id="select-artist"
    data-bind="value: selectedArtist">
        <option value="">Select Artist</option>
@{
    if (Model != null)
    {
        foreach (var item in Model)
        {
            <option value="@item.ArtistId">@item.Name</option>
        }
    }
}
</select>

NEXT, change the visible binding on the select-album parent div. REPLACE

<div data-bind="visible: albums().length > 0">

WITH

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

NEXT, change the visible binding on the select-track parent div. REPLACE

<div data-bind="visible: tracks().length > 0">

WITH

<div data-bind="visible: selectedArtist, visible: selectedAlbum">

NOTE: Bindings are applied in order from left to right. In the data-bind above, visible: selectedArtist is applied before visible: selectedAlbum.

In the javascript code block at the bottom of Views/Chinook/CascadeSelect.cshtml, DELETE these two lines of javascript:

getArtists();

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

AND

DELETE the function:

function getArtists() {
    $.getJSON("/Chinook/GetArtists", null, function (data) {
        viewModel.artists(data);
        // reset jQueryMobile selection label
        $("#select-artist").selectmenu("refresh");
    });
}

Review

After all of those changes, your Views/Chinook/CascadeSelect.cshtml view should like this:

@model List
@{
    ViewBag.Title = "CascadeSelect";
}

<h2>CascadeSelect</h2>

<label for="select-artist" class="ui-hidden-accessible">Artist</label>
<select name="select-artist" id="select1"
    data-bind="value: selectedArtist">
        <option value="">Select Artist</option>
@{
    if (Model != null)
    {
        foreach (var item in Model)
        {<a href="~/Scripts/">~/Scripts/</a>
            <option value="@item.ArtistId">@item.Name</option>
        }
    }
}
</select>

<div data-bind="visible: selectedArtist">
    <label for="select-album" class="ui-hidden-accessible">Album</label>
    <select name="select-album" id="select2"
        data-bind="options: albums,
        optionsText: 'Title',
        optionsValue: 'AlbumId',
        optionsCaption: 'Select Album',
        value: selectedAlbum">
    </select>
</div>

<div data-bind="visible: selectedArtist, visible: selectedAlbum">
    <label for="select-track" class="ui-hidden-accessible">Track</label>
    <select name="select-track" id="select3"
        data-bind="options: tracks,
        optionsText: 'Name',
        optionsValue: 'TrackId',
        optionsCaption: 'Select Track'">
    </select>
</div>

<script type="text/javascript">
    //for jQueryMobile use this instead of document ready
    $("#page").live("pageinit", function (event) {
        //getArtists();
    });

    var viewModel = function () {
        var self = this;
        self.albums = ko.observableArray([]);
        self.tracks = ko.observableArray([]);

        //selected value observable and onchange subscription to fire data lookup for child select
        self.selectedAlbum = ko.observable();
        self.selectedAlbum.subscribe(function (value) {
            if (value) {
                getTracks(value);
            }
        });
        self.selectedArtist = ko.observable();
        self.selectedArtist.subscribe(function (value) {
            if (value) {
                getAlbums(value);
            }
        });
    };
    var viewModel = new viewModel();
    ko.applyBindings(viewModel);

    function getAlbums(artistId) {
        $.getJSON("/Chinook/GetAlbums/" + artistId, null, function (data) {
            viewModel.albums(data);
            // reset jQueryMobile selection label
            $("#select-album").selectmenu("refresh");
        });
    }

    function getTracks(albumId) {
        $.getJSON("/Chinook/GetTracks/" + albumId, null, function (data) {
            viewModel.tracks(data);
            // reset jQueryMobile selection label
            $("#select-track").selectmenu("refresh");
        });
    }
</script>

Run

Select F5 or one of the debug options to run the app.

References:* KnockoutJS Simplify dynamic JavaScript UIs by applying the Model-Vifew-View Model (MVVM) pattern.

comments powered by Disqus