TicketMonster Tutorial

Building The User UI Using HTML5

What Will You Learn Here?

We’ve just implemented the business services of our application, and exposed them through RESTful endpoints. Now we 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.

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, we will use a client-side MVC framework to structure the application, which provides amongst others:

  • routing support within the single page application;

  • event-driven interaction between views and data;

  • simplified CRUD invocations on RESTful services.

In this application we use the client-side MVC framework "backbone.js".

backbone usage
Figure 2. Backbone architecture

Modularity

In order to provide good separation of concerns, we split the JavaScript code into modules. Ensuring that all the modules of the application are loaded properly at runtime becomes a complex task, as the application size increases. To conquer this complexity, we use the Asynchronous Module Definition mechanism as implemented by the "require.js" library.

Tip
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 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 which are applied when the application views are rendered.

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

Mobile and desktop versions

The page flow and structure, as well as feature set, are slightly different for mobile and desktop, and therefore we will build two variants of the single-page-application, one for desktop and one for mobile. As the application variants are very similar, we will cover the desktop version of the application first, and then we will explain what is different in the mobile version.

Setting up the structure

Before we start developing the user interface, we need to set up the general application structure and add the JavaScript libraries. First, we create the directory structure:

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

We put stylesheets in resources/css folder, images in resources/img, and HTML view templates in resources/templates. resources/js contains the JavaScript code, split between resources/js/libs - which contains the libraries used by the application, resources/js/app - which contains the application code, and resources/js/configurations which contains module definitions for the different versions of the application - i.e. mobile and desktop. The resources/js/app folder will contain the application modules, in subsequent subdirectories, for models, collections, routers and views.

The first step in implementing our solution is adding the stylesheets and JavaScript libraries to the resources/css and resources/js/libs:

require.js

AMD support, along with the plugin:

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

jQuery

general purpose library for HTML traversal and manipulation

Underscore

JavaScript utility library (and a dependency of Backbone)

Backbone

Client-side MVC framework

Bootstrap

UI components and stylesheets for page structuring

Now, we create the main page of the application (which is the URL loaded by the browser):

src/main/webapp/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"/>

    <script type="text/javascript" src="resources/js/libs/modernizr-2.0.6.js"></script>
    <script type="text/javascript" src="resources/js/libs/require.js"
            data-main="resources/js/configurations/loader"></script>
</head>
<body>
</body>
</html>

As you can see, the page does not contain much. It loads Modernizr (for HTML5 and CSS3 feature detection) and RequireJS (for loading JavaScript modules in an asynchronous manner). Once RequireJS is loaded by the browser, it will configure itself to use a baseUrl of resources/js/configurations (specified via the data-main attribute on the script tag). All scripts loaded by RequireJS will use this baseUrl unless specified otherwise.

RequireJS will then load a script having a module ID of loader (again, specified via the data-main attribute):

src/main/webapp/resources/js/configurations/loader.js
//detect the appropriate module to load
define(function () {

    /*
     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).
     */

    var environment;

    if (Modernizr.touch || Modernizr.mq("only all and (max-width: 480px)")) {
        environment = "mobile"
    } else {
        environment = "desktop"
    }

    require([environment]);
});

This script detects the current client (mobile or desktop) based on its capabilities (touch or not) and loads another JavaScript module (desktop or mobile) defined in the resources/js/configurations folder (aka the baseUrl) depending on the detected features. In the case of the desktop client, the code is loaded from resources/js/configurations/desktop.js.

src/main/webapp/resources/js/configurations/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
 */
requirejs.config({
    baseUrl: "resources/js",
    paths: {
        jquery:'libs/jquery-1.9.1',
        underscore:'libs/underscore',
        text:'libs/text',
        bootstrap: 'libs/bootstrap',
        backbone: 'libs/backbone',
        utilities: 'app/utilities',
        router:'app/router/desktop/router'
    },
    // We shim Backbone.js and Underscore.js since they don't declare AMD modules
    shim: {
        'backbone': {
            deps: ['jquery', 'underscore'],
            exports: 'Backbone'
        },

        'underscore': {
            exports: '_'
        }
    }
});

define("initializer", ["jquery"],
    function ($) {
    // Configure jQuery to append timestamps to requests, to bypass browser caches
    // Important for MSIE
    $.ajaxSetup({cache:false});
    $('head').append('<link type="text/css" rel="stylesheet" href="resources/css/screen.css"/>');
    $('head').append('<link rel="stylesheet" href="resources/css/bootstrap.css" type="text/css" media="all"/>');
    $('head').append('<link rel="stylesheet" href="resources/css/custom.css" type="text/css" media="all">');
    $('head').append('<link href="http://fonts.googleapis.com/css?family=Rokkitt" rel="stylesheet" type="text/css">');
});

// Now we load the dependencies
// This loads and runs the 'initializer' and 'router' modules.
require([
    'initializer',
    'router'
], function(){
});

define("configuration", {
    baseUrl : ""
});

The module loads all the utility libraries, converting them to AMD modules where necessary (like it is the case for Backbone). It also defines two modules of its own - an initializer that loads the application stylesheets for the page, and the configuration module that allows customizing the REST service URLs (this will become in handy in a further tutorial).

Before we add any functionality, let us create a first landing page. We will begin by setting up a critical piece of the application, the router.

Routing

The router allows for navigation in our application via bookmarkable URLs, and we will define it as follows:

src/main/webapp/resources/js/app/router/desktop/router.js
/**
 * A module for the router of the desktop application
 */
define("router", [
    'jquery',
    'underscore',
    'configuration',
    'utilities',
    'text!../templates/desktop/main.html'
],function ($,
            _,
            config,
            utilities,
            MainTemplate) {

    $(document).ready(new function() {
       utilities.applyTemplate($('body'), MainTemplate)
    })

    /**
     * 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({
        initialize: function() {
            //Begin dispatching routes
            Backbone.history.start();
        },
        routes:{
        }
    });

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

    return router;
});

Remember, this is a single page application. You can either navigate using urls such as http://localhost:8080/ticket-monster/index.html#events or using relative urls (from within the application, this being exactly what the main menu does). The fragment after the hash sign represents the url within the single page, on which the router will act, according to the mappings set up in the routes property.

The main module needs to load it. Because the router depends on all the other components (models, collections and views) of the application, directly or indirectly, it is the only component that is explicitly loaded in the desktop definition, which we change as follows:

src/main/webapp/resources/js/configurations/desktop.js
requirejs.config({
    baseUrl: "resources/js",
    paths: {
        jquery:'libs/jquery-1.9.1',
        underscore:'libs/underscore',
        text:'libs/text',
        order:'libs/order',
        bootstrap: 'libs/bootstrap',
        backbone: 'libs/backbone',
        utilities: 'app/utilities',
        router:'app/router/desktop/router'
    },
    // We shim Backbone.js and Underscore.js since they don't declare AMD modules
    shim: {
        'backbone': {
            deps: ['jquery', 'underscore'],
            exports: 'Backbone'
        },

        'underscore': {
            exports: '_'
        }
    }
});
  ...

require([
    'order!initializer',
    'order!underscore',
    'order!backbone',
    'order!router'
], function(){
});

During the router set up, we load the page template for the entire application. TicketMonster uses a templating library in order to separate application logic from it’s actual graphical content. The actual HTML is described in template files, which are applied by the application, when necessary, on a DOM element - effectively populating it’s content. So the general content of the page, as described in the body element is described in a template file too. Let us define it.

/src/main/webapp/resources/templates/desktop/main.html
<!--
    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="logo"><div class="wrap"><h1>Ticket Monster</h1></div></div>
<div id="container">
    <div id="menu">
        <div class="navbar">
            <div class="navbar-inner">
                <div class="container">
                    <ul class="nav">
                        <li><a href="#about">About</a></li>
                        <li><a href="#events">Events</a></li>
                        <li><a href="#venues">Venues</a></li>
                        <li><a href="#bookings">Bookings</a></li>
                        <li><a href="booking-monitor.html">Monitor</a></li>
                        <li><a href="admin">Administration</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/dualbrand_as7eap.png" alt="HTML5"/></div>
</footer>

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

Setting up the initial views

Let us complete our application setup by creating an initial landing page. The first thing that we will need to do is to add a view component.

src/main/resources/js/app/views/desktop/home.js
/**
 * The About view
 */
define([
    'utilities',
    'text!../../../../templates/desktop/home.html'
], function (utilities, HomeTemplate) {

    var HomeView = Backbone.View.extend({
        render:function () {
            utilities.applyTemplate($(this.el),HomeTemplate,{});
            return this;
        }
    });

    return HomeView;
});

Functionally, this is a very basic component - it only renders the splash page of the application, but it helps us introduce a new concept that will be heavily used throughout the application views. One main role of a view is to describe the logic for manipulating the page content. It will do so by defining a function named render which will be invoked by the application. In this very simple case, all that the view does is to create the content of the splash page. You can proceed by copying the content of src/main/webapp/resources/templates/desktop/home.html to your project.

Tip
Backbone Views

Views are logical representations of user interface elements that can interact with data components, such as models in an event-driven fashion. Apart from defining the logical structure of your user interface, views handle events resulting from the user interaction (e.g. clicking a DOM element or selecting an element into a list), translating them into logical actions inside the application.

Once we defined a view, we must tell the router to navigate to it whenever requested. We will add the following mapping to the router:

src/main/webapp/resources/js/app/router/desktop/router.js
    ...
    var Router = Backbone.Router.extend({
        ...
        routes : {
            "":"home",
            "about":"home"
        },
        home : function () {
            utilities.viewManager.showView(new HomeView({el:$("#content")}));
        }
    });
    ...

We have just told the router to invoke the home function whenever the user navigates to the root of the application or uses a #about hash. The method will simply cause the HomeView defined above to render.

Now you can navigate to http://localhost:8080/ticket-monster/#about or http://localhost:8080/ticket-monster and see the results.

Displaying 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 define a Backbone model for holding event data. Nearly all domain entities (booking, event, venue) are represented by a corresponding Backbone model:

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

The Event model can perform CRUD operations against the REST services we defined earlier.

Tip
Backbone Models

Backbone models contain data as well as much of the logic surrounding it: conversions, validations, computed properties, and access control. They also perform CRUD operations with the REST service.

The Events collection

We 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([
    // The collection element type and configuration are dependencies
    'app/models/event',
    'configuration'
], function (Event, config) {
    /**
     *  Here we define the Bookings collection
     *  We will use it for CRUD operations on Bookings
     */
    var Events = Backbone.Collection.extend({
        url: config.baseUrl + "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.

Tip
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([
    'utilities',
    'text!../../../../templates/desktop/events.html'
], function (
    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')
                }), false, function(item){
                    return item.id
                });
            utilities.applyTemplate($(this.el), eventsTemplate, {categories:categories, model:this.model})
            $(this.el).find('.item:first').addClass('active');
            $(".carousel").carousel();
            $(".collapse").collapse();
            $("a[rel='popover']").popover({trigger:'hover',container:'body'});
            return this;
        },
        update:function () {
            $("a[rel='popover']").popover('hide')
        }
    });

    return  EventsView;
});

As we explained, earlier, 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 this by writing these instructions directly in the method, but that would make it hard to change the page design later on. Instead, we 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"
                       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="#events/<%- 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>

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

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

Now that the views are in place, we need to add another routing rule to the application.

src/main/webapp/resources/js/app/router/desktop/router.js
    var Router = Backbone.Router.extend({
        ...
        routes : {
            ...,
            "events":"events"
        },
        ...,
        events:function () {
            var events = new Events();
            var eventsView = new EventsView({model:events, el:$("#content")});
            events.bind("reset",
                function () {
                    utilities.viewManager.showView(eventsView);
                }).fetch();
        }
    });

The events function handles the #events fragment and will retrieve the events in our application via a REST call. We don’t manually perform the REST call as it is triggered the by invocation of fetch on the Events collection, as discussed earlier.

The reset event on the collection is invoked when the data from the server is received, and the collection is populated. This triggers the rendering of the events view (which is bound to the #content div).

The whole process is event orientated - the models, views and controllers interact through events.

Viewing a single event

With the events list view now in place, we can add a view to display the details of each individual event, allowing the user to select a venue and performance time.

We already have the models in place so all we need to do is to create the additional view and expand the router. First, we’ll implement the view:

src/main/webapp/resources/js/app/views/desktop/event-detail.js
define([
    'utilities',
    'require',
    'text!../../../../templates/desktop/event-detail.html',
    'text!../../../../templates/desktop/media.html',
    'text!../../../../templates/desktop/event-venue-description.html',
    'configuration',
    'bootstrap'
], function (
    utilities,
    require,
    eventDetailTemplate,
    mediaTemplate,
    eventVenueDescriptionTemplate,
    config,
    Bootstrap) {

    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(config.baseUrl + "rest/shows?event=" + this.model.get('id'), function (shows) {
                self.shows = shows
                $("#venueSelector").empty().append("<option value='0' selected>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')
            })
            return this;
        },
        beginBooking:function () {
            require("router").navigate('/book/' + $("#venueSelector option:selected").val() + '/' + $("#performanceTimes").val(), true)
        },
        refreshShows:function (event) {
            event.stopPropagation();
            $("#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 more complex than the global events view, as portions of the page need to be updated when the user chooses a venue.

ui event details
Figure 4. On the event details page some fragments are re-rendered when the user selects a venue

The view responds to three different events:

  • changing the current venue triggers a reload of the venue details and the venue image, as well as the performance times. The application retrieves the performance times through a REST call.

  • changing the day of the performance causes the performance time selector to reload.

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

The corresponding templates for the three fragments rendered above are:

src/main/webapp/resources/templates/desktop/event-detail.html
<div class="row-fluid" xmlns="http://www.w3.org/1999/html">
    <h2 class="page-header special-title light-font"><%=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="span4 well">
        <h3 class="page-header">When?</h3>
        <select class="span6" id="dayPicker"/>
        <select class="span6" id="performanceTimes"/>


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

Now that the view exists, we 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 () {
                    utilities.viewManager.showView(eventDetailView);
                }).fetch();
        }
    }
    ...
);

As you can see, this is very similar to the previous view and route, except that now the application can accept parameterized URLs (e.g. http://localhost:8080/ticket-monster/index#events/1). This URL can be entered directly into the browser, or it can be navigated to as a relative path (e.g. #events/1) from within the applicaton.

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 the event, the venue and the performance time, and must now create the booking. Users can select one of the available sections for the show’s venue, and then enter the number of tickets required for each category available for this show (Adult, Child, etc.). They then add the tickets to the current order, which causes the summary view to be updated. Users can also remove tickets from the order. When the order is complete, they enter their contact information (e-mail address) and submit the order to the server.

First, we add the new view:

src/main/webapp/resources/js/app/views/desktop/create-booking.js
define([
    'utilities',
    'require',
    'configuration',
    '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 (
    utilities,
    require,
    config,
    bookingConfirmationTemplate,
    createBookingTemplate,
    ticketEntriesTemplate,
    ticketSummaryViewTemplate){


    var TicketCategoriesView = Backbone.View.extend({
        id:'categoriesView',
        intervalDuration : 100,
        formValues : [],
        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();
            }
            this.watchForm();
            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
            });
            // update model
            if ($.isNumeric(value) && value > 0) {
                modifiedModelEntry.quantity = parseInt(value);
            }
            else {
                delete modifiedModelEntry.quantity;
            }
            // display error messages
            if (value.length > 0 &&
                   (!$.isNumeric(value)  // is a non-number, other than empty string
                        || value <= 0 // is negative
                        || parseFloat(value) != parseInt(value))) { // is not an integer
                $("#error-input-"+ticketPriceId).empty().append("Please enter a positive integer value");
                $("#ticket-category-fieldset-"+ticketPriceId).addClass("error")
            } else {
                $("#error-input-"+ticketPriceId).empty();
                $("#ticket-category-fieldset-"+ticketPriceId).removeClass("error")
            }
            // are there any outstanding errors after this update?
            // if yes, disable the input button
            if (
               $("div[id^='ticket-category-fieldset-']").hasClass("error") ||
                   _.isUndefined(modifiedModelEntry.quantity) ) {
              $("input[name='add']").attr("disabled", true)
            } else {
              $("input[name='add']").removeAttr("disabled")
            }
        },
        watchForm: function() {
            if($("#sectionSelectorPlaceholder").length) {
                var self = this;
                $("input[name*='tickets']").each( function(index,element) {
                    if(element.value !== self.formValues[element.name]) {
                        self.formValues[element.name] = element.value;
                        $("input[name='"+element.name+"']").change();
                    }
                });
                this.timerObject = setTimeout(function() {
                    self.watchForm();
                }, this.intervalDuration);
            } else {
                this.onClose();
            }
        },
        onClose: function() {
            if(this.timerObject) {
                clearTimeout(this.timerObject);
                delete this.timerObject;
            }
        }
    });

    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.bookingRequest.tickets.splice(this.model.index, 1);
        }
    });

    var CreateBookingView = Backbone.View.extend({

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

            var self = this;
            $.getJSON(config.baseUrl + "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();
                self.watchForm();
            });
            return this;
        },
        refreshPrices:function (event) {
            var ticketPrices = _.filter(this.show.ticketPrices, function (item) {
                return item.section.id == event.currentTarget.value;
            });
            var sortedTicketPrices = _.sortBy(ticketPrices, function(ticketPrice) {
                return ticketPrice.ticketCategory.description;
            });
            var ticketPriceInputs = new Array();
            _.each(sortedTicketPrices, 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
            $("input[name='submit']").attr("disabled", true)
            $.ajax({url: (config.baseUrl + "rest/bookings"),
                data:JSON.stringify(bookingRequest),
                type:"POST",
                dataType:"json",
                contentType:"application/json",
                success:function (booking) {
                    this.model = {}
                    $.getJSON(config.baseUrl +'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>')
                    }
                    $("input[name='submit']").removeAttr("disabled");
                })

        },
        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;
                $("#error-email").empty();
            } else {
                $("#error-email").empty().append("Please enter a valid e-mail address");
                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);
            }
        },
        watchForm: function() {
            if($("#email").length) {
                var self = this;
                var element = $("#email");
                if(element.val() !== self.formValues["email"]) {
                    self.formValues["email"] = element.val();
                    $("#email").change();
                }
                this.timerObject = setTimeout(function() {
                    self.watchForm();
                }, this.intervalDuration);
            } else {
                this.onClose();
            }
        },
        onClose: function() {
            if(this.timerObject) {
                clearTimeout(this.timerObject);
                delete this.timerObject;
            }
            this.ticketCategoriesView.close();
        }
    });

    return CreateBookingView;
});

The code above may be surprising! After all, we said that we were going to add a single view, but instead, we added three! This view makes use of two subviews (TicketCategoriesView and TicketSummaryView) for re-rendering parts of the main view. Whenever the user changes the current section, the list of available tickets is updated. Whenever the user adds the tickets to the booking, the booking summary is re-rendered. Changes in quantities or the target email may enable or disable the submission button - the booking is validated whenever changes to it are made. We do not create separate modules for the subviews, since they are not referenced outside the module itself.

The booking submission is handled by the save method which constructs a JSON object, as required by a POST to 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="special-title light-font">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 class="special-title light-font"><%=show.event.name%>
            <small><%=show.venue.name%>, <%=new Date(performance.date).toPrettyString()%></p></small>
        </h2>
    </div>
</div>
<div class="row-fluid">
    <div class="span6 well">
       <h3 class="page-header">Select tickets</h3>
        <form class="form-horizontal">
        <div id="sectionSelectorPlaceholder">
            <div class="control-group">
                <label class="control-label" for="sectionSelect"><strong>Section</strong></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>
        </form>
        <div id="ticketCategoriesViewPlaceholder"></div>
    </div>
    <div id="request-summary" class="span5 offset1 well">
        <h3 class="page-header">Order summary</h3>
        <div id="ticketSummaryView" class="row-fluid"/>
        <h3 class="page-header">Checkout</h3>
        <div class="row-fluid">
            <form class="form-search">
            <input type='email' id="email" placeholder="Email" required/>
            <input type='button' class="btn btn-primary" name="submit" value="Checkout"
                   disabled="true"/>
            <p class="help-block error-notification"  id="error-email"></p>
            </form>
        </div>
    </div>
</div>
src/main/webapp/resources/templates/desktop/ticket-categories.html
<% if (ticketPrices.length > 0) { %>
<form class="form-horizontal">
    <% _.each(ticketPrices, function(ticketPrice) { %>
    <div class="control-group" id="ticket-category-fieldset-<%=ticketPrice.id%>">
        <label class="control-label"><strong><%=ticketPrice.ticketCategory.description%></strong></label>

        <div class="controls">
            <div class="input-append">
                <input class="span6" rel="tooltip" title="Enter value"
                       data-tm-id="<%=ticketPrice.id%>"
                       placeholder="Number of tickets"
                       name="tickets-<%=ticketPrice.ticketCategory.id%>"/>
                <span class="add-on">@ $<%=ticketPrice.price%></span>

                <p class="help-block" id="error-input-<%=ticketPrice.id%>"></p>
            </div>
        </div>
    </div>
    <% }) %>

<p>&nbsp;</p>

<div class="control-group">
    <label class="control-label"/>

    <div class="controls">
        <input type="button" class="btn btn-primary" disabled="true" name="add" value="Add tickets"/>
    </div>
</div>
</div>
</form>
<% } %>
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")
                           });
            utilities.viewManager.showView(createBookingView);
        }
    }
    ...
);

This concludes the implementation of the booking use case. We started by listing the available events, continued by selecting a venue and performance time, and ended by choosing tickets and completing the order.

The other use cases: a booking starting from venues and view 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 uses approximately the same architecture as the desktop version. Any differences are due to the functional changes in the mobile version and the use of jQuery mobile.

Setting up the structure

The first step in implementing our solution is to copy the CSS and JavaScript libraries to resources/css and resources/js/libs:

require.js

AMD support, along with the plugin:

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

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 have already built the desktop application, some files may already be in place.)

For mobile clients, the main page will display the mobile version of the application, by loading the mobile AMD module of the application. Let us create it.

/src/main/webapp/resources/js/configurations/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({
    baseUrl:"resources/js",
    paths: {
        jquery:'libs/jquery-1.9.1',
        jquerymobile:'libs/jquery.mobile-1.3.2',
        text:'libs/text',
        underscore:'libs/underscore',
        backbone: 'libs/backbone',
        order: 'libs/order',
        utilities: 'app/utilities',
        router:'app/router/mobile/router'
    },
    // We shim Backbone.js and Underscore.js since they don't declare AMD modules
    shim: {
        'backbone': {
            deps: ['underscore', 'jquery'],
            exports: 'Backbone'
        },

        'underscore': {
            exports: '_'
        }
    }
});

define("configuration", function() {
    if (window.TicketMonster != undefined && TicketMonster.config != undefined) {
        return {
            baseUrl: TicketMonster.config.baseRESTUrl
        };
    } else {
        return {
            baseUrl: ""
        };
    }
});

define("initializer", [
    'jquery',
    'utilities',
    'text!../templates/mobile/main.html'
], function ($,
             utilities,
             MainTemplate) {
    // Configure jQuery to append timestamps to requests, to bypass browser caches
    // Important for MSIE
    $.ajaxSetup({cache:false});
    $('head').append('<link rel="stylesheet" href="resources/css/jquery.mobile-1.3.2.css"/>');
    $('head').append('<link rel="stylesheet" href="resources/css/m.screen.css"/>');
    // Bind to mobileinit before loading jQueryMobile
    $(document).bind("mobileinit", function () {
        // Prior to creating and starting the router, we disable jQuery Mobile's own routing mechanism
        $.mobile.hashListeningEnabled = false;
        $.mobile.linkBindingEnabled = false;
        $.mobile.pushStateEnabled = false;
        utilities.applyTemplate($('body'), MainTemplate);
    });
    // Then (load jQueryMobile and) start the router to finally start the app
    require(['router']);
});

// Now we declare all the dependencies
// This loads and runs the 'initializer' module.
require(['initializer']);

In this application, we combine Backbone and jQuery Mobile. Each framework has its own strengths; jQuery Mobile provides UI components and touch support, whilst Backbone provides MVC support. There is some overlap between the two, as jQuery Mobile provides its own navigation mechanism which we disable.

We also define a configuration module which allows the customization of the base URLs for RESTful invocations. This module does not play any role in the mobile web version. We will come to it, however, when discussing hybrid applications.

We also define a special initializer module (initializer) that, when loaded, adds the stylesheets and applies the template for the general structure of the page in the body element. In the initializer module we make customizations in order to get the two frameworks working together - disabling the jQuery Mobile navigation. Let us add the template definition for the template loaded by the initializer module.

src/main/webapp/resources/templates/mobile/main.html
<!--
    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" data-role="page" data-ajax="false"></div>

Next, we create the application router.

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

    /**
     * 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({
        initialize: function() {
            //Begin dispatching routes
            Backbone.history.start();
        },
        defaultHandler:function (actions) {
            if ("" != actions) {
                $.mobile.changePage("#" + actions, {transition:'slide', changeHash:false, allowSamePageTransition:true});
            }
        }
    });

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

    return router;
});

In the router code we add the defaultHandler to the router for handling jQuery Mobile transitions between internal pages (such as the ones generated by a nested listview).

Next, we need to create a first page.

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/img/dualbrand_as7eap.png" width="300px"/>
    <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 mobile 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, we must tell jQuery Mobile explicitly to enhance the page content in order to create the mobile view. Here, we trigger the jQuery Mobile pagecreate event explicitly to ensure that the page gets the appropriate look and feel.

The events view

First, we display a list of events (just as in the desktop view). Since mobile interfaces are more constrained, we will just show a simple list view:

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

    var EventsView = Backbone.View.extend({
        render:function () {
            var categories = _.uniq(
                _.map(this.model.models, function(model){
                    return model.get('category')
                }), false, function(item){
                    return item.id
                });
            utilities.applyTemplate($(this.el), eventsView,  {categories:categories, model:this.model})
            $(this.el).trigger('pagecreate');
            return this;
        }
    });

    return EventsView;
});

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

Next, we add 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 () {
                    utilities.viewManager.showView(eventsView);
                }).fetch();
        }
        ...
    });
    ...
});

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

Displaying an individual event

Now, we create the view to display an event:

src/main/webapp/resources/js/app/views/mobile/event-detail.js
define([
    'utilities',
    'require',
    'configuration',
    'text!../../../../templates/mobile/event-detail.html',
    'text!../../../../templates/mobile/event-venue-description.html'
], function (
    utilities,
    require,
    config,
    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(config.baseUrl + "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');
            return this;
        },
        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;
});

Once again, this is very similar to the desktop version. Now we add the 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>

Finally, we add this to the router, explicitly indicating to 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 () {
                    utilities.viewManager.showView(eventDetailView);
                    $.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 to allow the user to book some tickets.

Booking tickets

The views to book tickets are simpler than the desktop version. Users can select a section and enter the number of tickets for each category however, there is no way to add or remove tickets from an order. Once the form is filled out, the user can only submit it.

First, we create the views:

src/main/webapp/resources/js/app/views/mobile/create-booking.js
define([
    'utilities',
    'configuration',
    '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 (
    utilities,
    config,
    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:(config.baseUrl + "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(config.baseUrl + "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 structure the desktop application, except that the summary view is not rendered inline but after a page transition.

Next, we create 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 is re-rendered whenever the section is changed:

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 is 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>

The confirmation 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>

Finally, we create 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 registering the view with 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")
                      });
            utilities.viewManager.showView(createBookingView);
        },
        ...
        });
    ...
});

More Resources

To learn more about writing HTML5 + REST applications with JBoss, take a look at the Aerogear project.

Share the Knowledge

Find this guide useful?

Feedback

Find a bug in the guide? Something missing? You can fix it by [forking the repository](http://github.com/jboss-jdf/ticket-monster), making the correction and [sending a pull request](http://help.github.com/send-pull-requests). If you're just plain stuck, feel free to ask a question in the [user discussion forum](http://jboss.org/jdf/forums/jdf-users).

Recent Changelog

  • Mar 10, 2014: Prepare for development of 2.5.1-snapshot Vineet Reynolds
  • Mar 10, 2014: Prepare for 2.5.0.final release Vineet Reynolds
  • Oct 09, 2013: Jdf-229 updated the source listing in the tutorial Vineet Reynolds
  • Oct 04, 2013: Jdf-494 upgraded jquerymobile to 1.3.2 Vineet Reynolds
  • Sep 02, 2013: Corrected ticketmonster guide for javascript library uses Vineet Reynolds
  • Jul 13, 2013: Forge-345 improved the styling for code listings Vineet Reynolds
  • Jul 10, 2013: Modified the tutorial generator to use asciidoctor Vineet Reynolds
  • Jun 27, 2013: Oops. changes to tax headers removed the authors metadata. correcting it Rafael Benevides
  • Jun 24, 2013: Switching from setex to tax headers completely Rafael Benevides
  • Jun 07, 2013: Jdf-356 corrected instructions for configuring desktop, mobile and cordova environments Vineet Reynolds
  • Jun 07, 2013: Jdf-187 documentation corrections for the chapter on building user ui Vineet Reynolds
  • May 20, 2013: Jdf-320 upgrade jquery to 1.9.1 and jquery mobile to 1.3.1 Vineet Reynolds
  • May 14, 2013: Minor fixes for typos and incorrect file references Emmanuel Bernard
  • Nov 11, 2012: Prepare for 2.0.5.final release Marius Bogoevici
  • Nov 07, 2012: Update user front end guide to match the new project structure Marius Bogoevici

See full history »