This blog is part of a series about building Single Page Applications using ASP.NET MVC 4 beta. When writing this post I assumed you have read the first and second posts. The code snippets are just the incremental changes that are needed since the second post
- Single Page Applications - Part 1 - Basic Desktop application
- Single Page Applications - Part 2 - Advanced Desktop Application
- Single Page Applications - Part 3 - Basic Mobile Application
- Single Page Applications - Part 4 - Sorting, Filtering and Manipulation data with upshot.js
- Single Page Applications - Part 5 - With MVC4 RC
The mobile viewmodel
In this post I'm adding a mobile component to the DeliveryTracker application I have been describing in my previous posts. I want to have an overview page per delivery, an overview page per customer and a detail view for the currently selected delivery. In order to do that, I'll create a new MobileDeliveriesViewModel in the DeliveriesViewModel.js file. This new class inherits from the existing DeliveriesViewModel class to inherit the existing features but adds some of its own:
- self.currentDelivery is an observable (knockout.js) variable to keep track of the currently selected delivery which we need in order to navigate from the overview pages to the detail page
- self.nav keeps the navigation history (nav.js) for our single page. It gives the impression to the user of navigating between different pages as usual. But it enables back button and allows creation of bookmarks within the single page application.
- The name of the view will be stored in the view parameter. The default page will be the deliveries view.
- A deliveryId paramater will be included in the querystring and intialized to null
- On navigation we extract the deliveryId from the querystring and use it to filter the datasource
- self.showsomething functions to invoke the navigation functionality. The showDelivery function receives the delivery object that was clicked so it can extract the deliveryId from it and attach it to the querystring so the delivery view can read it.
function MobileDeliveriesViewModel() { //inherit from DeliveriesViewModel var self = this; DeliveriesViewModel.call(self); // Data self.currentDelivery = ko.observable(); self.nav = new NavHistory({ params: { view: 'deliveries', deliveryId: null }, onNavigate: function (navEntry) { var requestedDeliveryId = navEntry.params.deliveryId; self.dataSource.findById(requestedDeliveryId, self.currentDelivery); } }); //Operations self.showDeliveries = function () { self.nav.navigate({ view: 'deliveries' }) }; self.showCustomers = function () { self.nav.navigate({ view: 'customers' }) }; self.showDelivery = function (delivery) { self.nav.navigate({ view: 'delivery', deliveryId: delivery.DeliveryId() }) }; self.nav.initialize({ linkToUrl: true }); }
Note that the onNavigate function uses the findById method on the upshot datasource that doesn't exist in the original beta release of ASP.NET MVC 4. Add the following javascript code to the upshot.knockout.extensions.js file. The highlighted line below is already present in the file so don't add it again.
(function (ko, upshot, undefined) { upshot.RemoteDataSource.prototype.findById = function (id, updateTarget) { function search() { var self = this; var foundEntity = $.grep(ko.utils.unwrapObservable(self.getEntities()), function (entity) { return self.getEntityId(entity) === updateTarget._id; })[0]; updateTarget(foundEntity); } if (!('_id' in updateTarget)) { // TODO: What is the provision to 'unbind' here? this.bind("refreshSuccess", search); } updateTarget._id = id; search.call(this); };
Also note that in the parent DeliveriesViewModel class, bufferChanges is put back to false in order to have immediate upshot synchronization with the service back-end
function DeliveriesViewModel() { // Private var self = this; var dataSourceOptions = { providerParameters: { url: "/api/DataService", operationName: "GetDeliveriesForToday" }, entityType: "Delivery:#DeliveryTracker.Models", bufferChanges: false, mapping: Delivery }; // Snip the rest of the code // ... // ... }
The mobile layout
ASP.NET MVC can support mobile browsers by letting you create specific mobile pages using the ".mobile.cshtml" extension. In order to test these pages I installed this User-Agent Switcher for Chrome.
In the \Views\Shared folder, create two new files:
- _SpaScripts.cshtml: partial view that groups the different javascript includes
- _Layout.mobile.cshtml: layout page for mobile browsers
The _SpaScripts.cshtml: is quite simply
<script src="~/Scripts/jquery-1.6.4.min.js" type="text/javascript"></script> <script src="~/Scripts/modernizr-2.0.6-development-only.js" type="text/javascript"></script> <script src="~/Scripts/knockout-2.0.0.js" type="text/javascript"></script> <script src="~/Scripts/arrayUtils.js" type="text/javascript"></script> <script src="~/Scripts/upshot.min.js" type="text/javascript"></script> <script src="~/Scripts/upshot.compat.knockout.js" type="text/javascript"></script> <script src="~/Scripts/upshot.knockout.extensions.js" type="text/javascript"></script> <script src="~/Scripts/native.history.js" type="text/javascript"></script> <script src="~/Scripts/nav.js" type="text/javascript"></script>
The _Layout.mobile.cshtml: includes the _SpaScripts partial view and the navigation bar. The two list items in the navigation bar use data-binding to attach their functionality in a declarative way.
- Their click events are linked to their respective show functions in the MobileDeliveriesViewModel
- Their CSS style is adapted so they appear as active when their view is showing. You can find the Site.Mobile.css file in the code download at the end of this post.
<!DOCTYPE html> <html lang="en"> <head> <meta charset="utf-8" /> <title>@ViewBag.Title</title> <link href="~/favicon.ico" rel="shortcut icon" type="image/x-icon" /> <link href="~/Content/Site.Mobile.css" rel="Stylesheet" type="text/css" /> <script src="@System.Web.Optimization.BundleTable.Bundles.ResolveBundleUrl("~/Scripts/js")"> </script> <meta name="viewport" content="width=device-width" /> @Html.Partial("_SpaScripts") <script src="~/Scripts/App/DeliveriesViewModel.js" type="text/javascript"></script> </head> <body> <nav class="bar"> <ul> <li data-bind="click: showDeliveries, css: { active: nav.params().view == 'deliveries' }">Deliveries</li> <li data-bind="click: showCustomers, css: { active: nav.params().view == 'customers' }">Customers</li> </ul> </nav> @RenderBody() </body> </html>
The mobile views
Now add a Index.mobile.cshtml to the \Views\Home folder. It's quite simple. When the DOM is ready a javascript function initializes the upshot library and initializes the knockout bindings to the MobileDeliveriesViewModel. The actual views are defined as partial views that are shown whenever their views are active.
Notice that the _DeliveryDetails view in Steven Sandersons example used the with: data-bind parameter. I couldn't get it to work so I encapsulated it within a parent div that checks the active view
@{ ViewBag.Title = "Deliveries"; } <script type="text/javascript"> $(function () { upshot.metadata(@(Html.Metadata <DeliveryTracker.Controllers.DataServiceController>())); ko.applyBindings( new MobileDeliveriesViewModel( )); }); </script> <div data-bind="if: nav.params().view == 'deliveries'"> @Html.Partial("_DeliveriesList") </div> <div data-bind="if: nav.params().view == 'customers'"> @Html.Partial("_CustomersList") </div> <div data-bind="if: nav.params().view == 'delivery'"> <div data-bind="with: currentDelivery"> @Html.Partial("_DeliveryDetails") </div>
The final items left to do are defining the partial views themselves. The only new functionality here is the $root pseudo-variable used by knockouts for-each data-binding which is used to bind the showDelivery method of the MobileDeliveriesViewModel to each item in the list
_CustomersList.cshtml
<div class="content"> <label><input type="checkbox" data-bind="checked: excludeDelivered" /> Only show undelivered items</label> <div data-bind="foreach: deliveriesForCustomer"> <h4 data-bind="text: key.Name"></h4> <ol class="deliveries" data-bind="foreach: values"> <li data-bind="text: Description, css: { updated: IsUpdated, delivered: IsDelivered }, click: $root.showDelivery"></li> </ol> </div> </div>
_DeliveriesList.cshtml
<div class="content"> <label><input data-bind="checked: excludeDelivered" type="checkbox" /> Only show undelivered items</label> <ol class="deliveries" data-bind="foreach: deliveries"> <li data-bind="css: { updated: IsUpdated, delivered: IsDelivered }, click: $root.showDelivery"> <span data-bind="text: Description"></span> <span class="details" data-bind="text: Customer().Name"></span> </li> </ol> </div>
_DeliveryDetails.cshtml
<div class="title bar"> <button data-bind="click: History.back">< Back</button> <h1 data-bind="text: Description"></h1> </div> <div class="content"> <p>Delivery #<strong data-bind="text: DeliveryId"></strong></p> <div class="callout" data-bind="with: Customer"> <h1>Customer</h1> <label>Name</label> <strong data-bind="text: Name"></strong> <label>Address</label> <strong data-bind="text: Address"></strong> </div> <label class="checkbox" data-bind="css: { delivered: IsDelivered }"> <input type="checkbox" data-bind="checked: IsDelivered" /> Delivered </label> </div>
Following navigation map shows the mobile application in action
- The 'deliveries' screen with the list of deliveries. Clicking a delivery navigates to the 'delivery' screen
- The 'customers' screen with the list of deliveries grouped by customer. Clicking a delivery navigates to the 'delivery' screen. In the URL you can see the 'view=customer' parameter.
- The 'delivery' screen containing the delivery details per item. In the URL you can see the 'view' and the 'deliveryId' parameters. Clicking 'Back' navigates back to the previous page.
- All of these views exist on the same webpage but by changing the URL, the back button and bookmarking functions as you would expect
Download the source code
You can download the source code (Visual Studio 2010 solution) from my Skydrive.
1 comment:
Thanks, Bart for the write up. That was helpful.
Post a Comment