Welcome back to part two of this tutorial; in part one we looked at some of the model, collection and view basics for when working with Backbone and saw how to render individual contact views using a master view bound to a collection.
In this part of the tutorial, we're going to look at how we can filter our view based on user input, and how we can add a router to give our basic application some URL functionality.
We'll need the source files from part one as we'll be building on the existing code for this part. I'd strongly recommend reading part one if you haven't already.
Reacting to User Input
You may have noticed in part one that each of our individual models has an attributed called type which categorises each model based on whether it relates to a friend, family member of colleague. Let's add a select element to our master view that will let the user filter the contacts based on these types.
Now, we can hardcode a select menu into our underlying HTML and manually add options for each of the different types. But, this wouldn’t be very forward thinking; what if we add a new type later on, or delete all of the contacts of a certain type? Our application doesn’t yet have the capability to add or remove contacts (part three spoiler alert!), but it's still best to take these kinds of things into consideration, even at this early stage of our application.
As such, we can easily build a select element dynamically based on the existing types. We will add a tiny bit of HTML to the underlying page first; add the following new elements to the contacts container:
<header> <div id="filter"><label>Show me:</label></div> </header>
That's it, we've an outer <header>
element to act as a general container, within which is another container with an id
attribute, and a <label>
with some explanatory text.
Now let's build the <select>
element. First we'll add two new methods to our DirectoryView
mater view; the first one will extract each unique type and the second will actually build the drop-down. Both methods should be added to the end of the view:
getTypes: function () { return _.uniq(this.collection.pluck("type"), false, function (type) { return type.toLowerCase(); }); }, createSelect: function () { var filter = this.el.find("#filter"), select = $("<select/>", { html: "<option>All</option>" }); _.each(this.getTypes(), function (item) { var option = $("<option/>", { value: item.toLowerCase(), text: item.toLowerCase() }).appendTo(select); }); return select; }
The first of our methods, getTypes()
returns an array created using Underscore's uniq()
method. This method accepts an array as an argument and returns a new array containing only unique items. The array we pass into the uniq()
method is generated using Backbone's pluck()
method, which is a simple way to pull all values of a single attribute out of a collection of models. The attribute we are interested in here is the type
attribute.
In order to prevent case issues later on, we should also normalise the types to lowercase. We can use an iterator function, supplied as the third argument to uniq()
, to transform each value before it is put through the comparator. The function receives the current item as an argument so we just return the item in lowercase format. The second argument passed to uniq()
, which we set to false
here, is a flag used to indicate whether the array that is being compared has been sorted.
The second method, createSelect()
is slightly larger, but not much more complex. Its only purpose is to create and return a new <select>
element, so we can call this method from somewhere else in our code and receive a shiny new drop-down box with an option for each of our types. We start by giving the new <select
element a default <option>
with the text All
.
We then use Underscore's each()
method to iterate over each value in the array returned by our getTypes()
method. For each item in the array we create a new <option>
element, set its text to the value of the current item (in lowercase) and then append it to the <select>
.
To actually render the <select>
element to the page, we can add some code to our master view's initialize()
method:
this.$el.find("#filter").append(this.createSelect());
The container for our master view is cached in the $el
property that Backbone automatically adds to our view class, so we use this to find the filter container and append the <select
element to it.
If we run the page now, we should see our new <select>
element, with an option for each of the different types of contact:
Filtering the View
So now we have our <select
menu, we can add the functionality to filter the view when an option is selected. To do this, we can make use of the master view's events
attribute to add a UI event handler. Add the following code directly after our renderSelect()
method:
events: { "change #filter select": "setFilter" },
The events
attribute accepts an object of key:value
pairs where each key specifies the type of event and a selector to bind the event handler to. In this case we are interested in the change
event that will be fired by the <select
element within the #filter
container. Each value in the object is the event handler which should be bound; in this case we specify setFilter
as the handler.
Next we can add the new handler:
setFilter: function (e) { this.filterType = e.currentTarget.value; this.trigger("change:filterType"); },
All we need to do in the setFilter()
function is set a property on the master view called filterType
, which we set to the value of the option that was selected, which is available via the currentTarget
property of the event object that is automatically passed to our handler.
Once the property has been added or updated we can also trigger a custom change
event for it using the property name as a namespace. We'll look at how we can use this custom event in just a moment, but before we do, we can add the function that will actually perform the filter; after the setFilter()
method add the following code:
filterByType: function () { if (this.filterType === "all") { this.collection.reset(contacts); } else { this.collection.reset(contacts, { silent: true }); var filterType = this.filterType, filtered = _.filter(this.collection.models, function (item) { return item.get("type").toLowerCase() === filterType; }); this.collection.reset(filtered); } }
We first check whether the master view's filterType
property is set to all
; if it is, we simply repopulate the collection with the complete set of models, the data for which is stored locally on our contacts
array.
If the property does not equal all
, we still reset the collection to get all the contacts back in the collection, which is required in order to switch between the different types of contact, but this time we set the silent
option to true
(you'll see why this is necessary in a moment) so that the reset
event is not fired.
We then store a local version of the view's filterType
property so that we can reference it within a callback function. We use Underscore's filter()
method to filter the collection of models. The filter()
method accepts the array to filter and a callback function to execute for each item in the array being filtered. The callback function is passed the current item as an argument.
The callback function will return true
for each item that has a type
attribute equal to the value that we just stored in the variable. The types are converted to lowercase again, for the same reason as before. Any items that the callback function returns false
for are removed from the array.
Once the array has been filtered, we call the reset()
method once more, passing in the filtered array. Now we're ready to add the code that will wire up the setType()
method, the filterType
property and filterByType()
method.
Binding Events to the Collection
As well as binding UI events to our interface using the events
attribute, we can also bind event handlers to collections. In our setFilter()
method we fired a custom event, we now need to add the code that will bind the filterByType()
method to this event; add the following code to the initialize()
method of our master view:
this.on("change:filterType", this.filterByType, this);
We use Backbone's on()
method in order to listen for our custom event. We specify the filterByType()
method as the handler function for this event using the second argument of on()
, and can also set the context for the callback function by setting this
as the third argument. The this
object here refers to our master view.
In our filterByType
function, we reset the collection in order to repopulate it with either all of the models, or the filtered models. We can also bind to the reset
event in order to repopulate the collection with model instances. We can specify a handler function for this event as well, and the great thing is, we've already got the function. Add the following line of code directly after the change
event binding:
this.collection.on("reset", this.render, this);
In this case we're listening for the reset
event and the function we wish to invoke is the collection's render()
method. We also specify that the callback should use this
(as in the instance of the master view) as its context when it is executed. If we don't supply this
as the third argument, we will not be able to access the collection inside the render()
method when it handles the reset
event.
At this point, we should now find that we can use the select box to display subsets of our contacts. The reason why we set the silent
option to true in our filterByType()
method is so that the view is not re-rendered unnecessarily when we reset the collection at the start of the second branch of the conditional. We need to do this so that we can filter by one type, and then filter by another type without losing any models.
Routing
So, what we've got so far is alright, we can filter our models using the select box. But wouldn’t it be awesome if we could filter the collection using a URL as well? Backbone's router module gives us this ability, let's see how, and because of the nicely decoupled way that we've structured our filtering so far, it's actually really easy to add this functionality. First we need to extend the Router module; add the following code after the master view:
var ContactsRouter = Backbone.Router.extend({ routes: { "filter/:type": "urlFilter" }, urlFilter: function (type) { directory.filterType = type; directory.trigger("change:filterType"); } });
The first property we define in the object passed to the Router's extend()
method is routes
, which should be an object literal where each key is a URL to match and each value is a callback function when the URL is matched. In this case we are looking for URLs that start with #filter
and end with anything else. The part of the URL after the filter/
part is passed to the function we specify as the callback function.
Within this function we set or update the filterType
property of the master view and then trigger our custom change
event once again. This is all we need to do in order to add filtering functionality using the URL. We still need to create an instance of our router however, which we can do by adding the following line of code directly after the DirectoryView
instantiation:
var contactsRouter = new ContactsRouter();
We should now be able to enter a URL such as #filter/family
and the view will re-render itself to show just the contacts with the type family:
So that's pretty cool right? But there's still one part missing – how will users know to use our nice URLs? We need to update the function that handles UI events on the <select
element so that the URL is updated when the select box is used.
To do this requires two steps; first of all we should enable Backbone's history support by starting the history service after our app is initialised; add the following line of code right at the end of our script file (directly after we initialise our router):
Backbone.history.start();
From this point onwards, Backbone will monitor the URL for hash changes. Now, when we want to update the URL after something happens, we just call the navigate()
method of our router. Change the filterByType()
method so that it appears like this:
filterByType: function () { if (this.filterType === "all") { this.collection.reset(contacts); <b>contactsRouter.navigate("filter/all");</b> } else { this.collection.reset(contacts, { silent: true }); var filterType = this.filterType, filtered = _.filter(this.collection.models, function (item) { return item.get("type") === filterType; }); this.collection.reset(filtered); <b>contactsRouter.navigate("filter/" + filterType);</b> } }
Now when the select box is used to filter the collection, the URL will be updated and the user can then bookmark or share the URL, and the back and forward buttons of the browser will navigate between states. Since version 0.5 Backbone has also supported the pushState API, however, in order for this to work correctly the server must be able to render the pages that are requested, which we have not configured for this example, hence using the standard history module.
Summary
In this part of the tutorial, we looked at a couple more Backbone modules, specifically the Router, History and Events modules. We've now looked at all of the different modules that come with Backbone.
We also looked at some more Underscore methods, including filter()
, which we used to filter down our collection to only those models containing a specific type.
Lastly, we looked at Backbone's Router module, which allowed us to set routes that can be matched by our application in order to trigger methods, and the History module which we can use to remember state and keep the URL updated with hash fragments.
One point to take away is the loosely coupled nature of our filtering functionality; when we added filtering via the select menu, it was done in such a way that it was very quick and easy to come along afterwards and add a completely new method of filtering without having to change our filter()
method. This is one of the keys to successfully building non-trivial, maintainable and scalable JavaScript applications. If we wanted, it would be very easy to add another, completely new method of filtering, which having to change our filtering method.
In the next part of this series, we'll go back to working with models and see how we can remove models from, and add new ones to the collection.
Comments