View on GitHub

backbone-torso

A holistic approach to Backbone applications

Torso Data Behavior

API Section

Goals:

Binding views to data sources is a task that increases in complexity when the views require data that depends on each other. This behavior is meant to address that complexity and provide a simple and clean API to define relationships between models in the context of a view. This Data Behavior removes the need to manage private collection dependencies manually and instead focus on defining the relationship between the models.

Example without the behavior:

Display the title of each article and the descriptions of each post on the article. Let us assume for now that breaking these up into a list view and child views isn’t appropriate and that we need all of the models in a single view.

var ArticleAndPostsView = Torso.View.extend({
  initialize: function(options) {
    this._articlePrivateCollection = require('app/article/articleCacheCollection').createPrivateCollection(this.cid);
    this._postPrivateCollection = require('app/post/postCacheCollection').createPrivateCollection(this.cid);
    
    this.listenTo(this._articlePrivateCollection, 'change:postIds fetched', this._trackNewPostIds);
    
    this.setArticleIds(options.articleIds || this._initialArticleIds);
  },
  
  setArticleIds: function(articleIds) {
    this._articlePrivateCollection.trackAndFetchIds(articleIds);
  },
  
  _trackNewPostIds: function() {
    var postIds = _.flatten(this._articlePrivateCollection.pluck('postIds'));
    this._postPrivateCollection.trackAndFetchIds(postIds);
  }
});

Example with the behavior:

Same as above, but lets you focus on the relationship instead of the plumbing.

var ArticleAndPostsView = Torso.View.extend({
  behaviors: {
    articles: {
      behavior: Torso.behaviors.DataBehavior,
      cache: require('app/article/articleCacheCollection'),
      ids: { property: 'viewState:articleIds' }
    },
    posts: {
      behavior: Torso.behaviors.DataBehavior,
      cache: require('app/post/postCacheCollection'),
      ids: { property: 'behaviors.articles.data:postIds' }
    }
  },
  
  initialize: function(options) {
    this.setArticleIds(options.articleIds || this._initialArticleIds);
  },
  
  setArticleIds: function(articleIds) {
    this.set('articleIds', articleIds);
  }
});

And we can share behavior definitions removing even more boilerplate:

var ArticlesDataBehavior = Torso.behaviors.DataBehavior.extend({
  cache: require('app/article/articleCacheCollection'),
  ids: { property: 'viewState:articleIds' }
});

var PostsOfArticlesDataBehavior = Torso.behaviors.DataBehavior.extend({
  cache: require('app/post/postCacheCollection'),
  ids: { property: 'behaviors.articles.data:postIds' }
});

// Define the view (ideally each behavior and view have their own files and can be imported separately)
var ArticleAndPostsView = Torso.View.extend({
  behaviors: {
    articles: ArticlesDataBehavior,
    posts: PostsOfArticlesDataBehavior,
  },
  
  initialize: function(options) {
    this.setArticleIds(options.articleIds || this._initialArticleIds);
  },
  
  setArticleIds: function(articleIds) {
    this.set('articleIds', articleIds);
  }
});

Table of Contents

  1. Background
  2. Simple Configuration
  3. Object models from IDs
  4. Configuration methods
  5. Getting IDs
  6. Accessing data from View
  7. Accessing data from template
  8. Triggering a recalculation of ids and refresh of cached data
  9. Update Events Configuration
  10. Events listened to
  11. Events emitted
  12. Error Handling
  13. Description of all Options
  14. Description of all Public Methods

Background

Retrieving data using Torso caches (Collections) and private collections involves a 2 step process:

  1. Obtain the id(s) of the objects you want. This could be from a list on another object, a criteria call to the server, url parameters, etc.

  2. Use those id(s) to get the object model(s). By having a unified API for accessing models via id(s) through the cache you can guarantee that the same model is used across all of your views when accessing the same object.

Simple Configuration

Data Behaviors are meant to allow simple configuration of both sides of this process (how to get ids and how to get objects based on ids).

The simplist use case for the data behavior is when a collection of ids are fixed and known at view creation time and the expected result is a collection of data items.

var PostsView = Torso.View.extend({
  behaviors: {
    posts: {
      behavior: Torso.behaviors.DataBehavior,
      cache: require('app/post/postCacheCollection'),
      ids: [ 'yesterday-by-the-river', 'tomorrow-by-the-sea' ]
    }
  }
});

This identifies the postCacheCollection.js as the cache to use for posts and that it should always retrieve the same two posts for this view identified by: “yesterday-by-the-river” and “tomorrow-by-the-sea”.

Object models from IDs

Once IDs have been identified, then the object models need to be fetched. This is managed by the cache collection so that models of the same type can be retrieved in batches and refreshed in bulk. There are 2 properties that configure this interaction returnSingleResult and alwaysFetch.

returnSingleResult

Set to true if you expect to only be working with a single id. False is the default and is useful when working with mulitple ids.

Basic example of returnSingleResult:

var ArticleView = Torso.View.extend({
  behaviors: {
    article: {
      behavior: Torso.behaviors.DataBehavior,
      cache: require('app/article/articleCacheCollection'),
      returnSingleResult: true,
      id: 1234
    }
  }
});

alwaysFetch

Set to true (fetch mode) if you want every refresh of the widget to retrieve data from the server (this will be the closest to “real-time” and will make sure your views are always showing the most up-to-date information). True is also the most resource intensive since it fetches from the server on every update. False (pull mode) is the default and will use the cached models if they exist for the given ids. False will reduce the traffic to the backend server in exchange for better performance on the frontend. A mix of the two can be configured by using false (pull) and a polling collection as a cache. Then you can guarantee that the data is no older than your poll interval while still reducing server traffic (assuming your poll interval is longer than how often you data behavior refreshes).

Basic example of alwaysFetch

var PostsView = Torso.View.extend({
  behaviors: {
    posts: {
      behavior: Torso.behaviors.DataBehavior,
      cache: require('app/post/postCacheCollection'),
      alwaysFetch: true,
      ids: [ 'yesterday-by-the-river', 'tomorrow-by-the-sea' ]
    }
  }
});

This will cause the Data Behavior to pull posts with the ids “yesterday-by-the-river” and “tomorrow-by-the-sea” from the server whenever a refresh of the data is requested.

Configuration methods

There are main ways to set the options used by the DataBehavior. The first is via behavior options defined on the view (see examples above). The other is through direct extension and provides a way to factor out common configurations into their own DataBehavoirs.

var YesterdayAndTomorrowPostDataBehavior = Torso.behaviors.DataBehavior.extend({
  cache: require('app/post/postCacheCollection'),
  ids: [ 'yesterday-by-the-river', 'tomorrow-by-the-sea' ]
});

var PostsView = Torso.View.extend({
  behaviors: {
    posts: YesterdayAndTomorrowPostDataBehavior
  }
});

The configuration methods can be mixed, for example to create a data behaivor for a given object (ids -> object side of the configuration) and let the view or extension add how to retrieve the ids.

var PostDataBehavior = Torso.behaviors.DataBehavior.extend({
  cache: require('app/post/postCacheCollection')
});

var PostsView = Torso.View.extend({
  behaviors: {
    posts: {
      behavior: PostDataBehavior,
      ids: [ 'yesterday-by-the-river', 'tomorrow-by-the-sea' ]
    }
  }
});

Getting IDs

This is the most complex side of the configuration due to the number of places that provide ids.

For these configurations we will extend one of the following two behaviors depending on whether we want a single result returned or a collection.

var PostDataBehavior = Torso.behaviors.DataBehavior.extend({
  cache: require('app/post/postCacheCollection')
});

var ArticleDataBehavior = Torso.behaviors.DataBehavior.extend({
  cache: require('app/article/articleCacheCollection'),
  returnSingleResult: true
});

Which can be used like this:

var ArticleWithPostsView = Torso.View.extend({
  behaviors: {
    article: {
      behavior: ArticleDataBehavior,
      id: 1234
    },
    posts: {
      behavior: PostDataBehavior,
      ids: [ 'yesterday-by-the-river', 'tomorrow-by-the-sea' ]
    }
  }
});

The main configuration option for identifying which ids to use is ids. This option is aliased with id to provide a more readable configuration, but usage of id vs. ids has no functional impact.

Cell-like idContainer Support

The ids option can get the ids from other objects. This api is based on idContainer objects that are “cell-like” (https://runkit.com/torso/cell). Which means they have a getter to retrieve properties that takes a string identifying the (nested) property where the ids are and trigger change:<property name> events whenever the (nested) id property changes. This common contract allows the data behavior to simplify the configuration to identifying:

  1. The idContainer
  2. The property of the idContainer that contains the ids

The following objects already implement this full contract and need no modifications to depend on them:

Direct Property Support

An alternative to the cell-like contract described above is direct properties on the idContainer object. If a property exists on the idContainer that matches the provided property name then it will be used instead of the getter described above.

Nested Property Support

Configuration for direct properties supports nested properties idContainer:some.nested.property:

var idContainer = { some: { nested: { property: true }}}

For cell-like idContainers the support of nested properties is defined by whether .get() of the idContainer supports nested properties (the syntax for defining the property is the same between direct and cell-like properties).

Change Events

Change events are handled separately and are listened for on the idContainer regardless of whether the id property is a direct property or a cell-like property. If change events are implemented then the ids will be automatically calculated and the objects will be fetched from the cache when the change event is triggered on the idContainer.

Configuring ids using a string description.

To use a property that is defined on the view as a field just specify the name of the property in the ids configuration:

var ArticleView = Torso.View.extend({
  behaviors: {
    article: {
      behavior: ArticleDataBehavior,
      id: { property: '_articleId' }
    }
  },
    
  _articleId: 'the-first-article'
});

In this case the id for the article will be “the-first-article”.

Note: Due to the duck-typing involved in allowing a single String id we have to use the additional construct of an object containing a field named “property” for identifying a property using a simple string. If this restriction was lifted, then the following would be ambiguous:

var AnAmbiguousViewExample = Torso.View.extend({
  behaviors: {
    article: {
      behavior: ArticleDataBehavior,
      id: 'anId'
    }
  },
    
  anId: 10
});

The id to use could be either the value ‘anId’ or the number 10.

Other ID Containers

IdContainers can be defined using the string property syntax or using more complex configurations where the context is identified separately.

String Syntax

The string syntax is a simplified syntax meant to address a majority of the use cases of identifying both the idContainer and the id property. The container and property is separated by a “:” and the property or container can be a nested property name using bean syntax.

Possible idContainers:

Examples:

The cache being used by the behavior is even conveniently passed in as an argument to the function in case you have generic ids logic and want to swap out the cache for different data behaviors.

The context (“this”) of the function is this Data Behavior.

A good place for the “how to get ids from the server” logic is a method on the cache collection.

var ArticleSearchDataBehavior = ArticleDataBehavior.extend({
  updateEvents: 'model:change:searchString',
  ids: function(articleCollectionCache) {
    var searchString = this.view.model.get('searchString');
    if (!searchString) {
      return null;
    }

    return articleCollectionCache.fetchIdsBySearchString({ searchString: searchString });
  }
});

articleCollectionCache.fetchIdsBySearchString = function(criteria) {
  return $.ajax({
    url: 'article/criteria',
    contentType: 'application/json',
    type: 'POST',
    dataType: 'json',
    data: JSON.stringify(criteria)
  });
});

Accessing data from View

The view can access the behavior’s data via the .data property on the behavior. It has .toJSON() and .get() methods for data access.

Data access examples will use the ArticleWithPostsView defined earlier.

Accessing data from template

The view will automatically add the data from the behavior to the result of the view’s prepare method. It will also add the loading statuses of the behavior:

{
  loading: true, // true if either ids or objects are being loaded.
  loadingIds: true, // true if ids are being loaded.
  loadingObjects: true, // true if objects are being loaded.
  data: ...
}

Result of prepare() of ArticleWithPostsView after data has been loaded.

{
  ...,
  article: {
    loading: false,
    loadingIds: false,
    loadingObjects: false,
    data: {
      id: 1234,
      title: 'some article title',
      body: 'The content of the article...'
    }
  },
  posts: {
    loading: false,
    loadingIds: false,
    loadingObjects: false,
    data: [{
      id: 'yesterday-by-the-river',
      title: 'Yesterday by the River',
      text: 'Yesterday I took a walk by the river...'
    }, {
      id: 'tomorrow-by-the-sea',
      title: 'Tomorrow by the Sea',
      text: 'Tomorrow I am going to walk along the beach...'
    }]
  }
}

Triggering a recalculation of ids and refresh of cached data

Update Events Configuration

When direct dependence on id properties that fire their own change events are not enough you can also setup update events. These are additional events that, when fired, will trigger a refresh of the ids and data in the data behavior.

Event emitters are defined the same way as idContainers and follow the same rules. The only difference is that the event emitters use the event as the base object while idContainers use the view.

Specifially updateEvents will assume anything before the first “:” is defining the container and anything after is defining the event (which means event names can have additional “:” in them).

var ArticleView = Torso.View.extend({
  behaviors: {
    article: {
      behavior: ArticleDataBehavior,
      updateEvents: ['view.model:change:articleId', 'magazineConfiguration:change:enabled'],
      ids: function() {
        if (this.view.magazineConfiguration.isEnabled()) {
          return this.view.model.get('articleId');
        }
      }
    }
  }
});

Special keywords for the event emitter string definition:

For more complicated objects that can not be referenced from the behavior, an event name to container (or function that returns a container) hash can be supplied.

var ArticleView = Torso.View.extend({
  behaviors: {
    article: {
      behavior: ArticleDataBehavior,
      updateEvents: [{
        'change:articleId': function() {
          return this.view.getArticleIdContainer();
        }
      }, {
        'change:enabled': magazineConfiguration
      }],
      ids: function() {
        if (magazineConfiguration.isEnabled()) {
          return this.view.getArticleIdContainer().get('articleId');
        }
      }
    }
  }
});

Note: When using an object to define the event name to event emitter mapping, you can define multiple event/emitter pairs (assuming the event names do not overlap). When defining the same event name for different objects it needs to be defined as another object in the array. Otherwise the events can be grouped in a single object.

var ArticleView = Torso.View.extend({
  behaviors: {
    article: {
      behavior: ArticleDataBehavior,
      updateEvents: [
        {
          'change:enabled': magazineConfiguration,
          'fetched': someOtherEventEmitter,
          'some:random:event': yetAnotherEventEmitter
        },
        {
          'change:enabled': applicationConfiguration
        }
      ],
      ids: ...
    }
  }
});

Events listened to

The Data Behavior listens to the following events on the idContainer. It ignores the arguments to those events.

Events emitted

Error Handling

Description of all Options

Description of all Public Methods

[data]. means that the same method is also aliased on the data object and can be access edither from the data behavior directly or from the data property of the data behavior.