domingo, 27 de novembro de 2016

Tutorial: a contact manager application with MarionetteJS v3

Warning: opinions ahead


Marionette and Backbone are libraries that do not force an specific application structure, letting for the developer pick the one that better work for him. I've been playing with Marionette and Backbone for a while using several approaches. From using vanilla Backbone with Bootstrap (a good example of how not build an application) to using Marionette with Ionic CSS.

As result of this journey i settled with opinionated approaches for both data binding and routing. For data binding i'm using rivets and for routing a customized router built on top of cherrytree. While the rivets specific code can be easily replaced by, e.g., an traditional templating solution like Handlebars, the application structure used in this tutorial relies on specific router features making undoable to replace it.

This tutorial assumes the reader knows ES6 constructs as import statements, Promise, arrow functions and also is familiar with Backbone.

What?


We will implement the same application as the one built in this Aurelia tutorial. It will use the same assets, but will not to try to follow / reuse the architecture or code. Requires a browser with native Promise implementation.

The architecture will resemble, loosely, the used in an Ember tutorial. Something like data down, events up.

The source code can be found at github.

Setting the environment



npm init

//Webpack (v1) with Babel and Html loaders
npm install webpack html-loader babel-loader babel-core babel-preset-es2015 --save-dev

//Marionette and its dependencies
npm install jquery underscore backbone backbone.radio backbone.marionette --save

//Bootstrap
npm install bootstrap --save

//The routing library
npm install marionette.routing --save

//Rivets 
npm install rivets rivets-backbone-adapter --save

//in package.json:

 "scripts": {
    "build": "webpack",
    "watch": "webpack --watch"
  }

Download the assets and extract into directory src

Create a simple index.html into root folder, only with a div and script tags:



<div id="app"></div>
<script src="build/bundle.js"></script>

Configuring webpack


Directly to the webpack.config.js:

var path = require('path');

module.exports = {
  entry: __dirname + '/src/main.js',
  output: {
    path: __dirname + '/build',
    filename: "bundle.js"
  },
  devtool: "source-map",
  resolve: {
    alias: {
      marionette: 'backbone.marionette'
    }
  },
  module: {
    loaders: [
      {test: /\.html$/, loader: 'html'},
      {
        test: /\.js$/, loader: 'babel?presets[]=es2015', include: [path.resolve(__dirname, 'src')]
      }
    ]
  }
};


Pretty standard stuff: use src/main.js as entry, dist/bundle.js as output with a source map. Use Html loader for *.html files and Babel loader for *.js files inside src using es2015 preset

Configuring the route map


An important step is to configure the routes at the application start.

Here is the code of main.js:

import { createRouter, middleware} from 'marionette.routing';

let router = createRouter({log: true, logError: true});

router.map(function (route) {
  route('application', {path: '/', abstract: true}, function () {
    route('contacts', {}, function () {
      route('contactdetail', {path: ':contactid'})
    })
  })
});

router.use(middleware);

router.listen();


The createRouter is used to, heh..., create the router with the options to log transitions and errors to console.

Three routes are configured: application, the root one, contacts and the leaf contactid. The route nesting means that to a route be active all parents should also be active. So if contacts is active, we can assume that application route is also active, but we cannot know before hand if contactdetails is active.

The path is constructed using route owns path appended to the parent one. When the path is omitted, the name is used. So contactdetails will match the following URL pattern /contacts/:contactid.

Note also the abstract option passed to application route. It means that is not supposed to be navigated to. More info in cherrytree docs

The middleware is the bridge between cherrytree and Marionette Routing interfaces.

Calling listen, the router starts to monitor URL changes and does the first transition using the current location.

Time for the first build. Type npm run build and launch index.html. In the developer tools (F12) console,  the transition info should be logged. Update the URL hash to index.html#contacts. An error message ("Error: Unable to create route instance: routeClass or viewClass must be defined") should appear. Let's fix it.

Meeting the Route class


The Route class provides properties to define declaratively a view to be rendered as well mechanisms to do messaging with the view or other routes using the patterns promoted by Marionette. It acts as a logical point in the application structure and can be used to, among other things, fetch and cache data. In fact, its interface is mostly routing agnostic and could be used decoupled from the router.

Create application/route.js (ApplicationRoute) file and put:

import {Route} from 'marionette.routing';
import {WebAPI} from '../web-api';

export default Route.extend({
  activate(){
    this.api = this.api || new WebAPI();
  },

  channelName: 'api',

  radioRequests: {
    'getContactList': function () {
      return this.api.getContactList()
    }
  }
})

The activate method is called each time a route becomes active i.e. when the URL matches the route path pattern. Since we configured the application route path to '/' and all other routes are child of it, this means that it always be active. Such routes are useful for the setup of data or services required application wide.

The ApplicationRoute creates an api instance in activate and make its interface public through a Radio message system. The channelName and radioRequests properties are inherited from Marionette.Object. Basically it says to reply with a call to api.getContactList the request for 'getContactList' in 'api' channel. More info here.

Now the contacts/route.js (ContactsRoute) file:


import {Route} from 'marionette.routing';
import Radio from 'backbone.radio';
import {Contacts} from '../entities';

export default Route.extend({
  activate(){
    let contactsPromise = Radio.channel('api').request('getContactList');
    return contactsPromise.then(contactsData => {
      this.contacts = new Contacts(contactsData)
    })
  }
})


Since the activate methods are called sequentially, from parent to children routes, we can safely use the Radio 'api' channel configured in the ApplicationRoute. The 'getContactList' request returns a promise to which is added a resolve handler that creates and caches the contacts collection.

Another important characteristic of activate is that will resolve the return value as a promise and will only proceeds for the next route when is resolved. If the returned promise is rejected the transition is canceled.

Now update the route definitions in main.js to use its corresponding classes:


import ApplicationRoute from './application/route';
import ContactsRoute from './contacts/route';

[..]

router.map(function (route) {
  route('application', {path: '/', abstract: true, routeClass: ApplicationRoute}, function () {
    route('contacts', {routeClass: ContactsRoute}, function () {
      route('contactdetail', {path: ':contactid'})
    })
  })
});

Rendering the contacts view


The view layout consists of the contact list in the left and the contact detail on right.

Put the markup below in contacts/template.html

<nav class="navbar navbar-default navbar-fixed-top" role="navigation">
    <div class="navbar-header">
        <a class="navbar-brand" href="#">
            Contacts
        </a>
    </div>
</nav>

<div class="container">
    <div class="row">
        <div class="col-md-4">
            <div class="contact-list">

            </div>
        </div>
        <div class="col-md-8 contact-outlet"></div>
    </div>
</div>

Note the div elements with contact-list and contact-outlet classes that will be used as regions to hold the actual views.

Below is the contact list view implementation in contacts/view.js:


import Mn from 'backbone.marionette';
import DataBinding from '../databinding';

const itemHtml = `          
        <a href="#">
          <h4 class="list-group-item-heading">{model:firstName} {model:lastName}</h4>
          <p class="list-group-item-text">{model:email}</p>
        </a>`;

const ContactItemView = Mn.View.extend({
  behaviors: [DataBinding],
  tagName: 'li',
  className: 'list-group-item',
  html: itemHtml
});

const ContactListView = Mn.CollectionView.extend({
  tagName: 'ul',
  className: 'list-group',
  childView: ContactItemView
});

DataBinding is a Behavior class that automatically binds the html using rivets.

We define ContactListView that will render the markup of a Bootstrap list group using ContactItemView which is responsible to render each list item.

Now the main view, still in contacts/view.js, responsible for defining the layout

export default Mn.View.extend({
  html: require('./template.html'),
  behaviors: [DataBinding],
  regions: {
    contactlist: '.contact-list',
    outlet: '.contact-outlet'
  },
  initialize(options) {
    this.contacts = options.contacts
  },
  onRender() {
    this.showChildView('contactlist', new ContactListView({collection: this.contacts}))
  }
})

It defines two regions: contactlist and outlet. The onRender event is used to show a ContactListView instance  in the contactlist region.

Now tell the contacts route (contacts/route.js) what view to render:

[..]
import ContactsView from './view';

export default Route.extend({
  [..]
  viewClass: ContactsView,
  viewOptions() {
    return {
      contacts: this.contacts
    }
  }
})

Do a new build a launch index.html#contacts

 What? A blank page? An "Error: No outlet region" in console? There's a missing piece in route configuration:

//main.js
import Mn from 'backbone.marionette';

[..]

router.rootRegion = new Mn.Region({el: '#app'});

We need to define a root region in router where the top level routes will show its views.

If everything went right, reloading should show a unstyled list:





Time to configure Bootstrap. Copy bootstrap.min.css and glyphicons-halflings-regular.eot to assets folder and update index.html:

<head>
    [..]
    <link rel="stylesheet" href="assets/bootstrap.min.css">
    <link rel="stylesheet" href="src/styles.css">
</head>

Much better.

Is possible to load Bootstrap using webpack, and the initial idea was to use in this tutorial, but, it requires additional configuration so, for sake of simplicity, just using the static files directly.

Setting the default route


Until now, is necessary to update the URL hash manually to go to contacts route. To automatically go to a default route there are a couple of options:

Update the location hash before start listening (it will work only at startup):

In main.js:


if (location.hash.length <= 1) {
  location.hash = '#contacts'
}
router.listen();

In application route activate, a not so good approach since ties the ApplicationRoute class to "/" path:

activate(transition){
    this.api = this.api || new WebAPI();
    if (transition.path === '/') {
      transition.then(function () {
        transition.redirectTo('contacts');
      });
    }
  }

And finally using "before:transition" event, which we will use given its flexibility:

Radio.channel('router').on('before:transition', function (transition) {
  if (transition.path === '/') {
    transition.redirectTo('contacts')
  }
});

router.listen();


Using redirect both in activate and in 'before:transition' event requires removing the abstract flag from application route options.

The contact detail


The view code (contactdetail/view.js) is pretty slim. Aside from defining the html and configuring the data binding, it maps an HTML event ('click #save-contact') to a view event ('save:model') using the triggers property:


import Mn from 'backbone.marionette';
import DataBinding from '../databinding';

export default Mn.View.extend({
  html: require('template.html'),

  behaviors: [DataBinding],

  triggers: {
    'click #save-contact': 'save:model'
  }
});

The markup is a basic Bootstrap form annotated with some rivets directives.

Let's implement the Route class that will be responsible to inject the model and listen to the view events.


In contactdetail/route.js:


//ContactDetailRoute
export default Route.extend({
  activate(transition){
    let contacts = this.getContext(transition).request('contacts');
    this.contact = contacts.findWhere({id: +transition.params.contactid});
    if (!this.contact) {
      throw new Error('Unable to resolve contact with id', transition.params.contactid);
    }
  },

  viewClass: ContactDetailView,

  viewOptions() {
    return {
      model: this.contact.clone()
    }
  },

  viewEvents: {
    'save:model': 'onSaveModel'
  },

  onSaveModel(view) {
    let attributes = _.clone(view.model.attributes);
    this.contact.clear({silent:true}).set(attributes);
  }

})


Is necessary to update contacts/route.js with the contextRequests definition

//ContactsRoute
export default Route.extend({
  [..]
  contextRequests: {
    contacts: function () {
      return this.contacts
    }
  }
})

In activate, we get a reference for the contacts collection and then the contact model corresponding to the contactid passed through transition.params. Noteworthy the usage of route context to retrieve the contacts collection, instead of using a global Radio channel, like was done for the WebAPI instance.

The request will be resolved by the nearest parent (ContactsRoute) that registered a reply using contextRequests property. This features allows to use the same Radio semantics in a scoped way thus helping to prevent the global Radio namespace be cluttered.

In viewOptions, a clone of the model is injected into the view and in onSaveModel (bound to 'save:model' event through viewEvents) the changes are saved back to the original model instance. This approach seems overkill in this example, since we could simple pass the actual model instance and let the view save it directly, but it allows to make reusable views. For example, we can use ContactDetailView to also edit a newly created contact.

Rebuild and go to #contacts/1. You should be able to view, edit and save the contact info.

A note about nested rendering

One of the  Marionette Routing benefits is the ability to handle nested state in a consistent manner. It also allows to nest views with a minimal declarative configuration. When a route is configured to show a view, it will look for the nearest parent route with a rendered view which must have a region named "outlet". If no parent route with a rendered view is found, the rootRegion is used.

In our app, the ContactDetailView is rendered into the outlet region defined in ContactsView. Note also that when changing from, e.g, #contacts/1 to #contacts/2 only contact detail view is rerendered.

Refinements


To select a contact when click in a contact list item is as simple as setting a click event in ContactItemView and do a transitionTo request in router channel:


const ContactItemView = Mn.View.extend({
  [..]
  events: {
    'click': 'onClick'
  },
  onClick(e) {
    e.preventDefault();
    Radio.channel('router').request('transitionTo', 'contactdetail', {contactid: this.model.get('id')})
  }
});

We could also render the a element with the appropriate href attribute value that would work the same.

Highlighting the selected contact item, requires a bit more of work. In ContactsView.setSelected we get the ContactListView instance using getRegion, resets any item with an 'active' class, find the ContactItemView instance associated with the passed contactid using children.find and add 'active' class to its element.

Since the view is only rendered after all routes are activated, setSelected is called inside a resolve handler of the transition. Note that the "before:activate" event is registered in activate and unregistered in deactivate to catch only events of child routes.

//ContactsView
export default Mn.View.extend({
   [..] 
   setSelected(contactId) {
    let listView = this.getRegion('contactlist').currentView
    listView.$('.list-group-item').removeClass('active')
    if (contactId) {
      let itemView = listView.children.find(function (view) {
        return view.model.get('id') == contactId
      })
      if (itemView) {
        itemView.$el.addClass('active')
      }
    }
  }
})

//ContactsRoute
export default Route.extend({
  activate(){
    this.listenTo(Radio.channel('router'), 'before:activate', this.onChildActivate);
    [..]
  },

  [..]

  onChildActivate(transition, route) {
    if (!transition.params.contactid) {
      return;
    }
    //must be done after transition finishes to ensure view is rendered
    transition.then(() => {
      this.view.setSelected(transition.params.contactid)
    })
  },

  deactivate() {
    this.stopListening(Radio.channel('router'), 'before:activate')
  }
})

    
This approach demonstrates how to catch events from child routes but is a lot of code for a common use case. With this in mind i created a Marionette Behavior (RouterLink) that takes care both of active class update as well click event configuration. This commit show how to use it.

Finally, let's render a view in the contact detail placeholder when no contact is selected. This can be done through a default child route for contacts route:


//main.js:

router.map(function (route) {
  route('application', {path: '/', routeClass: ApplicationRoute}, function () {
    route('contacts', {routeClass: ContactsRoute, abstract: true}, function () {
      route('contacts.default', {path: '', viewClass: ContactNoSelectionView,
        viewOptions: {message: 'Please Select a Contact.'}})
      route('contactdetail', {path: ':contactid', routeClass: ContactDetailRoute})
    })
  })
});

Radio.channel('router').on('before:transition', function (transition) {
  if (transition.path === '/') {
    transition.redirectTo('contacts.default')
  }
});

ContactNoSelectionView is an unremarkable Marionette view.

Important here is the definition of 'contacts.default' route with an empty path. It means that it will be activated when URL matches the path of the parent route, i.e., it becomes the default child route.

Another point is the absence of a routeClass option. Instead, viewClass and viewOptions are defined directly, useful for views that do not require customized logic to be instantiated.

Final thoughts


In days where most web development is done with React and Angular, using Backbone and Marionette seems outmoded. But i'm confident they still has a place.

After struggling with various approaches, i think the architecture presented in this tutorial, is suitable for large scale applications. More than an organized way of loading, manipulating state, it allows to create reusable / independent modules.

This application is an example. We could transplant all the code contained in contacts route and below, including HTML, as a sub application of a bigger one.  The only requirement is to have a Radio 'api' channel with a suitable 'getContactList' request, no need to worry what URL will be used or where it will render. In this way, we could develop sub applications independently assembling them into the full application just swapping the route configuration.

There are still some improvements to be done, like the possibility of add a contact, show a loading indicator, implement transition effects and prevent the exit from the contact detail route when there are pending changes. It will be left for an eventual second part of this tutorial.

Nenhum comentário:

Postar um comentário