Problem: How do we manage are views and localise navigation?

Setup

You will need copies of the following files:

PLEASE NOTE: You must add a reference to the .d.ts file at the top of the typescript file


        ⁄⁄⁄ <path="typings/backbone.marionette/index.d.ts" ⁄>
    

Motivation

In previous lectures we looked at Model, Collection, various Views and combining these to create a dynamic single page application that exchanged data with a REST webservice. Now we want to investigate a more structured approach to create this application and also use localised URLs that allow us to switch Views etc, without changing the webpage.

Usage

The main page is going to look a bit different this time, because of creating and displaying a View it will create and start the Application.

class Example14Page
{
    PageLoad(): void
    {
        let app: ExampleAppNS.MainApp = new ExampleAppNS.MainApp('#main-content');
        app.Start();
    }
}


$(function ()
{
    let page: ExamplePage = new ExamplePage();
    page.PageLoad();
});

The Applicaton class looks like this:

namespace ExampleAppNS
{
    export class MainApp extends Backbone.Marionette.Application
    {
        constructor(el: any)
        {
            super('main');      // Name the application

            // 'start' event is automatically generated by Marionette
            // when Application start() is called.
            this.on('start', this.OnStart);

            // Wait until your entire application is initialized to start Backbone.history
            this.on('start', this.OnStartHistory);

            // We have to trigger 'stop' event ourselves
            this.on('stop', this.OnStop);

            new Router(new Controller(this, new PageLayout(el)));
        }

        Start(): void
        {
            this.start();
        }

        Stop(): void
        {
            this.trigger('stop');
        }

        OnStart = (): void =>
        {
        }

        OnStop = (): void =>
        {
        }

        OnStartHistory(): void
        {
            if (Backbone.history)
            {
                Backbone.history.start();
            }
        }
    }
}

The Applicaton class is responsible for starting and stopping the application and monitors the corresponding events. The constructor, sets up the event handlers OnStart(), OnStartHistory() and OnStop() to listen for the 'start' and 'stop' events. Two methods, Start() and Stop() are also provided, in the case of Start() it wraps the Marionette() start() function while Stop() triggers the 'stop' event (there is actually no way of stopping an application in Marionette) to clear up an resources being used by the application.

Internal URLs can be specified by adding a hash '#' character at the start to indicate that these URLs should be processed by the browser itself. For example:

<a href="#display/view1">Switch to view 1</a>

Marionette promotes the separation of concerns by splitting the handling of these URLs between a Router and a Controller class. The Router defines the routes, the method names associated with each of them and the controller to use. The Controller defines the methods called when routes are matched in the Router.

The code looks like this:

namespace ExampleAppNS
{
   class Router extends Backbone.Marionette.AppRouter
    {
        constructor(controller: Controller)
        {
            super({
                controller: controller,
                appRoutes: {
                    "": "RenderPage",
                    "display/:view": "RenderView"
                },
                routes: {}  // No routes handled, directly in the Router class
            });
        }
    }

    class Controller extends Backbone.Marionette.Controller
    {
        private _app: MainApp;
        private _pageLayout: PageLayout;


        constructor(app: MainApp, page: PageLayout)
        {
            super();
            this._app = app;
            this._pageLayout = page;
        }

        RenderPage(): void
        {
            this._pageLayout.render();
        }

        RenderView(view: string): void
        {
            switch (view)
            {
                case 'view1':
                    this._pageLayout.getRegion('view_region').show(this._pageLayout.View1, { preventDestroy: true });
                    break;
                case 'view2':
                    this._pageLayout.getRegion('view_region').show(this._pageLayout.View2, { preventDestroy: true });
                    break;
                default:
                    break;
            }
        }
    }
}

In the Router class, the first route is "" which is the default route that is matched when the page first loads. The second route is "display/:view" which matches the URL #display/viewname where the viewname is either 'view1' or 'view2' (see previous example using <a> tag). If the URL matches one of these routes the associated callback function is called. The ":view" specified in the "display" route, requests that Backbone extract the text from the URLfollowing "#display/"and copy it into an argument called "view" to be passed to the callback function. The options provided to the parent AppRouter class constructor are controller, appRoutes and routes. If the callback function is added directly to the Router class then the route should be added to the routes option hash, but in our case the handlers will be added to the controller. Thus, we use the controller opton to specify the controller object containing the callbacks and the appRoutes option hash to specify the routes. For example, in the above code the route "display/:view": "RenderView" is added to appRoutes and results in the callback RenderView being called in the Controller class. Likewise the route "": "RenderPage" calls the RenderPage callback in the Controller class to be called when page first loads (this is the default route). RenderPage simply renders the PageLayout class (i.e. LayoutView) and RenderView displays the selected view in the region named "view_region" within the LayoutView. Notice the view argument of the RenderView callback is extracted from the URL for us, so if the URL is "#display/view1" the view argument is equal to 'view1'.

We store the View objects in the PageLayout calls because when using Marionette we should try to avoid destroying Views. However, if you don't set the preventDestroy option then the previous view is automatically destroyed when a new view is displayed in the region. When showing the view again, you will need to create a new instance of it.

this._pageLayout.getRegion('view_region').show(new ViewTwo()); <-- Create new instance of ViewTwo

The LayoutView class looks like this:

namespace ExampleAppNS
{
    class EmptyModel extends Backbone.Model { }

    class PageLayout extends Backbone.Marionette.LayoutView<EmptyModel>
    {
        private _view1: ViewOne;
        private _view2: ViewTwo;

        constructor(el: any)
        {
            super({
                el: el,
                template: "#page-layout-template",
                regions: {
                    view_region: '#view-region'
                }
            });

            this.events = <any>{
                "click input[type='button'][value='Change Name']": this.ChangeNameButtonClicked
            };

            this.delegateEvents();

            this._view1 = new ViewOne();
            this._view2 = new ViewTwo();   
        }

        get View1(): ViewOne { return this._view1; }
        get View2(): ViewTwo { return this._view2; }

        private ChangeNameButtonClicked = (event: JQueryEventObject): void =>
        {
            this._view2.Data.Name = $("input[type='text'].name-textbox").val();  <-- Change name being displayed in ViewTwo
            Backbone.history.navigate("display/view2", { trigger: true });
        }
    }
}

Notice how the change in the "name" entered in the textbox is written to the Name property of the ViewTwo classes data. We are trying to avoid directly playing with jQuery and we are going to make use of Backbones built in capabilities.

Because we are discussing the Application class, the PageLayout class provided here is quite simple. It contains two Views which are rendered into a single region named 'view_region' with a button "Change View" used to read a name from a textbox and switch to View2. The LayoutView template looks like this:

<script id="page-layout-template" type="text/html">
	<div id="view-region" class="well"></div>
	<a href="#display/view1">Switch to view 1</a>
	<form>
    	<input type="text" class="name-textbox" /><input type="button" value="Change Name" />
	</form>
</script>

The main page HTML looks like this:

<!DOCTYPE html>
	<html xmlns="http://www.w3.org/1999/xhtml">
	<head>
	<title></title>
	<link href="Content/bootstrap.css" rel="stylesheet" />
	</head>
	<body>
		..... View templates

		<div id="main-content" class="well">
 		 	Main page layout goes here!!
		</div>
....Script tags ..... </body> </html>

The other views and their templates are also quite simple.

namespace ExampleAppNS
{
    class ViewOne extends Backbone.Marionette.ItemView<EmptyModel>
    {
        constructor()
        {
            super({
                template: '#view-1-template'
            });
        }
    }

    class ViewTwoData extends Backbone.Model
    {
        constructor()
        {
            super({
                'name': ""
            });
        }

        get Name(): string { return this.get('name'); }
        set Name(value: string) { this.set('name', value); }
    }

    class ViewTwo extends Backbone.Marionette.ItemView<ViewTwoData>
    {
        constructor()
        {
            super({
                model: new ViewTwoData(),
                template: '#view-2-template'
            });

            this.modelEvents = {
                'change': this.render
            }

            this.delegateEvents();
        }

        get Data(): ViewTwoData { return this.model; }
    }
}
<script id="view-1-template" type="text/html">
  <p>Display view 1</p>
</script>
<script id="view-2-template" type="text/html"> <p>Display view 2: name is <%=name%></p> </script>

The only interesting thing about these ItemView classes is the technique used to display the name in the view. Instead of using jQuery to change the name displayed in ViewTwo, we create a dummy Model class called ViewTwoData with a "name" attribute. If this value changes, then ViewTwo detects the 'change' event on the Model class and re-renders itself. Look back at the PageLayout class and you will see the name in the ViewTwoData Model is being changed when the "Change View" button is clicked. If you have a View that displays a mixture of attributes that should be written to the webservice and other attributes just used to store or display information in the same View (like the 'name' attribute), then you will have to rewrite the save() function on the Model class. If you are interested ModelExt code can be provided to solve this problem for you, just ask for it.

Details

Continuing the previous exercise, convert the Book application you have been creating to use the Application. AppRouter and Controller classes. List all the books and provide buttons (anchor tags) to switch to displaying different book details, and provide a way of switching back to the list.

Hints

  1. Check the URL of your webservice
  2. Careful, if you are mixing attributes that just contain info to be displayed in the View with attributes that need to be written back to the webservice, you may need the ModelExt class.