Tuesday, 2 June 2015

A Backgrid Cell for displaying a link to a model's Url

When creating web applications that use Backbone I've found the Backgrid library useful for rapidly creating a user interface with a lot of functionality. With just a few lines of javascript you can display a table of data that allows sorting and in-row editing, and which updates a backing Backbone collection, thereby hooking directly into the infrastructure of the rest of your web page. And, like all good javascript frameworks, it:
  • has very few dependencies - just Backbone, Underscore (which Backbone requires anyway) and jQuery (which you will almost certainly be using anyway).
  • packs down nice and small - just 25KB.
  • is very extensible...
So I'd like to share a very simple extension that I've created, a Backgrid cell that will render a hyperlink to the model that it is displaying.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
    /* Cell which displays a link to the model's Url. View can be extended to set:
    *   title - title of the link
    *   target - frame that the link targets, defaults to '_self'
    */
    var ModelUri = Backgrid.UriCell.extend({
        render: function () {
            this.$el.empty();
            var rawValue = this.model.get(this.column.get("name"));
            var formattedValue = this.formatter.fromRaw(rawValue, this.model);
            this.$el.append($("<a>", {
                href: this.model.url(),
                title: this.title || formattedValue,
                target: this.target || '_self'
            }).text(formattedValue));
            this.delegateEvents();
            return this;
        }
    });

When using this cell the 'name' of the column definition should point at the model attribute that contains the link text. The column definition doesn't need to contain any information about where to load the link target from as it uses model.url() for that. If you look at the source of Backgrid itself you'll see that I've changed very little from the render method of the UriCell cell. A quick commentary on what the code does:
  • On line 8 this.column.get("name") retrieves the name of the attribute that contains the link text (the 'column' object is, itself, a model).
  • Also on line 8, this.model.get(...) retrieves the value for the link text from the bound model.
  • Line 9 uses standard Backgrid functionality to transform the raw value retrieved from the model using the cell's formatter. My cell uses the default formatter that simply returns the value that is passed to it, but if you wanted to extend ModelUri further to use a different formatter then you could.
  • Line 11 sets the link target to the value of model.url().
  • Line 13 allows the target frame to be specified, defaults to '_self'.
  • Line 14 sets the link text to the formatted value.
To use the cell you would need code that looked something like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
 var UserCol = Backbone.Collection.extend({
  model: Backbone.Model,
  urlRoot: '/_users'
 });
 var grid = new Backgrid.Grid({
  selector: '#gridHolder',
  collectionClass: UserCol,
  comparator: 'reference',
  columns: [
   { name: "reference", editable: false, cell: ModelUri, label: "Filename" },
   { name: "processingState", editable: false, cell: 'string', label: "Status" }
  ]
 });

If you wanted the cell to render a link that would open the model url in a new tab/window then the 'target' property of the cell needs to be set to '_blank'. This can be achieved by further extending ModelUri; one way to do this is shown below:


1
2
3
4
5
6
7
8
9
 var grid = new Backgrid.Grid({
  selector: '#gridHolder',
  collectionClass: UserCol,
  comparator: 'reference',
  columns: [
   { name: "reference", editable: false, cell: ModelUri.extend({target: '_blank'}), label: "Filename" },
   { name: "processingState", editable: false, cell: 'string', label: "Status" }
  ]
 });


Hope you find this useful!

Monday, 1 June 2015

'add' and 'reset' events from Backbone Collections

In one of my current projects I'm using Backbone to lazy load and render data in a hierarchical structure (a treeview in this case). The data that I'm displayed is returned from the backend in database order, so I set the comparator property on the Backbone Collection to ensure a more friendly sort order. And this is where I encountered a slight 'gotcha' (it wouldn't be fair to call it a bug) which I thought it might be worth documenting in case it tripped anyone else up.

I've created a JS Bin to demonstrate the gotcha which I'll talk you through here:

1. Create a basic HTML page to hold everything


 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
<!DOCTYPE html>
<html>
<head>
<meta name="description" content="JS Bin to demonstrate the fact that Backbone collections that have a comparator to ensure a certain sort order raise 'add' events in the unsorted order when multiple models are added via the 'Collection.set' method">
<script src="https://code.jquery.com/jquery-2.1.1.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/underscore.js/1.8.3/underscore-min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/backbone.js/1.1.2/backbone-min.js"></script>
  <meta charset="utf-8">
  <title>JS Bin</title>
</head>
<body>
  <h1>List appears below</h1>
  <ol>
    
  </ol>
</body>
</html>

2. Define a basic Collection and View


 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
var ExampleCollection = Backbone.Collection.extend({
      comparator: 'title',
      model: Backbone.Model
    }),
    ExampleView = Backbone.View.extend({
      tagName: 'li',
      render: function() {
        this.$el.text(this.model.get('title'));
        return this;
      }
    });

I haven't defined a Model as I'm going to use the base Backbone.Model function to create my model objects. As you can see above:

  • ExampleCollection objects will sort their models by their 'title' attribute.
  • ExampleView objects will render a list item that will contain the 'title' attribute of their model. Note that the render function is not responsible for adding the view to the DOM (this is a fairly common approach in Backbone).

3. Create an ExampleCollection object and attach event handlers 

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
var col = new ExampleCollection(),
  $container = $('ol');

col.on('add', function(model, collection) {
  $container.append(new ExampleView({model: model, collection: collection}).render().$el);
});
col.on('reset', function(collection) {
  collection.each(function(model) {
    $container.append(new ExampleView({model: model, collection: collection}).render().$el);
  });
});

As you can see the two event handlers attached to 'add' and 'reset' ensure between them that whenever a model is added to the collection (whether it is added by a 'set' or by a 'reset') an ExampleView object will be created and appended to the only <ol> on the page.

4. Add some models to our collection and watch what happens!


1
2
3
4
5
6
7
col.set([new Backbone.Model({
  id: 1, title: 'Zebra'
}),new Backbone.Model({
  id: 2, title: 'Adder'
}),new Backbone.Model({
  id: 3, title: 'Porcupine'
})], {sort: true});

As we are passing {sort: true} to the set method you might expect that we will see a sorted list of models on screen, but what we actually see is this:

List appears below

  1. Zebra
  2. Adder
  3. Porcupine

Changing the line that populates the collection from col.set to col.reset results in what we expected to see first time; the models are now correctly sorted:

List appears below

  1. Adder
  2. Porcupine
  3. Zebra

Why is this? The answer is that when multiple models are added to the collection in the first example (using col.set([model1,model2], {sort: true})) although the collection is sorted correctly (you can verify this by debugging if you want) the 'add' events are raised using the original ordering of the models, not the sorted ordering. When multiple models are added using a 'reset' then the 'reset' event is passed the full, sorted collection, and so we add the view to the HTML in sort order.

You could grumble about this but I think such complaints are misplaced for two reasons:

  1. If you're populating an empty collection using multiple models then 'reset' is really the correct method to use rather than 'set'.
  2. The 'set' method is extremely versatile; it can be used to merge models, add models or to both merge and add models as part of a single operation; it can be called on sorted or unsorted collections; it can be used to populate an empty collection or make updates to a collection that is already populated. When 'set' is used in some of these scenarios it would not be meaningful or a worthwhile use of CPU time to raise the 'add' events in the sort order.
So that's why I think that this behaviour is a 'gotcha' rather than a bug. Something else worth noting is that this means that when populating collections by calling 'fetch' you need to use {reset: true} in the options to ensure that the 'reset' method is called and a 'reset' event raised.