diff --git a/backbonestore.nw b/backbonestore.nw new file mode 100644 index 0000000..29a8252 --- /dev/null +++ b/backbonestore.nw @@ -0,0 +1,467 @@ +\documentclass{article} +\usepackage{noweb} +\usepackage{hyperref} +\begin{document} + +I've been learning how to use \nwanchorto{http://documentcloud.github.com/backbone/}{Backbone.js}, a nifty little library for +organizing your client-side Javascript into a classic +Model-View-Controller paradigm while trying (and to some extent, +succeeding) in trying to burden you, the user, with as little +additional learning as possible. I consider this a good thing; the +overhead of learning a library and its accompaning DSL represent +additional cognitive loads that developers can better use elsewhere. +Keeping as much as possible within familiar paradigms is not just +useful, it's necessary as our programs get bigger. + +The tutorial for Backbone is woefully lacking in specifics, and the +example program, Todo, doesn't really have much chops in teaching you +the ins and outs of Backbone, especially not its new Controller and +History modules. But in the announcement for Backbone.Controller, +Jeremy Ashkenas hid a clue: There's another library, Sammy.js, that +does something similar, and they do have a tutorial called \nwanchorto{http://code.quirkey.com/sammy/tutorials/json_store_part1.html}{The JsonStore}. + +In the spirit of The JSON Store, I present The Backbone Store, and +online store written entirely in JSON and operating entirely within a +single page. + +Let's start by showing you the HTML that we're going to be exploiting: + +<>= + + + + + + The Backbone Store + + + <> + + <> + + + +
+ + +
+
+
+ + + + + + + +@ + +This is taken, more or less, straight from The JSON Store. I've +included one extra thing, aside from jQuery and Backbone, and that's +the \nwanchorto{https://github.com/jquery/jquery-tmpl}{jQuery Templates kit}. We'll discuss those in a minute. There's a +simplified JSON file that comes in the download; it contains six +record albums that the store sells. (Unlike the JSON store, these +albums don't exist; the covers were generated during a round of \nwanchorto{http://elfs.livejournal.com/756709.html}{The Album Cover +Game}.) + +There are two views, the index and the item. So, using +[[Backbone.Controller]], we're going to route the following: + +<>= + routes: { + "": "index", + "item/:id": "item", + }, +@ + +Unlike Sammy, Backbone mostly only routes GET commands. Routes are to +routes to views; everything else happens more or less under the +covers. + +As Backbone is running, the [[Backbone.History]] module is listening to +the hash object, waiting for it to change so that it can trigger a +``route'' event, in which case the function named as the value in the +route is called. + +There are a few things I want to track: the index view, the individual +product views, and the shopping cart. + +<>= + _index: null, + _products: null, + _cart :null, +@ + +Using backbone, I have a list of products. So, I should declare +those. The basic product is just a model, with nothing to show for +it; the list of products is a [[Backbone.Collection]], with one feature, +the [[comparator]], which sorts the albums in order by album title. + +<>= +var Product = Backbone.Model.extend({}); + +var ProductCollection = Backbone.Collection.extend({ + model: Product, + comparator: function(item) { + return item.get('title'); + } +}); +@ + +That's not very exciting. Let's show this list, with a View. Let's +call it IndexView: + +<>= +var IndexView = Backbone.View.extend({ + el: $('#main'), + indexTemplate: $("#indexTmpl").template(), + + render: function() { + var sg = this; + this.el.fadeOut('fast', function() { + sg.el.empty(); + $.tmpl(sg.indexTemplate, sg.model.toArray()).appendTo(sg.el); + sg.el.fadeIn('fast'); + }); + return this; + } + +}); +@ + +This code defines a [[Backbone.View]] object, in which the parent element +is #main, the render function fades out the existing elements in that +position and replaces them with the contents of a rendered jQuery +template, and then fades the element back in. + +The index template looks like this: + +<>= + +@ + +There's some + \nwanchorto{http://en.wikipedia.org/wiki/Law_of_Demeter}{Demeter violations} +going on here, in that I have to know about the [[attributes]] of a +Backbone model, something that's normally hidden within the class. +But this is good enough for our purposes. The above is a jQuery +template, and the [[\$\{\}]] syntax is what's used to dereference +variables within a template. + +(As an aside, I think that the [[set]] and [[get]] methods of +[[Backbone.Model]] are a poor access mechanism. I understand why they're +there, and I can only hope that someday + \nwanchorto{http://ejohn.org/blog/javascript-getters-and-setters/}{Javascript + Getter and Setters} become so well-established as to make [[set]] +and [[get]] irrelevant.) + +Now, we can render the index view: + +<>= + index: function() { + this._index.render(); + }, +@ + +At this point, well, we need an application. A controller. And we +need to initialize it, and call it. Here's what it looks like (some +of this, you've already seen): + +<>= +var Workspace = Backbone.Controller.extend({ +<> + +<> + +<> + +<> + +<> +}); + +workspace = new Workspace(); +Backbone.history.start(); +@ + +There are two things left in our workspace, that we haven't defined. +The intialization, and the product render. + +Initialization consists of getting our product list, creating a +shopping cart to hold ``desired'' products (and in quantity!), and +creating the index view. (Product views, we'll discuss in a moment). + +<>= + initialize: function() { + var ws = this; + if (this._index === null) { + $.ajax({ + url: 'data/items.json', + dataType: 'json', + data: {}, + success: function(data) { + ws._cart = new Cart(); + new CartView({model: ws._cart}); + ws._products = new ProductCollection(data); + ws._index = new IndexView({model: ws._products}); + Backbone.history.loadUrl(); + } + }); + return this; + } + return this; + }, +@ + +We haven't defined the Cart yet, but that's all right. We'll get to +it.) But here you see a lot of what's already existent being used: we +get a ProductCollection, and an IndexView. + +That last line is curious. It's an instruction to Backbone to look at +the URL; if the user navigated to something other than the home page, +it's to use the routes defined to go there. Users can now bookmark +places in your site other than the home page. Yes, the bookmark will +be funny and have at least one + \nwanchorto{http://en.wiktionary.org/wiki/octothorpe}{octothorpe} in it, but +it will work. + +Let's deal with the shopping cart: + +<>= +var CartItem = Backbone.Model.extend({ + update: function(amount) { + this.set({'quantity': this.get('quantity') + amount}); + } +}); + + +var Cart = Backbone.Collection.extend({ + model: CartItem, + getByPid: function(pid) { + return this.detect(function(obj) { return (obj.get('product').cid == pid); }); + }, +}); +@ + +A little rocket science here: A [[Cart]] contains [[CartItems]]. Each +``item'' represents a quantity of a [[Product]]. (I know, that always +struck me as odd, but that's how most online stores do it.) +[[CartItem]] has an update method that allows you to add more (but not +remove any-- hey, the Sammy store wasn't any smarter, and this is For +Demonstration Purposes Only), and we use the [[set]] method to make +sure that a ``change'' event is triggered. + +The [[Cart]], in turn, has a method, getByPid (``Product ID''), which +is meant to assist other objects in finding the [[CartItem]] +associated with a specific product. Here, I'm just using the Backbone +default client id. + +The cart is represented by a little tag in the upper right-hand corner +of the view; it never goes away, and its count is always the total +number of [[Products]] (not [[CartItem]]s) ordered. So the +[[CartView]] needs to update whenever a [[CartItem]] is added or +updated. And we want a nifty little animation to go with it: + +<>= +var CartView = Backbone.View.extend({ + el: $('.cart-info'), + + initialize: function() { + this.model.bind('change', _.bind(this.render, this)); + }, + + render: function() { + var sum = this.model.reduce(function(m, n) { return m + n.get('quantity'); }, 0); + this.el + .find('.cart-items').text(sum).end() + .animate({paddingTop: '30px'}) + .animate({paddingTop: '10px'}); + } +}); +@ + +A couple of things here: the render is rebound to [[this]] to make +sure it renders in the context of the view. I found that that was not +always happening. Note the use of [[reduce]], a nifty method from +[[underscore.js]] that allows you to build a result out an array using +an anonymous function. This reduce, obviously, sums up the total +quantity of items in the cart. Also, jQuery enthusiasts could learn +(I certainly did!) from the [[.find()]] and [[.end()]] methods, which +push a child object onto the stack to be animated, and then pop it off +after the operation has been applied. + +The biggest thing left is the [[ProductView]]. It's skeleton looks +like this: + +<>= +var ProductView = Backbone.View.extend({ + el: $('#main'), + itemTemplate: $("#itemTmpl").template(), + +<> + + initialize: function(options) { + this.cart = options.cart; + }, + +<> + +<> +}); + +@ + +First, we find the element we're going to work with, and the template. +I expect the ProductView to be where we'll add items to the cart, so +the initializer here expects to have a handle on the cart. + +And the template: + +<>= + +@ + +One extra item: note the octothorpe used as the target link for +``Home''. I kept thinking an empty link or just ``/'' would be +appropriate, but no, it's an octothorpe. + +Rendering the product is not difficult: + +<>= + render: function() { + var sg = this; + this.el.fadeOut('fast', function() { + sg.el.empty(); + $.tmpl(sg.itemTemplate, sg.model).appendTo(sg.el); + sg.el.fadeIn('fast'); + }); + return this; + } +@ + +That looks familiar. + +Updating the product, however, is a whole 'nother story. Note that +each product has a form associated with it. We need to intercept any +form update events and manipulate our shopping cart. We have two +objects that can do that: the input field, and the submit button. I +need to intercept those events: + +<>= + events: { + "keypress .uqf" : "updateOnEnter", + "click .uq" : "update", + }, +@ + +Backbone uses a curious definition of an event with an ``event +selector'', followed by a target method of the View class. Backbone +is also limited about what events can be used here, as the following +events cannot be wrapped by jQuery's delegate method and do not work: +``focus'', ``blur'', ``change'', ``submit'', and ``reset''. + +The update then becomes straightforward. We're in a view for a +specific product; we must see if the customer has a [[CartItem]] for +that product in the [[Cart]], and add or update it as needed. Like +so: + +<>= + update: function(e) { + e.preventDefault(); + var cart_item = this.cart.getByPid(this.model.cid); + if (_.isUndefined(cart_item)) { + cart_item = new CartItem({product: this.model, quantity: 0}); + this.cart.add(cart_item, {silent: true}); + } + cart_item.update(parseInt($('.uqf').val())); + }, + + updateOnEnter: function(e) { + if (e.keyCode == 13) { + return this.update(e); + } + }, +@ + +We [[preventDefault]] to keep the traditional meaning of the submit +button from triggering. When the [[CartItem]] is updated, it triggers +a ``change'' event, and the [[CartView]] will update itself +automatically. I added the ``silent'' option to keep the ``change'' +event from triggering twice when adding a new [[CartItem]] to the +[[Cart]]. + +And now I'm down to one last thing. I haven't defined that product +render call in the application controller. The one thing I don't want +to do is have [[ProductViews]] for every product, if I don't need +them. So I want to build them as-needed, but keep them, and associate +them with the local [[Product]], so they can be recalled whenever we +want. The underscore function [[isUndefined]] is excellent for this. + +<>= + item: function(id) { + if (_.isUndefined(this._products.getByCid(id)._view)) { + this._products.getByCid(id)._view = new ProductView({model: this._products.getByCid(id), + cart: this._cart}); + } + this._products.getByCid(id)._view.render(); + } +@ + +And now my store looks like + +<>= +<> + +<> + +<> + +<> + +<> + +<> +@ + +As always, this code is available at github. + +\end{document} diff --git a/index.html b/index.html index dc3f0a2..2cc2c0f 100644 --- a/index.html +++ b/index.html @@ -2,53 +2,51 @@ "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd"> - - - - - - - The Backbone Store - + The Backbone Store + + + + +