Skip to content

Latest commit

 

History

History
562 lines (387 loc) · 23.8 KB

README.md

File metadata and controls

562 lines (387 loc) · 23.8 KB

batman.js

batman.js is a framework for building rich single-page browser applications. It is written in CoffeeScript and its API is developed with CoffeeScript in mind, but of course you can use plain old JavaScript too.

It's got:

  • a stateful MVC architecture
  • a powerful binding system
  • routable controller actions
  • pure HTML views
  • toolchain support built on node.js and cake

The APIs are heavily inspired by Rails and designed to make Rails devs feel right at home.

We're targeting Chrome, Safari 4+, Firefox 3+, and IE 7+ for compatibility, although some of those require you to include es5shim.

Here's some code:

class Shopify extends Batman.App
  @root 'products#index'

  @resources 'products'

class Shopify.Product extends Batman.Model
  @persist Batman.RestStorage

class Shopify.ProductsController extends Batman.Controller
  index: ->

  show: (params) ->
    Shopify.Product.find params.id, (err, product) =>
      @set('product', product)

views/products/index.html

<ul id="products">
  <li data-foreach-product="Product.all" data-mixin="animation">
    <a data-route="product" data-bind="product.name">name will go here</a>
  </li>

  <li><span data-bind="'product' | pluralize products.length"></span></li>
</ul>

Installation

If you haven't already, you'll need to install node.js version 0.8.x and npm. Then:

npm install -g batman

Generate a new batman.js app somewhere, called my_app:

cd ~/code
batman new my_app

Fire it up:

cd my_app
batman server # (or just "batman s")

Now visit http://localhost:1047 and start playing around!

The Basics

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.

Events

Events in batman.js are pretty simple. Handling an event is like subscribing to a named channel on a particular object, and firing an event is like publishing an argument list (which might be empty) to all subscribers of that channel.

class BatBelt.Gadget extends Batman.Object
  constructor: -> @usesRemaining = 5
  use: (howManyTimes) ->
    if howManyTimes <= @usesRemaining
      @usesRemaining -= howManyTimes
      @fire('use', howManyTimes)

You can use the on method to register handler functions which then get called whenever fire is called for the specified event name. The handlers get whichever extra arguments were passed to fire after the event name itself:

gadget.on 'use', (howManyTimes) ->
  console.log "gadget was used #{howManyTimes} times!"
gadget.use(2)
# console output: "gadget was used 2 times!"

Properties

The observe function is used to observe changes to properties. This forms the basis of the binding system. Here's a simple example:

gadget.observe 'name', (newVal, oldVal) ->
  console.log "name changed from #{oldVal} to #{newVal}!"
gadget.set 'name', 'Batarang'
# console output: "name changed from undefined to Batarang!"

When you observe a property, you are really just handling its "change" event, which happens to get fired with the new and old values.

Of course you can also get properties to return their values, and if you want to remove them completely then you can unset them:

gadget.get 'name'
# returns: 'Batarang'
gadget.unset 'name'
# console output: "name changed from Batarang to undefined!"

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 them.

Custom Accessors

What's the point of using gadget.get 'name' instead of just gadget.name? Well, batman.js properties don't always correspond with vanilla JS properties. Let's write a Box class with a custom getter for its volume:

class Box extends Batman.Object
  constructor: (@length, @width, @height) ->
  @accessor 'volume',
    get: (key) -> @get('length') * @get('width') * @get('height')

box = new Box(16,16,12)
box.get 'volume'
# returns 3072

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:

box.observe 'volume', (newVal, oldVal) ->
  console.log "volume changed from #{oldVal} to #{newVal}!"
box.set 'height', 6
# console output: "volume changed from 3072 to 1536!"

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:

class Person extends Batman.Object
  constructor: (name) -> @set 'name', name
  @accessor 'name',
    get: (key) -> [@get('firstName'), @get('lastName')].join(' ')
    set: (key, val) ->
      [first, last] = val.split(' ')
      @set 'firstName', first
      @set 'lastName', last
    unset: (key) ->
      @unset 'firstName'
      @unset 'lastName'

Keypaths

If you want to get at properties of properties, use keypaths:

employee.get 'team.manager.name'

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:

employee.observe 'team.manager.name', (newVal, oldVal) ->
  console.log "you now answer to #{newVal || 'nobody'}!"
manager = employee.get 'team.manager'
manager.set 'name', 'Bill'
# console output: "you now answer to Bill!"

If any component of the keypath is set to something that would change the overall value, then observers will fire:

employee.set 'team', larrysTeam
# console output: "you now answer to Larry!"
employee.team.unset 'manager'
# console output: "you now answer to nobody!"
employee.set 'team', jessicasTeam
# console output: "you now answer to Jessica!"

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.

Architecture

The MVC architecture of batman.js fits together like this:

  • Controllers are persistent objects which render the views and give them mediated access to the model layer.
  • Views are written in pure HTML, and use data-* attributes to create bindings with model data and event handlers exposed by the controllers.
  • Models have validations, lifecycle events, a built-in identity map, and can use arbitrary storage backends (Batman.LocalStorage, Batman.RestStorage, and Batman.RailsStorage are included).

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 uses pushState where supported, otherwise falling back to hash-bang fragment identifers.

The App Class

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.

Here's a simple app class:

class BatBelt extends Batman.App
  @root 'app#index'
  @route 'faq/:questionID', 'app#faq'
  @resources 'gadgets'

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.

Routes

Routes are defined in a few different ways.

@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.

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.

@root 'app#index' is just a shorthand for @route '/', 'app#index'.

You can also mark a segment of your route as optional, allowing URLs to match with or without those optional segments. Eg. @route 'calendar(/:type(/:date))', calendar#index will match all of the following URLs:

/calendar
/calendar/y
/calendar/m/2012-11

When the segments are missing, your controller action is handed an undefined value for that param.

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:

@route 'gadgets', 'gadgets#index'
@route 'gadgets/new', 'gadgets#new'
@route 'gadgets/:id', 'gadgets#show'
@route 'gadgets/:id/edit', 'gadgets#edit'

@resources can also take a list of labels to set up routing for multiple models at once:

@resources 'stores', 'products', 'customers'

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:

BatBelt.Gadget.find 1, (err, gadget) =>
  @redirect gadget unless err? # redirects to "/gadgets/1"

or

@redirect BatBelt.Gadget # redirects to "/gadgets"

Controllers

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.

class BatBelt.AppController extends Batman.Controller
  index: ->

  faq: (params) ->
    @set('question', @get('questions').get(params.questionID)

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. You can prevent this implicit rendering by calling @render false in your action.

Controllers are also a fine place to put event handlers used by your views. Here's one that uses jQuery to toggle a CSS class on a button:

class MyApp.BigRedButtonController extends Batman.Controller
  index: ->

  buttonWasClicked: (node, event) ->
    $(node).toggleClass('activated')

If you want to redirect to some route, you can use @redirect:

buttonWasClicked: (node, event) ->
  $(node).toggleClass('activated')
  @redirect '/apocalypse/'

Views

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:

<div class="user">
  <img data-bind-src="user.avatarURL" />
  <p data-bind="user.name"></p>
</div>

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.

batman.js uses a bunch of these data attributes for different things:

Binding properties

  • 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.

  • data-bind-foo="bar.baz": defines a one-way binding from the given property bar.baz to any attribute foo on the node.

  • 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 or removed.

Handling DOM events

  • 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.

  • data-event-change="foo.bar": like data-event-click, but fires on change events.

  • 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.

Managing contexts

  • 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.

Rendering Views

  • 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.

  • 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. If multiple data-contentfor nodes with the same identifier are present in a view, their contents will be concatenated in the corresponding data-yield node.

  • data-partial="shared/sidebar": renders the view at the path /views/shared/sidebar.html within this node. Note that /views/ is prepended.

  • data-route="'/some/path'" or data-route="some.model" or data-route="{'controller': 'products', 'action': 'index'}": 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).

Models

batman.js models:

  • can persist to various storage backends
  • only serialize a defined subset of their properties as JSON
  • use a state machine to expose lifecycle events
  • can validate with synchronous or asynchronous operations

Attributes

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:

  class Article extends Batman.Model
    @encode 'body_html', 'title', 'author', 'summary_html', 'blog_id', 'id', 'user_id'
    @encode 'created_at', 'updated_at', 'published_at', Batman.Encoders.railsDate
    @encode 'tags',
      encode: (tagSet) -> tagSet.toArray().join(', ')
      decode: (tagString) -> new Batman.Set(tagString.split(', ')...)

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.

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 the name of the key on the primaryKey class property:

class User extends Batman.Model
  @primaryKey: 'handle'
  @encode 'handle', 'email'

States

  • empty: a new model instance remains in this state until some persisted attribute is set on it.
  • loading: entered when the model instance's load() method is called.
  • loaded: entered after the model's storage adapter has completed loading updated attributes for the instance. Immediately transitions to the clean state.
  • dirty: entered when one of the model's persisted attributes changes.
  • validating: entered when the validation process has started.
  • 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.
  • saving: entered when the storage adapter has begun saving the model.
  • saved: entered after the model's storage adapter has completed saving the model. Immediately transitions to the clean state.
  • clean: indicates that none of an instance's attributes have been changed since the model was saved or loaded.
  • destroying: entered when the model instance's destroy() method is called.
  • destroyed: indicates that the storage adapter has completed destroying this instance.

Validation

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.

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:

class User extends Batman.Model
  @encode 'login', 'password'
  @validate 'login', presence: yes, maxLength: 16
  @validate 'password', 'passwordConfirmation', presence: yes, lengthWithin: [6,255]

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.

Persistence

To specify a storage adapter for persisting a model, use the @persist macro in its class definition:

class Product extends Batman.Model
  @persist Batman.LocalStorage

Now when you call save() or load() on a product, it will use the browser window's localStorage to retrieve or store the serialized data.

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:

class Product extends Batman.Model
  @persist Batman.RestStorage
  @url = "/admin/products"
  url: -> "/admin/products/#{@get('id')}"

Associations

Batman models support belongsTo, hasOne, and hasMany associations. Here's a simple example:

class App.Store extends Batman.Model
  @hasMany 'products', options

class App.Product extends Batman.Model
  @belongsTo 'store', options

The following options are available:

  • namespace: Tells Batman to look for the associated model under the provided namespace. (Defaults to Batman.currentApp.)
  • name: Tells Batman to use the given model name instead. (Defaults to an interpretation of the association's label.)
  • saveInline: Tells Batman whether to encode associations as inline JSON when the base model is saved. (Defaults to false for belongsTo, and true for hasOne and hasMany.)
  • autoload: Tells Batman whether to make a request to the server the first time the association is getted. Defaults to true.

Associations can be loaded via foreign keys or inline JSON:

localStorage =
  stores1:
    id: 1
    name: "JSON Store"
    product: {id: 1, store_id: 1, name: "JSON Product"}

  stores2:
    id: 2
    name: "Foreign Key Store"
  products3:
    id: 3
    store_id: 2
    name: "Foreign Key Product Product"

Association saving is done inline:

store = new Store name: "Angry Birds"
product1 = new Product name: "Foo"
product2 = new Product name: "Bar"

store.set 'products', new Batman.Set(product1, product2)
store.save (error, record) ->
  throw error if error
  console.log localStorage["stores#{record.get('id')}"]
  # => {
  #   id: ...
  #   name: "Angry Birds"
  #   products: [{name: "Foo", store_id: ...}, {name: "Bar", store_id: ...}]
  # }

(Notice that the products did not receive IDs. This is because association saving is non-cascading, meaning that each model needs to be saved individually to fully persist. You can always call toJSON on a model instance to see what will be stored.)

Associations also support reloading and loaded introspection:

store = new Store name: "Angry Birds"
store.get('products.loaded') #=> false
store.get('products').load (err, products) ->
  throw err if err
  store.get('products.loaded') #=> true

Associations can be rendered via keypaths, using the same labels you use to create the association:

<div data-foreach-product="store.products" data-bind="product.name"></div>

Contributing

Build Status

Well-tested contributions are always welcome! Here's what you should do:

1. Clone the repo

git clone https://github.com/batmanjs/batman.git

2. Install dependencies using NPM

npm install .

3. Run the tests

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.

To run on the command line, run the following command from the project root:

cake test

To run in the browser, start a web server to serve up the tests:

coffee tests/serve.coffee

...then visit http://localhost:1047/tests/batman/test.html.

4. Write some test-driven code

The tests are in tests/batman. All test files in there are automatically included into the browser.

5. Update the documentation

The docs are rendered using percolate and require that pygments is already installed.

Pygments is a Python library, and can be installed using easy_install.

sudo easy_install Pygments

To render the docs, run the following command from the project root:

cake doc

6. Create a pull request

If it's good code that fits with the goals of the project, we'll merge it in!

License

batman.js is copyright 2012 by Shopify, released under the MIT License (see LICENSE for details).