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:
- CascadeSelect() returns the CascadeSelect View
- GetArtists() returns an json array of artists
- GetAlbums() returns an json array of albums by artist id
- 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
- move the client script bundle loaders into the document head
- add a link to the knockoutjs script in the document head
- 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.