Transforming Collections - Full Stack Web Development with Backbone.js (2014)

Full Stack Web Development with Backbone.js (2014)

Chapter 5. Transforming Collections

Now that the details of a movie can be rendered, let’s look at a more realistic movies program. When there are tens or hundreds of movies, users might want to quickly sort and filter them, as well as paginate through a large collection. The process of filtering, sorting, and paginating revolves around adding and removing models from a collection, so we are going to study how to transform the structure of a Backbone collection next.

In the Munich Cinema example, our main goal is to give users a way to quickly find an interesting movie. We especially want to provide basic search and filtering options for better navigation through the movie program.

The goal of this chapter is to provide an overview on the following topics:

§ Sorting a collection

§ Filtering a collection

§ Using Backbone.Obscura to wrap sorting, filtering, and paginating

Functional Enhancements

When you read the documentation of Backbone.Collection, you will stumble upon an important piece of information to access and mutate a collection:

Backbone proxies to Underscore.js to provide 28 iteration functions on Backbone. Collection.

You already saw some examples of using map. In the sections to follow, you will learn the relevance of sortBy and filter.

Sorting

First, we look at sorting models (in this case, movies). Sorting models is a common task for a Backbone collection. Usually, you need to define a comparator function to get the correct positions of models in a collection.

From the documentation at Backbone.js, the purpose of a comparator is described as follows:

A comparator function takes two models, and returns –1 if the first model should come before the second, 0 if they are of the same rank and 1 if the first model should come after.

Let’s experiment a bit for sorting movies on showtimes. First, we set up some date helpers in app/models/movie.js:

var Backbone = require('backbone');

var Movie = Backbone.Model.extend({

// convert an Epoch timestamp to a Date object

toShowtimeDate: function() {

var d = new Date(0);

d.setUTCSeconds(this.get('showtime'));

return d;

},

// show a Date in the locale timezone

showtimeToString: function() {

return this.toShowtimeDate().toLocaleString();

}

});

module.exports = Movie;

For learning purposes, we create new collection with a comparator and a log output. In a new app/collections/moviesByShowtime.js file, you can write:

var Backbone = require('backbone');

var _ = require('underscore');

var Movie = require('models/movie');

var MoviesByShowtime = Backbone.Collection.extend({

model: Movie,

comparator: function(m) {

return -m.toShowtimeDate();

},

log: function() {

console.log(this.models);

this.each(function(movie) {

console.log(movie.get('title') + " " + movie.showtimeToString() +

"(" + movie.get('showtime') + ")");

});

}

});

module.exports = MoviesByShowtime;

With the comparator shown here, the movies can be sorted in reverse order by showtime. For example, a user could sort movies such that movies for the weekend are shown first (i.e., an “earlier” showtime would appear on the top of the list). It is important to note that when you use a comparator like this one, movies are sorted when they are “inserted” into the collection.

Let’s browserify that single file with:

$ browserify -r ./app/collections/moviesByShowtime.js:movies > static/movies.js

And, you can also browserify the raw data of movies.json to easily access them from the browser:

$ browserify -r ./movies.json:raw > static/data.js

To experience some sorting fun, let’s load both files from static/index.html:

<script src="movies.js"></script>

<script src="data.js"></script>

When you now create a new movies instance:

> Movies = require('movies');

> raw = require('raw');

> var moviesByShowtime = new Movies(raw);

you can see the movies set sorted:

> moviesByShowtime.log();

Indiana Jones IV 6.1.2014 10:19:40(1388999980)

Quantum of Solace 6.1.2014 04:46:20(1388979980)

La Dolce Vita 4.1.2014 02:46:20(1388799980)

Although the order of the movies collection was mutated, the state of the models remained constant. This is important, because movies could be sorted according to different criteria, but all movies models are kept in the collection. When filtering a collection, this can be different.

Using a single comparator function is somewhat limiting when users want to sort according to multiple criteria (e.g., showtime, genre, or rating of a movie). Luckily, Underscore.js adds sortBy to a Backbone collection. With sortBy, we inject a comparator function, without using a single comparator on a collection.

For sorting movies at Munich Cinema, we need multiple sort functions to sort movies by their rating, showtime, and title. When you invoke sortBy on a collection, you obtain the list of models in a new order.

To use sortBy, write a special function to sort movies by titles. Back in our Movies collection at app/collection/movies.js, you can add:

Movies = Backbone.Collection.extend({

// ...

sortByTitle: function() {

return this.sortBy('title');

}

});

After bundling, you can run a sort by invoking sortByTitle:

> var Movies = require('movies');

> var movies = new Movies(raw);

> sorted = new Movies(movies.sortByTitle());

> sorted.log();

As output, you should get:

Argo 3.1.2014 12:39:40 (1388749180)

Avatar 29.12.2013 20:26:20 (1388345180)

Dead Man Down 3.1.2014 19:36:20 (1388774180)

Django Unchained 11.12.2013 17:26:20 (1386779180)

In contrast to the first example, the sorted output of one collection is inserted into a new collection. When you have an existing collection, you can simplify sorting to adding new items:

> sorted.reset(movies.sortByTitle())

The same strategy can be used for sorting movies according to other criteria. To complete the exercise, let’s add the following code to app/collections/movies.js:

sortByRating: function() {

var sorted = this.sortBy(function(m) {

return (10 - m.get('rating'));

});

return sorted;

},

sortByShowtime: function() {

return this.sortBy('showtime');

}

With sortByRating and sortByShowtime, movies can be sorted according to two more criteria.

To round up the examples, let’s wire up these function to the UI. For this, you need to provide some buttons in the UI for sorting. You can define a small view as follows in app/views/sort.js:

var Backbone = require('backbone');

var SortView = Backbone.View.extend({

events: {

'click #by_title': 'sortByTitle',

'click #by_rating': 'sortByRating',

'click #by_showtime': 'sortByShowtime',

},

sortByTitle: function(ev) {

this.movies.reset(this.movies.sortByTitle());

},

sortByRating: function(ev) {

this.movies.reset(this.movies.sortByRating());

},

sortByShowtime: function(ev) {

this.movies.reset(this.movies.sortByShowtime());

},

initialize: function() {

this.movies = this.collection;

}

});

module.exports = SortView;

We also need to extend the layout template and make sure the events are properly resolved. One way to do this is by adding the following to app/views/layout.js:

render: function() {

this.$el.html(this.template());

this.currentDetails.setElement(this.$('#details')).render();

this.overview.setElement(this.$('#overview')).render();

this.controls.setElement(this.$('#controls'));

return this;

},

initialize: function(options) {

this.overview = new MoviesList({

collection: options.router.movies,

router: options.router

});

this.controls = new Controls({ collection: options.router.movies });

}

And we include a piece of HTML in the layout template:

template: _.template(' \

<header> \

<a href="#">Home</a> \

<nav id="controls"> \

<button id="by_title">By Title</button> \

<button id="by_rating">By Rating</button>\

<button id="by_showtime">By Showtime</button> \

</nav> \

</header> \

<div id="overview"> \

</div> \

<div id="details"> \

</div>')

A user would now be able to sort movies in the DOM according to different criteria. Don’t worry that this inline template starts to look awkward. In Chapter 6, you learn how to use a templating engine to keep templates in a separate file. If you haven’t followed the examples of this chapter with an editor, you can play with it on the book’s website.

Filtering

The next goal is to provide filtering options for movies. This will allow users to find movies, for example, that belong to a specific genre. Once the user has selected the appropriate filter options, the movie program should automatically update to show just those movies that meet the chosen criteria.

Let’s look briefly at what filtering does. Basically, filtering is an applied set theory, as shown in Figure 5-1. There is a superset containing all elements, and the filtered collections are subsets matching certain criteria. As such, filtering is a transformation or projection from input to output collection.

Filtering a collection means that collections are transformed; filtered sets only contain items that match certain criteria (e.g., movies from a category “drama,” “action,” or “comedy”)

Figure 5-1. Filtering a collection means that collections are transformed; filtered sets only contain items that match certain criteria (e.g., movies from a category “drama,” “action,” or “comedy”)

First, we add a bit of markup for playing with a filtering action:

<div id="filter-controls">

<select name="genre">

<option value="all">

All

</option>

<option value='drama'>

Drama

</option>

<option value='action'>

Action

</option>

</select>

</div>

When filtering a collection, users dynamically add and remove models. As filtering can destroy the state of a collection, a copy of the original collection must be saved for reference.

The idea of working with a copy (or proxy) of the original set can look as follows. Because a Backbone collection can be initialized by passing models, you can create a copy of the movies collection:

var superset = new Backbone.Collection(movies.models);

This superset can now be used as starting point for filtering models. Imagine how filter controls for genres might get called in app/views/layouts.js:

genresView = new GenresView({collection: movies, superset: superset});

Next, you can extend the UI for sorting to a general UI for controlling filter and sort:

var Backbone = require('backbone');

var _ = require('underscore');

var $ = Backbone.$;

var ControlsView = Backbone.View.extend({

events: {

'click #by_title': 'sortByTitle',

'click #by_rating': 'sortByRating',

'click #by_showtime': 'sortByShowtime',

'change select[name="genre"]': 'selectGenre'

},

selectGenre: function(ev) {

var genre = $("select[name='genre']").val();

var that = this;

if (genre === "all") {

that.collection.reset(that.superset.toJSON());

}

else {

that.collection.reset(that.superset.toJSON());

this.filterByCategory(genre);

}

},

filterByCategory: function(genre) {

var filtered = this.movies.filter(function(m) {

return (_.indexOf(m.get('genres'), genre) !== -1)

});

this.collection.reset(filtered);

},

sortByTitle: function(ev) {

this.movies.reset(this.movies.sortByTitle());

},

sortByRating: function(ev) {

this.movies.reset(this.movies.sortByRating());

},

sortByShowtime: function(ev) {

this.movies.reset(this.movies.sortByShowtime());

},

initialize: function(options) {

this.movies = this.collection;

this.superset = options.superset;

}

});

module.exports = ControlsView;

Besides the actions for sorting that were discussed in the beginning, a number of new things are included:

§ You pass the superset via the options helper and save this for usage later.

§ The genres of a movie are stored in a nested array, where you need to filter only on one value. Because this array can potentially contain many values, you use an Underscore.js helper to check the values in the array for a matching genre.

§ Before the movies collection is filtered, you reset the collection with the filtered set.

Let’s quickly check that we can filter the movie program for any movie. If everything works, all movies without Action will be removed from the collection, as we see in Figure 5-2. When we then re-select All, we see the original collection. You can also see the example in action on thebook’s website.

From the use of Underscore functions, you see already that building a filter is a bit more advanced than the sorting UI. Apart from mutating the state of the collection, genres can come as a function of the available movies and good filters need to take that into account. Also, we might work with another API endpoint that synchronizes with the Genres collection. These advanced approaches will be a topic for the later chapters.

Backbone.Obscura

Sorting and filtering collections are very common, so it is wise to avoid reinventing the wheel. And, by using a plug-in from the Backbone ecosystem, we get an additional strategy to mutate collections for free: pagination.

We can now filter and sort movies in the UI; for sorting, we keep all models in a collection, but models are removed for filtering, and we need to save a copy of the original collection

Figure 5-2. We can now filter and sort movies in the UI; for sorting, we keep all models in a collection, but models are removed for filtering, and we need to save a copy of the original collection

Backbone.Obscura is a plug-in by Jeremy Morell that includes support for sorting, filtering, and paginating. Let’s look at how this plug-in can replace a lot of our boilerplate code from before.

To get started with a plug-in, we need to include the plug-in in our Backbone stack. You can add the following dependency with npm:

$ npm install backbone.obscura --save

The plug-in will replace part of the manual work you did previously and provide helpers to paginate a collection out of the box.

Let’s start by requiring Backbone.Obscura in app/views/layout.js:

Backbone.Obscura = require('backbone.obscura');

Once the plug-in is available, it can proxy our movies collection. Backbone.Obscura keeps track of a superset by itself. From the proxy, you can access the original collection by calling superset(). Also, Backbone.Obscura delegates events to the proxy as needed.

To initialize the proxy, you wrap the original movies collection as follows in the constructor of app/views/layout.js:

this.proxy = new Backbone.Obscura(options.router.movies);

From here on, you use the proxy in the application for rendering data:

this.addView('#overview', new MoviesList({

collection: this.proxy,

router: options.router

}));

this.controls = new Controls({ proxy: this.proxy });

In app/views/controls.js, you can apply the sort functions provided by using setSort():

sortByTitle: function(ev) {

app.movies.setSort("title", "asc");

},

sortByRating: function(ev) {

app.movies.setSort("rating", "desc");

},

sortByShowtime: function(ev) {

app.movies.setSort("showtime", "asc");

}

And for the filtering, you can use the filterBy() method, which can take an attribute or callback function:

that.proxy.filterBy(genre, function(movie) {

var genreFound = _.indexOf(movie.get('genres'), genre.value);

return (genreFound !== -1);

});

With the callback function, advanced filters such as filtering on multiple genres becomes quite easy, too:

var genres = _.map($('input[type=checkbox]:checked'), function(genre) {

that.proxy.filterBy(genre.value, function(m) {

return (_.findWhere(m.get('genres'), genre.value))

})

Last, there is a nice way to remove all filters with:

this.proxy.resetFilters();

For pagination, you have several helpers, too. To set the number of items on a page, you can use:

this.proxy.setPerPage(4);

And, to browser the collection, you can do:

paginateNext: function() {

this.proxy.nextPage();

},

paginatePrev: function() {

console.log("**");

this.proxy.prevPage();

}

You can play with the demo on the book’s website. Figure 5-3 shows how the current interface looks.

With Backbone.Obscura, you can proxy collections such that sorting, filtering, and paginating functions become a piece of cake

Figure 5-3. With Backbone.Obscura, you can proxy collections such that sorting, filtering, and paginating functions become a piece of cake

Conclusion

In this chapter, you played with important functions to mutate the state of a collection. Both nondestructive (e.g., sorting) and destructive (e.g., filtering) methods were discussed. Then, you met Backbone.Obscura, a plug-in from the Backbone ecosystem that boosts functions of collections for sorting, filtering, and paginating.

So far, our user interface is still a bit basic. In particular, we haven’t discussed good ways to use advanced templates for view rendering. Also, as we create more views and collections, automating our workflow for application development becomes important. So, in the next chapter, you will learn important ideas on templating. After having discussed templates, the book will move toward discussing view templates and backend requirements.