In this whirlwind tutorial, we’ll build a blog with batman.js and Firebase.
To get a feel for batman.js, let’s build an blog where:
- People may sign in (with Github)
- The owner may create, edit and destroy posts
- Other signed-in users may leave comments and delete their own comments
- The owner may destroy comments
If you run into any problems on the way, just let me know in the comments section at the bottom of this page! Also, the completed source of this tutorial is available on Github.
Preface: Batman.js Objects and Properties
If you’re brand new to batman.js, here’s the quick-and-dirty:
Batman.Object
is the superclass of (almost) all objects in batman.js. Properties of Batman.Object
s are also called accessors, becuase they’re always defined with @accessor
in the class definition.
There are 2 possible syntaxes:
- Read and write accessors:
class App.Comment extends Batman.Model
@accessor 'mood',
get: (key) -> # getter function
set: (key, value) -> # setter function
- Read-only accessors:
@accessor 'isPositive', (key) -> # getter function only
@accessor
is your friend. Use @accessor
whenever you can (it can often replace functions, too). Accessors are automatically tracked by batman.js, so view bindings and other accessors are automatically updated. You can defined accessors in your Batman.Model
, Batman.Controller
and Batman.View
subclasses.
Accessors are always accessed via get
and set
:
myComment.set("mood", "pensive")
myComment.get("mood")
These property names are also called keypaths and maybe be “deep”, chained with .
:
myComment.get('post.name') # equivalent to myComment.get('post').get('name')
Under the hood, accessors power batman.js’s automatic source tracking and view bindings. Now, back to your regularly scheduled programming!
Setup
To build this blog, you’ll need:
Also, you’ll need a copy of rmosolgo/batmanjs-starter, which can be installed with:
cd ~/code # or wherever you keep it
git clone git@github.com:rmosolgo/batman-starter.git batmanjs_blog
cd batmanjs_blog
npm install
You can make sure it’s all ready-to-go with:
npm install -g gulp
gulp
Then visit localhost:9000. If you see Welcome to batman.js!
, then you’re all set!
Storage and Authentication
We don’t have a server for this app, but we do have to set up Firebase!
Set Up Firebase
First, open Firebase and click Login
and click the Github logo. Then, create a new app. Any name will work, for example rm-batmanjs-blog
. Ok, you have a firebase!
Register Your App with Github
Then, in another tab, sign into Github, and click: Account Settings
(top right) > Applications
(in the sidebar) > Register New Application
. Add this information:
- Application name: firebase name (eg,
rm-batmanjs-blog
) - Application URL:
http://#{firebase name}.firebaseapp.com
(eg,http://rm-batmanjs-blog.firebaseapp.com
) - Callback URL:
https://auth.firebase.com/auth/github/callback
(provided by Firebase)
Click Register Application
. Ok, you have your Client ID and Client Secret!
Now, provide the Client ID and Client Secret to Firebase. In your Firebase app manangement tab, click Manage App
> Simple Login
> Github
:
- Check
Enabled
- Paste in Client ID and Client Secret
(Firebase automatically saves your input.)
Configure Your Batman.js App
Now, configure your app to use your firebase. Open app.coffee
, then replace the @syncsWithFirebase
name and add @authorizesWithFirebase()
. For example, it should look like:
@syncsWithFirebase "rm-batmanjs-blog"
@authorizesWithFirebase()
Also in app.coffee
, make a app accessor isAdmin
, looking up your github ID from https://api.github.com/users/#{yourUserId}
:
@classAccessor 'isAdmin', -> @get('currentUser.uid') is "github:{yourGitHubId}"
To show the Log In
/Log Out
buttons, remove the <!-- requires @authorizesWithFirebase
/ -->
comment wrapper in index.html
.
Now, you will see the Log In
button, and it will log you in with Github!
At the end of this post, we’ll use Firebase Security Rules to provide “server-side” authentication, which is a must-have!
Posts
To add posts to our blog, we will:
- define the
App.Post
model - define
App.PostsController
and make routes to it - write some HTML for the controller to render
Post Model
In a batman.js project, models go in the models/
directory. In the starter package, you’ll find the App.Greeting
model in greeting.coffee
. Remove it. Then, add post.coffee
. Here’s the Post
model:
class App.Post extends Batman.Model
@resourceName: 'post'
@persist BatFire.Storage
@encode 'title', 'content'
@validate 'title', presence: true
@validate 'content', minLength: 25
@belongsToCurrentUser(ownership: true)
@encodesTimestamps()
@accessor 'createdAtFormatted', ->
@get('created_at')?.toDateString()
Let’s break that down:
Class Definition
class App.Post extends Batman.Model
In a batman.js app, all models are children of Batman.Model
. Since we’re using CoffeeScript’s extend
, you can extend your own models, too – the inheritance hierarchy will be maintained.
Persistence
@resourceName: 'post'
@persist BatFire.Storage
@encode 'title', 'content'
These define how the model is persisted:
@resourceName
is a minification-safe model name. It may also define “where” to save the model (for example, a URL segment).@persist
says whichBatman.StorageAdapter
will connect this model to a storage backend. We’re using a Firebase adapter, but batman.js also ships withBatman.LocalStorage
andBatman.RestStorage
.Batman.RailsStorage
is in thebatman.rails
extra.@encode
tells batman.js which attributes will be persisted with the storage adapter.
Validations
@validate 'title', presence: true
@validate 'content', minLength: 25
Batman.js models may validate their attributes. See the docs for all supported validators and the custom validation API.
Special BatFire.Storage Functions
@belongsToCurrentUser(ownership: true)
@encodesTimestamps()
These are provided by BatFire.Storage
as conveniences.
@belongsToCurrentUser(ownership: true)
addscreated_by_uid
to our model and provides client-side validation that only the creator may alter any persisted records@encodesTimestamps()
defines and encodescreated_at
andupdated_at
attributues.
Accessors
@accessor 'createdAtFormatted', ->
@get('created_at')?.toDateString()
This shows how you can define properties on your models. Now, post.get('createdAtFormatted')
will return a (slightly) prettier version of the created_at
date string. Since it’s a Batman.Object
accessor, if created_at
somehow changed, createdAtFormatted
would also be updated.
PostsController
Batman.Controller
is modeled after Rails controllers. It has actions that are invoked by routes and are responsible for rendering views. They belong in controllers/
, so create controllers/posts_controller.coffee
. Let’s define a controller to render our posts:
class App.PostsController extends App.ApplicationController
routingKey: 'posts'
index: ->
@set 'posts', App.Post.get('all.sortedByDescending.created_at')
new: ->
@set 'post', new App.Post
show: (params) ->
App.Post.find params.id, (err, record) =>
throw err if err?
@set 'post', record
edit: (params) ->
App.Post.find params.id, (err, record) =>
throw err if err?
@set 'post', record.transaction()
savePost: (post) ->
post.save (err, record) =>
if err
if !(err instanceof Batman.ErrorsSet)
throw err
else
@redirect(action: "index")
destroyPost: (post) ->
post.destroy (err, record) =>
@redirect(action: "index")
Here, you can see:
App.PostsController extends App.ApplicationController
: all controllers extends a base controller. In big apps,ApplicationController
is home to things like error handling and dialog render helpers.- Controllers must have a
routingKey
. This is a minification-safe name which is used by the router. - Controllers have actions which fetch data and render views. In
PostsController
, the actions areindex
,new
,show
, andedit
. savePost
anddestroyPost
will be invoked by user input (described in the HTML section, next)
Let’s also add routes for this controller. In app.coffee
, remove any @root
or @resources
declarations and add:
@root 'posts#index'
@resources 'posts'
This sets up /
to dispatch PostsController
’s index
action and sets up resource-based routes for PostsController
.
There are a few other things to point out:
- We didn’t call
@render
in any of our actions. This is because batman.js automatically renders after any controller actions that didn’t explicitly render. This is called the implicit render and may be overriden, for example, if you want to wait for data to load before rendering views. - Actions and event handlers are both functions on the controller. This is possible because the controller is in the binding context of the view (see “Render Context” in the bindings guide).
Also, since we have routes, let’s update the navbar <ul>
in index.html
to look like this:
<ul class="nav navbar-nav">
<li><a data-route='routes.posts'>Blog Posts</a></li>
<li data-showif='isAdmin'><a data-route='routes.posts.new'>New Post</a></li>
</ul>
(More about those data-
attributes to follow…)
Posts HTML
We need HTML to be rendered in by our controller. HTML templates are distinct from views, but may be used together. This is described in detail below. For now, let’s add some HTML. In a batman.js project, HTML for a controller action belongs in html/#{routingKey}/#{action}.html
.
show.html
Let’s define html/posts/show.html
. It will be loaded by posts#show
to display a post instance:
<div class='row'>
<div class='col-sm-12'>
<h1 class='page-header'>
<span data-bind='post.title'></span>
<small data-bind='post.createdAtFormatted'></small>
</h1>
</div>
</div>
<div class='row'>
<p class='col-sm-12' data-bind='post.content'></p>
</div>
Besides the bootstrap boilerplate, you might notice data-bind
on some of these HTML tags. data-*
attributes is how batman.js binds data to the DOM. Those attributes are called data bindings.
The data-bind
binding is the simplest data binding: it simply connects the node to the property which is passed to it.
When combining data and text, it’s common to use <span data-bind="..."></span>
, as in the <h1/>
above.
index.html
Let’s define html/posts/index.html
:
<div class='row'>
<h1 class='col-sm-12'>
<span data-bind='"Post" | pluralize posts.length'></span>
</h1>
</div>
<ul class='list-unstyled'>
<li data-foreach-post='posts'>
<div class='row'>
<a data-route='routes.posts[post]'>
<p class='lead col-sm-4' data-bind='post.title'></p>
</a>
<div class='col-sm-2'>
<a data-showif='post.isOwnedByCurrentUser' class='btn btn-warning pull-right' data-route='routes.posts[post].edit'>Edit</a>
</div>
<div class='col-sm-2'>
<a data-showif='post.isOwnedByCurrentUser' class='btn btn-danger pull-right' data-event-click='destroyPost | withArguments post'>Delete</a>
</div>
<span class='text-muted col-sm-4'>
Posted on
<span data-bind="post.createdAtFormatted"></span>
</span>
</div>
<div class='row'>
<p class='col-sm-12' data-bind='post.content | truncate 100'></p>
</div>
</li>
</ul>
<div class='row' data-showif='isAdmin'>
<div class='col-sm-2'>
<a class='btn btn-default' data-route='routes.posts.new'>New Post</a>
</div>
</div>
Let’s look at some interesting parts:
View Filters
<span data-bind='"Post" | pluralize posts.length'></span>
This will output things like 3 Posts
. It takes a plain string, then passes it to the pluralize view filter, with posts.length
as an argument. Since it’s bound to posts.length
, it will automatically update whenever the number of Post
s change.
There are quite a lot of batman.js view filters, be sure to check out the documentation.
Iterator Binding
<ul class='list-unstyled'>
<li data-foreach-post='posts'>
<!-- ... -->
</li>
</ul>
The data-foreach-#{item}="collection"
binding is how you bind to a collection. The <li />
is called the “prototype node”, and one will be rendered for each item in the collection. As long as "collection"
is a batman.js data structure (ie, not a plain JS array), the binding will be automatically updated when items are added and removed. (Unless you explicitly make arrays yourself, you don’t have to worry; batman.js always uses observable data structures like Batman.Set and Batman.Hash.)
Named Routes
<a data-route='routes.posts[post]'>
<!-- ... -->
</a>
The data-route
binding is how you link to other routes in your app. The “route query” passed to the binding is based on your declared routes. Here are a few other valid routes:
data-route="routes.posts" # => goes to `posts#index`
data-route="routes.posts.new" # => goes to `posts#new`
data-route="routes.posts[myPost]" # => goes to `posts#show` for a post instance `myPost`
data-route="routes.posts[myPost].edit" # => goes to `posts#edit` for a post instance `myPost`
In the binding above, post
refers to a post instance, so the <a/>
will point to that post’s show
page.
Showif / Event
<a data-showif='post.isOwnedByCurrentUser' class='btn btn-danger pull-right' data-event-click='destroyPost | withArguments post'>Delete</a>
This has two bindings:
data-showif
shows the node if the keypath returns truthy.isOwnedByCurrentUser
is provided byBatFire.Storage
.data-event-click
points to a function to call when the node is clicked, in this caseAppPostsController::destroyPost
, which we defined above
new.html
For new.html
, let’s plan ahead: we’ll make new.html
include a reusable form, form.html
. So, new.html
is very simple:
<div class='row'>
<h1 class='col-sm-12'>
New Post
</h1>
</div>
<div data-partial='posts/form'></div>
Partial
<div data-partial='posts/form'></div>
This will render html/posts/form.html
inside that node.
Let’s add form.html
:
<form data-formfor-post='post' data-event-submit='savePost | withArguments post'>
<div class='errors alert alert-warning' data-showif='post.errors.length'>
</div>
<div class='form-group'>
<label>Title</label>
<input type='text' class='form-control' data-bind='post.title' />
</div>
<div class='form-group'>
<label>Content</label>
<textarea class='form-control' data-bind='post.content'></textarea>
</div>
<div class='form-group'>
<input type='submit' class='btn btn-primary' value='Save' />
<a class='btn btn-danger' data-route='routes.posts'>Cancel</a>
</div>
</form>
Let’s examine some of the details:
Form Binding
<form data-formfor-post='post' data-event-submit='savePost | withArguments post'>
<!-- ... -->
</form>
The data-formfor-#{formName}="item"
binding will automatically bind validation errors to the element matching .errors
:
<div class='errors alert alert-warning' data-showif='post.errors.length'>
Also, the data-event-submit
will invoke App.PostsController::savePost
when the form is submitted.
Input Bindings
<input type='text' class='form-control' data-bind='post.title' />
When you use data-bind
on an <input />
(or <select />
, etc), you create a two-way binding. Any changes to the input will change the attribute of the model.
You can bind to all different kinds of inputs.
edit.html
In edit.html
, let’s reuse our form.html
partial:
<div class='row'>
<h1 class='col-sm-12'>
Edit Post
</h1>
</div>
<div data-partial='posts/form'>
</div>
Where were the views?
In batman.js, views are CoffeeScript classes that render templates and maintain bindings. They’re intantiated and destroyed when controller actions are rendered. It’s a bit like this:
ROUTER --> CONTROLLER --> VIEW --> HTML TEMPLATE
- responds to URL change - executes action - parses bindings from HTML - copied into views
- dispatches controller action - renders view - inserts HTML into DOM - just sits there
- maintains bindings
You might have noticed that we made a controller and a template, but no Batman.View
. Why not?
This is because Batman.Controller
will use a vanilla Batman.View
to render your HTML unless you define one by hand. Custom views a great for a ton of things:
- Rendering specialized UI components
- Integrating other librarires, like jQuery plugins or leaflet.js
- Animating page changes
But we didn’t need one, so we didn’t make one!
(PS: Learn more about controllers’ default views or custom views.)
Comments
Let’s allow other signed-in users to comment on our blog posts. We’ll need to:
- define the model,
App.Comment
- associate it to
App.Post
- add a comment form to
posts/show
App.Comment
Open up models/comment.coffee
and define App.Comment
:
class App.Comment extends Batman.Model
@resourceName: 'comment'
@persist BatFire.Storage
@encode 'content'
@belongsTo 'post'
@validate 'content', presence: true
@belongsToCurrentUser()
@encodesTimestamps()
@accessor 'createdAtFormatted', ->
@get('created_at')?.toDateString()
@accessor 'canBeDeleted', ->
@get('isOwnedByCurrentUser') || App.get('isAdmin')
Most of this looks familiar: persistence, encoding, validations, accessors. There is one new thing:
Model Association
@belongsTo 'post'
This defines a model association between Comment
and Post
. In this case, we defined a belongsTo
association, so:
- A
Comment
has apost
attribute:
myComment.get('post') # => <Post instance>
- A
Comment
will encodepost_id
, which is theid
of its associatedPost
.
We also need to add this concern to our Post
-related code. Open models/post.coffee
, and after your @encode
call, add:
class App.Post extends Batman.Model
# ...
@hasMany 'comments', inverseOf: 'post'`
We have defined a hasMany
relation from Post to Comment. So, a Post has a comments
attribute, which returns a Batman.Set
full of Comments:
myPost.get('comments') # => <Batman.Set [Comment, Comment...]>
Since Post
and Comment
are associated, we have to make sure that a Post
’s Comment
s are destroyed when the Post
is destroyed. So, update App.PostsController::destroyPost
:
destroyPost: (post) ->
post.get('comments').forEach (c) -> c.destroy()
post.destroy (err, record) =>
@redirect(action: "index")
Now, whenever you destroy a Post
, you’ll also destroy its comments, so you don’t end up with orphaned comments. We used Batman.Set::forEach
– see this blog post for an introduction to Batman.Set
!
Comment Form
Let’s add comment form to posts/show
so that users can log in. Append each of these blocks of HTML to the bottom of html/posts/show.html
.
Heading
<div class='row'>
<div class='col-sm-12'>
<h3> Comments </h3>
</div>
</div>
Nothing to see here, move along …
List of Comments
This will render existing comments for a post:
<div class='row'>
<ul class='list-unstyled'>
<!-- render comments: -->
<li data-foreach-comment='post.comments' >
<p class='col-sm-4'>
<strong class='pull-right'>
On <span data-bind='comment.createdAtFormatted'></span>, <span data-bind='comment.created_by_username'></span> said:
</strong>
</p>
<p class='col-sm-6' data-bind='comment.content'></p>
<div class='col-sm-2' data-showif='comment.canBeDeleted'>
<a class='btn btn-danger btn-xs' data-event-click='destroyComment | withArguments comment'> Delete </a>
</div>
</li>
<!-- "design" for empty state -->
<li class='col-sm-12' data-showif='post.comments.isEmpty'>
<p class='text-muted'>No comments yet!</p>
</li>
</ul>
</div>
A few things of note:
- There’s a
data-foreach
binding with a<li/>
prototype node. I included another<li/>
withdata-showif='post.comments.isEmpty'
, just in case there aren’t any comments yet. data-showif='comment.canBeDeleted'
is using the accessor we defined in the model definition.- We’re using
data-event-click='destroyComment | withArguments comment'
but we haven’t defineddestroyComment
yet. We’ll do that next!
Comment Form
Notice that there are actually two parts of the HTML: one to show if loggedOut
, the other to show if loggedIn
:
<div class='row' data-showif='loggedOut'>
<div class='col-sm-12'>
<div class='well'>
<p>You must be <a data-event-click='login'>logged in</a> to leave a comment!</p>
</div>
</div>
</div>
<div class='row' data-showif='loggedIn'>
<div class='col-sm-12'>
<form data-formfor-comment='newComment' data-event-submit='saveComment | withArguments newComment'>
<div class='form-group'>
<label>New Comment:</label>
<textarea
class='form-control'
data-bind='newComment.content'
data-bind-placeholder='"Leave a comment as " | append currentUser.username | append "..."'
>
</textarea>
</div>
<input type='submit' class='btn btn-primary' value='Leave a comment' />
</form>
</div>
</div>
Conditionals in HTML
<div data-showif='loggedOut'>
<!-- show this to logged-out users -->
</div>
<div data-showif='loggedIn'>
<!-- show this to logged-in users -->
</div>
Using multiple data-showif
/data-hideif
bindings is a common way of expressing conditional logic in batman.js templates.
Binding to Attributes
<textarea data-bind-placeholder='"Leave a comment as " | append currentUser.username | append "..."' ></textarea>
Here, we have bound data to the <textarea />
’s placeholder
attribute. You can use data-bind-#{attr}
to bind to any HTML attribute.
Use a Custom View
Views inject new accessors and functions into the render context. They also have lifecycle hooks that can be used for initialization, etc.
To handle some actions with the comment form, we’ll implement the default view for the posts#show
action. Open views/posts/posts_show_view.coffee
and add:
class App.PostsShowView extends Batman.View
viewWillAppear: ->
@_resetComment()
saveComment: (comment) ->
# set up the association:
comment.set 'post', @get('controller.post')
comment.save (err, record) =>
throw err if err?
@_resetComment()
_resetComment: ->
@set('newComment', new App.Comment)
destroyComment: (comment) ->
comment.destroy (err, r) ->
throw err if err?
Because our view is named App.PostsShowView
, it will automatically be used by the posts#new
controller action. It’s called the “default view” of posts#show
.
Notably:
data-event
handlers may be on controllers or views; both of them are in the “render context”.- we used a lifecycle hook,
viewWillAppear
, to initialize our empty form. - we set the comment’s
post
duringsaveComment
because it might not have loaded yet when the view is rendered. You can also avoid this problem by waiting until data is loaded to render the view.
Firebase Security Rules
You always need server-side validation to accompany client-side validations. Otherwise, a mean-spirited user could wreck your data from the JS console.
It’s beyond the scope of this post to explain Firebase security rules, but here are some to go with this app (be sure to insert your Github ID instead of mine!) :
/* These rules are provided for imformational purposes only :) */
{
"rules": {
/* All items are namespaced by `BatFire` */
"BatFire" : {
/* Make `@syncs` accessors read-only */
"syncs" : {
".read" : true,
".write" : false
},
/* All records namespaced by `records` */
"records" : {
"scoped" : {
/* "Server-side" validation for @belongsToCurrentUser(scoped: true) */
"$uid" : {
".write" : "$uid == auth.uid",
".read" : "$uid == auth.uid"
}
},
"posts" : {
".read" : true,
".write" : "'github:2231765' == auth.uid " /* that's me */
},
"comments" : {
".read" : true,
"$recordId" : {
/* can be deleted by creator or by admin ... me */
".write" : "!data.exists() || auth.uid == data.child('created_by_uid').val() || 'github:2231765' == auth.uid"
}
},
"$resourceName" : {
/* "Server-side" validation for @belongsToCurrentUser(ownership: true) */
"$recordId" : {
/* Allows non-belongsToCurrentUser records to be written but protect owned ones */
".write" : "!data.hasChild('has_user_ownership') || data.child('created_by_uid').val() == auth.uid"
},
".read" : true
/* nothing gets written here -- everything gets an ID _before_ create */
}
}
},
/* Everything else is fair game */
"$other" : {
".read" : true,
".write" : true
}
}
}
Wrap Up
Congratulations, you have a beautiful new blog! You can let the whole world see it by deploying it to Firebase:
- update
firebase.json
to have your Firebase name (eg,"rm-batmanjs-blog"
) npm install -g firebase-tools
firebase deploy
firebase open
And you’re live!
I hope you have enjoyed this tour of batman.js! For more information:
- check out the batman.js website or the Batman.js MVC Cookbook
- join the mailing list
- drop by the IRC channel (#batmanjs)
- leave a comment here or open an issue on the github repo