Skip to content

Instantly share code, notes, and snippets.

@ShayDavidson
Last active February 20, 2017 08:55
Show Gist options
  • Star 9 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save ShayDavidson/4578770 to your computer and use it in GitHub Desktop.
Save ShayDavidson/4578770 to your computer and use it in GitHub Desktop.
eBay Israel Social Center's Backbone Marionette Extensions.

eBay Israel Social Center's Marionette Extensions

This document is a summary of the convention we harnesed in the eBay Israel Social Center (ISC) for client-side development. It is built upon Backbone.Marionette and relies heavily on its Application, Module, Controller and AppRouter objects. We extended these classes with syntactic sugar, bootstrapping and other features we needed in order to simplify the way we work.

NOTE: The extensions were built up until v1.0.0-rc2 and do not include (yet) the Backbone update changes (we are still using EventBinders for example).

We used Marionette with our extensions to build the StubHub 'Go With Friends' service. The service allows users to arrange group events, invite friends, see who's in, and decide on the best tickets for everyone.

Some examples:

ex1

ex2

You can check the service yourself by going to StubHub, picking an event, and clicking the Go With Friends button at the top left.

How do we use Modules?

A Marionette Module is a representation of a page or flow which includes a Controller, Router and other classes required for the flow. The Module does not control the flow. It contains the ‘instructions’ for how to initialize such one (we call it the 'DNA of the flow').

We also use it for namespacing. Since most of our classes are wrapped in a App.module definition, we can reference other classes or objects within the module as Module.NeededClass instead of its full path:

App.module 'SelectionFlow', (Module, App) ->

  class Module.Component
    # ... class definition

  object = new Module.Component() # instead of App.SelectionFlow.Component

We used to use modules as global vents for the flow they represents, but it turned out it couples them too tightly to the Module. Instead we now pass vents/EventAggregators to whatever needs them.

How do we structure our Application?

Each module has its own folder. The folder contains a definition file (index.js), a Controller and an optional Router. The rest of the Module's classes are contained in folders named by their roles (models, views etc).

struc

Flow Module

Flow Modules represent complex components (made of several views and models), with a controller that mediates between the different views of the components. In some cases the different states in those components also have dedicated routes.

They are defined similarly to generic Modules:

App.flowModule 'SelectionFlow', options

The flowModule method accepts the following options:

  • define: Additional definition for the Module. Here we can add new initializers, finalizers and Module logic.
App.flowModule 'SelectionFlow', define: (Module, App) ->
 @addInitializer (options) ->
  # initializer code.
 
 @utilityMethod = ->
  # utility method code.
  • region: A Marionette Region object in which the page will be shown. If one is not given, the Module will use the region option used in the Module's start call instead.
 App.flowModule 'SelectionFlow', region: App.mainRegion
  • startFlow: A boolean value which determines whether the flow starts automatically once the Module initialization sequence ends (by calling the Module's Controller start method).

What happens when a Flow Module is started?

The Module creates instances of a Controller which is defined under the Module's namespace. The Controller receives the same options used in the Module's start call, and also saves a reference of the region given in the Module's definition/start call.

After the Controller is created, the Module also creates an AppRouter similarly (only if one is defined) with that Controller in its options.

How do I close/end a Flow?

You can either close the flow temporiarly using the Module's Controller close method (more on that further on), or shut the flow completely (unitialization) by calling the Module's stop method.

Page Modules

A Page module is a Flow Module which represents a page in the application. There should only be a single active Page Module in the application simultaneously. A Page Module is very similar to a Flow Module and can accept the same options.

They are different by:

  • The startFlow option is always set to true - The flow starts automatically once the initialization sequence ends.
  • The Module checks if the Application object has a defaultRegion property. If so, it uses that Region as the region the page will be shown in.

Page Modules are defined similarly to Flow Modules:

App.pageModule 'ShowPage', options

Pages can be easily replaced by calling one Page Module's stop method, and right after that calling the start method of a different Page Module.

How do we use Controllers?

If a Flow Module instructs how to build a flow, then a Controller is the actual object which manages it. We use the Controller objects to:

  • Define all the available major actions within a flow (or page).
  • Create and show the layout and views which are used in the flow.
  • Hold the models, collections and services instances required in the flow.
  • Manage other complex components which are represented by Flow Modules.
App.module 'SelectionFlow', (Module, App) ->

  class Module.Controller extends Backbone.Marionette.Controller

    initialize: (options) ->
      # create models' instances.
      # include flow modules.

    onStart: ->
      # bind to events
      # models bootstrapping.
      # show layout.

    onClose: ->
      # close layout
      # reset models.

    # actions

How do we use Flow Modules within Controllers?

Sometimes higher-level Controllers have to interact with complex components that are represented by Flow Modules. In that case, we use our own Controller extension - the includeModule method:

initialize: ->
  @selectionController = @includeModule(App.SelectionComponent, options)

When we include a Flow Module with the includeModule method, the Controller starts that Module, and returns a reference of its Controller instance. After this, we can call its methods directly when needed (e.g. start, actions) and bind to events it triggers:

_bindToEvents: ->
  @bindTo(@, 'select:clicked',  @selectionController.show(), @selectionController)
  @bindTo(@selectionController, 'done:clicked',  @saveThings, @)

When the stop method of a Controller is called, all the included Flow Modules are stopped as well (which stops their Controllers as well). The Controller stop method, unlike close, means the Controller is uninitialized completely, rather than closed for a time being.

'This behavior is similar to submodules, why duplicate it?'

We added this extension after we had to use some complex components in different parts of our application. Originally we used these complex components as submodules:

App.module 'CreationFlow'

App.module 'CreationFlow.SelectionFlow' # the submodule.

Later on we discovered we would need the SelectionFlow component in a different part of the application, and had to find a way to separate it from its parent module. Using includeModule we can manage these components from the outside (an higher-level Controller) and make sure they don't becomes zombies after a flow is no longer needed (calling the `stop method).

How do we manage the Views of the flow?

When a Controller is initialized by a Module, it receives a Marionette Region instance in the region option. We can then show views and layout in this region:

  • setLayout(LayoutClass, options) - Creates an instance of LayoutClass with options and shows it on the Controller's region. The method also saves a reference to the layout. You should usually call this method on the Controller's onStart method.
  • showViewInRegion(ViewClass, region, options) - Creates an instance of ViewClass with options and shows it in given region.

Having a region option, allows us to open certain flows in different regions. The most noteable example is opening a component on the page page (main region) or in a popup (which we have a region for).

How do the Router and Controller communicate?

A Controller action is called in two cases:

  • The URL changes, then AppRouter calls a corresponding action method.
  • Something happens in the Application (e.g. user click), which triggers an action.

We don't simply change the URL and rely on the Router to call the corresponding action when we want to perform one:

  • It couples the flow heavily to the Router. What would happen if we want to get rid of URL changing in the application?
  • It makes the flow harder to follow. We would have to go to the Router and see what action is called when the URL changes instead of seeing the action itself called in the code.

Instead, the AppRouter binds to Controller events on its initialize method (we had to add an EventBinder to Marionette's AppRouter). The Controller triggers these events at the end of actions:

In the Controller:

showLandingPage: ->
  # action code.
  @trigger('landing:show')

and in the Router:

appRoutes:
  'landing': 'showLandingPage'

initialize: ->
  @bindTo(@controller, 'landing:show', -> Backbone.history.navigate('landing'))

The handler calls Backbone.navigate without the trigger option set to true. This way, the URL will be changed but the action will not be called again.

How do we inform the Controller that it should call an action based only on a URL change?

This is relevant only when:

  • The page loads with the action hash already in the URL.
  • The user changes the URL manually.

In this case, the AppRouter will automatically call the corresponding action as defined in its appRoutes. This however creates a problem: The action is called but the Controller is not yet started (no bound events, no bootstrapped models, no layout shown etc).

In order to prevent this, we simply add a start() call at the beginning of each action. In order to prevent starting the Controller if it's already started, we added a started flag to the Controller which prevent starting an already started Controller.

# controller action
inviteMail: ->
  @start()    # this will not start the controller if it's already started.
  @showLayout(MailInviteView, ...)

Initialization Sequence of a Flow Module

first

Start and end flows of a Controller

second

Code example

Component's Flow Module definition

App.flowModule "SelectionFlow"

Component's Controller definition

App.module "SelectOptions", (Module, App) ->

  class Module.Controller extends Backbone.Marionette.Controller

    initialize: (options) ->
      @mainModel = options.model
      @selectedObjects = new Module.SelectedCollection()

    onStart: ->
      @_bindToEvents()
      @selectedObjects.reset()
      @setLayout(App.PageLayout)

    onClose: (options = {}) ->
      @region.close()

    showWelcomePage: ->
      @start()  
      @_showWelcomeScreen()
      @trigger('landing:show')

    showMainView: ->
      @start()
      @_showTitle()
      @_showSelectComponent()
      @trigger('select:show')

    ## VIEWS
    _showTitle: ->
      return unless @options.hasTitle
      @showViewInRegion(Module.TitleView, @layout.title, model: @mainModel)

    _showWelcomeScreen: ->
      @showViewInRegion(Module.WelcomeView, @layout.right)

    _showSelectComponent: ->
      @showViewInRegion(Module.SelectOptionsView, @layout.right, collection: @selectedObjects)
      
    _handleError: ->
      # error code.

    ## PRIVATE
    _bindToEvents: ->
      @bindTo(@mainModel,  'error',  @_handleError)
      @bindTo(@region, 'close',  @close, @) # in case the region is closed externally.

Component's AppRouter definition

App.module 'SelectionFlow', (Module, App) ->

  class Module.Router extends Backbone.Marionette.AppRouter

    appRoutes:
      'select': 'showMainView'

    initialize: ->
      @bindTo(@controller, 'select:show', -> Backbone.history.navigate('select'))
      @bindTo(@controller, 'close',       -> Backbone.history.navigate(''))
_.extend Backbone.Marionette.Module.prototype,
# Syntactic sugar to Module's 'start' event handlers.
# Start callbacks are used to make an initialization sequence chronlogical (first run initializers, and then start callbacks).
addStartCallback: (callback) ->
@bindTo(@, 'start', callback, @)
# Adds a Module initializer which creates instances of the Module's Controller and AppRouter (if defined).
initializeFlowControls: ->
@addInitializer (options = {}) ->
# Create a Controller instance.
controllerClass = @['Controller']
@controller = new controllerClass(options)
# Create an AppRouter instance, if defined.
routerClass = @['Router'](options)
@router = new routerClass(controller: @controller) if routerClass? # Create the instance if it has a defined class.
# Always stop the flow the Controller manages once you stop the Module.
# `stop`, unlike `close`, means the Controller is uninitialized completely, rather than closed for a time being.
@addFinalizer =>
@controller.stop()
# The purpose of the Application object extension is to define methods which define certain types of Modules.
# Similar to `App.module` which defines a generic Module, these methods define Modules with specific bootstrapping.
_.extend Backbone.Marionette.Application.prototype,
# Defines a Marionette Module which represents a flow.
# A Flow Module can accepts these options:
#
# region: Marionette Region object to show a certain page layout.
# Overrides the `defaultRegion` property of the Application (if exists).
#
# startFlow: Whether to start the controller once the initialization sequence ends or not.
#
# define: Further definition to the Module.
flowModule: (name, options = {}) ->
@module name, startWithParent: false, define: ->
# Create a Controller and an AppRouter (if defined) instances.
@initializeFlowControls(options)
# Merge in additional definitions which were defined in the `define` option.
@addDefinition(options.define) if options.define
# Start the flow at the end of the initializatiobn sequence if the `startFlow` option is true.
@addStartCallback(-> @controller.start()) if options.startFlow
# Defines a Marionette's Module which represents a page.
# A Page Module is an extended Flow Module, this it can receive the same options.
pageModule: (name, options = {}) ->
# Determines the region property passed to the Flow Module.
throw 'A region is required for starting a Page Module!' unless (@defaultRegion or options.region)
options.region = @defaultRegion unless options.region
# Set to start the flow once the initialization sequence ends.
options.startFlow = true
# Define the Page Module as a Flow Module.
@flowModule name, options
# Overriding the constructor in order to add `region` as an instance variable.
Marionette.Controller = (options) ->
@triggerMethod = Marionette.triggerMethod
@options = options || {}
@region = Marionette.getOption(@, 'region') # Added by us.
Marionette.addEventBinder(@)
@initialize(@options) if _.isFunction(@initialize)
# Methods extensions.
_.extend Backbone.Marionette.Controller.prototype,
# "A Flow Module is initialized by a higher-level Controller that need to interact with it."
# This method allows an high-level Controller to rely on other Flow Module's controller.
# It saves a reference to these Modules in its @submodules array.
includeModule: (module, options = {}) ->
(@submodules ||= []).push(module)
module.start(options)
module.controller # Return an instance of the controller.
# 'Starts' or shows the flow the Controller represents.
start: (options = {}) ->
return if @started # Don't start if already started.
@started = true
@triggerMethod('start', options) # Allows to bootstrap models and bind to events on a custom `onStart` method.
# We copied this from Marionette but removed the `unbind()` call. It's done in the `stop` method instead.
close: (options = {}) ->
@unbindAll()
@triggerMethod('close', options)
@started = false
# Tells the Controller it's no longer (and will not be) required.
# Uninitalizes all the Flow Modules this Controller relies on as well.
stop: ->
_.each(@submodules, (module) -> module.stop())
@close() if @started
@unbind()
# Creates an instance of `LayoutClass` with `options` and shows it on the Controller's region.
# The method also saves a reference to the layout.
# You should call this method on the Controller's `onStart` method.
setLayout: (layout, options = {}) ->
@layout = @showViewInRegion(layout, @region, options)
# Creates an instance of `ViewClass` with `options` and shows it in given region.
showViewInRegion: (view, region, options = {}) ->
viewInstance = new view(options)
region.show(viewInstance)
viewInstance
# This extension simply adds an event binder to the AppRouter and saves a reference of the Controller.
# This way the Router can listen to the Controller events and change the URL accordingly:
#
# App.module "Flow", (Module, App) ->
#
# class Module.Router extends Backbone.Marionette.AppRouter
#
# appRoutes:
# ...
#
# initialize: ->
# @bindTo(@controller, 'flow:show', -> Backbone.history.navigate('flow'))
# @bindTo(@controller, 'close', -> Backbone.history.navigate(''))
Backbone.Marionette.AppRouter.prototype.constructor = (options) ->
Marionette.addEventBinder(@) # Added by us.
@options = options
@controller = Marionette.getOption(@, 'controller') # Added by us.
Backbone.Router.prototype.constructor.apply(@, arguments)
@processAppRoutes(@controller, @appRoutes) if @appRoutes
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment