1 | # batman.js
|
2 |
|
3 | [batman.js](http://batmanjs.org/) is a framework for building rich single-page browser applications. It is written in [CoffeeScript](http://jashkenas.github.com/coffee-script/) and its API is developed with CoffeeScript in mind, but of course you can use plain old JavaScript too.
|
4 |
|
5 | #### It's got:
|
6 |
|
7 | * a stateful MVC architecture
|
8 | * a powerful binding system
|
9 | * routable controller actions
|
10 | * pure HTML views
|
11 | * toolchain support built on [node.js](http://nodejs.org) and [cake](http://jashkenas.github.com/coffee-script/#cake)
|
12 |
|
13 | The APIs are heavily inspired by [Rails](http://rubyonrails.org/) and designed to make Rails devs feel right at home.
|
14 |
|
15 | We're targeting Chrome, Safari 4+, Firefox 3+, and IE 7+ for compatibility, although some of those require you to include [es5shim](https://github.com/kriskowal/es5-shim).
|
16 |
|
17 | #### Here's some code:
|
18 |
|
19 | ```coffeescript
|
20 | class Shopify extends Batman.App
|
21 | @root 'products#index'
|
22 | @resources 'products'
|
23 |
|
24 | class Shopify.Product extends Batman.Model
|
25 | @persist Batman.RestStorage
|
26 |
|
27 | class Shopify.ProductsController extends Batman.Model
|
28 | index: ->
|
29 | @redirect action: 'show', id: 1
|
30 |
|
31 | show: (params) ->
|
32 | @product = Shopify.Product.find params.id
|
33 | ```
|
34 |
|
35 | #### views/products/index.html
|
36 |
|
37 | ```html
|
38 | <ul id="products">
|
39 | <li data-foreach-product="Product.all" data-mixin="animation">
|
40 | <a data-route="product" data-bind="product.name">name will go here</a>
|
41 | </li>
|
42 |
|
43 | <li><span data-bind="products.length"></span> <span data-bind="'products' | pluralize products.length"></span></li>
|
44 | </ul>
|
45 | ```
|
46 |
|
47 | ## Installation
|
48 |
|
49 | If you haven't already, you'll need to install [node.js](http://nodejs.org) and [npm](http://npmjs.org/). Then:
|
50 |
|
51 | npm install -g batman
|
52 |
|
53 | Generate a new batman.js app somewhere, called my_app:
|
54 |
|
55 | cd ~/code
|
56 | batman new my_app
|
57 |
|
58 | Fire it up:
|
59 |
|
60 | cd my_app
|
61 | batman server # (or just "batman s")
|
62 |
|
63 | Now visit [http://localhost:1047](http://localhost:1047) and start playing around!
|
64 |
|
65 | ## The Basics
|
66 |
|
67 | Most of the classes you work with in your app code will descend from `Batman.Object`, which gives you some nice things that are used extensively by the rest of the system.
|
68 |
|
69 | ### Events
|
70 |
|
71 | If you want to define observable events on your objects, just wrap a function with the `@event` macro in a class definition:
|
72 |
|
73 | ```coffeescript
|
74 | class BatBelt.Gadget extends Batman.Object
|
75 | constructor: -> @usesLeft = 5
|
76 | use: @event (times) ->
|
77 | return false unless (@usesLeft - times) >= 0
|
78 | @usesLeft -= times
|
79 | ```
|
80 |
|
81 | You can observe the event with some callback, and fire it by just calling the event function directly. The observer callback gets whichever arguments were passed into the event function. But if the even function returns `false`, then the observers won't fire:
|
82 |
|
83 | ```coffeescript
|
84 | gadget.observe 'use', (times) ->
|
85 | console.log "gadget was used #{times} times!"
|
86 | gadget.use(2)
|
87 | # console output: "gadget was used 2 times!"
|
88 | gadget.use(6)
|
89 | # nothing happened!
|
90 | ```
|
91 |
|
92 | ### Observable Properties
|
93 |
|
94 | The `observe` function is also used to observe changes to properties. This forms the basis of the binding system. Here's a simple example:
|
95 |
|
96 | ```coffeescript
|
97 | gadget.observe 'name', (newVal, oldVal) ->
|
98 | console.log "name changed from #{oldVal} to #{newVal}!"
|
99 | gadget.set 'name', 'Batarang'
|
100 | # console output: "name changed from undefined to Batarang!"
|
101 | ```
|
102 |
|
103 | You can also `get` properties to return their values, and if you want to remove them completely then you can `unset` them:
|
104 |
|
105 | ```coffeescript
|
106 | gadget.get 'name'
|
107 | # returns: 'Batarang'
|
108 | gadget.unset 'name'
|
109 | # console output: "name changed from Batarang to undefined!"
|
110 | ```
|
111 |
|
112 | By default, these properties are stored like plain old JavaScript properties: that is, `gadget.name` would return "Batarang" just like you'd expect. But if you set the gadget's name with `gadget.name = 'Shark Spray'`, then the observer function you set on `gadget` will not fire. So when you're working with batman.js properties, use `get`/`set`/`unset` to read/write/delete properties.
|
113 |
|
114 |
|
115 | ### Custom Accessors
|
116 |
|
117 | What's the point of using `gadget.get 'name'` instead of just `gadget.name`? Well, a Batman properties doesn't need to correspond with a vanilla JS property. Let's write a `Box` class with a custom getter for its volume:
|
118 |
|
119 | ```coffeescript
|
120 | class Box extends Batman.Object
|
121 | constructor: (@length, @width, @height) ->
|
122 | @accessor 'volume',
|
123 | get: (key) -> @get('length') * @get('width') * @get('height')
|
124 |
|
125 | box = new Box(16,16,12)
|
126 | box.get 'volume'
|
127 | # returns 3072
|
128 | ```
|
129 |
|
130 | The really cool thing about this is that, because we used `@get` to access the component properties of `volume`, batman.js can keep track of those dependencies and let us observe the `volume` directly:
|
131 |
|
132 | ```coffeescript
|
133 | box.observe 'volume', (newVal, oldVal) ->
|
134 | console.log "volume changed from #{oldVal} to #{newVal}!"
|
135 | box.set 'height', 6
|
136 | # console output: "volume changed from 3072 to 1536!"
|
137 | ```
|
138 |
|
139 | The box's `volume` is a read-only attribute here, because we only provided a getter in the accessor we defined. Here's a `Person` class with a (rather naive) read-write accessor for their name:
|
140 |
|
141 | ```coffeescript
|
142 | class Person extends Batman.Object
|
143 | constructor: (name) -> @set 'name', name
|
144 | @accessor 'name',
|
145 | get: (key) -> [@get('firstName'), @get('lastName')].join(' ')
|
146 | set: (key, val) ->
|
147 | [first, last] = val.split(' ')
|
148 | @set 'firstName', first
|
149 | @set 'lastName', last
|
150 | unset: (key) ->
|
151 | @unset 'firstName'
|
152 | @unset 'lastName'
|
153 | ```
|
154 |
|
155 | ### Keypaths
|
156 |
|
157 | If you want to get at properties of properties, use keypaths:
|
158 |
|
159 | ```coffeescript
|
160 | employee.get 'team.manager.name'
|
161 | ```
|
162 |
|
163 | This does what you expect and is pretty much the same as `employee.get('team').get('manager').get('name')`. If you want to observe a deep keypath for changes, go ahead:
|
164 |
|
165 | ```coffeescript
|
166 | employee.observe 'team.manager.name', (newVal, oldVal) ->
|
167 | console.log "you now answer to #{newVal || 'nobody'}!"
|
168 | manager = employee.get 'team.manager'
|
169 | manager.set 'name', 'Bill'
|
170 | # console output: "you now answer to Bill!"
|
171 | ```
|
172 |
|
173 | If any component of the keypath is set to something that would change the overall value, then observers will fire:
|
174 |
|
175 | ```coffeescript
|
176 | employee.set 'team', larrysTeam
|
177 | # console output: "you now answer to Larry!"
|
178 | employee.team.unset 'manager'
|
179 | # console output: "you now answer to nobody!"
|
180 | employee.set 'team', jessicasTeam
|
181 | # console output: "you now answer to Jessica!"
|
182 | ```
|
183 |
|
184 | batman.js's dependency tracking system makes sure that no matter how weird your object graph gets, your observers will fire exactly when they should.
|
185 |
|
186 | ## Architecture
|
187 |
|
188 | The MVC architecture of batman.js fits together like this:
|
189 |
|
190 | * Controllers are persistent objects which render the views and give them mediated access to the model layer.
|
191 | * Views are written in pure HTML, and use `data-*` attributes to create bindings with model data and event handlers exposed by the controllers.
|
192 | * Models have validations, lifecycle events, a built-in identity map, and can use arbitrary storage backends (`Batman.LocalStorage` and `Batman.RestStorage` are included).
|
193 |
|
194 | A batman.js application is served up in one page load, followed by asynchronous requests for various resources as the user interacts with the app. Navigation within the app is handled via [hash-bang fragment identifers](http://www.w3.org/QA/2011/05/hash_uris.html), with [pushState](https://developer.mozilla.org/en/DOM/Manipulating_the_browser_history#Adding_and_modifying_history_entries) support forthcoming.
|
195 |
|
196 |
|
197 | ### The App Class
|
198 |
|
199 | Sitting in front of everything else is a subclass of `Batman.App` which represents your application as a whole and acts as a namespace for your other app classes. The app class never gets instantiated; your main interactions with it are using macros in its class definition, and calling `run()` on it when it's time to fire up your app.
|
200 |
|
201 | Here's a simple app class:
|
202 |
|
203 | ```coffeescript
|
204 | class BatBelt extends Batman.App
|
205 | @global yes
|
206 |
|
207 | @controller 'app', 'gadgets'
|
208 | @model 'gadget'
|
209 |
|
210 | @root 'app#index'
|
211 | @route 'faq/:questionID', 'app#faq'
|
212 | @resources 'gadgets'
|
213 | ```
|
214 |
|
215 | The `@global yes` declaration just makes the class global on the browser's `window` object.
|
216 |
|
217 | The calls to `@controller` and `@model` load external app classes with XHRs. For the controllers, this ends up fetching `/controllers/app_controller.coffee` and `/controllers/gadgets_controller.coffee`. The gadget model gets loaded from `/models/gadget.coffee`.
|
218 |
|
219 | #### Routes
|
220 |
|
221 | Routes are defined in a few different ways.
|
222 |
|
223 | `@route` takes two strings, one representing a path pattern and the other representing a controller action. In the above example, `'faq/:questionID'` matches any path starting with "/faq/" and having one other segment. That segment is then passed as a named param to the controller action function specified by the second string argument.
|
224 |
|
225 | For the FAQ route, `'app#faq'` specifies the `faq` function on `BatBelt.AppController`, which should take a `params` argument and do something sensible with `params.questionID`.
|
226 |
|
227 | `@root 'app#index'` is just a shorthand for `@route '/', 'app#index'`.
|
228 |
|
229 | The `@resources` macro takes a resource name which should ideally be the underscored-pluralized name of one of your models. It sets up three routes, as if you'd used the `@route` macro like so:
|
230 |
|
231 | ```coffeescript
|
232 | @route 'gadgets', 'gadgets#index'
|
233 | @route 'gadgets/:id', 'gadgets#show'
|
234 | @route 'gadgets/:id/edit', 'gadgets#edit'
|
235 | ```
|
236 |
|
237 | In addition to setting up these routes, the call to `@resources` keeps track of the fact that the `Gadget` model can be accessed in these ways. This lets you load these routes in your controllers or views by using model instances and classes on their own:
|
238 |
|
239 | ```coffeescript
|
240 | class BatBelt.GadgetsController extends Batman.Controller
|
241 | someEventHandler: (node, event) ->
|
242 | @redirect BatBelt.Gadget.find(1) # redirects to "/gadgets/1"
|
243 | someOtherHandler: (node, event) ->
|
244 | @redirect BatBelt.Gadget # redirects to "/gadgets"
|
245 | ```
|
246 |
|
247 | ### Controllers
|
248 |
|
249 | batman.js controllers are singleton classes with one or more instance methods that can serve as routable actions. Because they're singletons, instance variables persist as long as the app is running.
|
250 |
|
251 | ```coffeescript
|
252 | class BatBelt.AppController extends Batman.Controller
|
253 | index: ->
|
254 | faq: (params) ->
|
255 | @question = @questions.get(params.questionID)
|
256 | ```
|
257 |
|
258 | Now when you navigate to `/#!/faq/what-is-art`, the dispatcher runs this `faq` action with `{questionID: "what-is-art"}`. It also makes an implicit call to `@render`, which by default will look for a view at `/views/app/faq.html`. The view is rendered within the main content container of the page, which is designated by setting `data-yield="main"` on some tag in the layout's HTML.
|
259 |
|
260 | Controllers are also a fine place to put event handlers used by your views. Here's one that uses [jQuery](http://jquery.com/) to toggle a CSS class on a button:
|
261 |
|
262 | ```coffeescript
|
263 | class MyApp.BigRedButtonController extends Batman.Controller
|
264 | index: ->
|
265 |
|
266 | buttonWasClicked: (node, event) ->
|
267 | $(node).toggleClass('activated')
|
268 | ```
|
269 |
|
270 | If you want to redirect to some route, you can use `@redirect`:
|
271 |
|
272 | ```coffeescript
|
273 | buttonWasClicked: (node, event) ->
|
274 | $(node).toggleClass('activated')
|
275 | @redirect '/apocalypse/'
|
276 | ```
|
277 |
|
278 | ### Views
|
279 |
|
280 | You write views in plain HTML. These aren't templates in the usual sense: the HTML is rendered in the page as-is, and you use `data-*` attributes to specify how different parts of the view bind to your app's data. Here's a very small view which displays a user's name and avatar:
|
281 |
|
282 | ```html
|
283 | <div class="user">
|
284 | <img data-bind-src="user.avatarURL" />
|
285 | <p data-bind="user.name"></p>
|
286 | </div>
|
287 | ```
|
288 |
|
289 | The `data-bind` attribute on the `<p>` tag sets up a binding between the user's `name` property and the content of the tag. The `data-bind-src` attribute on the `<img>` tag binds the user's `avatarURL` property to the `src` attribute of the tag. You can do the same thing for arbitrary attribute names, so for example `data-bind-href` would bind to the `href` attribute.
|
290 |
|
291 | batman.js uses a bunch of these data attributes for different things:
|
292 |
|
293 | #### Binding properties
|
294 |
|
295 | * `data-bind="foo.bar"`: for most tags, this defines a one-way binding with the contents of the node: when the given property `foo.bar` changes, the contents of the node are set to that value. When `data-bind` is set on a form input tag, a _two-way_ binding is defined with the _value_ of the node, such that any changes from the user will update the property in realtime.
|
296 |
|
297 | * `data-bind-foo="bar.baz"`: defines a one-way binding from the given property `bar.baz` to any attribute `foo` on the node.
|
298 |
|
299 | * `data-foreach-bar="foo.bars"`: used to render a collection of zero or more items. If the collection descends from `Batman.Set`, then the DOM will be updated when items are added, removed. If it's a descendent of `Batman.SortableSet`, then its current sort.
|
300 |
|
301 | #### Handling DOM events
|
302 |
|
303 | * `data-event-click="foo.bar"`: when this node is clicked, the function specified by the keypath `foo.bar` is called with the node object as the first argument, and the click event as the second argument.
|
304 |
|
305 | * `data-event-change="foo.bar"`: like `data-event-click`, but fires on change events.
|
306 |
|
307 | * `data-event-submit="foo.bar"`: like `data-event-click`, but fires either when a form is submitted (in the case of `<form>` nodes) or when a user hits the enter key when an `<input>` or `<textarea>` has focus.
|
308 |
|
309 | #### Managing contexts
|
310 |
|
311 | * `data-context="foo.bar"`: pushes a new context onto the context stack for children of this node. If the context is `foo.bar`, then children of this node may access properties on `foo.bar` directly, as if they were properties of the controller.
|
312 |
|
313 | #### Rendering Views
|
314 |
|
315 | * `data-yield="identifier"`: used in your layout to specify the locations that other views get rendered into when they are rendered. By default, a controller action renders each whole view into whichever node is set up to yield `"main"`. If you want some content in a view to be rendered into a different `data-yield` node, you can use `data-contentfor`.
|
316 |
|
317 | * `data-contentfor="identifier"`: when the view is rendered into your layout, the contents of this node will be rendered into whichever node has `data-yield="identifier"`. For example, if your layout has `"main"` and `"sidebar"` yields, then you may put a `data-contentfor="sidebar"` node in a view and it will be rendered in the sidebar instead of the main content area.
|
318 |
|
319 | * `data-partial="/views/shared/sidebar"`: renders the view at the path `/views/shared/sidebar.html` within this node.
|
320 |
|
321 | * `data-route="/some/path"` or `data-route="some.model"`: loads a route when this node is clicked. The route can either be specified by a path beginning with a slash "/", or by a property leading to either a model instance (resulting in a resource's "show" action) or a model class (for the resource's "index" action).
|
322 |
|
323 |
|
324 | ### Models
|
325 |
|
326 | batman.js models:
|
327 |
|
328 | * can persist to various storage backends
|
329 | * only serialize a defined subset of their properties as JSON
|
330 | * use a state machine to expose lifecycle events
|
331 | * can validate with synchronous or asynchronous operations
|
332 |
|
333 | #### Attributes
|
334 |
|
335 | A model object may have arbitrary properties set on it, just like any JS object. Only some of those properties are serialized and persisted to its storage backends, however. You define persisted attributes on a model with the `encode` macro:
|
336 |
|
337 | ```coffeescript
|
338 | class Article extends Batman.Model
|
339 | @encode 'body_html', 'title', 'author', 'summary_html', 'blog_id', 'id', 'user_id'
|
340 | @encode 'created_at', 'updated_at', 'published_at',
|
341 | encode: (time) -> time.toISOString()
|
342 | decode: (timeString) -> Date.parse(timeString)
|
343 | @encode 'tags',
|
344 | encode: (tagSet) -> tagSet.toArray().join(', ')
|
345 | decode: (tagString) -> new Batman.Set(tagString.split(', ')...)
|
346 | ```
|
347 |
|
348 | Given one or more strings as arguments, `@encode` will register these properties as persisted attributes of the model, to be serialized in the model's `toJSON()` output and extracted in its `fromJSON()`. Properties that aren't specified with `@encode` will be ignored for both serialization and deserialization. If an optional coder object is provided as the last argument, its `encode` and `decode` functions will be used by the model for serialization and deserialization, respectively.
|
349 |
|
350 | By default, a model's primary key (the unchanging property which uniquely indexes its instances) is its `id` property. If you want your model to have a different primary key, specify it with the `@id` macro:
|
351 |
|
352 | ```coffeescript
|
353 | class User extends Batman.Model
|
354 | @encode 'handle', 'email'
|
355 | @id 'handle'
|
356 | ```
|
357 |
|
358 | #### States
|
359 |
|
360 | * `empty`: a new model instance remains in this state until some persisted attribute is set on it.
|
361 | * `loading`: entered when the model instance's `load()` method is called.
|
362 | * `loaded`: entered after the model's storage adapter has completed loading updated attributes for the instance. Immediately transitions to the `clean` state.
|
363 | * `dirty`: entered when one of the model's persisted attributes changes.
|
364 | * `validating`: entered when the validation process has started.
|
365 | * `validated`: entered when the validation process has completed. Immediately after entering this state, the model instance transitions back to either the `dirty` or `clean` state.
|
366 | * `saving`: entered when the storage adapter has begun saving the model.
|
367 | * `saved`: entered after the model's storage adapter has completed saving the model. Immediately transitions to the `clean` state.
|
368 | * `clean`: indicates that none of an instance's attributes have been changed since the model was `saved` or `loaded`.
|
369 | * `destroying`: entered when the model instance's `destroy()` method is called.
|
370 | * `destroyed`: indicates that the storage adapter has completed destroying this instance.
|
371 |
|
372 | #### Validation
|
373 |
|
374 | Before models are saved to persistent storage, they run through any validations you've defined and the save is cancelled if any errors were added to the model during that process.
|
375 |
|
376 | Validations are defined with the `@validate` macro by passing it the properties to be validated and an options object representing the particular validations to perform:
|
377 |
|
378 | ```coffeescript
|
379 | class User extends Batman.Model
|
380 | @encode 'login', 'password'
|
381 | @validate 'login', presence: yes, maxLength: 16
|
382 | @validate 'password', 'passwordConfirmation', presence: yes, lengthWithin: [6,255]
|
383 | ```
|
384 |
|
385 | The options get their meaning from subclasses of `Batman.Validator` which have been registered by adding them to the `Batman.Validators` array. For example, the `maxLength` and `lengthWithin` options are used by `Batman.LengthValidator`.
|
386 |
|
387 |
|
388 | #### Persistence
|
389 |
|
390 | To specify a storage adapter for persisting a model, use the `@persist` macro in its class definition:
|
391 |
|
392 | ```coffeescript
|
393 | class Product extends Batman.Model
|
394 | @persist Batman.LocalStorage
|
395 | ```
|
396 |
|
397 | Now when you call `save()` or `load()` on a product, it will use the browser window's [localStorage](https://developer.mozilla.org/en/dom/storage) to retrieve or store the serialized data.
|
398 |
|
399 | If you have a REST backend you want to connect to, `Batman.RestStorage` is a simple storage adapter which can be subclassed and extended to suit your needs. By default, it will assume your CamelCased-singular `Product` model is accessible at the underscored-pluralized "/products" path, with instances of the resource accessible at `/products/:id`. You can override these path defaults by assigning either a string or a function-returning-a-string to the `url` property of your model class (for the collection path) or to the prototype (for the member path). For example:
|
400 |
|
401 | ```coffeescript
|
402 | class Product extends Batman.Model
|
403 | @persist Batman.RestStorage
|
404 | @url = "/admin/products"
|
405 | url: -> "/admin/products/#{@id}"
|
406 | ```
|
407 |
|
408 | # Contributing
|
409 |
|
410 | Well-tested contributions are always welcome! Here's what you should do:
|
411 |
|
412 | #### 1. Clone the repo
|
413 |
|
414 | git clone https://github.com/Shopify/batman.git
|
415 |
|
416 | #### 2. Run the tests
|
417 |
|
418 | You can test batman.js locally either on the command line or in the browser and both should work. Tests are written in Coffeescript using [QUnit](http://docs.jquery.com/QUnit#API_documentation).
|
419 |
|
420 | To run on the command line, run the following command from the project root:
|
421 |
|
422 | cake test
|
423 |
|
424 | To run in the browser, start a web server to serve up the tests:
|
425 |
|
426 | batman server
|
427 |
|
428 | ...then visit `http://localhost:1047/test/batman/test.html`.
|
429 |
|
430 | #### 3. Write some test-driven code
|
431 |
|
432 | The tests are in `tests/batman`. You'll need to source any new test files in `tests/batman/test.html`.
|
433 |
|
434 | #### 4. Create a pull request
|
435 |
|
436 | If it's good code that fits with the goals of the project, we'll merge it in!
|
437 |
|
438 | # License
|
439 |
|
440 | batman.js is copyright 2011 by [Shopify](http://www.shopify.com), released under the MIT License (see LICENSE for details).
|