Skip to content
Find file
Fetching contributors…
Cannot retrieve contributors at this time
2140 lines (1873 sloc) 87 KB

Building the User UI using HTML5

What Will You Learn Here?

You’ve just implemented the business services of your application, and exposed them through RESTful endpoints. Now you need to implement a flexible user interface that can be easily used with both desktop and mobile clients. After reading this tutorial, you will understand our front-end design and the choices that we made in its implementation. Topics covered include:

  • Creating single-page applications using HTML5, JavaScript and JSON

  • Using JavaScript frameworks for invoking RESTful endpoints and manipulating page content

  • Feature and device detection

  • Implementing a version of the user interface that is optimized for mobile clients using JavaScript frameworks such as jQuery mobile

The tutorial will show you how to perform all these steps in JBoss Developer Studio, including screenshots that guide you through. For those of you who prefer to watch and learn, the included video shows you how we performed all the steps.

First, the basics

In this tutorial, we will build a single-page application. All the necessary code: HTML, CSS and JavaScript is retrieved within a single page load. Rather than refreshing the page every time the user changes a view, the content of the page will be redrawn by manipulating the DOM in JavaScript. The application uses REST calls to retrieve data from the server.

single page app
Figure 1. Single page application

Client-side MVC Support

Because this is a moderately complex example, which involves multiple views and different types of data, you will use a client-side MVC framework to structure your application, which provides amongst others:

  • routing support within your single page application;

  • event-driven interaction between views and data;

  • simplified CRUD invocations on RESTful services.

The client-side MVC framework that we use in this application in backbone.js. Its general architecture is shown below:

backbone usage
Figure 2. Backbone interaction in Ticket Monster

Modularity

As the application becomes more modular, the code also becomes more fragmented in order to support good separation of concerns. Ensuring that all the modules of the application are loaded properly at runtime becomes a more complex task as the application size increases. For conquering this complexity, we will be using the Asynchronous Module Definition mechanism as implemented by the require.js library.

Asynchronous Module Definition

The Asynchronous Module Definition (AMD) API specifies a mechanism for defining modules such that the module and its dependencies can be asynchronously loaded. This is particularly well suited for the browser environment where synchronous loading of modules incurs performance, usability, debugging, and cross-domain access problems.

Templating

Instead of manipulating the DOM directly, and mixing up HTML with the JavaScript code, we create HTML markup fragments separately as templates, to be applied when the application views render.

In this application we use the templating support provided by underscore.js.

Mobile and desktop versions

We will build two variants of the single-page-application: one for the desktop and one for mobile clients. This is because the page flow and structure, as well as feature set are slightly different in each case. Because they are very similar, we will cover the desktop version of the application first, and then we will explain what is different in the case of the mobile version.

Setting up the structure

Before we start developing the user interface, we need to set up the general application structure and copy the library files. So the first thing to do is to create the directory structure.

ui file structure
Figure 3. File structure for our web application

We will copy all stylesheets in the resources/css folder, resources/img contains the images and resources/templates contains the HTML templates for generating the views.

resources/js will contain the JavaScript code, split between resource/js/lib - which contains the libraries used by the application and resources/js/app which contains the application code. The latter will contain the application modules, in subsequent subdirectories, for models, collections, routers and views.

The first step in implementing our solution is copying the corresponding files into the resources/css and resources/js/lib folders:

  • require.js - for AMD support, along with its plugins

    • text - for loading text files, in our case the HTML templates

    • order - for enforcing load ordering if necessary

  • jQuery - general purpose library for HTML traversal and manipulation

  • Underscore - JavaScript utility library (and a dependency of Backbone)

  • Backbone - Client-side MVC frame

  • Bootstrap - UI components and stylesheets for page structuring

Then, we will create the main page of the application (which will be actually referenced by the browser).

src/main/webapp/desktop-index.html
<!DOCTYPE html>
<html>
<head>
    <title>Ticket Monster</title>
    <meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
    <meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=0;">

    <link type="text/css" rel="stylesheet" href="resources/css/screen.css"/>
    <link rel="stylesheet" href="resources/css/bootstrap.css" type="text/css" media="all"/>
    <script data-main="resources/js/main-desktop" src="resources/js/libs/require.js"></script>

    <!-- Add JavaScript library for IE6-8 support of HTML5 elements -->
    <!--[if lt IE 9]>
    <script src="http://html5shim.googlecode.com/svn/trunk/html5.js"></script>
    <![endif]-->

</head>
<body>

<!--
    The main layout of the page - contains the menu and the 'content' &lt;div/&gt; in which all the
    views will render the content.
-->
<div id="container">
    <div id="menu">
        <div class="navbar">
            <div class="navbar-inner">
                <div class="container">
                    <a class="brand">JBoss Ticket Monster</a>
                    <ul class="nav">
                        <li><a href="#events">Events</a></li>
                        <li><a href="#venues">Venues</a></li>
                        <li><a href="#bookings">Bookings</a></li>
                        <li><a href="#about">About</a></li>
                    </ul>
                </div>
            </div>
        </div>
    </div>
    <div id="content" class="container-fluid">
    </div>
</div>

<footer style="">
    <div style="text-align: center;"><img src="resources/img/logo.png" alt="HTML5"/></div>
</footer>

</body>
</html>

As you can see, the page does not contain too much. It loads the custom stylesheet of the application, as well as the one required by Twitter bootstrap, sets up instructions for loading the application scripts and defines a general structure of the page.

The actual HTML code of the page contains a menu definition which will be present on all the pages, as well as an empty element named content which is essentially a placeholder for the application views. When a view is displayed, it will apply a template and populate it.

The JavaScript code of the page is loaded by require.js, according to the module definition contained in resources/js/main-desktop.js, as follows:

src/main/webapp/resources/js/main-desktop.js
/**
 * Shortcut alias definitions - will come in handy when declaring dependencies
 * Also, they allow you to keep the code free of any knowledge about library
 * locations and versions
 */
require.config({
    paths: {
        jquery:'libs/jquery-1.7.1',
        underscore:'libs/underscore',
        text:'libs/text',
        order:'libs/order',
        bootstrap: 'libs/bootstrap',
        utilities: 'app/utilities',
        router
    }
});

// Backbone is not AMD-ready, so a individual module is declared
define("backbone", [
    // the order plugin is used to ensure that the modules are loaded in the right order
    'order!jquery',
    'order!underscore',
    'order!libs/backbone'], function(){
    return Backbone;
});

// Now we declare all the dependencies
require([
    'order!jquery',
    'order!underscore',
    'order!backbone',
    'text',
    'order!bootstrap',
], function(){
    console.log('all loaded')
});

As you see, this module loads all the utility libraries. Later on, when we will have written the application code too, we will load it here as well.

Visualising Events

The first use case that we implement is event navigation. The users will be able to view the list of events and select the one that they want to attend. After doing so, they will select a venue, and will be able to choose a performance date and time.

The Event model

We will define a Backbone model for holding individual event data. Nearly each domain entity (booking, event, venue) is represented by a corresponding Backbone model.

src/main/webapp/resources/js/app/models/event.js
/**
 * Module for the Event model
 */
define([
    'backbone' // depends and imports Backbone
], function (Backbone) {
    /**
     * The Event model class definition
     * Used for CRUD operations against individual events
     */
    var Event = Backbone.Model.extend({
        urlRoot:'rest/events' // the URL for perfoming CRUD operations
    });
    // export the Event class
    return Event;
});

The Event model can perform CRUD operations directly against the REST services.

Backbone Models

Backbone models contain the interactive data as well as a large part of the logic surrounding it: conversions, validations, computed properties, and access control. The can also perform CRUD operations with the REST service.

The Events collection

We will define a Backbone collection for handling groups of events (like the events list).

src/main/webapp/resources/js/app/collections/events.js
/**
 * Module for the Events collection
 */
define([
    // Backbone and the collection element type are dependencies
    'backbone',
    'app/models/event'
], function (Backbone, Event) {
    /**
     *  Here we define the Bookings collection
     *  We will use it for CRUD operations on Bookings
     */
    var Events = Backbone.Collection.extend({
        url:"rest/events", // the URL for performing CRUD operations
        model: Event,
        id:"id", // the 'id' property of the model is the identifier
        comparator:function (model) {
            return model.get('category').id;
        }
    });
    return Events;
});

By mapping the model and collection to a REST endpoint you can perform CRUD operations without having to invoke the services explicitly. You will see how that works a bit later.

Backbone Collections

Collections are ordered sets of models. They can handle events which are fired as a result of a change to a individual member, and can perform CRUD operations for syncing up contents against RESTful services.

The EventsView view

Now that we have implemented the data components of the example, we need to create the view that displays them.

src/main/webapp/resources/js/app/views/desktop/events.js
define([
    'backbone',
    'utilities',
    'text!../../../../templates/desktop/events.html'
], function (
    Backbone,
    utilities,
    eventsTemplate) {

    var EventsView = Backbone.View.extend({
        events:{
            "click a":"update"
        },
        render:function () {
            var categories = _.uniq(
                _.map(this.model.models, function(model){
                    return model.get('category')
                }, function(item){
                    return item.id
                }));
            utilities.applyTemplate($(this.el), eventsTemplate, {categories:categories, model:this.model})
            $(this.el).find('.item:first').addClass('active');
            $(".collapse").collapse()
            $("a[rel='popover']").popover({trigger:'hover'});
            return this
        },
        update:function () {
            $("a[rel='popover']").popover('hide')
        }
    });

    return  EventsView;
});

The view is attached to a DOM element (the el property). When the render method is invoked, it manipulates the DOM and renders the view. We could have achieved that by writing these instructions directly in the method, but that would make it hard to change the page design later on. Rather than that, we will create a template and apply it, thus separating the HTML view code from the view implementation.

src/main/webapp/resources/templates/desktop/events.html
<div class="row-fluid">
    <div class="span3">
        <div id="itemMenu">

            <%
            _.each(categories, function (category) {
            %>
            <div class="accordion-group">
                <div class="accordion-heading">
                    <a class="accordion-toggle" style="color: #fff; background: #000;"
                       data-target="#category-<%=category.id%>-collapsible" data-toggle="collapse"
                       data-parent="#itemMenu"><%= category.description %></a>
                </div>
                <div id="category-<%=category.id%>-collapsible" class="collapse in accordion-body">
                    <div id="category-<%=category.id%>" class="accordion-inner">

                        <%
                        _.each(model.models, function (model) {
                        if (model.get('category').id == category.id) {
                        %>
                        <p><a href="#venues/<%=model.attributes.id%>" rel="popover"
                              data-content="<%=model.attributes.description%>"
                              data-original-title="<%=model.attributes.name%>"><%=model.attributes.name%></a></p>
                        <% }
                        });
                        %>
                    </div>
                </div>
            </div>
            <% }); %>
        </div>
    </div>
    <div id='itemSummary' class="span9">
        <div class="row-fluid">
            <div class="span11">
                <div id="eventCarousel" class="carousel">
                    <!-- Carousel items -->
                    <div class="carousel-inner">
                        <%_.each(model.models, function(model) { %>
                        <div class="item">
                            <img src='rest/media/<%=model.attributes.mediaItem.id%>'/>

                            <div class="carousel-caption">
                                <h4><%=model.attributes.name%></h4>

                                <p><%=model.attributes.description%></p>
                                <a class="btn btn-danger" href="#events/<%=model.id%>">Book tickets</a>
                            </div>
                        </div>
                        <% }) %>
                    </div>
                    <!-- Carousel nav -->
                    <a class="carousel-control left" href="#eventCarousel" data-slide="prev">&lsaquo;</a>
                    <a class="carousel-control right" href="#eventCarousel" data-slide="next">&rsaquo;</a>
                </div>
            </div>
        </div>
    </div>
</div>

Besides applying the template and preparing the data that will be used to fill it (the categories and model entries in the map), this method also performs the JavaScript calls that are required to initialize the UI components (in this case the Twitter Bootstrap carousel and popover).

A view can also listen to events fired by children of it’s el root element. In this case, the update method is set up within the events property of the class to listen to clicks on anchors.

Now that the views are in place, you will need to add a routing rule to the application. We will create a router and add our first routes.

Routing

We will continue by defining a Router which provides linkable, bookmarkable and shareable URLs for the various locations in our application.

src/main/webapp/resources/js/app/router/desktop/router.js
/**
 * A module for the router of the desktop application
 */
define("router", [
    'jquery',
    'underscore',
    'backbone',
    'app/collections/events',
    'app/views/desktop/events',
],function ($,
            _,
            Backbone,
            Events,
            EventsView) {

    /**
     * The Router class contains all the routes within the application -
     * i.e. URLs and the actions that will be taken as a result.
     *
     * @type {Router}
     */

    var Router = Backbone.Router.extend({
        routes:{
            "":"events", // listen to #events
            "events":"events" // listen to #events
        },
        events:function () {
        	//initialize the events collection
            var events = new Events();
            // create an events view
            var eventsView = new EventsView({model:events, el:$("#content")});
            // render the view when the collection elements are fetched from the
            // RESTful service
            events.bind("reset",
                function () {
                    eventsView.render();
            }).fetch();
        });

    // Create a router instance
    var router = new Router();

    // Begin routing
    Backbone.history.start();

    return router;
});

Remember, this is a single page application. You will be able to navigate either using urls such as http://localhost:8080/ticket-monster/desktop-index.html#events or from with relative urls from within the application itself (this being exactly what the main menu does). The portion after the hash sign represents the url within the page, the one on which the router will act. The routes property maps urls to controller function. In the example above, we have two controller functions.

events handles the #events URL and will retrieve the events in our application through a REST call. You don’t have to do the REST call yourself, it will be triggered the fetch invocation on the Events collection (remember our earlier point about mapping collections to REST urls?). The reset event on the collection is invoked when the data from the server is received and the collection is populated, and this triggers the rendering of the events view (which is bound to the #content div). Notice how the whole process is orchestrated in an event-driven fashion - the models, views and controllers interact through events.

Once the router has been defined, all that remains is to cause is to be loaded by the main module definition. Because the router depends on all the other components (models, collections and views) of the application, directly or indirectly, it will be the only one that is explicitly listed in the main-desktop definition, which will change as follows:

src/main/webapp/resources/js/main-desktop.js
require.config({
    paths: {
        jquery:'libs/jquery-1.7.1',
        underscore:'libs/underscore',
        text:'libs/text',
        order:'libs/order',
        bootstrap: 'libs/bootstrap',
        utilities: 'app/utilities',
        router:'app/router/desktop/router'
    }
});

  ...

// Now we declare all the dependencies
require([
    'order!jquery',
    'order!underscore',
    'order!backbone',
    'text',
    'order!bootstrap',
    'router'
], function(){
    console.log('all loaded')
});

Viewing a single event

With the events list view now in place, we can begin implementing the next step of the use case: adding a view for visualizing the details of an individual event, selecting a venue and a performance time.

We already have the models in place so all we need to do is to create a additional view and expand the router. The view comes first.

src/main/webapp/resources/js/app/views/desktop/event-detail.js
define([
    'backbone',
    'utilities',
    'require',
    'text!../../../../templates/desktop/event-detail.html',
    'text!../../../../templates/desktop/media.html',
    'text!../../../../templates/desktop/event-venue-description.html',
    'bootstrap'
], function (
    Backbone,
    utilities,
    require,
    eventDetailTemplate,
    mediaTemplate,
    eventVenueDescriptionTemplate) {
    var EventDetail = Backbone.View.extend({
        events:{
            "click input[name='bookButton']":"beginBooking",
            "change select[id='venueSelector']":"refreshShows",
            "change select[id='dayPicker']":"refreshTimes"
        },
        render:function () {
            $(this.el).empty()
            utilities.applyTemplate($(this.el), eventDetailTemplate, this.model.attributes);
            $("#bookingOption").hide();
            $("#venueSelector").attr('disabled', true);
            $("#dayPicker").empty();
            $("#dayPicker").attr('disabled', true)
            $("#performanceTimes").empty();
            $("#performanceTimes").attr('disabled', true)
            var self = this
            $.getJSON("rest/shows?event=" + this.model.get('id'), function (shows) {
                self.shows = shows
                $("#venueSelector").empty().append("<option value='0'>Select a venue</option>");
                $.each(shows, function (i, show) {
                    $("#venueSelector").append("<option value='" + show.id + "'>"
                           + show.venue.address.city + " : " + show.venue.name + "</option>")
                });
                $("#venueSelector").removeAttr('disabled')
                if ($("#venueSelector").val()) {
                    $("#venueSelector").change()
                }
            })
        },
        beginBooking:function () {
            require("router").navigate('/book/' +
                      $("#venueSelector option:selected").val() + '/' + $("#performanceTimes").val(), true)
        },
        refreshShows:function (event) {
            $("#dayPicker").empty();

            var selectedShowId = event.currentTarget.value;

            if (selectedShowId != 0) {
                var selectedShow = _.find(this.shows, function (show) {
                    return show.id == selectedShowId
                });
                this.selectedShow = selectedShow;
                utilities.applyTemplate($("#eventVenueDescription"), eventVenueDescriptionTemplate, {venue:selectedShow.venue});
                var times = _.uniq(_.sortBy(_.map(selectedShow.performances, function (performance) {
                    return (new Date(performance.date).withoutTimeOfDay()).getTime()
                }), function (item) {
                    return item
                }));
                utilities.applyTemplate($("#venueMedia"), mediaTemplate, selectedShow.venue)
                $("#dayPicker").removeAttr('disabled')
                $("#performanceTimes").removeAttr('disabled')
                _.each(times, function (time) {
                    var date = new Date(time)
                    $("#dayPicker").append("<option value='" + date.toYMD() + "'>"
                          + date.toPrettyStringWithoutTime() +
                          "</option>")
                })
                this.refreshTimes()
                $("#bookingWhen").show(100)
            } else {
                $("#bookingWhen").hide(100)
                $("#bookingOption").hide()
                $("#dayPicker").empty()
                $("#venueMedia").empty()
                $("#eventVenueDescription").empty()
                $("#dayPicker").attr('disabled', true)
                $("#performanceTimes").empty()
                $("#performanceTimes").attr('disabled', true)
            }

        },
        refreshTimes:function () {
            var selectedDate = $("#dayPicker").val();
            $("#performanceTimes").empty()
            if (selectedDate) {
                $.each(this.selectedShow.performances, function (i, performance) {
                    var performanceDate = new Date(performance.date);
                    if (_.isEqual(performanceDate.toYMD(), selectedDate)) {
                        $("#performanceTimes").append("<option value='" + performance.id + "'>"
                            + performanceDate.getHours().toZeroPaddedString(2) + ":" + performanceDate.getMinutes().toZeroPaddedString(2) + "</option>")
                    }
                })
            }
            $("#bookingOption").show()
        }

    });

    return  EventDetail;
});

This view is already a bit more complex than the global events view. The main reason for that happening is that certain portions of the page need to be updated whenever the user chooses a different venue.

ui event details
Figure 4. On the event details page some fragments need to be re-rendered when the user changes the venue

The view responds to three different events:

  • a of the current venue will trigger a reload of the details and venue image, as well as of the performance times. The application will retrieve the latter information through a RESTful web service;

  • changing the performance day will cause the performance time selector to reload;

  • once the venue and performance time and date have been selected, the user can navigate to the booking page.

The corresponding templates for the three fragments used above are shown below

src/main/webapp/resources/templates/desktop/event-detail.html
<div class="row-fluid" xmlns="http://www.w3.org/1999/html">
    <h2 class="page-header"><%=name%></h2>
</div>
<div class="row-fluid">
    <div class="span4 well">
        <div class="row-fluid"><h3 class="page-header span6">What?</h3>
            <img width="100" src='rest/media/<%=mediaItem.id%>'/></div>
        <div class="row-fluid">
            <p>&nbsp;</p>

            <div class="span12"><%= description %></div>
        </div>
    </div>
    <div class="span4 well">
        <div class="row-fluid"><h3 class="page-header span6">Where?</h3>
            <div class="span6" id='venueMedia'/>
        </div>
        <div class='row-fluid'><select id='venueSelector'/>
            <div id="eventVenueDescription"/>
        </div>
    </div>
    <div id='bookingWhen' style="display: none;" class="span2 well">
        <h3 class="page-header">When?</h3>
        <select class="span2" id="dayPicker">
            <option value="-1">Select a day</option>
        </select>
        <select class="span2" id="performanceTimes"/>
            <option value="-1">Select a time</option>
        </select>

        <div id='bookingOption'><input name="bookButton" class="btn btn-primary" type="button"
                                       value="Order tickets"></div>
    </div>
</div>
src/main/webapp/resources/templates/desktop/event-venue-description.html
<address>
    <p><%= venue.description %></p>
    <p><strong>Address:</strong></p>
    <p><%= venue.address.street %></p>
    <p><%= venue.address.city %>, <%= venue.address.country %></p>
</address>
src/main/webapp/resources/templates/desktop/event-venue-description.html
<address>
    <p><%= venue.description %></p>
    <p><strong>Address:</strong></p>
    <p><%= venue.address.street %></p>
    <p><%= venue.address.city %>, <%= venue.address.country %></p>
</address>

Now that the view has actually been created, we need to add it to the router:

src/main/webapp/resources/js/app/router/desktop/router.js
/**
 * A module for the router of the desktop application
 */
define("router", [
    ...
    'app/models/event',
	...,
    'app/views/desktop/event-detail'
],function (
			...
            Event,
            ...
            EventDetailView) {

    var Router = Backbone.Router.extend({
        routes:{
            ...
            "events/:id":"eventDetail",
        },
        ...
        eventDetail:function (id) {
            var model = new Event({id:id});
            var eventDetailView = new EventDetailView({model:model, el:$("#content")});
            model.bind("change",
                function () {
                    eventDetailView.render();
                }).fetch();
        });
});

As you can see, this is extremely similar to the previous view and route, except that right now the application can also navigate routes such as http://localhost:8080/ticket-monster/desktop-index#events/1. This can be entered directly in the browser or it can be navigated as a relative path to #events/1 from within the applicaton, which is what the collapsible events menu above does.

With this in place, all that remains is to implement the final view of this use case, creating the bookings.

Creating Bookings

The user has chosen an event, a venue and a performance time, so the last step in our implementation is creating a booking. Users can select one of the available sections for the show’s venue, and once they do so, they will be able to enter a number of tickets for each category available for this show (Adult, Child, etc.) and add them to the current order. Once they do so, a summary view gets updated. Users can also choose to remove tickets from the order. When the order is complete, they can enter the contact information (e-mail address) and can submit it to the server.

First, we will add the new view:

src/main/webapp/resources/js/app/views/desktop/create-booking.js
define([
    'backbone',
    'utilities',
    'require',
    'text!../../../../templates/desktop/booking-confirmation.html',
    'text!../../../../templates/desktop/create-booking.html',
    'text!../../../../templates/desktop/ticket-categories.html',
    'text!../../../../templates/desktop/ticket-summary-view.html',
    'bootstrap'
],function (
    Backbone,
    utilities,
    require,
    bookingConfirmationTemplate,
    createBookingTemplate,
    ticketEntriesTemplate,
    ticketSummaryViewTemplate){


    var TicketCategoriesView = Backbone.View.extend({
        id:'categoriesView',
        events:{
            "change input":"onChange"
        },
        render:function () {
            if (this.model != null) {
                var ticketPrices = _.map(this.model, function (item) {
                    return item.ticketPrice;
                });
                utilities.applyTemplate($(this.el), ticketEntriesTemplate, {ticketPrices:ticketPrices});
            } else {
                $(this.el).empty();
            }
            return this;
        },
        onChange:function (event) {
            var value = event.currentTarget.value;
            var ticketPriceId = $(event.currentTarget).data("tm-id");
            var modifiedModelEntry = _.find(this.model, function(item) { return item.ticketPrice.id == ticketPriceId});
            if ($.isNumeric(value) && value > 0) {
                modifiedModelEntry.quantity = parseInt(value);
            }
            else {
                delete modifiedModelEntry.quantity;
            }
        }
    });

    var TicketSummaryView = Backbone.View.extend({
        tagName:'tr',
        events:{
            "click i":"removeEntry"
        },
        render:function () {
            var self = this;
            utilities.applyTemplate($(this.el), ticketSummaryViewTemplate, this.model.bookingRequest);
        },
        removeEntry:function () {
            this.model.tickets.splice(this.model.index, 1);
        }
    });

    var CreateBookingView = Backbone.View.extend({

        events:{
            "click input[name='submit']":"save",
            "change select":"refreshPrices",
            "keyup #email":"updateEmail",
            "click input[name='add']":"addQuantities",
            "click i":"updateQuantities"
        },
        render:function () {

            var self = this;
            $.getJSON("rest/shows/" + this.model.showId, function (selectedShow) {

                self.currentPerformance = _.find(selectedShow.performances, function (item) {
                    return item.id == self.model.performanceId;
                });

                var id = function (item) {return item.id;};
                // prepare a list of sections to populate the dropdown
                var sections = _.uniq(_.sortBy(_.pluck(selectedShow.ticketPrices, 'section'), id), true, id);
                utilities.applyTemplate($(self.el), createBookingTemplate, {
                    sections:sections,
                    show:selectedShow,
                    performance:self.currentPerformance});
                self.ticketCategoriesView = new TicketCategoriesView({model:{}, el:$("#ticketCategoriesViewPlaceholder") });
                self.ticketSummaryView = new TicketSummaryView({model:self.model, el:$("#ticketSummaryView")});
                self.show = selectedShow;
                self.ticketCategoriesView.render();
                self.ticketSummaryView.render();
                $("#sectionSelector").change();
            });
        },
        refreshPrices:function (event) {
            var ticketPrices = _.filter(this.show.ticketPrices, function (item) {
                return item.section.id == event.currentTarget.value;
            });
            var ticketPriceInputs = new Array();
            _.each(ticketPrices, function (ticketPrice) {
                ticketPriceInputs.push({ticketPrice:ticketPrice});
            });
            this.ticketCategoriesView.model = ticketPriceInputs;
            this.ticketCategoriesView.render();
        },
        save:function (event) {
            var bookingRequest = {ticketRequests:[]};
            var self = this;
            bookingRequest.ticketRequests = _.map(this.model.bookingRequest.tickets, function (ticket) {
                return {ticketPrice:ticket.ticketPrice.id, quantity:ticket.quantity}
            });
            bookingRequest.email = this.model.bookingRequest.email;
            bookingRequest.performance = this.model.performanceId
            $.ajax({url:"rest/bookings",
                data:JSON.stringify(bookingRequest),
                type:"POST",
                dataType:"json",
                contentType:"application/json",
                success:function (booking) {
                    this.model = {}
                    $.getJSON('rest/shows/performance/' + booking.performance.id, function (retrievedPerformance) {
                        utilities.applyTemplate($(self.el), bookingConfirmationTemplate, {booking:booking, performance:retrievedPerformance })
                    });
                }}).error(function (error) {
                    if (error.status == 400 || error.status == 409) {
                        var errors = $.parseJSON(error.responseText).errors;
                        _.each(errors, function (errorMessage) {
                            $("#request-summary").append('<div class="alert alert-error"><a class="close" data-dismiss="alert">×</a><strong>Error!</strong> ' + errorMessage + '</div>')
                        });
                    } else {
                        $("#request-summary").append('<div class="alert alert-error"><a class="close" data-dismiss="alert">×</a><strong>Error! </strong>An error has occured</div>')
                    }

                })

        },
        addQuantities:function () {
            var self = this;

            _.each(this.ticketCategoriesView.model, function (model) {
                if (model.quantity != undefined) {
                    var found = false;
                    _.each(self.model.bookingRequest.tickets, function (ticket) {
                        if (ticket.ticketPrice.id == model.ticketPrice.id) {
                            ticket.quantity += model.quantity;
                            found = true;
                        }
                    });
                    if (!found) {
                        self.model.bookingRequest.tickets.push({ticketPrice:model.ticketPrice, quantity:model.quantity});
                    }
                }
            });
            this.ticketCategoriesView.model = null;
            $('option:selected', 'select').removeAttr('selected');
            this.ticketCategoriesView.render();
            this.updateQuantities();
        },
        updateQuantities:function () {
            // make sure that tickets are sorted by section and ticket category
            this.model.bookingRequest.tickets.sort(function (t1, t2) {
                if (t1.ticketPrice.section.id != t2.ticketPrice.section.id) {
                    return t1.ticketPrice.section.id - t2.ticketPrice.section.id;
                }
                else {
                    return t1.ticketPrice.ticketCategory.id - t2.ticketPrice.ticketCategory.id;
                }
            });

            this.model.bookingRequest.totals = _.reduce(this.model.bookingRequest.tickets, function (totals, ticketRequest) {
                return {
                    tickets:totals.tickets + ticketRequest.quantity,
                    price:totals.price + ticketRequest.quantity * ticketRequest.ticketPrice.price
                };
            }, {tickets:0, price:0.0});

            this.ticketSummaryView.render();
            this.setCheckoutStatus();
        },
        updateEmail:function (event) {
            if ($(event.currentTarget).is(':valid')) {
                this.model.bookingRequest.email = event.currentTarget.value;

            } else {
                delete this.model.bookingRequest.email;
            }
            this.setCheckoutStatus();
        },
        setCheckoutStatus:function () {
            if (this.model.bookingRequest.totals != undefined && this.model.bookingRequest.totals.tickets > 0 && this.model.bookingRequest.email != undefined && this.model.bookingRequest.email != '') {
                $('input[name="submit"]').removeAttr('disabled');
            }
            else {
                $('input[name="submit"]').attr('disabled', true);
            }
        }
    });

    return CreateBookingView;
});

The code above may be surprising: after all, we said that we were going to add a single view, but instead, we have added three! The reason is that this view makes use of subviews (TicketCategoriesView and TicketSummaryView) for re-rendering parts of the main view. Whenever the user changes the current section selection, it will display a list of available tickets, by price category. Whenever the user adds the tickets to the main request, the current summary will be re-rendered. Changes in quantities or the target email may enable or disable the submission button - the booking request data is re-validated in the process. We do not create separate modules for the subviews, since they are not referenced outside the module itself.

The user submission is handled by the save method which constructs the a JSON object in the format required by a POST at http://localhost:8080/ticket-monster/rest/bookings, and performs the AJAX call. In case of a successful response, a confirmation view is rendered. On failure, a warning is displayed and the user may continue to edit the form.

The corresponding templates for the views above are shown below:

src/main/webapp/resources/templates/desktop/booking-confirmation.html
<div class="row-fluid">
    <h2 class="page-header">Booking #<%=booking.id%> confirmed!</h2>
</div>
<div class="row-fluid">
    <div class="span5 well">
        <h4 class="page-header">Checkout information</h4>
        <p><strong>Email: </strong><%= booking.contactEmail %></p>
        <p><strong>Event: </strong> <%= performance.event.name %></p>
        <p><strong>Venue: </strong><%= performance.venue.name %></p>
        <p><strong>Date: </strong><%= new Date(booking.performance.date).toPrettyString() %></p>
        <p><strong>Created on: </strong><%= new Date(booking.createdOn).toPrettyString() %></p>
    </div>
    <div class="span5 well">
        <h4 class="page-header">Ticket allocations</h4>
        <table class="table table-striped table-bordered" style="background-color: #fffffa;">
            <thead>
            <tr>
                <th>Ticket #</th>
                <th>Category</th>
                <th>Section</th>
                <th>Row</th>
                <th>Seat</th>
            </tr>
            </thead>
            <tbody>
            <% $.each(_.sortBy(booking.tickets, function(ticket) {return ticket.id}), function (i, ticket) { %>
            <tr>
                <td><%= ticket.id %></td>
                <td><%=ticket.ticketCategory.description%></td>
                <td><%=ticket.seat.section.name%></td>
                <td><%=ticket.seat.rowNumber%></td>
                <td><%=ticket.seat.number%></td>
            </tr>
            <% }) %>
            </tbody>
        </table>
    </div>
</div>
<div class="row-fluid" style="padding-bottom:30px;">
    <div class="span2"><a href="#">Home</a></div>
</div>
src/main/webapp/resources/templates/desktop/create-booking.html
<div class="row-fluid">
    <div class="span12">
        <h2><%=show.event.name%>
            <small><%=show.venue.name%>, <%=new Date(performance.date).toPrettyString()%></p></small>
        </h2>
    </div>
</div>
<div class="row-fluid">
    <div class="span5 well">
        <h4 class="page-header">Select tickets</h4>

        <div id="sectionSelectorPlaceholder" class="row-fluid">
            <div class="control-group">
                <label class="control-label" for="sectionSelect">Section</label>
                <div class="controls">
                    <select id="sectionSelect">
                        <option value="-1" selected="true">Choose a section</option>
                        <% _.each(sections, function(section) { %>
                        <option value="<%=section.id%>"><%=section.name%> - <%=section.description%></option>
                        <% }) %>
                    </select>
                </div>
            </div>
        </div>
        <div id="ticketCategoriesViewPlaceholder" class="row-fluid"></div>
    </div>
    <div id="request-summary" class="span5 offset1 well">
        <h4 class="page-header">Order summary</h4>
        <div id="ticketSummaryView" class="row-fluid"/>
        <h4 class="page-header">Checkout</h4>
        <div class="row-fluid">
            <input type='email' id="email" placeholder="Email" required/>
            <input type='button' class="btn btn-primary" name="submit" value="Checkout"
                   style="margin-bottom:9px;" disabled="true"/>
        </div>
    </div>
</div>
src/main/webapp/resources/templates/desktop/ticket-categories.html
<% if (ticketPrices.length > 0) { %>
<% _.each(ticketPrices, function(ticketPrice) { %>
<div id="ticket-category-input-<%=ticketPrice.id%>">
    <div class="control-group">
        <label class="control-label"><%=ticketPrice.ticketCategory.description%></label>

        <div class="controls">
            <div class="input-append">
                <input class="span2" rel="tooltip" title="Enter value" type="number" min="1"
                                             max="9"
                                             data-tm-id="<%=ticketPrice.id%>"
                                             placeholder="Number of tickets"
                                             name="tickets-<%=ticketPrice.ticketCategory.id%>"/>
                <span class="add-on" style="margin-bottom:9px">@ $<%=ticketPrice.price%></span>
            </div>
        </div>
    </div>
</div>
<% }) %>
<div class="control-group">
    <label class="control-label"/>
    <div class="controls">
        <input type="button" class="btn btn-primary" name="add" value="Add tickets"/>
    </div>
</div>
<% } %>
src/main/webapp/resources/templates/desktop/ticket-summary-view.html
<div class="span12">
    <% if (tickets.length>0) { %>
    <table class="table table-bordered table-condensed row-fluid" style="background-color: #fffffa;">
        <thead>
        <tr>
            <th colspan="5"><strong>Requested tickets</strong></th>
        </tr>
        <tr>
            <th>Section</th>
            <th>Category</th>
            <th>Quantity</th>
            <th>Price</th>
            <th></th>
        </tr>
        </thead>
        <tbody id="ticketRequestSummary">
        <% _.each(tickets, function (ticketRequest, index, tickets) { %>
        <tr>
            <td><%= ticketRequest.ticketPrice.section.name %></td>
            <td><%= ticketRequest.ticketPrice.ticketCategory.description %></td>
            <td><%= ticketRequest.quantity %></td>
            <td>$<%=ticketRequest.ticketPrice.price%></td>
            <td><i class="icon-trash"/></td>
        </tr>
        <% }); %>
        </tbody>
    </table>
    <p/>
    <div class="row-fluid">
        <div class="span5"><strong>Total ticket count:</strong> <%= totals.tickets %></div>
        <div class="span5"><strong>Total price:</strong> $<%=totals.price%></div></div>
    <% } else { %>
    No tickets requested.
    <% } %>
</div>

Finally, once the view is available, we can add it’s corresponding routing rule:

src/main/webapp/resources/js/app/router/desktop/router.js
/**
 * A module for the router of the desktop application
 */
define("router", [
    ...
    'app/views/desktop/create-booking',
	...
],function (
			...
            CreateBooking
            ...
            ) {

    var Router = Backbone.Router.extend({
        routes:{
            ...
            "book/:showId/:performanceId":"bookTickets",
        },
        ...
        bookTickets:function (showId, performanceId) {
            var createBookingView =
            	new CreateBookingView({
            		model:{ showId:showId,
            			    performanceId:performanceId,
            			    bookingRequest:{tickets:[]}},
            			    el:$("#content")
            			   });
            createBookingView.render();
        }
});

This concludes the implementation of a full booking use case, starting with listing events, continuing with selecting a venue and performance time, and ending with choosing tickets and completing the order.

The other use cases: full booking starting from venues and visualizing existing bookings are conceptually similar, so you can just copy the remaining files in the src/main/webapp/resources/js/app/models, src/main/webapp/resources/js/app/collections, src/main/webapp/resources/js/app/views/desktop and the remainder of src/main/webapp/resources/js/app/routers/desktop/router.js.

Mobile view

The mobile version of the application follows roughly the same architecture as the desktop version. The distinctions are mainly caused by the functional differences between the two versions, as well as the use of jQuery mobile.

Setting up the structure

The first step in implementing our solution is copying the corresponding files into the resources/css and resources/js/lib folders:

  • require.js - for AMD support, along with its plugins

    • text - for loading text files, in our case the HTML templates

    • order - for enforcing load ordering if necessary

  • jQuery - general purpose library for HTML traversal and manipulation;

  • Underscore - JavaScript utility library (and a dependency of Backbone);

  • Backbone - Client-side MVC framework;

  • jQuery Mobile - user interface system for mobile devices;

(If you built the desktop application following the previous example, some files may already be in place.)

Next, we will add the mobile main page.

src/main/webapp/mobile-index.html
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html>
<html>
<head>
    <title>Ticket Monster - mobile version</title>
    <meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
    <meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=0"/>

    <link rel="stylesheet" href="resources/css/jquery.mobile-1.1.0.css"/>
    <link rel="stylesheet" href="resources/css/m.screen.css"/>
    <script data-main="resources/js/main-mobile" src="resources/js/libs/require.js"></script>
</head>
<body>

<div id="container" data-role="page" data-ajax="false"></div>

</body>
</html>

As you can see, this page is even simpler. We just load the stylesheets for the application, and then we use require.js to load the JavaScript code and page fragments. All the pages will render inside the container element, which has a data-role attribute with the page value. This means that this is a jQuery Mobile page.

Then, we will add the module loader.

src/main/webapp/resources/js/main-mobile.js
/**
 * Shortcut alias definitions - will come in handy when declaring dependencies
 * Also, they allow you to keep the code free of any knowledge about library
 * locations and versions
 */
require.config({
    paths: {
        jquery:'libs/jquery-1.7.1',
        jquerymobile:'libs/jquery.mobile-1.1.0',
        text:'libs/text',
        order: 'libs/order',
        utilities: 'app/utilities',
        router:'app/router/mobile/router'
    }
});

define('underscore',[
    'libs/underscore'
], function(){
    return _;
});

define("backbone", [
    'order!jquery',
    'order!underscore',
    'order!libs/backbone'
], function(){
    return Backbone;
});


// Now we declare all the dependencies
require(['router'],
       function(){
    console.log('all loaded')
});

In this application, we combine Backbone and jQuery Mobile. Each framework has its own strengths: jQuery Mobile provides the UI components and touch device support, whereas Backbone provides the MVC support. However, there is some overlap between the two, as jQuery Mobile provides its own navigation mechanism which will need to be disabled. So in the router code below you will find the customizations that need to be performed in order to get the two frameworks working together - disabling the jQuery Mobile navigation and the defaultHandler added to the route for handling jQuery Mobile transitions between internal pages (such as the ones generated by a nested listview).

src/main/webapp/resources/js/app/router/mobile/router.js
/**
 * A module for the router of the desktop application.
 *
 */
define("router",[
    'jquery',
    'jquerymobile',
    'underscore',
    'backbone',
    'utilities'
],function ($,
            jqm,
            _,
            Backbone,
            Booking,
            utilities) {

    // prior to creating an starting the router, we disable jQuery Mobile's own routing mechanism
    $.mobile.hashListeningEnabled = false;
    $.mobile.linkBindingEnabled = false;
    $.mobile.pushStateEnabled = false;

    /**
     * The Router class contains all the routes within the application - i.e. URLs and the actions
     * that will be taken as a result.
     *
     * @type {Router}
     */
    var Router = Backbone.Router.extend({
    	//no routes added yet
    	defaultHandler:function (actions) {
            if ("" != actions) {
                $.mobile.changePage("#" + actions, {transition:'slide', changeHash:false, allowSamePageTransition:true});
            }
        }
    });

    // Create a router instance
    var router = new Router();

    // Begin routing
    Backbone.history.start();

    return router;
});

Please note that the router will be also have more responsibilities, will interact with more libraries and it will declare them as its dependencies. We won’t specify them in the main loader.

The landing page

The first page in our application is the landing page. First, we add the template for it.

src/main/webapp/resources/templates/mobile/home-view.html
<div data-role="header">
    <h3>Ticket Monster</h3>
</div>
<div data-role="content" align="center">
    <img src="resources/gfx/icon_large.png"/>
    <h4 align="left">Find events</h4>
    <ul data-role="listview">
        <li>
            <a href="#events">By Category</a>
        </li>
        <li>
            <a href="#venues">By Location</a>
        </li>
    </ul>
</div>

Now we have to add the page to the router:

src/main/webapp/resources/js/app/router/mobile/router.js
/**
 * A module for the router of the desktop application.
 *
 */
define("router",[
    ...
    'text!../templates/mobile/home-view.html'
],function (
		...
        HomeViewTemplate) {

	...
    var Router = Backbone.Router.extend({
        routes:{
            "":"home"
        },
        ...
        home:function () {
            utilities.applyTemplate($("#container"), HomeViewTemplate);
            try {
                $("#container").trigger('pagecreate');
            } catch (e) {
                // workaround for a spurious error thrown when creating the page initially
            }
    	}
    });
    ...
});

Because jQuery Mobile navigation is disabled in order to be able to take advantage of backbone’s support, we need to tell jQuery Mobile explicitly to enhance the page content in order to create the mobile view - in this case, we trigger the jQuery Mobile pagecreate event explicitly to ensure that the page gets the appropriate look and feel.

The events view

Just as in the case of the desktop view, we would like to display a list of events first. Since mobile interfaces are more constrained, we will just show a simple list view. First we will create the appropriate Backbone view.

src/main/webapp/resources/js/app/views/mobile/events.js
define([
    'backbone',
    'utilities',
    'text!../../../../templates/mobile/events.html'
], function (
    Backbone,
    utilities,
    eventsView) {

    var EventsView = Backbone.View.extend({
        render:function () {
            var categories = _.uniq(
                _.map(this.model.models, function(model){
                    return model.get('category')
                }, function(item){
                    return item.id
                }));

            utilities.applyTemplate($(this.el), eventsView,  {categories:categories, model:this.model})
            $(this.el).trigger('pagecreate')
        }
    });

    return EventsView;
});

As you can see, it is conceptually very similar to the desktop view, the main difference being the explicit hint to jQuery mobile through the pagecreate event invocation.

The next step is adding the template for rendering the view.

src/main/webapp/resources/templates/mobile/events.html
<div data-role="header">
    <a data-role="button" data-icon="home" href="#">Home</a>
    <h3>Categories</h3>
</div>
<div data-role="content" id='itemMenu'>
    <div id='categoryMenu' data-role='listview' data-filter='true' data-filter-placeholder='Event category name ...'>
        <%
        _.each(categories, function (category) {
        %>
        <li>
            <a href="#"><%= category.description %></a>
            <ul id="category-<%=category.id%>">
                <%
                _.each(model.models, function (model) {
                if (model.get('category').id == category.id) {
                %>
                <li>
                    <a href="#events/<%=model.attributes.id%>"><%=model.attributes.name%></a>
                </li>
                <% }
                });
                %>
            </ul>
        </li>
        <% }); %>
    </div>
</div>

And finally, we need to instruct the router to invoke the page.

src/main/webapp/resources/js/app/router/mobile/router.js
/**
 * A module for the router of the desktop application.
 *
 */
define("router",[
    ...
	'app/collections/events',
	...
	'app/views/mobile/events'
	...
],function (
	...,
	Events,
	...,
	EventsView,
	...) {

	...
    var Router = Backbone.Router.extend({
        routes:{
        	...
            "events":"events"
            ...
        },
        ...
        events:function () {
            var events = new Events;
            var eventsView = new EventsView({model:events, el:$("#container")});
            events.bind("reset",
                function () {
                    eventsView.render();
                }).fetch();
        }
        ...
    });
    ...
});

Just as in the case of the desktop application, the list of events will be accessible at #events, like for example http://localhost:8080/ticket-monster/mobile-index.html#events.

Viewing an individual event

For viewing individual event details, we need to create the view first.

src/main/webapp/resources/js/app/views/mobile/event-detail.js
define(['backbone',
    'utilities',
    'require',
    'text!../../../../templates/mobile/event-detail.html',
    'text!../../../../templates/mobile/event-venue-description.html'
], function (
    Backbone,
    utilities,
    require,
    eventDetail,
    eventVenueDescription) {

    var EventDetailView = Backbone.View.extend({
        events:{
            "click a[id='bookButton']":"beginBooking",
            "change select[id='showSelector']":"refreshShows",
            "change select[id='performanceTimes']":"performanceSelected",
            "change select[id='dayPicker']":'refreshTimes'
        },
        render:function () {
            $(this.el).empty()
            utilities.applyTemplate($(this.el), eventDetail, this.model.attributes)
            $(this.el).trigger('create')
            $("#bookButton").addClass("ui-disabled")
            var self = this;
            $.getJSON("rest/shows?event=" + this.model.get('id'), function (shows) {
                self.shows = shows;
                $("#showSelector").empty().append("<option data-placeholder='true'>Choose a venue ...</option>");
                $.each(shows, function (i, show) {
                    $("#showSelector").append("<option value='" + show.id + "'>" + show.venue.address.city + " : " + show.venue.name + "</option>");
                });
                $("#showSelector").selectmenu('refresh', true)
                $("#dayPicker").selectmenu('disable')
                $("#dayPicker").empty().append("<option data-placeholder='true'>Choose a show date ...</option>")
                $("#performanceTimes").selectmenu('disable')
                $("#performanceTimes").empty().append("<option data-placeholder='true'>Choose a show time ...</option>")
            });
            $("#dayPicker").empty();
            $("#dayPicker").selectmenu('disable');
            $("#performanceTimes").empty();
            $("#performanceTimes").selectmenu('disable');
            $(this.el).trigger('pagecreate');
        },
        performanceSelected:function () {
            if ($("#performanceTimes").val() != 'Choose a show time ...') {
                $("#bookButton").removeClass("ui-disabled")
            } else {
                $("#bookButton").addClass("ui-disabled")
            }
        },
        beginBooking:function () {
            require('router').navigate('book/' + $("#showSelector option:selected").val() + '/' + $("#performanceTimes").val(), true)
        },
        refreshShows:function (event) {

            var selectedShowId = event.currentTarget.value;

            if (selectedShowId != 'Choose a venue ...') {
                var selectedShow = _.find(this.shows, function (show) {
                    return show.id == selectedShowId
                });
                this.selectedShow = selectedShow;
                var times = _.uniq(_.sortBy(_.map(selectedShow.performances, function (performance) {
                    return (new Date(performance.date).withoutTimeOfDay()).getTime()
                }), function (item) {
                    return item
                }));
                utilities.applyTemplate($("#eventVenueDescription"), eventVenueDescription, {venue:selectedShow.venue});
                $("#detailsCollapsible").show()
                $("#dayPicker").removeAttr('disabled')
                $("#performanceTimes").removeAttr('disabled')
                $("#dayPicker").empty().append("<option data-placeholder='true'>Choose a show date ...</option>")
                _.each(times, function (time) {
                    var date = new Date(time)
                    $("#dayPicker").append("<option value='" + date.toYMD() + "'>" + date.toPrettyStringWithoutTime() + "</option>")
                });
                $("#dayPicker").selectmenu('refresh')
                $("#dayPicker").selectmenu('enable')
                this.refreshTimes()
            } else {
                $("#detailsCollapsible").hide()
                $("#eventVenueDescription").empty()
                $("#dayPicker").empty()
                $("#dayPicker").selectmenu('disable')
                $("#performanceTimes").empty()
                $("#performanceTimes").selectmenu('disable')
            }


        },
        refreshTimes:function () {
            var selectedDate = $("#dayPicker").val();
            $("#performanceTimes").empty().append("<option data-placeholder='true'>Choose a show time ...</option>")
            if (selectedDate) {
                $.each(this.selectedShow.performances, function (i, performance) {
                    var performanceDate = new Date(performance.date);
                    if (_.isEqual(performanceDate.toYMD(), selectedDate)) {
                        $("#performanceTimes").append("<option value='" + performance.id + "'>" + performanceDate.getHours().toZeroPaddedString(2) + ":" + performanceDate.getMinutes().toZeroPaddedString(2) + "</option>")
                    }
                })
                $("#performanceTimes").selectmenu('enable')
            }
            $("#performanceTimes").selectmenu('refresh')
            this.performanceSelected()
        }

    });

    return EventDetailView;
});

Again, this is very similar to the desktop version, the main differences being due to the specific jQuery Mobile invocations. And now we need to provide the actual page templates

src/main/webapp/resources/templates/mobile/event-detail.html
<div data-role="header">
    <h3>Book tickets</h3>
</div>
<div data-role="content">
    <h3><%=name%></h3>
    <img width='100px' src='rest/media/<%=mediaItem.id%>'/>
    <p><%=description%></p>
    <div data-role="fieldcontain">
        <label for="showSelector"><strong>Where</strong></label>
        <select id='showSelector' data-mini='true'/>
    </div>

    <div data-role="collapsible" data-content-theme="c" style="display: none;"
         id="detailsCollapsible">
        <h3>Venue details</h3>

        <div id="eventVenueDescription">
        </div>
    </div>

    <div data-role='fieldcontain'>
        <fieldset data-role='controlgroup'>
            <legend><strong>When</strong></legend>
            <label for="dayPicker">When:</label>
            <select id='dayPicker' data-mini='true'/>

            <label for="performanceTimes">When:</label>
            <select id="performanceTimes" data-mini='true'/>

        </fieldset>
    </div>

</div>
<div data-role="footer" class="ui-bar ui-grid-c">
    <div class="ui-block-a"></div>
    <div class="ui-block-b"></div>
    <div class="ui-block-c"></div>
    <a id='bookButton' class="ui-block-e" data-theme='b' data-role="button" data-icon="check">Book</a>
</div>
src/main/webapp/resources/templates/mobile/event-venue-description.html
<img width="100" src="rest/media/<%=venue.mediaItem.id%>"/></p>
<%= venue.description %>
<address>
    <p><strong>Address:</strong></p>
    <p><%= venue.address.street %></p>
    <p><%= venue.address.city %>, <%= venue.address.country %></p>
</address>

And finally, we need to instruct add this to the router, explicitly indicating jQuery Mobile that a transition has to take place after the view is rendered - in order to allow the page to render correctly after it has been invoked from the listview.

src/main/webapp/resources/js/app/router/mobile/router.js
/**
 * A module for the router of the desktop application.
 *
 */
define("router",[
    ...
	'app/model/event',
	...
	'app/views/mobile/event-detail'
	...
],function (
	...,
	Event,
	...,
	EventDetailView,
	...) {

	...
    var Router = Backbone.Router.extend({
        routes:{
        	...
            "events/:id":"eventDetail",
            ...
        },
        ...
        eventDetail:function (id) {
            var model = new Event({id:id});
            var eventDetailView = new EventDetailView({model:model, el:$("#container")});
            model.bind("change",
                function () {
                    eventDetailView.render();
                    $.mobile.changePage($("#container"), {transition:'slide', changeHash:false});
                }).fetch();
        }
        ...
    });
    ...
});

Just as the desktop version, the mobile event detail view allows users to choose a venue and a performance time. The next step is booking some tickets.

Booking tickets

The process of booking tickets is simpler than in the case of desktop version. Users can select a section and enter the number of tickets for each category - however, there is no process of adding and removing tickets to an order, once the form is filled out, the users can submit it.

First, we will create the views:

src/main/webapp/resources/js/app/views/mobile/create-booking.js
define([
    'backbone',
    'utilities',
    'require',
    'text!../../../../templates/mobile/booking-details.html',
    'text!../../../../templates/mobile/create-booking.html',
    'text!../../../../templates/mobile/confirm-booking.html',
    'text!../../../../templates/mobile/ticket-entries.html',
    'text!../../../../templates/mobile/ticket-summary-view.html'
], function (
    Backbone,
    utilities,
    require,
    bookingDetailsTemplate,
    createBookingTemplate,
    confirmBookingTemplate,
    ticketEntriesTemplate,
    ticketSummaryViewTemplate) {

    var TicketCategoriesView = Backbone.View.extend({
        id:'categoriesView',
        events:{
            "change input":"onChange"
        },
        render:function () {
            var views = {};

            if (this.model != null) {
                var ticketPrices = _.map(this.model, function (item) {
                    return item.ticketPrice;
                });
                utilities.applyTemplate($(this.el), ticketEntriesTemplate, {ticketPrices:ticketPrices});
            } else {
                $(this.el).empty();
            }
            $(this.el).trigger('pagecreate');
            return this;
        },
        onChange:function (event) {
            var value = event.currentTarget.value;
            var ticketPriceId = $(event.currentTarget).data("tm-id");
            var modifiedModelEntry = _.find(this.model, function(item) { return item.ticketPrice.id == ticketPriceId});
            if ($.isNumeric(value) && value > 0) {
                modifiedModelEntry.quantity = parseInt(value);
            }
            else {
                delete modifiedModelEntry.quantity;
            }
        }
    });

     var TicketSummaryView = Backbone.View.extend({
        render:function () {
            utilities.applyTemplate($(this.el), ticketSummaryViewTemplate, this.model.bookingRequest)
        }
    });

    var ConfirmBookingView = Backbone.View.extend({
        events:{
            "click a[id='saveBooking']":"save",
            "click a[id='goBack']":"back"
        },
        render:function () {
            utilities.applyTemplate($(this.el), confirmBookingTemplate, this.model)
            this.ticketSummaryView = new TicketSummaryView({model:this.model, el:$("#ticketSummaryView")});
            this.ticketSummaryView.render();
            $(this.el).trigger('pagecreate')
        },
        back:function () {
            require("router").navigate('book/' + this.model.bookingRequest.show.id + '/' + this.model.bookingRequest.performance.id, true)

        }, save:function (event) {
            var bookingRequest = {ticketRequests:[]};
            var self = this;
            _.each(this.model.bookingRequest.tickets, function (collection) {
                _.each(collection, function (model) {
                    if (model.quantity != undefined) {
                        bookingRequest.ticketRequests.push({ticketPrice:model.ticketPrice.id, quantity:model.quantity})
                    };
                })
            });

            bookingRequest.email = this.model.email;
            bookingRequest.performance = this.model.performanceId;
            $.ajax({url:"rest/bookings",
                data:JSON.stringify(bookingRequest),
                type:"POST",
                dataType:"json",
                contentType:"application/json",
                success:function (booking) {
                    utilities.applyTemplate($(self.el), bookingDetailsTemplate, booking)
                    $(self.el).trigger('pagecreate');
                }}).error(function (error) {
                    alert(error);
                });
            this.model = {};
        }
    });


    var CreateBookingView = Backbone.View.extend({

        events:{
            "click a[id='confirmBooking']":"checkout",
            "change select":"refreshPrices",
            "blur input[type='number']":"updateForm",
            "blur input[name='email']":"updateForm"
        },
        render:function () {

            var self = this;

            $.getJSON("rest/shows/" + this.model.showId, function (selectedShow) {
                self.model.performance = _.find(selectedShow.performances, function (item) {
                    return item.id == self.model.performanceId;
                });
                var id = function (item) {return item.id;};
                // prepare a list of sections to populate the dropdown
                var sections = _.uniq(_.sortBy(_.pluck(selectedShow.ticketPrices, 'section'), id), true, id);

                utilities.applyTemplate($(self.el), createBookingTemplate, { show:selectedShow,
                    performance:self.model.performance,
                    sections:sections});
                $(self.el).trigger('pagecreate');
                self.ticketCategoriesView = new TicketCategoriesView({model:{}, el:$("#ticketCategoriesViewPlaceholder") });
                self.model.show = selectedShow;
                self.ticketCategoriesView.render();
                $('a[id="confirmBooking"]').addClass('ui-disabled');
                $("#sectionSelector").change();
            });

        },
        refreshPrices:function (event) {
            if (event.currentTarget.value != "Choose a section") {
                var ticketPrices = _.filter(this.model.show.ticketPrices, function (item) {
                    return item.section.id == event.currentTarget.value;
                });
                var ticketPriceInputs = new Array();
                _.each(ticketPrices, function (ticketPrice) {
                    var model = {};
                    model.ticketPrice = ticketPrice;
                    ticketPriceInputs.push(model);
                });
                $("#ticketCategoriesViewPlaceholder").show();
                this.ticketCategoriesView.model = ticketPriceInputs;
                this.ticketCategoriesView.render();
                $(this.el).trigger('pagecreate');
            } else {
                $("#ticketCategoriesViewPlaceholder").hide();
                this.ticketCategoriesView.model = new Array();
                this.updateForm();
            }
        },
        checkout:function () {
            this.model.bookingRequest.tickets.push(this.ticketCategoriesView.model);
            this.model.performance = new ConfirmBookingView({model:this.model, el:$("#container")}).render();
            $("#container").trigger('pagecreate');
        },
        updateForm:function () {

            var totals = _.reduce(this.ticketCategoriesView.model, function (partial, model) {
                if (model.quantity != undefined) {
                    partial.tickets += model.quantity;
                    partial.price += model.quantity * model.ticketPrice.price;
                    return partial;
                }
            }, {tickets:0, price:0.0});
            this.model.email = $("input[type='email']").val();
            this.model.bookingRequest.totals = totals;
            if (totals.tickets > 0 && $("input[type='email']").val()) {
                $('a[id="confirmBooking"]').removeClass('ui-disabled');
            } else {
                $('a[id="confirmBooking"]').addClass('ui-disabled');
            }
        }
    });
    return CreateBookingView;
});

The views follow the same view/subview breakdown principles as in the case of the desktop application, except that the summary view is not rendered inline but after a page transition.

The next step is creating the page fragment templates. First, the actual page.

src/main/webapp/resources/templates/mobile/create-booking.html
<div data-role="header">
    <h1>Book tickets</h1>
</div>
<div data-role="content">
    <p>
       <h3><%=show.event.name%></h3>
    </p>
    <p>
      <%=show.venue.name%>
    <p>
    <p>
      <small><%=new Date(performance.date).toPrettyString()%></small>
    </p>
    <div id="sectionSelectorPlaceholder">
        <div data-role="fieldcontain">
            <label for="sectionSelect">Section</label>
            <select id="sectionSelect">
                <option value="-1" selected="true">Choose a section</option>
                <% _.each(sections, function(section) { %>
                <option value="<%=section.id%>"><%=section.name%> - <%=section.description%></option>
                <% }) %>
            </select>
        </div>
    </div>
    <div id="ticketCategoriesViewPlaceholder" style="display:none;"/>

    <div class="fieldcontain">
        <label>Contact email</label>
        <input type='email' name='email' placeholder="Email"/>
    </div>
</div>
<div data-role="footer" class="ui-bar">
    <a href="#" data-role="button" data-icon="delete">Cancel</a>
    <a id="confirmBooking" data-icon="check" data-role="button" disabled>Checkout</a>
</div>

Next, the fragment that contains the input form for tickets, which will be re-rendered whenever the section selection changes.

src/main/webapp/resources/templates/mobile/ticket-entries.html
<% if (ticketPrices.length > 0) { %>
    <form name="ticketCategories">
    <h4>Select tickets by category</h4>
    <% _.each(ticketPrices, function(ticketPrice) { %>
      <div id="ticket-category-input-<%=ticketPrice.id%>"/>

      <fieldset data-role="fieldcontain">
         <label for="ticket-<%=ticketPrice.id%>"><%=ticketPrice.ticketCategory.description%>($<%=ticketPrice.price%>)</label>
        <input id="ticket-<%=ticketPrice.id%>" data-tm-id="<%=ticketPrice.id%>" type="number" placeholder="Enter value"
               name="tickets"/>
      </fieldset>
   <% }) %>
   </form>
<% } %>

Before submitting the request to the server, the order will be confirmed:

src/main/webapp/resources/templates/mobile/confirm-booking.html
<div data-role="header">
    <h1>Confirm order</h1>
</div>
<div data-role="content">
    <h3><%=show.event.name%></h3>
    <p><%=show.venue.name%></p>
    <p><small><%=new Date(performance.date).toPrettyString()%></small></p>
    <p><strong>Buyer:</strong>  <emphasis><%=email%></emphasis></p>
    <div id="ticketSummaryView"/>

</div>

<div data-role="footer" class="ui-bar">
    <div class="ui-grid-b">
        <div class="ui-block-a"><a id="cancel" href="#" data-role="button" data-icon="delete">Cancel</a></div>
        <div class="ui-block-b"><a id="goBack" data-role="button" data-icon="back">Back</a></div>
        <div class="ui-block-c"><a id="saveBooking" data-icon="check" data-role="button">Buy!</a></div>
    </div>
</div>

This page contains a summary subview:

src/main/webapp/resources/templates/mobile/ticket-summary-view.html
<table>
    <thead>
    <tr>
        <th>Section</th>
        <th>Category</th>
        <th>Price</th>
        <th>Quantity</th>
    </tr>
    </thead>
    <tbody>
    <% _.each(tickets, function(ticketRequest) { %>
    <% _.each(ticketRequest, function(model) { %>
    <% if (model.quantity != undefined) { %>
    <tr>
        <td><%= model.ticketPrice.section.name %></td>
        <td><%= model.ticketPrice.ticketCategory.description %></td>
        <td>$<%= model.ticketPrice.price %></td>
        <td><%= model.quantity %></td>
    </tr>
    <% } %>
    <% }) %>
    <% }) %>
    </tbody>
</table>
<div data-theme="c">
    <h4>Totals</h4>
    <p><strong>Total tickets: </strong><%= totals.tickets %></p>
    <p> <strong>Total price: $</strong><%= totals.price %></p>
</div>

And finally, the page that displays the booking confirmation.

src/main/webapp/resources/templates/mobile/booking-details.html
<div data-role="header">
    <h1>Booking complete</h1>
</div>
<div data-role="content">
    <table id="confirm_tbl">
        <thead>
        <tr>
            <td colspan="5" align="center"><strong>Booking <%=id%></strong></td>
        <tr>
        <tr>
            <th>Ticket #</th>
            <th>Category</th>
            <th>Section</th>
            <th>Row</th>
            <th>Seat</th>
        </tr>
        </thead>
        <tbody>
        <% $.each(_.sortBy(tickets, function(ticket) {return ticket.id}), function (i, ticket) { %>
        <tr>
            <td><%= ticket.id %></td>
            <td><%=ticket.ticketCategory.description%></td>
            <td><%=ticket.seat.section.name%></td>
            <td><%=ticket.seat.rowNumber%></td>
            <td><%=ticket.seat.number%></td>
        </tr>
        <% }) %>
        </tbody>
    </table>
</div>
<div data-role="footer" class="ui-bar">
    <div class="ui-block-b"><a id="back" href="#" data-role="button" data-icon="back">Back</a></div>
</div>

The last step is tying the view into the router.

src/main/webapp/resources/js/app/router/desktop/router.js
/**
 * A module for the router of the desktop application
 */
define("router", [
	...
    'app/views/mobile/create-booking',
    ...
],function (
			...
            CreateBookingView
            ...) {

    var Router = Backbone.Router.extend({
        routes:{
            ...
            "book/:showId/:performanceId":"bookTickets",
            ...
        },
        ...
        bookTickets:function (showId, performanceId) {
            var createBookingView = new CreateBookingView({
            			model:{showId:showId, performanceId:performanceId,
            			bookingRequest:{tickets:[]}},
            			el:$("#container")
            });
            createBookingView.render();
        },
        ...
        );
});

Device detection

Now we have two distinct single-page applications and we can point users to any of them
easily. But instead letting the user figure out which page do they want to get to, we could
simply redirect them to one of the pages based on the device that they have.

To this end, we are using `Modernizr.js` a JavaScript library that help us detect
device capabilities - and which you can use for much more thank just desktop vs. mobile
detection: it can identify which features from the HTML5 set are supported by a particular
browser at runtime, which is extremely helpful for implementing progressive enhancement in
applications.

So, the first step is to copy `modernizr.js` into `src/main/webapp/resources/js/libs`. Then,
you will add the `src/main/webapp/index.html` file with the following content:

.src/main/webapp/index.html
[source,html]
-------------------------------------------------------------------------------------------------------
<!DOCTYPE html>
<html>
<head>
    <script type="text/javascript" src="resources/js/libs/modernizr-2.0.6.js"></script>

	<!--
		A simple check on the client. For touch devices or small-resolution screens
		show the mobile client. By enabling the mobile client on a small-resolution screen
		we allow for testing outside a mobile device (like for example the Mobile Browser
		simulator in JBoss Tools and JBoss Developer Studio).
	 -->
    <script type="text/javascript">
        if (Modernizr.touch || Modernizr.mq("only all and (max-width: 480px)")) {
            location.replace('mobile-index.html')
        } else {
            location.replace('desktop-index.html')
        };
    </script>
</head>
<body>

</body>
</html>
-------------------------------------------------------------------------------------------------------

Now you can navigate to an URL like `http://localhost:8080/ticket-monster/` with either
a mobile device or a desktop browser, and you will be redirected to the appropriate page.
Something went wrong with that request. Please try again.