Commit c7284e71 authored by Ryan Eastridge's avatar Ryan Eastridge

add Thorax + Lumbar example

parent 627a254c
Thorax + Lumbar TodoMVC Example
===============================
This example uses [Thorax](http://thoraxjs.org) and [Lumbar](http://walmartlabs.github.com/lumbar). The compiled JavaScript is included in the repo, to re-build the files run:
npm install
npm start
Lumbar will create a `public/base.js` file that contains the core libraries needed to run the application, and a master router that listens to all routes defined in `lumbar.json`. When one of those routes is visited the appropriate module file is loaded, in this case `public/todomvc.js`.
\ No newline at end of file
{
"application": {
"name": "Application",
"module": "base"
},
"modules": {
"base": {
"scripts": [
{
"src": "../../../assets/base.js",
"global": true
},
{
"src": "../../../assets/jquery.min.js",
"global": true
},
{
"src": "../../../assets/lodash.min.js",
"global": true
},
{
"src": "../../../assets/handlebars.min.js",
"global": true
},
{
"src": "src/js/lib/backbone.js",
"global": true
},
{
"src": "src/js/lib/backbone.js",
"global": true
},
{
"src": "src/js/lib/backbone-localstorage.js",
"global": true
},
{
"src": "src/js/lib/thorax.js",
"global": true
},
{
"src": "src/js/lib/script.js",
"global": true
},
{
"src": "src/js/lib/lumbar-loader.js"
},
{
"src": "src/js/lib/lumbar-loader-events.js"
},
{
"src": "src/js/lib/lumbar-loader-standard.js"
},
{
"src": "src/js/lib/lumbar-loader-backbone.js"
},
{
"src": "src/js/init.js"
},
{
"module-map": true
}
]
},
"todomvc": {
"routes": {
"": "setFilter",
":filter": "setFilter"
},
"scripts": [
"src/js/models/todo.js",
"src/js/collections/todos.js",
"src/js/views/todo-item.js",
"src/js/views/stats.js",
"src/js/views/app.js",
"src/js/routers/todomvc.js",
"src/js/app.js"
]
}
},
"templates": {
"template": "Thorax.templates['{{{without-extension name}}}'] = '{{{data}}}';",
"src/js/views/app.js": [
"src/templates/app.handlebars"
],
"src/js/views/stats.js": [
"src/templates/stats.handlebars"
]
}
}
{
"name": "thorax-lumbar-todomvc",
"version": "0.0.1",
"devDependencies": {
"lumbar": "git://github.com/beastridge/lumbar.git"
},
"scripts": {
"start": "lumbar build lumbar.json public"
}
}
\ No newline at end of file
This source diff could not be displayed because it is too large. You can view the blob instead.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
<title>Thorax • TodoMVC</title>
<link rel="stylesheet" href="../../../../assets/base.css">
<!--[if IE]>
<script src="../../../../assets/ie.js"></script>
<![endif]-->
</head>
<body>
<script src="base.js"></script>
</body>
</html>
Application['todomvc'] = (function() {
var module = {exports: {}};
var exports = module.exports;
/* router : todomvc */
module.name = "todomvc";
module.routes = {"":"setFilter",":filter":"setFilter"};
(function() {
'use strict';
// Todo Model
// ----------
// Our basic **Todo** model has `title`, `order`, and `completed` attributes.
window.app.Todo = Backbone.Model.extend({
// Default attributes for the todo
// and ensure that each todo created has `title` and `completed` keys.
defaults: {
title: '',
completed: false
},
// Toggle the `completed` state of this todo item.
toggle: function() {
this.save({
completed: !this.get('completed')
});
},
isVisible: function () {
var isCompleted = this.get('completed');
if (window.app.TodoFilter === '') {
return true;
} else if (window.app.TodoFilter === 'completed') {
return isCompleted;
} else if (window.app.TodoFilter === 'active') {
return !isCompleted;
}
}
});
}());
;;
(function() {
'use strict';
// Todo Collection
// ---------------
// The collection of todos is backed by *localStorage* instead of a remote
// server.
var TodoList = Backbone.Collection.extend({
// Reference to this collection's model.
model: window.app.Todo,
// Save all of the todo items under the `"todos"` namespace.
localStorage: new Store('todos-backbone'),
// Filter down the list of all todo items that are finished.
completed: function() {
return this.filter(function( todo ) {
return todo.get('completed');
});
},
// Filter down the list to only todo items that are still not finished.
remaining: function() {
return this.without.apply( this, this.completed() );
},
// We keep the Todos in sequential order, despite being saved by unordered
// GUID in the database. This generates the next order number for new items.
nextOrder: function() {
if ( !this.length ) {
return 1;
}
return this.last().get('order') + 1;
},
// Todos are sorted by their original insertion order.
comparator: function( todo ) {
return todo.get('order');
}
});
// Create our global collection of **Todos**.
window.app.Todos = new TodoList();
}());
;;
$(function() {
'use strict';
// Todo Item View
// --------------
// The DOM element for a todo item...
Thorax.View.extend({
//... is a list tag.
tagName: 'li',
// Cache the template function for a single item.
name: 'todo-item',
// The DOM events specific to an item.
events: {
'click .toggle': 'toggleCompleted',
'dblclick label': 'edit',
'click .destroy': 'clear',
'keypress .edit': 'updateOnEnter',
'blur .edit': 'close',
// The "rendered" event is triggered by Thorax each time render()
// is called and the result of the template has been appended
// to the View's $el
rendered: function() {
this.$el.toggleClass( 'completed', this.model.get('completed') );
}
},
// Toggle the `"completed"` state of the model.
toggleCompleted: function() {
this.model.toggle();
},
// Switch this view into `"editing"` mode, displaying the input field.
edit: function() {
this.$el.addClass('editing');
this.$('.edit').focus();
},
// Close the `"editing"` mode, saving changes to the todo.
close: function() {
var value = this.$('.edit').val().trim();
if ( value ) {
this.model.save({ title: value });
} else {
this.clear();
}
this.$el.removeClass('editing');
},
// If you hit `enter`, we're through editing the item.
updateOnEnter: function( e ) {
if ( e.which === ENTER_KEY ) {
this.close();
}
},
// Remove the item, destroy the model from *localStorage* and delete its view.
clear: function() {
this.model.destroy();
}
});
});
;;
Thorax.View.extend({
name: 'stats',
events: {
'click #clear-completed': 'clearCompleted',
// The "rendered" event is triggered by Thorax each time render()
// is called and the result of the template has been appended
// to the View's $el
rendered: 'highlightFilter'
},
initialize: function() {
// Whenever the Todos collection changes re-render the stats
// render() needs to be called with no arguments, otherwise calling
// it with arguments will insert the arguments as content
window.app.Todos.on('all', _.debounce(function() {
this.render();
}), this);
},
// Clear all completed todo items, destroying their models.
clearCompleted: function() {
_.each( window.app.Todos.completed(), function( todo ) {
todo.destroy();
});
return false;
},
// Each time the stats view is rendered this function will
// be called to generate the context / scope that the template
// will be called with. "context" defaults to "return this"
context: function() {
var remaining = window.app.Todos.remaining().length;
return {
itemText: remaining === 1 ? 'item' : 'items',
completed: window.app.Todos.completed().length,
remaining: remaining
};
},
// Highlight which filter will appear to be active
highlightFilter: function() {
this.$('#filters li a')
.removeClass('selected')
.filter('[href="#/' + ( window.app.TodoFilter || '' ) + '"]')
.addClass('selected');
}
});;;
Thorax.templates['src/templates/stats'] = '<span id=\"todo-count\"><strong>{{remaining}}</strong> {{itemText}} left</span>\n<ul id=\"filters\">\n <li>\n {{#link \"/\" class=\"selected\"}}All{{/link}}\n </li>\n <li>\n {{#link \"/active\"}}Active{{/link}}\n </li>\n <li>\n {{#link \"/completed\"}}Completed{{/link}}\n </li>\n</ul>\n{{#if completed}}\n <button id=\"clear-completed\">Clear completed ({{completed}})</button>\n{{/if}}\n';$(function( $ ) {
'use strict';
// The Application
// ---------------
// Our overall **AppView** is the top-level piece of UI.
Thorax.View.extend({
// This will assign the template Thorax.templates['app'] to the view and
// create a view class at Thorax.Views['app']
name: 'app',
// Delegated events for creating new items, and clearing completed ones.
events: {
'keypress #new-todo': 'createOnEnter',
'click #toggle-all': 'toggleAllComplete',
// The collection helper in the template will bind the collection
// to the view. Any events in this hash will be bound to the
// collection.
collection: {
all: 'toggleToggleAllButton'
},
rendered: 'toggleToggleAllButton'
},
// Unless the "context" method is overriden any attributes on the view
// will be availble to the context / scope of the template, make the
// global Todos collection available to the template.
// Load any preexisting todos that might be saved in *localStorage*.
initialize: function() {
this.todosCollection = window.app.Todos;
this.todosCollection.fetch();
this.render();
},
toggleToggleAllButton: function() {
this.$('#toggle-all').attr('checked', !this.todosCollection.remaining().length);
},
// This function is specified in the collection helper as the filter
// and will be called each time a model changes, or for each item
// when the collection is rendered
filterTodoItem: function(model) {
return model.isVisible();
},
// Generate the attributes for a new Todo item.
newAttributes: function() {
return {
title: this.$('#new-todo').val().trim(),
order: window.app.Todos.nextOrder(),
completed: false
};
},
// If you hit return in the main input field, create new **Todo** model,
// persisting it to *localStorage*.
createOnEnter: function( e ) {
if ( e.which !== ENTER_KEY || !this.$('#new-todo').val().trim() ) {
return;
}
window.app.Todos.create( this.newAttributes() );
this.$('#new-todo').val('');
},
toggleAllComplete: function() {
var completed = this.$('#toggle-all')[0].checked;
window.app.Todos.each(function( todo ) {
todo.save({
'completed': completed
});
});
}
});
});
;;
Thorax.templates['src/templates/app'] = '<section id=\"todoapp\">\n <header id=\"header\">\n <h1>todos</h1>\n <input id=\"new-todo\" placeholder=\"What needs to be done?\" autofocus>\n </header>\n {{^empty todosCollection}}\n <section id=\"main\">\n <input id=\"toggle-all\" type=\"checkbox\">\n <label for=\"toggle-all\">Mark all as complete</label>\n {{#collection todosCollection filter=\"filterTodoItem\" item-view=\"todo-item\" tag=\"ul\" id=\"todo-list\"}}\n <div class=\"view\">\n <input class=\"toggle\" type=\"checkbox\" {{#if completed}}checked{{/if}}>\n <label>{{title}}</label>\n <button class=\"destroy\"></button>\n </div>\n <input class=\"edit\" value=\"{{title}}\">\n {{/collection}}\n </section>\n {{view \"stats\" tag=\"footer\" id=\"footer\"}}\n {{/empty}}\n</section>\n<div id=\"info\">\n <p>Double-click to edit a todo</p>\n <p>Written by <a href=\"https://github.com/addyosmani\">Addy Osmani</a> &amp; <a href=\"https://github.com/beastridge\">Ryan Eastridge</a></p>\n <p>Part of <a href=\"http://todomvc.com\">TodoMVC</a></p>\n</div>\n';(function() {
'use strict';
// Todo Router
// ----------
new (Thorax.Router.extend({
// The module variable is set inside of the file
// generated by Lumbar
name: module.name,
routes: module.routes,
setFilter: function( param ) {
// Set the current filter to be used
window.app.TodoFilter = param ? param.trim().replace(/^\//, '') : '';
// Thorax listens for a `filter` event which will
// force the collection to re-filter
window.app.Todos.trigger('filter');
}
}));
}());
;;
var ENTER_KEY = 13;
$(function() {
// Kick things off by creating the **App**.
var view = new Thorax.Views['app']();
$('body').append(view.el);
});
;;
return module.exports;
}).call(this);
var ENTER_KEY = 13;
$(function() {
// Kick things off by creating the **App**.
var view = new Thorax.Views['app']();
$('body').append(view.el);
});
(function() {
'use strict';
// Todo Collection
// ---------------
// The collection of todos is backed by *localStorage* instead of a remote
// server.
var TodoList = Backbone.Collection.extend({
// Reference to this collection's model.
model: window.app.Todo,
// Save all of the todo items under the `"todos"` namespace.
localStorage: new Store('todos-backbone'),
// Filter down the list of all todo items that are finished.
completed: function() {
return this.filter(function( todo ) {
return todo.get('completed');
});
},
// Filter down the list to only todo items that are still not finished.
remaining: function() {
return this.without.apply( this, this.completed() );
},
// We keep the Todos in sequential order, despite being saved by unordered
// GUID in the database. This generates the next order number for new items.
nextOrder: function() {
if ( !this.length ) {
return 1;
}
return this.last().get('order') + 1;
},
// Todos are sorted by their original insertion order.
comparator: function( todo ) {
return todo.get('order');
}
});
// Create our global collection of **Todos**.
window.app.Todos = new TodoList();
}());
//all templates are assumed to be in the templates directory
Thorax.templatePathPrefix = 'src/templates/';
var app = window.app = module.exports;
$(function() {
app.initBackboneLoader();
Backbone.history.start();
});
// A simple module to replace `Backbone.sync` with *localStorage*-based
// persistence. Models are given GUIDS, and saved into a JSON object. Simple
// as that.
// Generate four random hex digits.
function S4() {
return (((1+Math.random())*0x10000)|0).toString(16).substring(1);
};
// Generate a pseudo-GUID by concatenating random hexadecimal.
function guid() {
return (S4()+S4()+"-"+S4()+"-"+S4()+"-"+S4()+"-"+S4()+S4()+S4());
};
// Our Store is represented by a single JS object in *localStorage*. Create it
// with a meaningful name, like the name you'd give a table.
var Store = function(name) {
this.name = name;
var store = localStorage.getItem(this.name);
this.data = (store && JSON.parse(store)) || {};
};
_.extend(Store.prototype, {
// Save the current state of the **Store** to *localStorage*.
save: function() {
localStorage.setItem(this.name, JSON.stringify(this.data));
},
// Add a model, giving it a (hopefully)-unique GUID, if it doesn't already
// have an id of it's own.
create: function(model) {
if (!model.id) model.id = model.attributes.id = guid();
this.data[model.id] = model;
this.save();
return model;
},
// Update a model by replacing its copy in `this.data`.
update: function(model) {
this.data[model.id] = model;
this.save();
return model;
},
// Retrieve a model from `this.data` by id.
find: function(model) {
return this.data[model.id];
},
// Return the array of all models currently in storage.
findAll: function() {
return _.values(this.data);
},
// Delete a model from `this.data`, returning it.
destroy: function(model) {
delete this.data[model.id];
this.save();
return model;
}
});
// Override `Backbone.sync` to use delegate to the model or collection's
// *localStorage* property, which should be an instance of `Store`.
Backbone.sync = function(method, model, options) {
var resp;
var store = model.localStorage || model.collection.localStorage;
switch (method) {
case "read": resp = model.id ? store.find(model) : store.findAll(); break;
case "create": resp = store.create(model); break;
case "update": resp = store.update(model); break;
case "delete": resp = store.destroy(model); break;
}
if (resp) {
options.success(resp);
} else {
options.error("Record not found");
}
};
// Backbone.js 0.9.2
// (c) 2010-2012 Jeremy Ashkenas, DocumentCloud Inc.
// Backbone may be freely distributed under the MIT license.
// For all details and documentation:
// http://backbonejs.org
(function(){
// Initial Setup
// -------------
// Save a reference to the global object (`window` in the browser, `global`
// on the server).
var root = this;
// Save the previous value of the `Backbone` variable, so that it can be
// restored later on, if `noConflict` is used.
var previousBackbone = root.Backbone;
// Create a local reference to slice/splice.
var slice = Array.prototype.slice;
var splice = Array.prototype.splice;
// The top-level namespace. All public Backbone classes and modules will
// be attached to this. Exported for both CommonJS and the browser.
var Backbone;
if (typeof exports !== 'undefined') {
Backbone = exports;
} else {
Backbone = root.Backbone = {};
}
// Current version of the library. Keep in sync with `package.json`.
Backbone.VERSION = '0.9.2';
// Require Underscore, if we're on the server, and it's not already present.
var _ = root._;
if (!_ && (typeof require !== 'undefined')) _ = require('underscore');
// For Backbone's purposes, jQuery, Zepto, or Ender owns the `$` variable.
var $ = root.jQuery || root.Zepto || root.ender;
// Set the JavaScript library that will be used for DOM manipulation and
// Ajax calls (a.k.a. the `$` variable). By default Backbone will use: jQuery,
// Zepto, or Ender; but the `setDomLibrary()` method lets you inject an
// alternate JavaScript library (or a mock library for testing your views
// outside of a browser).
Backbone.setDomLibrary = function(lib) {
$ = lib;
};
// Runs Backbone.js in *noConflict* mode, returning the `Backbone` variable
// to its previous owner. Returns a reference to this Backbone object.
Backbone.noConflict = function() {
root.Backbone = previousBackbone;
return this;
};
// Turn on `emulateHTTP` to support legacy HTTP servers. Setting this option
// will fake `"PUT"` and `"DELETE"` requests via the `_method` parameter and
// set a `X-Http-Method-Override` header.
Backbone.emulateHTTP = false;
// Turn on `emulateJSON` to support legacy servers that can't deal with direct
// `application/json` requests ... will encode the body as
// `application/x-www-form-urlencoded` instead and will send the model in a
// form param named `model`.
Backbone.emulateJSON = false;
// Backbone.Events
// -----------------
// Regular expression used to split event strings
var eventSplitter = /\s+/;
// A module that can be mixed in to *any object* in order to provide it with
// custom events. You may bind with `on` or remove with `off` callback functions
// to an event; trigger`-ing an event fires all callbacks in succession.
//
// var object = {};
// _.extend(object, Backbone.Events);
// object.on('expand', function(){ alert('expanded'); });
// object.trigger('expand');
//
var Events = Backbone.Events = {
// Bind one or more space separated events, `events`, to a `callback`
// function. Passing `"all"` will bind the callback to all events fired.
on: function(events, callback, context) {
var calls, event, node, tail, list;
if (!callback) return this;
events = events.split(eventSplitter);
calls = this._callbacks || (this._callbacks = {});
// Create an immutable callback list, allowing traversal during
// modification. The tail is an empty object that will always be used
// as the next node.
while (event = events.shift()) {
list = calls[event];
node = list ? list.tail : {};
node.next = tail = {};
node.context = context;
node.callback = callback;
calls[event] = {tail: tail, next: list ? list.next : node};
}
return this;
},
// Remove one or many callbacks. If `context` is null, removes all callbacks
// with that function. If `callback` is null, removes all callbacks for the
// event. If `events` is null, removes all bound callbacks for all events.
off: function(events, callback, context) {
var event, calls, node, tail, cb, ctx;
// No events, or removing *all* events.
if (!(calls = this._callbacks)) return;
if (!(events || callback || context)) {
delete this._callbacks;
return this;
}
// Loop through the listed events and contexts, splicing them out of the
// linked list of callbacks if appropriate.
events = events ? events.split(eventSplitter) : _.keys(calls);
while (event = events.shift()) {
node = calls[event];
delete calls[event];
if (!node || !(callback || context)) continue;
// Create a new list, omitting the indicated callbacks.
tail = node.tail;
while ((node = node.next) !== tail) {
cb = node.callback;
ctx = node.context;
if ((callback && cb !== callback) || (context && ctx !== context)) {
this.on(event, cb, ctx);
}
}
}
return this;
},
// Trigger one or many events, firing all bound callbacks. Callbacks are
// passed the same arguments as `trigger` is, apart from the event name
// (unless you're listening on `"all"`, which will cause your callback to
// receive the true name of the event as the first argument).
trigger: function(events) {
var event, node, calls, tail, args, all, rest;
if (!(calls = this._callbacks)) return this;
all = calls.all;
events = events.split(eventSplitter);
rest = slice.call(arguments, 1);
// For each event, walk through the linked list of callbacks twice,
// first to trigger the event, then to trigger any `"all"` callbacks.
while (event = events.shift()) {
if (node = calls[event]) {
tail = node.tail;
while ((node = node.next) !== tail) {
node.callback.apply(node.context || this, rest);
}
}
if (node = all) {
tail = node.tail;
args = [event].concat(rest);
while ((node = node.next) !== tail) {
node.callback.apply(node.context || this, args);
}
}
}
return this;
}
};
// Aliases for backwards compatibility.
Events.bind = Events.on;
Events.unbind = Events.off;
// Backbone.Model
// --------------
// Create a new model, with defined attributes. A client id (`cid`)
// is automatically generated and assigned for you.
var Model = Backbone.Model = function(attributes, options) {
var defaults;
attributes || (attributes = {});
if (options && options.parse) attributes = this.parse(attributes);
if (defaults = getValue(this, 'defaults')) {
attributes = _.extend({}, defaults, attributes);
}
if (options && options.collection) this.collection = options.collection;
this.attributes = {};
this._escapedAttributes = {};
this.cid = _.uniqueId('c');
this.changed = {};
this._silent = {};
this._pending = {};
this.set(attributes, {silent: true});
// Reset change tracking.
this.changed = {};
this._silent = {};
this._pending = {};
this._previousAttributes = _.clone(this.attributes);
this.initialize.apply(this, arguments);
};
// Attach all inheritable methods to the Model prototype.
_.extend(Model.prototype, Events, {
// A hash of attributes whose current and previous value differ.
changed: null,
// A hash of attributes that have silently changed since the last time
// `change` was called. Will become pending attributes on the next call.
_silent: null,
// A hash of attributes that have changed since the last `'change'` event
// began.
_pending: null,
// The default name for the JSON `id` attribute is `"id"`. MongoDB and
// CouchDB users may want to set this to `"_id"`.
idAttribute: 'id',
// Initialize is an empty function by default. Override it with your own
// initialization logic.
initialize: function(){},
// Return a copy of the model's `attributes` object.
toJSON: function(options) {
return _.clone(this.attributes);
},
// Get the value of an attribute.
get: function(attr) {
return this.attributes[attr];
},
// Get the HTML-escaped value of an attribute.
escape: function(attr) {
var html;
if (html = this._escapedAttributes[attr]) return html;
var val = this.get(attr);
return this._escapedAttributes[attr] = _.escape(val == null ? '' : '' + val);
},
// Returns `true` if the attribute contains a value that is not null
// or undefined.
has: function(attr) {
return this.get(attr) != null;
},
// Set a hash of model attributes on the object, firing `"change"` unless
// you choose to silence it.
set: function(key, value, options) {
var attrs, attr, val;
// Handle both `"key", value` and `{key: value}` -style arguments.
if (_.isObject(key) || key == null) {
attrs = key;
options = value;
} else {
attrs = {};
attrs[key] = value;
}
// Extract attributes and options.
options || (options = {});
if (!attrs) return this;
if (attrs instanceof Model) attrs = attrs.attributes;
if (options.unset) for (attr in attrs) attrs[attr] = void 0;
// Run validation.
if (!this._validate(attrs, options)) return false;
// Check for changes of `id`.
if (this.idAttribute in attrs) this.id = attrs[this.idAttribute];
var changes = options.changes = {};
var now = this.attributes;
var escaped = this._escapedAttributes;
var prev = this._previousAttributes || {};
// For each `set` attribute...
for (attr in attrs) {
val = attrs[attr];
// If the new and current value differ, record the change.
if (!_.isEqual(now[attr], val) || (options.unset && _.has(now, attr))) {
delete escaped[attr];
(options.silent ? this._silent : changes)[attr] = true;
}
// Update or delete the current value.
options.unset ? delete now[attr] : now[attr] = val;
// If the new and previous value differ, record the change. If not,
// then remove changes for this attribute.
if (!_.isEqual(prev[attr], val) || (_.has(now, attr) != _.has(prev, attr))) {
this.changed[attr] = val;
if (!options.silent) this._pending[attr] = true;
} else {
delete this.changed[attr];
delete this._pending[attr];
}
}
// Fire the `"change"` events.
if (!options.silent) this.change(options);
return this;
},
// Remove an attribute from the model, firing `"change"` unless you choose
// to silence it. `unset` is a noop if the attribute doesn't exist.
unset: function(attr, options) {
(options || (options = {})).unset = true;
return this.set(attr, null, options);
},
// Clear all attributes on the model, firing `"change"` unless you choose
// to silence it.
clear: function(options) {
(options || (options = {})).unset = true;
return this.set(_.clone(this.attributes), options);
},
// Fetch the model from the server. If the server's representation of the
// model differs from its current attributes, they will be overriden,
// triggering a `"change"` event.
fetch: function(options) {
options = options ? _.clone(options) : {};
var model = this;
var success = options.success;
options.success = function(resp, status, xhr) {
if (!model.set(model.parse(resp, xhr), options)) return false;
if (success) success(model, resp);
};
options.error = Backbone.wrapError(options.error, model, options);
return (this.sync || Backbone.sync).call(this, 'read', this, options);
},
// Set a hash of model attributes, and sync the model to the server.
// If the server returns an attributes hash that differs, the model's
// state will be `set` again.
save: function(key, value, options) {
var attrs, current;
// Handle both `("key", value)` and `({key: value})` -style calls.
if (_.isObject(key) || key == null) {
attrs = key;
options = value;
} else {
attrs = {};
attrs[key] = value;
}
options = options ? _.clone(options) : {};
// If we're "wait"-ing to set changed attributes, validate early.
if (options.wait) {
if (!this._validate(attrs, options)) return false;
current = _.clone(this.attributes);
}
// Regular saves `set` attributes before persisting to the server.
var silentOptions = _.extend({}, options, {silent: true});
if (attrs && !this.set(attrs, options.wait ? silentOptions : options)) {
return false;
}
// After a successful server-side save, the client is (optionally)
// updated with the server-side state.
var model = this;
var success = options.success;
options.success = function(resp, status, xhr) {
var serverAttrs = model.parse(resp, xhr);
if (options.wait) {
delete options.wait;
serverAttrs = _.extend(attrs || {}, serverAttrs);
}
if (!model.set(serverAttrs, options)) return false;
if (success) {
success(model, resp);
} else {
model.trigger('sync', model, resp, options);
}
};
// Finish configuring and sending the Ajax request.
options.error = Backbone.wrapError(options.error, model, options);
var method = this.isNew() ? 'create' : 'update';
var xhr = (this.sync || Backbone.sync).call(this, method, this, options);
if (options.wait) this.set(current, silentOptions);
return xhr;
},
// Destroy this model on the server if it was already persisted.
// Optimistically removes the model from its collection, if it has one.
// If `wait: true` is passed, waits for the server to respond before removal.
destroy: function(options) {
options = options ? _.clone(options) : {};
var model = this;
var success = options.success;
var triggerDestroy = function() {
model.trigger('destroy', model, model.collection, options);
};
if (this.isNew()) {
triggerDestroy();
return false;
}
options.success = function(resp) {
if (options.wait) triggerDestroy();
if (success) {
success(model, resp);
} else {
model.trigger('sync', model, resp, options);
}
};
options.error = Backbone.wrapError(options.error, model, options);
var xhr = (this.sync || Backbone.sync).call(this, 'delete', this, options);
if (!options.wait) triggerDestroy();
return xhr;
},
// Default URL for the model's representation on the server -- if you're
// using Backbone's restful methods, override this to change the endpoint
// that will be called.
url: function() {
var base = getValue(this, 'urlRoot') || getValue(this.collection, 'url') || urlError();
if (this.isNew()) return base;
return base + (base.charAt(base.length - 1) == '/' ? '' : '/') + encodeURIComponent(this.id);
},
// **parse** converts a response into the hash of attributes to be `set` on
// the model. The default implementation is just to pass the response along.
parse: function(resp, xhr) {
return resp;
},
// Create a new model with identical attributes to this one.
clone: function() {
return new this.constructor(this.attributes);
},
// A model is new if it has never been saved to the server, and lacks an id.
isNew: function() {
return this.id == null;
},
// Call this method to manually fire a `"change"` event for this model and
// a `"change:attribute"` event for each changed attribute.
// Calling this will cause all objects observing the model to update.
change: function(options) {
options || (options = {});
var changing = this._changing;
this._changing = true;
// Silent changes become pending changes.
for (var attr in this._silent) this._pending[attr] = true;
// Silent changes are triggered.
var changes = _.extend({}, options.changes, this._silent);
this._silent = {};
for (var attr in changes) {
this.trigger('change:' + attr, this, this.get(attr), options);
}
if (changing) return this;
// Continue firing `"change"` events while there are pending changes.
while (!_.isEmpty(this._pending)) {
this._pending = {};
this.trigger('change', this, options);
// Pending and silent changes still remain.
for (var attr in this.changed) {
if (this._pending[attr] || this._silent[attr]) continue;
delete this.changed[attr];
}
this._previousAttributes = _.clone(this.attributes);
}
this._changing = false;
return this;
},
// Determine if the model has changed since the last `"change"` event.
// If you specify an attribute name, determine if that attribute has changed.
hasChanged: function(attr) {
if (!arguments.length) return !_.isEmpty(this.changed);
return _.has(this.changed, attr);
},
// Return an object containing all the attributes that have changed, or
// false if there are no changed attributes. Useful for determining what
// parts of a view need to be updated and/or what attributes need to be
// persisted to the server. Unset attributes will be set to undefined.
// You can also pass an attributes object to diff against the model,
// determining if there *would be* a change.
changedAttributes: function(diff) {
if (!diff) return this.hasChanged() ? _.clone(this.changed) : false;
var val, changed = false, old = this._previousAttributes;
for (var attr in diff) {
if (_.isEqual(old[attr], (val = diff[attr]))) continue;
(changed || (changed = {}))[attr] = val;
}
return changed;
},
// Get the previous value of an attribute, recorded at the time the last
// `"change"` event was fired.
previous: function(attr) {
if (!arguments.length || !this._previousAttributes) return null;
return this._previousAttributes[attr];
},
// Get all of the attributes of the model at the time of the previous
// `"change"` event.
previousAttributes: function() {
return _.clone(this._previousAttributes);
},
// Check if the model is currently in a valid state. It's only possible to
// get into an *invalid* state if you're using silent changes.
isValid: function() {
return !this.validate(this.attributes);
},
// Run validation against the next complete set of model attributes,
// returning `true` if all is well. If a specific `error` callback has
// been passed, call that instead of firing the general `"error"` event.
_validate: function(attrs, options) {
if (options.silent || !this.validate) return true;
attrs = _.extend({}, this.attributes, attrs);
var error = this.validate(attrs, options);
if (!error) return true;
if (options && options.error) {
options.error(this, error, options);
} else {
this.trigger('error', this, error, options);
}
return false;
}
});
// Backbone.Collection
// -------------------
// Provides a standard collection class for our sets of models, ordered
// or unordered. If a `comparator` is specified, the Collection will maintain
// its models in sort order, as they're added and removed.
var Collection = Backbone.Collection = function(models, options) {
options || (options = {});
if (options.model) this.model = options.model;
if (options.comparator) this.comparator = options.comparator;
this._reset();
this.initialize.apply(this, arguments);
if (models) this.reset(models, {silent: true, parse: options.parse});
};
// Define the Collection's inheritable methods.
_.extend(Collection.prototype, Events, {
// The default model for a collection is just a **Backbone.Model**.
// This should be overridden in most cases.
model: Model,
// Initialize is an empty function by default. Override it with your own
// initialization logic.
initialize: function(){},
// The JSON representation of a Collection is an array of the
// models' attributes.
toJSON: function(options) {
return this.map(function(model){ return model.toJSON(options); });
},
// Add a model, or list of models to the set. Pass **silent** to avoid
// firing the `add` event for every new model.
add: function(models, options) {
var i, index, length, model, cid, id, cids = {}, ids = {}, dups = [];
options || (options = {});
models = _.isArray(models) ? models.slice() : [models];
// Begin by turning bare objects into model references, and preventing
// invalid models or duplicate models from being added.
for (i = 0, length = models.length; i < length; i++) {
if (!(model = models[i] = this._prepareModel(models[i], options))) {
throw new Error("Can't add an invalid model to a collection");
}
cid = model.cid;
id = model.id;
if (cids[cid] || this._byCid[cid] || ((id != null) && (ids[id] || this._byId[id]))) {
dups.push(i);
continue;
}
cids[cid] = ids[id] = model;
}
// Remove duplicates.
i = dups.length;
while (i--) {
models.splice(dups[i], 1);
}
// Listen to added models' events, and index models for lookup by
// `id` and by `cid`.
for (i = 0, length = models.length; i < length; i++) {
(model = models[i]).on('all', this._onModelEvent, this);
this._byCid[model.cid] = model;
if (model.id != null) this._byId[model.id] = model;
}
// Insert models into the collection, re-sorting if needed, and triggering
// `add` events unless silenced.
this.length += length;
index = options.at != null ? options.at : this.models.length;
splice.apply(this.models, [index, 0].concat(models));
if (this.comparator) this.sort({silent: true});
if (options.silent) return this;
for (i = 0, length = this.models.length; i < length; i++) {
if (!cids[(model = this.models[i]).cid]) continue;
options.index = i;
model.trigger('add', model, this, options);
}
return this;
},
// Remove a model, or a list of models from the set. Pass silent to avoid
// firing the `remove` event for every model removed.
remove: function(models, options) {
var i, l, index, model;
options || (options = {});
models = _.isArray(models) ? models.slice() : [models];
for (i = 0, l = models.length; i < l; i++) {
model = this.getByCid(models[i]) || this.get(models[i]);
if (!model) continue;
delete this._byId[model.id];
delete this._byCid[model.cid];
index = this.indexOf(model);
this.models.splice(index, 1);
this.length--;
if (!options.silent) {
options.index = index;
model.trigger('remove', model, this, options);
}
this._removeReference(model);
}
return this;
},
// Add a model to the end of the collection.
push: function(model, options) {
model = this._prepareModel(model, options);
this.add(model, options);
return model;
},
// Remove a model from the end of the collection.
pop: function(options) {
var model = this.at(this.length - 1);
this.remove(model, options);
return model;
},
// Add a model to the beginning of the collection.
unshift: function(model, options) {
model = this._prepareModel(model, options);
this.add(model, _.extend({at: 0}, options));
return model;
},
// Remove a model from the beginning of the collection.
shift: function(options) {
var model = this.at(0);
this.remove(model, options);
return model;
},
// Get a model from the set by id.
get: function(id) {
if (id == null) return void 0;
return this._byId[id.id != null ? id.id : id];
},
// Get a model from the set by client id.
getByCid: function(cid) {
return cid && this._byCid[cid.cid || cid];
},
// Get the model at the given index.
at: function(index) {
return this.models[index];
},
// Return models with matching attributes. Useful for simple cases of `filter`.
where: function(attrs) {
if (_.isEmpty(attrs)) return [];
return this.filter(function(model) {
for (var key in attrs) {
if (attrs[key] !== model.get(key)) return false;
}
return true;
});
},
// Force the collection to re-sort itself. You don't need to call this under
// normal circumstances, as the set will maintain sort order as each item
// is added.
sort: function(options) {
options || (options = {});
if (!this.comparator) throw new Error('Cannot sort a set without a comparator');
var boundComparator = _.bind(this.comparator, this);
if (this.comparator.length == 1) {
this.models = this.sortBy(boundComparator);
} else {
this.models.sort(boundComparator);
}
if (!options.silent) this.trigger('reset', this, options);
return this;
},
// Pluck an attribute from each model in the collection.
pluck: function(attr) {
return _.map(this.models, function(model){ return model.get(attr); });
},
// When you have more items than you want to add or remove individually,
// you can reset the entire set with a new list of models, without firing
// any `add` or `remove` events. Fires `reset` when finished.
reset: function(models, options) {
models || (models = []);
options || (options = {});
for (var i = 0, l = this.models.length; i < l; i++) {
this._removeReference(this.models[i]);
}
this._reset();
this.add(models, _.extend({silent: true}, options));
if (!options.silent) this.trigger('reset', this, options);
return this;
},
// Fetch the default set of models for this collection, resetting the
// collection when they arrive. If `add: true` is passed, appends the
// models to the collection instead of resetting.
fetch: function(options) {
options = options ? _.clone(options) : {};
if (options.parse === undefined) options.parse = true;
var collection = this;
var success = options.success;
options.success = function(resp, status, xhr) {
collection[options.add ? 'add' : 'reset'](collection.parse(resp, xhr), options);
if (success) success(collection, resp);
};
options.error = Backbone.wrapError(options.error, collection, options);
return (this.sync || Backbone.sync).call(this, 'read', this, options);
},
// Create a new instance of a model in this collection. Add the model to the
// collection immediately, unless `wait: true` is passed, in which case we
// wait for the server to agree.
create: function(model, options) {
var coll = this;
options = options ? _.clone(options) : {};
model = this._prepareModel(model, options);
if (!model) return false;
if (!options.wait) coll.add(model, options);
var success = options.success;
options.success = function(nextModel, resp, xhr) {
if (options.wait) coll.add(nextModel, options);
if (success) {
success(nextModel, resp);
} else {
nextModel.trigger('sync', model, resp, options);
}
};
model.save(null, options);
return model;
},
// **parse** converts a response into a list of models to be added to the
// collection. The default implementation is just to pass it through.
parse: function(resp, xhr) {
return resp;
},
// Proxy to _'s chain. Can't be proxied the same way the rest of the
// underscore methods are proxied because it relies on the underscore
// constructor.
chain: function () {
return _(this.models).chain();
},
// Reset all internal state. Called when the collection is reset.
_reset: function(options) {
this.length = 0;
this.models = [];
this._byId = {};
this._byCid = {};
},
// Prepare a model or hash of attributes to be added to this collection.
_prepareModel: function(model, options) {
options || (options = {});
if (!(model instanceof Model)) {
var attrs = model;
options.collection = this;
model = new this.model(attrs, options);
if (!model._validate(model.attributes, options)) model = false;
} else if (!model.collection) {
model.collection = this;
}
return model;
},
// Internal method to remove a model's ties to a collection.
_removeReference: function(model) {
if (this == model.collection) {
delete model.collection;
}
model.off('all', this._onModelEvent, this);
},
// Internal method called every time a model in the set fires an event.
// Sets need to update their indexes when models change ids. All other
// events simply proxy through. "add" and "remove" events that originate
// in other collections are ignored.
_onModelEvent: function(event, model, collection, options) {
if ((event == 'add' || event == 'remove') && collection != this) return;
if (event == 'destroy') {
this.remove(model, options);
}
if (model && event === 'change:' + model.idAttribute) {
delete this._byId[model.previous(model.idAttribute)];
this._byId[model.id] = model;
}
this.trigger.apply(this, arguments);
}
});
// Underscore methods that we want to implement on the Collection.
var methods = ['forEach', 'each', 'map', 'reduce', 'reduceRight', 'find',
'detect', 'filter', 'select', 'reject', 'every', 'all', 'some', 'any',
'include', 'contains', 'invoke', 'max', 'min', 'sortBy', 'sortedIndex',
'toArray', 'size', 'first', 'initial', 'rest', 'last', 'without', 'indexOf',
'shuffle', 'lastIndexOf', 'isEmpty', 'groupBy'];
// Mix in each Underscore method as a proxy to `Collection#models`.
_.each(methods, function(method) {
Collection.prototype[method] = function() {
return _[method].apply(_, [this.models].concat(_.toArray(arguments)));
};
});
// Backbone.Router
// -------------------
// Routers map faux-URLs to actions, and fire events when routes are
// matched. Creating a new one sets its `routes` hash, if not set statically.
var Router = Backbone.Router = function(options) {
options || (options = {});
if (options.routes) this.routes = options.routes;
this._bindRoutes();
this.initialize.apply(this, arguments);
};
// Cached regular expressions for matching named param parts and splatted
// parts of route strings.
var namedParam = /:\w+/g;
var splatParam = /\*\w+/g;
var escapeRegExp = /[-[\]{}()+?.,\\^$|#\s]/g;
// Set up all inheritable **Backbone.Router** properties and methods.
_.extend(Router.prototype, Events, {
// Initialize is an empty function by default. Override it with your own
// initialization logic.
initialize: function(){},
// Manually bind a single named route to a callback. For example:
//
// this.route('search/:query/p:num', 'search', function(query, num) {
// ...
// });
//
route: function(route, name, callback) {
Backbone.history || (Backbone.history = new History);
if (!_.isRegExp(route)) route = this._routeToRegExp(route);
if (!callback) callback = this[name];
Backbone.history.route(route, _.bind(function(fragment) {
var args = this._extractParameters(route, fragment);
callback && callback.apply(this, args);
this.trigger.apply(this, ['route:' + name].concat(args));
Backbone.history.trigger('route', this, name, args);
}, this));
return this;
},
// Simple proxy to `Backbone.history` to save a fragment into the history.
navigate: function(fragment, options) {
Backbone.history.navigate(fragment, options);
},
// Bind all defined routes to `Backbone.history`. We have to reverse the
// order of the routes here to support behavior where the most general
// routes can be defined at the bottom of the route map.
_bindRoutes: function() {
if (!this.routes) return;
var routes = [];
for (var route in this.routes) {
routes.unshift([route, this.routes[route]]);
}
for (var i = 0, l = routes.length; i < l; i++) {
this.route(routes[i][0], routes[i][1], this[routes[i][1]]);
}
},
// Convert a route string into a regular expression, suitable for matching
// against the current location hash.
_routeToRegExp: function(route) {
route = route.replace(escapeRegExp, '\\$&')
.replace(namedParam, '([^\/]+)')
.replace(splatParam, '(.*?)');
return new RegExp('^' + route + '$');
},
// Given a route, and a URL fragment that it matches, return the array of
// extracted parameters.
_extractParameters: function(route, fragment) {
return route.exec(fragment).slice(1);
}
});
// Backbone.History
// ----------------
// Handles cross-browser history management, based on URL fragments. If the
// browser does not support `onhashchange`, falls back to polling.
var History = Backbone.History = function() {
this.handlers = [];
_.bindAll(this, 'checkUrl');
};
// Cached regex for cleaning leading hashes and slashes .
var routeStripper = /^[#\/]/;
// Cached regex for detecting MSIE.
var isExplorer = /msie [\w.]+/;
// Has the history handling already been started?
History.started = false;
// Set up all inheritable **Backbone.History** properties and methods.
_.extend(History.prototype, Events, {
// The default interval to poll for hash changes, if necessary, is
// twenty times a second.
interval: 50,
// Gets the true hash value. Cannot use location.hash directly due to bug
// in Firefox where location.hash will always be decoded.
getHash: function(windowOverride) {
var loc = windowOverride ? windowOverride.location : window.location;
var match = loc.href.match(/#(.*)$/);
return match ? match[1] : '';
},
// Get the cross-browser normalized URL fragment, either from the URL,
// the hash, or the override.
getFragment: function(fragment, forcePushState) {
if (fragment == null) {
if (this._hasPushState || forcePushState) {
fragment = window.location.pathname;
var search = window.location.search;
if (search) fragment += search;
} else {
fragment = this.getHash();
}
}
if (!fragment.indexOf(this.options.root)) fragment = fragment.substr(this.options.root.length);
return fragment.replace(routeStripper, '');
},
// Start the hash change handling, returning `true` if the current URL matches
// an existing route, and `false` otherwise.
start: function(options) {
if (History.started) throw new Error("Backbone.history has already been started");
History.started = true;
// Figure out the initial configuration. Do we need an iframe?
// Is pushState desired ... is it available?
this.options = _.extend({}, {root: '/'}, this.options, options);
this._wantsHashChange = this.options.hashChange !== false;
this._wantsPushState = !!this.options.pushState;
this._hasPushState = !!(this.options.pushState && window.history && window.history.pushState);
var fragment = this.getFragment();
var docMode = document.documentMode;
var oldIE = (isExplorer.exec(navigator.userAgent.toLowerCase()) && (!docMode || docMode <= 7));
if (oldIE) {
this.iframe = $('<iframe src="javascript:0" tabindex="-1" />').hide().appendTo('body')[0].contentWindow;
this.navigate(fragment);
}
// Depending on whether we're using pushState or hashes, and whether
// 'onhashchange' is supported, determine how we check the URL state.
if (this._hasPushState) {
$(window).bind('popstate', this.checkUrl);
} else if (this._wantsHashChange && ('onhashchange' in window) && !oldIE) {
$(window).bind('hashchange', this.checkUrl);
} else if (this._wantsHashChange) {
this._checkUrlInterval = setInterval(this.checkUrl, this.interval);
}
// Determine if we need to change the base url, for a pushState link
// opened by a non-pushState browser.
this.fragment = fragment;
var loc = window.location;
var atRoot = loc.pathname == this.options.root;
// If we've started off with a route from a `pushState`-enabled browser,
// but we're currently in a browser that doesn't support it...
if (this._wantsHashChange && this._wantsPushState && !this._hasPushState && !atRoot) {
this.fragment = this.getFragment(null, true);
window.location.replace(this.options.root + '#' + this.fragment);
// Return immediately as browser will do redirect to new url
return true;
// Or if we've started out with a hash-based route, but we're currently
// in a browser where it could be `pushState`-based instead...
} else if (this._wantsPushState && this._hasPushState && atRoot && loc.hash) {
this.fragment = this.getHash().replace(routeStripper, '');
window.history.replaceState({}, document.title, loc.protocol + '//' + loc.host + this.options.root + this.fragment);
}
if (!this.options.silent) {
return this.loadUrl();
}
},
// Disable Backbone.history, perhaps temporarily. Not useful in a real app,
// but possibly useful for unit testing Routers.
stop: function() {
$(window).unbind('popstate', this.checkUrl).unbind('hashchange', this.checkUrl);
clearInterval(this._checkUrlInterval);
History.started = false;
},
// Add a route to be tested when the fragment changes. Routes added later
// may override previous routes.
route: function(route, callback) {
this.handlers.unshift({route: route, callback: callback});
},
// Checks the current URL to see if it has changed, and if it has,
// calls `loadUrl`, normalizing across the hidden iframe.
checkUrl: function(e) {
var current = this.getFragment();
if (current == this.fragment && this.iframe) current = this.getFragment(this.getHash(this.iframe));
if (current == this.fragment) return false;
if (this.iframe) this.navigate(current);
this.loadUrl() || this.loadUrl(this.getHash());
},
// Attempt to load the current URL fragment. If a route succeeds with a
// match, returns `true`. If no defined routes matches the fragment,
// returns `false`.
loadUrl: function(fragmentOverride) {
var fragment = this.fragment = this.getFragment(fragmentOverride);
var matched = _.any(this.handlers, function(handler) {
if (handler.route.test(fragment)) {
handler.callback(fragment);
return true;
}
});
return matched;
},
// Save a fragment into the hash history, or replace the URL state if the
// 'replace' option is passed. You are responsible for properly URL-encoding
// the fragment in advance.
//
// The options object can contain `trigger: true` if you wish to have the
// route callback be fired (not usually desirable), or `replace: true`, if
// you wish to modify the current URL without adding an entry to the history.
navigate: function(fragment, options) {
if (!History.started) return false;
if (!options || options === true) options = {trigger: options};
var frag = (fragment || '').replace(routeStripper, '');
if (this.fragment == frag) return;
// If pushState is available, we use it to set the fragment as a real URL.
if (this._hasPushState) {
if (frag.indexOf(this.options.root) != 0) frag = this.options.root + frag;
this.fragment = frag;
window.history[options.replace ? 'replaceState' : 'pushState']({}, document.title, frag);
// If hash changes haven't been explicitly disabled, update the hash
// fragment to store history.
} else if (this._wantsHashChange) {
this.fragment = frag;
this._updateHash(window.location, frag, options.replace);
if (this.iframe && (frag != this.getFragment(this.getHash(this.iframe)))) {
// Opening and closing the iframe tricks IE7 and earlier to push a history entry on hash-tag change.
// When replace is true, we don't want this.
if(!options.replace) this.iframe.document.open().close();
this._updateHash(this.iframe.location, frag, options.replace);
}
// If you've told us that you explicitly don't want fallback hashchange-
// based history, then `navigate` becomes a page refresh.
} else {
window.location.assign(this.options.root + fragment);
}
if (options.trigger) this.loadUrl(fragment);
},
// Update the hash location, either replacing the current entry, or adding
// a new one to the browser history.
_updateHash: function(location, fragment, replace) {
if (replace) {
location.replace(location.toString().replace(/(javascript:|#).*$/, '') + '#' + fragment);
} else {
location.hash = fragment;
}
}
});
// Backbone.View
// -------------
// Creating a Backbone.View creates its initial element outside of the DOM,
// if an existing element is not provided...
var View = Backbone.View = function(options) {
this.cid = _.uniqueId('view');
this._configure(options || {});
this._ensureElement();
this.initialize.apply(this, arguments);
this.delegateEvents();
};
// Cached regex to split keys for `delegate`.
var delegateEventSplitter = /^(\S+)\s*(.*)$/;
// List of view options to be merged as properties.
var viewOptions = ['model', 'collection', 'el', 'id', 'attributes', 'className', 'tagName'];
// Set up all inheritable **Backbone.View** properties and methods.
_.extend(View.prototype, Events, {
// The default `tagName` of a View's element is `"div"`.
tagName: 'div',
// jQuery delegate for element lookup, scoped to DOM elements within the
// current view. This should be prefered to global lookups where possible.
$: function(selector) {
return this.$el.find(selector);
},
// Initialize is an empty function by default. Override it with your own
// initialization logic.
initialize: function(){},
// **render** is the core function that your view should override, in order
// to populate its element (`this.el`), with the appropriate HTML. The
// convention is for **render** to always return `this`.
render: function() {
return this;
},
// Remove this view from the DOM. Note that the view isn't present in the
// DOM by default, so calling this method may be a no-op.
remove: function() {
this.$el.remove();
return this;
},
// For small amounts of DOM Elements, where a full-blown template isn't
// needed, use **make** to manufacture elements, one at a time.
//
// var el = this.make('li', {'class': 'row'}, this.model.escape('title'));
//
make: function(tagName, attributes, content) {
var el = document.createElement(tagName);
if (attributes) $(el).attr(attributes);
if (content) $(el).html(content);
return el;
},
// Change the view's element (`this.el` property), including event
// re-delegation.
setElement: function(element, delegate) {
if (this.$el) this.undelegateEvents();
this.$el = (element instanceof $) ? element : $(element);
this.el = this.$el[0];
if (delegate !== false) this.delegateEvents();
return this;
},
// Set callbacks, where `this.events` is a hash of
//
// *{"event selector": "callback"}*
//
// {
// 'mousedown .title': 'edit',
// 'click .button': 'save'
// 'click .open': function(e) { ... }
// }
//
// pairs. Callbacks will be bound to the view, with `this` set properly.
// Uses event delegation for efficiency.
// Omitting the selector binds the event to `this.el`.
// This only works for delegate-able events: not `focus`, `blur`, and
// not `change`, `submit`, and `reset` in Internet Explorer.
delegateEvents: function(events) {
if (!(events || (events = getValue(this, 'events')))) return;
this.undelegateEvents();
for (var key in events) {
var method = events[key];
if (!_.isFunction(method)) method = this[events[key]];
if (!method) throw new Error('Method "' + events[key] + '" does not exist');
var match = key.match(delegateEventSplitter);
var eventName = match[1], selector = match[2];
method = _.bind(method, this);
eventName += '.delegateEvents' + this.cid;
if (selector === '') {
this.$el.bind(eventName, method);
} else {
this.$el.delegate(selector, eventName, method);
}
}
},
// Clears all callbacks previously bound to the view with `delegateEvents`.
// You usually don't need to use this, but may wish to if you have multiple
// Backbone views attached to the same DOM element.
undelegateEvents: function() {
this.$el.unbind('.delegateEvents' + this.cid);
},
// Performs the initial configuration of a View with a set of options.
// Keys with special meaning *(model, collection, id, className)*, are
// attached directly to the view.
_configure: function(options) {
if (this.options) options = _.extend({}, this.options, options);
for (var i = 0, l = viewOptions.length; i < l; i++) {
var attr = viewOptions[i];
if (options[attr]) this[attr] = options[attr];
}
this.options = options;
},
// Ensure that the View has a DOM element to render into.
// If `this.el` is a string, pass it through `$()`, take the first
// matching element, and re-assign it to `el`. Otherwise, create
// an element from the `id`, `className` and `tagName` properties.
_ensureElement: function() {
if (!this.el) {
var attrs = getValue(this, 'attributes') || {};
if (this.id) attrs.id = this.id;
if (this.className) attrs['class'] = this.className;
this.setElement(this.make(this.tagName, attrs), false);
} else {
this.setElement(this.el, false);
}
}
});
// The self-propagating extend function that Backbone classes use.
var extend = function (protoProps, classProps) {
var child = inherits(this, protoProps, classProps);
child.extend = this.extend;
return child;
};
// Set up inheritance for the model, collection, and view.
Model.extend = Collection.extend = Router.extend = View.extend = extend;
// Backbone.sync
// -------------
// Map from CRUD to HTTP for our default `Backbone.sync` implementation.
var methodMap = {
'create': 'POST',
'update': 'PUT',
'delete': 'DELETE',
'read': 'GET'
};
// Override this function to change the manner in which Backbone persists
// models to the server. You will be passed the type of request, and the
// model in question. By default, makes a RESTful Ajax request
// to the model's `url()`. Some possible customizations could be:
//
// * Use `setTimeout` to batch rapid-fire updates into a single request.
// * Send up the models as XML instead of JSON.
// * Persist models via WebSockets instead of Ajax.
//
// Turn on `Backbone.emulateHTTP` in order to send `PUT` and `DELETE` requests
// as `POST`, with a `_method` parameter containing the true HTTP method,
// as well as all requests with the body as `application/x-www-form-urlencoded`
// instead of `application/json` with the model in a param named `model`.
// Useful when interfacing with server-side languages like **PHP** that make
// it difficult to read the body of `PUT` requests.
Backbone.sync = function(method, model, options) {
var type = methodMap[method];
// Default options, unless specified.
options || (options = {});
// Default JSON-request options.
var params = {type: type, dataType: 'json'};
// Ensure that we have a URL.
if (!options.url) {
params.url = getValue(model, 'url') || urlError();
}
// Ensure that we have the appropriate request data.
if (!options.data && model && (method == 'create' || method == 'update')) {
params.contentType = 'application/json';
params.data = JSON.stringify(model.toJSON());
}
// For older servers, emulate JSON by encoding the request into an HTML-form.
if (Backbone.emulateJSON) {
params.contentType = 'application/x-www-form-urlencoded';
params.data = params.data ? {model: params.data} : {};
}
// For older servers, emulate HTTP by mimicking the HTTP method with `_method`
// And an `X-HTTP-Method-Override` header.
if (Backbone.emulateHTTP) {
if (type === 'PUT' || type === 'DELETE') {
if (Backbone.emulateJSON) params.data._method = type;
params.type = 'POST';
params.beforeSend = function(xhr) {
xhr.setRequestHeader('X-HTTP-Method-Override', type);
};
}
}
// Don't process data on a non-GET request.
if (params.type !== 'GET' && !Backbone.emulateJSON) {
params.processData = false;
}
// Make the request, allowing the user to override any Ajax options.
return $.ajax(_.extend(params, options));
};
// Wrap an optional error callback with a fallback error event.
Backbone.wrapError = function(onError, originalModel, options) {
return function(model, resp) {
resp = model === originalModel ? resp : model;
if (onError) {
onError(originalModel, resp, options);
} else {
originalModel.trigger('error', originalModel, resp, options);
}
};
};
// Helpers
// -------
// Shared empty constructor function to aid in prototype-chain creation.
var ctor = function(){};
// Helper function to correctly set up the prototype chain, for subclasses.
// Similar to `goog.inherits`, but uses a hash of prototype properties and
// class properties to be extended.
var inherits = function(parent, protoProps, staticProps) {
var child;
// The constructor function for the new subclass is either defined by you
// (the "constructor" property in your `extend` definition), or defaulted
// by us to simply call the parent's constructor.
if (protoProps && protoProps.hasOwnProperty('constructor')) {
child = protoProps.constructor;
} else {
child = function(){ parent.apply(this, arguments); };
}
// Inherit class (static) properties from parent.
_.extend(child, parent);
// Set the prototype chain to inherit from `parent`, without calling
// `parent`'s constructor function.
ctor.prototype = parent.prototype;
child.prototype = new ctor();
// Add prototype properties (instance properties) to the subclass,
// if supplied.
if (protoProps) _.extend(child.prototype, protoProps);
// Add static properties to the constructor function, if supplied.
if (staticProps) _.extend(child, staticProps);
// Correctly set child's `prototype.constructor`.
child.prototype.constructor = child;
// Set a convenience property in case the parent's prototype is needed later.
child.__super__ = parent.prototype;
return child;
};
// Helper function to get a value from a Backbone object as a property
// or as a function.
var getValue = function(object, prop) {
if (!(object && object[prop])) return null;
return _.isFunction(object[prop]) ? object[prop]() : object[prop];
};
// Throw an error when a URL is needed, and none is supplied.
var urlError = function() {
throw new Error('A "url" property or function must be specified');
};
}).call(this);
module.exports.initBackboneLoader = function(loaderModule, failure) {
var lumbarLoader = (loaderModule || module.exports).loader;
// Setup backbone route loading
var handlers = {
routes: {}
};
var pendingModules = {};
for (var moduleName in lumbarLoader.map.modules) {
handlers['loader_' + moduleName] = (function(moduleName) {
return function() {
if (lumbarLoader.isLoaded(moduleName)) {
// The module didn't implement the proper route
failure && failure('missing-route', moduleName);
return;
} else if (pendingModules[moduleName]) {
// Do not exec the backbone callback multiple times
return;
}
pendingModules[moduleName] = true;
lumbarLoader.loadModule(moduleName, function(err) {
pendingModules[moduleName] = false;
if (err) {
failure && failure(err, moduleName);
return;
}
// Reload with the new route
Backbone.history.loadUrl();
});
};
})(moduleName);
}
// For each route create a handler that will load the associated module on request
for (var route in lumbarLoader.map.routes) {
handlers.routes[route] = 'loader_' + lumbarLoader.map.routes[route];
}
new (Backbone.Router.extend(handlers));
};
// Automatically initialize the loader if everything is setup already
if (module.exports.loader && module.exports.loader.map && window.Backbone) {
module.exports.initBackboneLoader();
}
(function() {
lumbarLoader.initEvents = function() {
// Needs to be defered until we know that backbone has been loaded
_.extend(lumbarLoader, Backbone.Events);
};
if (window.Backbone) {
lumbarLoader.initEvents();
}
var baseLoadModule = lumbarLoader.loadModule;
lumbarLoader.loadModule = function(moduleName, callback, options) {
options = options || {};
if (!options.silent) {
lumbarLoader.trigger && lumbarLoader.trigger('load:start', moduleName, undefined, lumbarLoader);
}
baseLoadModule(moduleName, function(error) {
if (!options.silent) {
lumbarLoader.trigger && lumbarLoader.trigger('load:end', lumbarLoader);
}
callback(error);
}, options);
};
})();
lumbarLoader.loadJS = function(moduleName, callback) {
return loadResources(moduleName, 'js', callback, function(href, callback) {
loadViaXHR(href, function(err, data) {
if (!err && data) {
try {
window.eval(data);
callback();
return true;
} catch (err) {
/* NOP */
}
}
callback(err ? 'connection' : 'javascript');
});
return 1;
}).length;
};
lumbarLoader.loadCSS = function(moduleName, callback) {
return loadResources(moduleName, 'css', callback, function(href) {
loadViaXHR(href, function(err, data) {
data && exports.loader.loadInlineCSS(data);
callback(err ? 'connecion' : undefined);
return !err;
});
return 1;
}).length;
};
function loadViaXHR(href, callback) {
var cache = LocalCache.get(href);
if (cache) {
// Dump off the stack to prevent any errors with loader module interaction
setTimeout(function() {
callback(undefined, cache);
}, 0);
return;
}
var xhr = new XMLHttpRequest();
xhr.onreadystatechange = function(){
if (xhr.readyState == 4) {
var success = (xhr.status >= 200 && xhr.status < 300) || (xhr.status == 0 && xhr.responseText);
if (callback(!success, xhr.responseText)) {
LocalCache.store(href, xhr.responseText, LocalCache.TTL.WEEK);
}
}
};
xhr.open('GET', href, true);
xhr.send(null);
}
lumbarLoader.loadJS = function(moduleName, callback) {
var loaded = loadResources(moduleName, 'js', callback, function(href, callback) {
$script(href, callback);
return 1;
});
return loaded.length;
};
lumbarLoader.loadCSS = function(moduleName, callback) {
var loaded = loadResources(moduleName, 'css', callback, function(href) {
var link = document.createElement('link');
link.rel = 'stylesheet';
link.type = 'text/css';
link.href = href;
return link;
});
if (callback) {
var interval = setInterval(function() {
var i = loaded.length;
while (i--) {
var sheet = loaded[i];
if ((sheet.sheet && ('length' in sheet.sheet.cssRules)) || (sheet.styleSheet && sheet.styleSheet.cssText)) {
loaded.splice(i, 1);
callback();
}
}
if (!loaded.length) {
clearInterval(interval);
}
}, 100);
}
return loaded.length;
};
var lumbarLoader = exports.loader = {
loadPrefix: typeof lumbarLoadPrefix === 'undefined' ? '' : lumbarLoadPrefix,
isLoaded: function(moduleName) {
return lumbarLoadedModules[moduleName] === true;
},
isLoading: function(moduleName) {
return !!lumbarLoadedModules[moduleName];
},
loadModule: function(moduleName, callback, options) {
options = options || {};
var loaded = lumbarLoadedModules[moduleName];
if (loaded) {
// We have already been loaded or there is something pending. Handle it
if (loaded === true) {
callback();
} else {
loaded.push(callback);
}
return;
}
loaded = lumbarLoadedModules[moduleName] = [callback];
var loadCount = 0,
expected = 1,
allInit = false;
function complete(error) {
loadCount++;
if (error || (allInit && loadCount >= expected)) {
lumbarLoadedModules[moduleName] = !error;
var moduleInfo = lumbarLoader.modules && lumbarLoader.modules[moduleName];
if (moduleInfo && moduleInfo.preload && !options.silent) {
preloadModules(moduleInfo.preload);
}
for (var i = 0, len = loaded.length; i < len; i++) {
loaded[i](error);
}
lumbarLoader.loadComplete && lumbarLoader.loadComplete(moduleName, error);
}
}
expected += lumbarLoader.loadCSS(moduleName, complete);
expected += lumbarLoader.loadJS(moduleName, complete);
// If everything was done sync then fire away
allInit = true;
complete();
},
loadInlineCSS: function(content) {
var style = document.createElement('style');
style.textContent = content;
appendResourceElement(style);
return style;
}
};
var lumbarLoadedModules = {},
lumbarLoadedResources = {},
fieldAttr = {
js: 'src',
css: 'href'
};
function loadResources(moduleName, field, callback, create) {
var module = moduleName === 'base' ? lumbarLoader.map.base : lumbarLoader.modules[moduleName], // Special case for the base case
loaded = [],
attr = fieldAttr[field];
field = module[field] || [];
if (Array.isArray ? !Array.isArray(field) : Object.prototype.toString.call(field) !== '[object Array]') {
field = [field];
}
for (var i = 0, len = field.length; i < len; i++) {
var object = field[i];
var href = checkLoadResource(object, attr);
if (href && !lumbarLoadedResources[href]) {
var el = create(href, function(err) {
if (err === 'connection') {
lumbarLoadedResources[href] = false;
}
callback(err);
});
lumbarLoadedResources[href] = true;
if (el && el.nodeType === 1) {
appendResourceElement(el);
}
loaded.push(el);
}
}
return loaded;
}
function appendResourceElement(element) {
return (document.head || document.getElementsByTagName('head')[0] || document.body).appendChild(element);
}
function preloadModules(modules) {
var moduleList = modules.slice();
for (var i = 0, len = modules.length; i < len; i++) {
lumbarLoader.loadModule(modules[i], function() {}, {silent: true});
}
}
var devicePixelRatio = parseFloat(sessionStorage.getItem('dpr') || window.devicePixelRatio || 1);
exports.devicePixelRatio = devicePixelRatio;
function checkLoadResource(object, attr) {
var href = lumbarLoader.loadPrefix + (object.href || object);
if ((!object.maxRatio || devicePixelRatio < object.maxRatio) && (!object.minRatio || object.minRatio <= devicePixelRatio)) {
if (document.querySelector('[' + attr + '="' + href + '"]')) {
return;
}
return href;
}
}
exports.moduleMap = function(map, loadPrefix) {
lumbarLoader.map = map;
lumbarLoader.modules = map.modules;
lumbarLoader.loadPrefix = loadPrefix || lumbarLoader.loadPrefix;
};
/*!
* $script.js Async loader & dependency manager
* https://github.com/ded/script.js
* (c) Dustin Diaz, Jacob Thornton 2011
* License: MIT
*/
!function (name, definition) {
if (typeof define == 'function') define(definition)
else if (typeof module != 'undefined') module.exports = definition()
else this[name] = definition()
}('$script', function() {
var win = this, doc = document, timeout = setTimeout
, head = doc.getElementsByTagName('head')[0]
, validBase = /^https?:\/\//
, old = win.$script, list = {}, ids = {}, delay = {}, scriptpath
, scripts = {}, s = 'string', f = false
, push = 'push', domContentLoaded = 'DOMContentLoaded', readyState = 'readyState'
, addEventListener = 'addEventListener', onreadystatechange = 'onreadystatechange'
function every(ar, fn, i) {
for (i = 0, j = ar.length; i < j; ++i) if (!fn(ar[i])) return f
return 1
}
function each(ar, fn) {
every(ar, function(el) {
return !fn(el)
})
}
if (!doc[readyState] && doc[addEventListener]) {
doc[addEventListener](domContentLoaded, function fn() {
doc.removeEventListener(domContentLoaded, fn, f)
doc[readyState] = 'complete'
}, f)
doc[readyState] = 'loading'
}
function $script(paths, idOrDone, optDone) {
paths = paths[push] ? paths : [paths];
var idOrDoneIsDone = idOrDone && idOrDone.call
, done = idOrDoneIsDone ? idOrDone : optDone
, id = idOrDoneIsDone ? paths.join('') : idOrDone
, queue = paths.length
function loopFn(item) {
return item.call ? item() : list[item]
}
function callback() {
if (!--queue) {
list[id] = 1
done && done()
for (var dset in delay) {
every(dset.split('|'), loopFn) && !each(delay[dset], loopFn) && (delay[dset] = [])
}
}
}
timeout(function() {
each(paths, function(path) {
if (scripts[path]) {
id && (ids[id] = 1)
return scripts[path] == 2 && callback()
}
scripts[path] = 1
id && (ids[id] = 1)
create(!validBase.test(path) && scriptpath ? scriptpath + path + '.js' : path, callback)
})
}, 0)
return $script
}
function create(path, fn) {
var el = doc.createElement('script')
, loaded = f
el.onload = el.onerror = el[onreadystatechange] = function () {
if ((el[readyState] && !(/^c|loade/.test(el[readyState]))) || loaded) return;
el.onload = el[onreadystatechange] = null
loaded = 1
scripts[path] = 2
fn()
}
el.async = 1
el.src = path
head.insertBefore(el, head.firstChild)
}
$script.get = create
$script.order = function (scripts, id, done) {
(function callback(s) {
s = scripts.shift()
if (!scripts.length) $script(s, id, done)
else $script(s, callback)
}())
}
$script.path = function(p) {
scriptpath = p
}
$script.ready = function(deps, ready, req) {
deps = deps[push] ? deps : [deps]
var missing = [];
!each(deps, function(dep) {
list[dep] || missing[push](dep);
}) && every(deps, function(dep) {return list[dep]}) ?
ready() : !function(key) {
delay[key] = delay[key] || []
delay[key][push](ready)
req && req(missing)
}(deps.join('|'))
return $script
}
$script.noConflict = function () {
win.$script = old;
return this
}
return $script
})
\ No newline at end of file
// Copyright (c) 2011-2012 @WalmartLabs
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to
// deal in the Software without restriction, including without limitation the
// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
// sell copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
//
(function() {
var Thorax;
//support zepto.forEach on jQuery
if (!$.fn.forEach) {
$.fn.forEach = function(iterator, context) {
$.fn.each.call(this, function(index) {
iterator.call(context || this, this, index);
});
}
}
if (typeof exports !== 'undefined') {
Thorax = exports;
} else {
Thorax = this.Thorax = {};
}
Thorax.VERSION = '2.0.0b3';
var handlebarsExtension = 'handlebars',
handlebarsExtensionRegExp = new RegExp('\\.' + handlebarsExtension + '$'),
viewNameAttributeName = 'data-view-name',
viewCidAttributeName = 'data-view-cid',
viewPlaceholderAttributeName = 'data-view-tmp',
viewHelperAttributeName = 'data-view-helper',
elementPlaceholderAttributeName = 'data-element-tmp';
_.extend(Thorax, {
templatePathPrefix: '',
//view instances
_viewsIndexedByCid: {},
templates: {},
//view classes
Views: {},
//certain error prone pieces of code (on Android only it seems)
//are wrapped in a try catch block, then trigger this handler in
//the catch, with the name of the function or event that was
//trying to be executed. Override this with a custom handler
//to debug / log / etc
onException: function(name, err) {
throw err;
}
});
Thorax.Util = {
createRegistryWrapper: function(klass, hash) {
var $super = klass.extend;
klass.extend = function() {
var child = $super.apply(this, arguments);
if (child.prototype.name) {
hash[child.prototype.name] = child;
}
return child;
};
},
registryGet: function(object, type, name, ignoreErrors) {
if (type === 'templates') {
//append the template path prefix if it is missing
var pathPrefix = Thorax.templatePathPrefix;
if (pathPrefix && pathPrefix.length && name && name.substr(0, pathPrefix.length) !== pathPrefix) {
name = pathPrefix + name;
}
}
var target = object[type],
value;
if (name.match(/\./)) {
var bits = name.split(/\./);
name = bits.pop();
bits.forEach(function(key) {
target = target[key];
});
} else {
value = target[name];
}
if (!target && !ignoreErrors) {
throw new Error(type + ': ' + name + ' does not exist.');
} else {
var value = target[name];
if (type === 'templates' && typeof value === 'string') {
value = target[name] = Handlebars.compile(value);
}
return value;
}
},
getViewInstance: function(name, attributes) {
attributes['class'] && (attributes.className = attributes['class']);
attributes.tag && (attributes.tagName = attributes.tag);
if (typeof name === 'string') {
var klass = Thorax.Util.registryGet(Thorax, 'Views', name, false);
return klass.cid ? _.extend(klass, attributes || {}) : new klass(attributes);
} else if (typeof name === 'function') {
return new name(attributes);
} else {
return name;
}
},
getValue: function (object, prop) {
if (!(object && object[prop])) {
return null;
}
return _.isFunction(object[prop])
? object[prop].apply(object, Array.prototype.slice.call(arguments, 2))
: object[prop];
},
//'selector' is not present in $('<p></p>')
//TODO: investigage a better detection method
is$: function(obj) {
return typeof obj === 'object' && ('length' in obj);
},
expandToken: function(input, scope) {
if (input && input.indexOf && input.indexOf('{' + '{') >= 0) {
var re = /(?:\{?[^{]+)|(?:\{\{([^}]+)\}\})/g,
match,
ret = [];
function deref(token, scope) {
var segments = token.split('.'),
len = segments.length;
for (var i = 0; scope && i < len; i++) {
if (segments[i] !== 'this') {
scope = scope[segments[i]];
}
}
return scope;
}
while (match = re.exec(input)) {
if (match[1]) {
var params = match[1].split(/\s+/);
if (params.length > 1) {
var helper = params.shift();
params = params.map(function(param) { return deref(param, scope); });
if (Handlebars.helpers[helper]) {
ret.push(Handlebars.helpers[helper].apply(scope, params));
} else {
// If the helper is not defined do nothing
ret.push(match[0]);
}
} else {
ret.push(deref(params[0], scope));
}
} else {
ret.push(match[0]);
}
}
input = ret.join('');
}
return input;
},
tag: function(attributes, content, scope) {
var htmlAttributes = _.clone(attributes),
tag = htmlAttributes.tag || htmlAttributes.tagName || 'div';
if (htmlAttributes.tag) {
delete htmlAttributes.tag;
}
if (htmlAttributes.tagName) {
delete htmlAttributes.tagName;
}
return '<' + tag + ' ' + _.map(htmlAttributes, function(value, key) {
if (typeof value === 'undefined') {
return '';
}
var formattedValue = value;
if (scope) {
formattedValue = Thorax.Util.expandToken(value, scope);
}
return key + '="' + Handlebars.Utils.escapeExpression(formattedValue) + '"';
}).join(' ') + '>' + (typeof content === 'undefined' ? '' : content) + '</' + tag + '>';
},
htmlAttributesFromOptions: function(options) {
var htmlAttributes = {};
if (options.tag) {
htmlAttributes.tag = options.tag;
}
if (options.tagName) {
htmlAttributes.tagName = options.tagName;
}
if (options['class']) {
htmlAttributes['class'] = options['class'];
}
if (options.id) {
htmlAttributes.id = options.id;
}
return htmlAttributes;
},
_cloneEvents: function(source, target, key) {
source[key] = _.clone(target[key]);
//need to deep clone events array
_.each(source[key], function(value, _key) {
if (_.isArray(value)) {
target[key][_key] = _.clone(value);
}
});
}
};
Thorax.View = Backbone.View.extend({
constructor: function() {
var response = Thorax.View.__super__.constructor.apply(this, arguments);
if (this.model) {
//need to null this.model so setModel will not treat
//it as the old model and immediately return
var model = this.model;
this.model = null;
this.setModel(model);
}
return response;
},
_configure: function(options) {
this._modelEvents = [];
this._collectionEvents = [];
Thorax._viewsIndexedByCid[this.cid] = this;
this.children = {};
this._renderCount = 0;
//this.options is removed in Thorax.View, we merge passed
//properties directly with the view and template context
_.extend(this, options || {});
//compile a string if it is set as this.template
if (typeof this.template === 'string') {
this.template = Handlebars.compile(this.template);
} else if (this.name && !this.template) {
//fetch the template
this.template = Thorax.Util.registryGet(Thorax, 'templates', this.name, true);
}
//HelperView will not have mixins so need to check
this.constructor.mixins && _.each(this.constructor.mixins, applyMixin, this);
this.mixins && _.each(this.mixins, applyMixin, this);
//_events not present on HelperView
this.constructor._events && this.constructor._events.forEach(function(event) {
this.on.apply(this, event);
}, this);
if (this.events) {
_.each(Thorax.Util.getValue(this, 'events'), function(handler, eventName) {
this.on(eventName, handler, this);
}, this);
}
},
_ensureElement : function() {
Backbone.View.prototype._ensureElement.call(this);
if (this.name) {
this.$el.attr(viewNameAttributeName, this.name);
}
this.$el.attr(viewCidAttributeName, this.cid);
},
_addChild: function(view) {
this.children[view.cid] = view;
if (!view.parent) {
view.parent = this;
}
return view;
},
destroy: function(options) {
options = _.defaults(options || {}, {
children: true
});
this.trigger('destroyed');
delete Thorax._viewsIndexedByCid[this.cid];
_.each(this.children, function(child) {
if (options.children) {
child.parent = null;
child.destroy();
}
});
if (options.children) {
this.children = {};
}
},
render: function(output) {
if (typeof output === 'undefined' || (!_.isElement(output) && !Thorax.Util.is$(output) && !(output && output.el) && typeof output !== 'string' && typeof output !== 'function')) {
if (!this.template) {
//if the name was set after the view was created try one more time to fetch a template
if (this.name) {
this.template = Thorax.Util.registryGet(Thorax, 'templates', this.name, true);
}
if (!this.template) {
throw new Error('View ' + (this.name || this.cid) + '.render() was called with no content and no template set on the view.');
}
}
output = this.renderTemplate(this.template);
} else if (typeof output === 'function') {
output = this.renderTemplate(output);
}
//accept a view, string, Handlebars.SafeString or DOM element
this.html((output && output.el) || (output && output.string) || output);
++this._renderCount;
this.trigger('rendered');
return output;
},
context: function() {
return this;
},
_getContext: function(attributes) {
var data = _.extend({}, Thorax.Util.getValue(this, 'context'), attributes || {}, {
cid: _.uniqueId('t'),
yield: function() {
return data.fn && data.fn(data);
},
_view: this
});
return data;
},
renderTemplate: function(file, data, ignoreErrors) {
var template;
data = this._getContext(data);
if (typeof file === 'function') {
template = file;
} else {
template = this._loadTemplate(file);
}
if (!template) {
if (ignoreErrors) {
return ''
} else {
throw new Error('Unable to find template ' + file);
}
} else {
return template(data);
}
},
_loadTemplate: function(file, ignoreErrors) {
return Thorax.Util.registryGet(Thorax, 'templates', file, ignoreErrors);
},
ensureRendered: function() {
!this._renderCount && this.render();
},
html: function(html) {
if (typeof html === 'undefined') {
return this.el.innerHTML;
} else {
var element = this.$el.html(html);
this._appendViews();
this._appendElements();
return element;
}
}
});
Thorax.View.extend = function() {
var child = Backbone.View.extend.apply(this, arguments);
child.mixins = _.clone(this.mixins);
Thorax.Util._cloneEvents(this, child, '_events');
Thorax.Util._cloneEvents(this, child, '_modelEvents');
Thorax.Util._cloneEvents(this, child, '_collectionEvents');
return child;
};
Thorax.Util.createRegistryWrapper(Thorax.View, Thorax.Views);
//helpers
Handlebars.registerHelper('super', function() {
var parent = this._view.constructor && this._view.constructor.__super__;
if (parent) {
var template = parent.template;
if (!template) {
if (!parent.name) {
throw new Error('Cannot use super helper when parent has no name or template.');
}
template = Thorax.Util.registryGet(Thorax, 'templates', parent.name, false);
}
if (typeof template === 'string') {
template = Handlebars.compile(template);
}
return new Handlebars.SafeString(template(this));
} else {
return '';
}
});
Handlebars.registerHelper('template', function(name, options) {
var context = _.extend({fn: options && options.fn}, this, options ? options.hash : {});
var output = Thorax.View.prototype.renderTemplate.call(this._view, name, context);
return new Handlebars.SafeString(output);
});
//view helper
var viewTemplateOverrides = {};
Handlebars.registerHelper('view', function(view, options) {
if (arguments.length === 1) {
options = view;
view = Thorax.View;
}
var instance = Thorax.Util.getViewInstance(view, options ? options.hash : {}),
placeholder_id = instance.cid + '-' + _.uniqueId('placeholder');
this._view._addChild(instance);
this._view.trigger('child', instance);
if (options.fn) {
viewTemplateOverrides[placeholder_id] = options.fn;
}
var htmlAttributes = Thorax.Util.htmlAttributesFromOptions(options.hash);
htmlAttributes[viewPlaceholderAttributeName] = placeholder_id;
return new Handlebars.SafeString(Thorax.Util.tag.call(this, htmlAttributes));
});
Thorax.HelperView = Thorax.View.extend({
_ensureElement: function() {
Thorax.View.prototype._ensureElement.apply(this, arguments);
this.$el.attr(viewHelperAttributeName, this._helperName);
},
context: function() {
return this.parent.context.apply(this.parent, arguments);
}
});
//ensure nested inline helpers will always have this.parent
//set to the view containing the template
function getParent(parent) {
while (parent._helperName) {
parent = parent.parent;
}
return parent;
}
Handlebars.registerViewHelper = function(name, viewClass, callback) {
if (arguments.length === 2) {
options = {};
callback = arguments[1];
viewClass = Thorax.HelperView;
}
Handlebars.registerHelper(name, function() {
var args = _.toArray(arguments),
options = args.pop(),
viewOptions = {
template: options.fn,
inverse: options.inverse,
options: options.hash,
parent: getParent(this._view),
_helperName: name
};
options.hash.id && (viewOptions.id = options.hash.id);
options.hash['class'] && (viewOptions.className = options.hash['class']);
options.hash.className && (viewOptions.className = options.hash.className);
options.hash.tag && (viewOptions.tagName = options.hash.tag);
options.hash.tagName && (viewOptions.tagName = options.hash.tagName);
var instance = new viewClass(viewOptions);
args.push(instance);
this._view.children[instance.cid] = instance;
this._view.trigger.apply(this._view, ['helper', name].concat(args));
this._view.trigger.apply(this._view, ['helper:' + name].concat(args));
var htmlAttributes = Thorax.Util.htmlAttributesFromOptions(options.hash);
htmlAttributes[viewPlaceholderAttributeName] = instance.cid;
callback.apply(this, args);
return new Handlebars.SafeString(Thorax.Util.tag(htmlAttributes, ''));
});
var helper = Handlebars.helpers[name];
return helper;
};
//called from View.prototype.html()
Thorax.View.prototype._appendViews = function(scope, callback) {
(scope || this.$el).find('[' + viewPlaceholderAttributeName + ']').forEach(function(el) {
var placeholder_id = el.getAttribute(viewPlaceholderAttributeName),
cid = placeholder_id.replace(/\-placeholder\d+$/, ''),
view = this.children[cid];
//if was set with a helper
if (_.isFunction(view)) {
view = view.call(this._view);
}
if (view) {
//see if the view helper declared an override for the view
//if not, ensure the view has been rendered at least once
if (viewTemplateOverrides[placeholder_id]) {
view.render(viewTemplateOverrides[placeholder_id](view._getContext()));
} else {
view.ensureRendered();
}
$(el).replaceWith(view.el);
//TODO: jQuery has trouble with delegateEvents() when
//the child dom node is detached then re-attached
if (typeof jQuery !== 'undefined' && $ === jQuery) {
if (this._renderCount > 1) {
view.delegateEvents();
}
}
callback && callback(view.el);
}
}, this);
};
//element helper
Handlebars.registerHelper('element', function(element, options) {
var cid = _.uniqueId('element'),
htmlAttributes = Thorax.Util.htmlAttributesFromOptions(options.hash);
htmlAttributes[elementPlaceholderAttributeName] = cid;
this._view._elementsByCid || (this._view._elementsByCid = {});
this._view._elementsByCid[cid] = element;
return new Handlebars.SafeString(Thorax.Util.tag.call(this, htmlAttributes));
});
Thorax.View.prototype._appendElements = function(scope, callback) {
(scope || this.$el).find('[' + elementPlaceholderAttributeName + ']').forEach(function(el) {
var cid = el.getAttribute(elementPlaceholderAttributeName),
element = this._elementsByCid[cid];
if (_.isFunction(element)) {
element = element.call(this._view);
}
$(el).replaceWith(element);
callback && callback(element);
}, this);
};
//$(selector).view() helper
$.fn.view = function(options) {
options = _.defaults(options || {}, {
helper: true
});
var selector = '[' + viewCidAttributeName + ']';
if (!options.helper) {
selector += ':not([' + viewHelperAttributeName + '])';
}
var el = $(this).closest(selector);
return (el && Thorax._viewsIndexedByCid[el.attr(viewCidAttributeName)]) || false;
};
_.extend(Thorax.View, {
mixins: [],
mixin: function(mixin) {
this.mixins.push(mixin);
}
});
function applyMixin(mixin) {
if (_.isArray(mixin)) {
this.mixin.apply(this, mixin);
} else {
this.mixin(mixin);
}
}
var _destroy = Thorax.View.prototype.destroy,
_on = Thorax.View.prototype.on,
_delegateEvents = Thorax.View.prototype.delegateEvents;
_.extend(Thorax.View, {
_events: [],
on: function(eventName, callback) {
if (eventName === 'model' && typeof callback === 'object') {
return addEvents(this._modelEvents, callback);
}
if (eventName === 'collection' && typeof callback === 'object') {
return addEvents(this._collectionEvents, callback);
}
//accept on({"rendered": handler})
if (typeof eventName === 'object') {
_.each(eventName, function(value, key) {
this.on(key, value);
}, this);
} else {
//accept on({"rendered": [handler, handler]})
if (_.isArray(callback)) {
callback.forEach(function(cb) {
this._events.push([eventName, cb]);
}, this);
//accept on("rendered", handler)
} else {
this._events.push([eventName, callback]);
}
}
return this;
}
});
_.extend(Thorax.View.prototype, {
freeze: function(options) {
this.model && this._unbindModelEvents();
options = _.defaults(options || {}, {
dom: true,
children: true
});
this._eventArgumentsToUnbind && this._eventArgumentsToUnbind.forEach(function(args) {
args[0].off(args[1], args[2], args[3]);
});
this._eventArgumentsToUnbind = [];
this.off();
if (options.dom) {
this.undelegateEvents();
}
this.trigger('freeze');
if (options.children) {
_.each(this.children, function(child, id) {
child.freeze(options);
}, this);
}
},
destroy: function() {
var response = _destroy.apply(this, arguments);
this.freeze();
return response;
},
on: function(eventName, callback, context) {
if (eventName === 'model' && typeof callback === 'object') {
return addEvents(this._modelEvents, callback);
}
if (eventName === 'collection' && typeof callback === 'object') {
return addEvents(this._collectionEvents, callback);
}
if (typeof eventName === 'object') {
//accept on({"rendered": callback})
if (arguments.length === 1) {
_.each(eventName, function(value, key) {
this.on(key, value, this);
}, this);
//events on other objects to auto dispose of when view frozen
//on(targetObj, 'eventName', callback, context)
} else if (arguments.length > 1) {
if (!this._eventArgumentsToUnbind) {
this._eventArgumentsToUnbind = [];
}
var args = Array.prototype.slice.call(arguments);
this._eventArgumentsToUnbind.push(args);
args[0].on.apply(args[0], args.slice(1));
}
} else {
//accept on("rendered", callback, context)
//accept on("click a", callback, context)
(_.isArray(callback) ? callback : [callback]).forEach(function(callback) {
var params = eventParamsFromEventItem.call(this, eventName, callback, context || this);
if (params.type === 'DOM') {
//will call _addEvent during delegateEvents()
if (!this._eventsToDelegate) {
this._eventsToDelegate = [];
}
this._eventsToDelegate.push(params);
} else {
this._addEvent(params);
}
}, this);
}
return this;
},
delegateEvents: function(events) {
this.undelegateEvents();
if (events) {
if (_.isFunction(events)) {
events = events.call(this);
}
this._eventsToDelegate = [];
this.on(events);
}
this._eventsToDelegate && this._eventsToDelegate.forEach(this._addEvent, this);
},
//params may contain:
//- name
//- originalName
//- selector
//- type "view" || "DOM"
//- handler
_addEvent: function(params) {
if (params.type === 'view') {
params.name.split(/\s+/).forEach(function(name) {
_on.call(this, name, bindEventHandler.call(this, 'view-event:' + params.name, params.handler), params.context || this);
}, this);
} else {
var boundHandler = containHandlerToCurentView(bindEventHandler.call(this, 'dom-event:' + params.name, params.handler), this.cid);
if (params.selector) {
//TODO: determine why collection views and some nested views
//need defered event delegation
var name = params.name + '.delegateEvents' + this.cid;
if (typeof jQuery !== 'undefined' && $ === jQuery) {
_.defer(_.bind(function() {
this.$el.on(name, params.selector, boundHandler);
}, this));
} else {
this.$el.on(name, params.selector, boundHandler);
}
} else {
this.$el.on(name, boundHandler);
}
}
}
});
var eventSplitter = /^(\S+)(?:\s+(.+))?/;
var domEvents = [
'mousedown', 'mouseup', 'mousemove', 'mouseover', 'mouseout',
'touchstart', 'touchend', 'touchmove',
'click', 'dblclick',
'keyup', 'keydown', 'keypress',
'submit', 'change',
'focus', 'blur'
];
var domEventRegexp = new RegExp('^(' + domEvents.join('|') + ')');
function containHandlerToCurentView(handler, cid) {
return function(event) {
var view = $(event.target).view({helper: false});
if (view && view.cid == cid) {
handler(event);
}
}
}
function bindEventHandler(eventName, callback) {
var method = typeof callback === 'function' ? callback : this[callback];
if (!method) {
throw new Error('Event "' + callback + '" does not exist ' + (this.name || this.cid) + ':' + eventName);
}
return _.bind(function() {
try {
method.apply(this, arguments);
} catch (e) {
Thorax.onException('thorax-exception: ' + (this.name || this.cid) + ':' + eventName, e);
}
}, this);
}
function eventParamsFromEventItem(name, handler, context) {
var params = {
originalName: name,
handler: typeof handler === 'string' ? this[handler] : handler
};
if (name.match(domEventRegexp)) {
var match = eventSplitter.exec(name);
params.name = match[1];
params.type = 'DOM';
params.selector = match[2];
} else {
params.name = name;
params.type = 'view';
}
params.context = context;
return params;
}
var modelCidAttributeName = 'data-model-cid',
modelNameAttributeName = 'data-model-name';
Thorax.Model = Backbone.Model.extend({
isEmpty: function() {
return this.isPopulated();
},
isPopulated: function() {
// We are populated if we have attributes set
var attributes = _.clone(this.attributes);
var defaults = _.isFunction(this.defaults) ? this.defaults() : (this.defaults || {});
for (var default_key in defaults) {
if (attributes[default_key] != defaults[default_key]) {
return true;
}
delete attributes[default_key];
}
var keys = _.keys(attributes);
return keys.length > 1 || (keys.length === 1 && keys[0] !== 'id');
}
});
Thorax.Models = {};
Thorax.Util.createRegistryWrapper(Thorax.Model, Thorax.Models);
Thorax.View._modelEvents = [];
function addEvents(target, source) {
_.each(source, function(callback, eventName) {
if (_.isArray(callback)) {
callback.forEach(function(cb) {
target.push([eventName, cb]);
}, this);
} else {
target.push([eventName, callback]);
}
});
}
_.extend(Thorax.View.prototype, {
context: function() {
return _.extend({}, this, (this.model && this.model.attributes) || {});
},
_bindModelEvents: function() {
bindModelEvents.call(this, this.constructor._modelEvents);
bindModelEvents.call(this, this._modelEvents);
},
_unbindModelEvents: function() {
this.model.trigger('freeze');
unbindModelEvents.call(this, this.constructor._modelEvents);
unbindModelEvents.call(this, this._modelEvents);
},
setModel: function(model, options) {
var oldModel = this.model;
if (model === oldModel) {
return this;
}
oldModel && this._unbindModelEvents();
if (model) {
this.$el.attr(modelCidAttributeName, model.cid);
if (model.name) {
this.$el.attr(modelNameAttributeName, model.name);
}
this.model = model;
this._setModelOptions(options);
this._bindModelEvents(options);
this.model.trigger('set', this.model, oldModel);
if (Thorax.Util.shouldFetch(this.model, this._modelOptions)) {
var success = this._modelOptions.success;
this._loadModel(this.model, this._modelOptions);
} else {
//want to trigger built in event handler (render() + populate())
//without triggering event on model
this._onModelChange();
}
} else {
this._modelOptions = false;
this.model = false;
this._onModelChange();
this.$el.removeAttr(modelCidAttributeName);
this.$el.attr(modelNameAttributeName);
}
return this;
},
_onModelChange: function() {
if (!this._modelOptions || (this._modelOptions && this._modelOptions.render)) {
this.render();
}
},
_loadModel: function(model, options) {
model.fetch(options);
},
_setModelOptions: function(options) {
if (!this._modelOptions) {
this._modelOptions = {
fetch: true,
success: false,
render: true,
errors: true
};
}
_.extend(this._modelOptions, options || {});
return this._modelOptions;
}
});
function getEventCallback(callback, context) {
if (typeof callback === 'function') {
return callback;
} else {
return context[callback];
}
}
function bindModelEvents(events) {
events.forEach(function(event) {
//getEventCallback will resolve if it is a string or a method
//and return a method
this.model.on(event[0], getEventCallback(event[1], this), event[2] || this);
}, this);
}
function unbindModelEvents(events) {
events.forEach(function(event) {
this.model.off(event[0], getEventCallback(event[1], this), event[2] || this);
}, this);
}
Thorax.View.on({
model: {
error: function(model, errors){
if (this._modelOptions.errors) {
this.trigger('error', errors);
}
},
change: function() {
this._onModelChange();
}
}
});
Thorax.Util.shouldFetch = function(modelOrCollection, options) {
var getValue = Thorax.Util.getValue,
isCollection = !modelOrCollection.collection && modelOrCollection._byCid && modelOrCollection._byId;
url = (
(!modelOrCollection.collection && getValue(modelOrCollection, 'urlRoot')) ||
(modelOrCollection.collection && getValue(modelOrCollection.collection, 'url')) ||
(isCollection && getValue(modelOrCollection, 'url'))
);
return url && options.fetch && !(
(modelOrCollection.isPopulated && modelOrCollection.isPopulated()) ||
(isCollection
? Thorax.Collection && Thorax.Collection.prototype.isPopulated.call(modelOrCollection)
: Thorax.Model.prototype.isPopulated.call(modelOrCollection)
)
);
};
$.fn.model = function() {
var $this = $(this),
modelElement = $this.closest('[' + modelCidAttributeName + ']'),
modelCid = modelElement && modelElement.attr(modelCidAttributeName);
if (modelCid) {
var view = $this.view();
if (view && view.model && view.model.cid === modelCid) {
return view.model || false;
}
var collection = $this.collection(view);
if (collection) {
return collection._byCid[modelCid] || false;
}
}
return false;
};
var _fetch = Backbone.Collection.prototype.fetch,
_reset = Backbone.Collection.prototype.reset,
collectionCidAttributeName = 'data-collection-cid',
collectionNameAttributeName = 'data-collection-name',
collectionEmptyAttributeName = 'data-collection-empty',
modelCidAttributeName = 'data-model-cid',
modelNameAttributeName = 'data-model-name',
ELEMENT_NODE_TYPE = 1;
Thorax.Collection = Backbone.Collection.extend({
model: Thorax.Model || Backbone.Model,
isEmpty: function() {
if (this.length > 0) {
return false;
} else {
return this.length === 0 && this.isPopulated();
}
},
isPopulated: function() {
return this._fetched || this.length > 0 || (!this.length && !Thorax.Util.getValue(this, 'url'));
},
fetch: function(options) {
options = options || {};
var success = options.success;
options.success = function(collection, response) {
collection._fetched = true;
success && success(collection, response);
};
return _fetch.apply(this, arguments);
},
reset: function(models, options) {
this._fetched = !!models;
return _reset.call(this, models, options);
}
});
Thorax.Collections = {};
Thorax.Util.createRegistryWrapper(Thorax.Collection, Thorax.Collections);
Thorax.View._collectionEvents = [];
//collection view is meant to be initialized via the collection
//helper but can alternatively be initialized programatically
//constructor function handles this case, no logic except for
//super() call will be exectued when initialized via collection helper
Thorax.CollectionView = Thorax.HelperView.extend({
constructor: function(options) {
Thorax.CollectionView.__super__.constructor.call(this, options);
//collection helper will initialize this.options, so need to mimic
this.options || (this.options = {});
this.collection && this.setCollection(this.collection);
Thorax.CollectionView._optionNames.forEach(function(optionName) {
options[optionName] && (this.options[optionName] = options[optionName]);
}, this);
},
_setCollectionOptions: function(collection, options) {
return _.extend({
fetch: true,
success: false,
errors: true
}, options || {});
},
setCollection: function(collection, options) {
this.collection = collection;
if (collection) {
collection.cid = collection.cid || _.uniqueId('collection');
this.$el.attr(collectionCidAttributeName, collection.cid);
if (collection.name) {
this.$el.attr(collectionNameAttributeName, collection.name);
}
this.options = this._setCollectionOptions(collection, _.extend({}, this.options, options));
bindCollectionEvents.call(this, collection, this.parent._collectionEvents);
bindCollectionEvents.call(this, collection, this.parent.constructor._collectionEvents);
collection.trigger('set', collection);
if (Thorax.Util.shouldFetch(collection, this.options)) {
this._loadCollection(collection);
} else {
//want to trigger built in event handler (render())
//without triggering event on collection
this.reset();
}
}
return this;
},
_loadCollection: function(collection) {
collection.fetch(this.options);
},
//appendItem(model [,index])
//appendItem(html_string, index)
//appendItem(view, index)
appendItem: function(model, index, options) {
//empty item
if (!model) {
return;
}
var itemView;
options = options || {};
//if index argument is a view
if (index && index.el) {
index = this.$el.children().indexOf(index.el) + 1;
}
//if argument is a view, or html string
if (model.el || typeof model === 'string') {
itemView = model;
model = false;
} else {
index = index || this.collection.indexOf(model) || 0;
itemView = this.renderItem(model, index);
}
if (itemView) {
if (itemView.cid) {
this._addChild(itemView);
}
//if the renderer's output wasn't contained in a tag, wrap it in a div
//plain text, or a mixture of top level text nodes and element nodes
//will get wrapped
if (typeof itemView === 'string' && !itemView.match(/^\s*\</m)) {
itemView = '<div>' + itemView + '</div>'
}
var itemElement = itemView.el ? [itemView.el] : _.filter($(itemView), function(node) {
//filter out top level whitespace nodes
return node.nodeType === ELEMENT_NODE_TYPE;
});
if (model) {
$(itemElement).attr(modelCidAttributeName, model.cid);
}
var previousModel = index > 0 ? this.collection.at(index - 1) : false;
if (!previousModel) {
this.$el.prepend(itemElement);
} else {
//use last() as appendItem can accept multiple nodes from a template
var last = this.$el.find('[' + modelCidAttributeName + '="' + previousModel.cid + '"]').last();
last.after(itemElement);
}
this._appendViews(null, function(el) {
el.setAttribute(modelCidAttributeName, model.cid);
});
this._appendElements(null, function(el) {
el.setAttribute(modelCidAttributeName, model.cid);
});
if (!options.silent) {
this.parent.trigger('rendered:item', this, this.collection, model, itemElement, index);
}
applyItemVisiblityFilter.call(this, model);
}
return itemView;
},
//updateItem only useful if there is no item view, otherwise
//itemView.render() provideds the same functionality
updateItem: function(model) {
this.removeItem(model);
this.appendItem(model);
},
removeItem: function(model) {
var viewEl = this.$('[' + modelCidAttributeName + '="' + model.cid + '"]');
if (!viewEl.length) {
return false;
}
var viewCid = viewEl.attr(viewCidAttributeName);
if (this.children[viewCid]) {
delete this.children[viewCid];
}
viewEl.remove();
return true;
},
reset: function() {
this.render();
},
render: function() {
this.$el.empty();
if (this.collection) {
if (this.collection.isEmpty()) {
this.$el.attr(collectionEmptyAttributeName, true);
this.appendEmpty();
} else {
this.$el.removeAttr(collectionEmptyAttributeName);
this.collection.forEach(function(item, i) {
this.appendItem(item, i);
}, this);
}
this.parent.trigger('rendered:collection', this, this.collection);
applyVisibilityFilter.call(this);
}
++this._renderCount;
},
renderEmpty: function() {
var viewOptions = {};
if (this.options['empty-view']) {
if (this.options['empty-context']) {
viewOptions.context = _.bind(function() {
return (_.isFunction(this.options['empty-context'])
? this.options['empty-context']
: this.parent[this.options['empty-context']]
).call(this.parent);
}, this);
}
var view = Thorax.Util.getViewInstance(this.options['empty-view'], viewOptions);
if (this.options['empty-template']) {
view.render(this.renderTemplate(this.options['empty-template'], viewOptions.context ? viewOptions.context() : {}));
} else {
view.render();
}
return view;
} else {
var emptyTemplate = this.options['empty-template'] || (this.parent.name && this._loadTemplate(this.parent.name + '-empty', true));
var context;
if (this.options['empty-context']) {
context = (_.isFunction(this.options['empty-context'])
? this.options['empty-context']
: this.parent[this.options['empty-context']]
).call(this.parent);
} else {
context = {};
}
return emptyTemplate && this.renderTemplate(emptyTemplate, context);
}
},
renderItem: function(model, i) {
if (this.options['item-view']) {
var viewOptions = {
model: model
};
//itemContext deprecated
if (this.options['item-context']) {
viewOptions.context = _.bind(function() {
return (_.isFunction(this.options['item-context'])
? this.options['item-context']
: this.parent[this.options['item-context']]
).call(this.parent, model, i);
}, this);
}
if (this.options['item-template']) {
viewOptions.template = this.options['item-template'];
}
var view = Thorax.Util.getViewInstance(this.options['item-view'], viewOptions);
view.ensureRendered();
return view;
} else {
var itemTemplate = this.options['item-template'] || (this.parent.name && this.parent._loadTemplate(this.parent.name + '-item', true));
if (!itemTemplate) {
throw new Error('collection helper in View: ' + (this.parent.name || this.parent.cid) + ' requires an item template.');
}
var context;
if (this.options['item-context']) {
context = (_.isFunction(this.options['item-context'])
? this.options['item-context']
: this.parent[this.options['item-context']]
).call(this.parent, model, i);
} else {
context = model.attributes;
}
return this.renderTemplate(itemTemplate, context);
}
},
appendEmpty: function() {
this.$el.empty();
var emptyContent = this.renderEmpty();
emptyContent && this.appendItem(emptyContent, 0, {
silent: true
});
this.parent.trigger('rendered:empty', this, this.collection);
}
});
Thorax.CollectionView._optionNames = [
'item-template',
'empty-template',
'item-view',
'empty-view',
'item-context',
'empty-context',
'filter'
];
function bindCollectionEvents(collection, events) {
events.forEach(function(event) {
this.on(collection, event[0], function() {
//getEventCallback will resolve if it is a string or a method
//and return a method
var args = _.toArray(arguments);
args.unshift(this);
return getEventCallback(event[1], this.parent).apply(this.parent, args);
}, this);
}, this);
}
function applyVisibilityFilter() {
if (this.options.filter) {
this.collection.forEach(function(model) {
applyItemVisiblityFilter.call(this, model);
}, this);
}
}
function applyItemVisiblityFilter(model) {
if (this.options.filter) {
$('[' + modelCidAttributeName + '="' + model.cid + '"]')[itemShouldBeVisible.call(this, model) ? 'show' : 'hide']();
}
}
function itemShouldBeVisible(model, i) {
return (typeof this.options.filter === 'string'
? this.parent[this.options.filter]
: this.options.filter).call(this.parent, model, this.collection.indexOf(model))
;
}
function handleChangeFromEmptyToNotEmpty() {
if (this.collection.length === 1) {
if(this.$el.length) {
this.$el.removeAttr(collectionEmptyAttributeName);
this.$el.empty();
}
}
}
function handleChangeFromNotEmptyToEmpty() {
if (this.collection.length === 0) {
if (this.$el.length) {
this.$el.attr(collectionEmptyAttributeName, true);
this.appendEmpty();
}
}
}
Thorax.View.on({
collection: {
filter: function(collectionView) {
applyVisibilityFilter.call(collectionView);
},
change: function(collectionView, model) {
//if we rendered with item views, model changes will be observed
//by the generated item view but if we rendered with templates
//then model changes need to be bound as nothing is watching
if (!collectionView.options['item-view']) {
collectionView.updateItem(model);
}
applyItemVisiblityFilter.call(collectionView, model);
},
add: function(collectionView, model, collection) {
handleChangeFromEmptyToNotEmpty.call(collectionView);
if (collectionView.$el.length) {
var index = collection.indexOf(model);
collectionView.appendItem(model, index);
}
},
remove: function(collectionView, model, collection) {
collectionView.$el.find('[' + modelCidAttributeName + '="' + model.cid + '"]').remove();
for (var cid in collectionView.children) {
if (collectionView.children[cid].model && collectionView.children[cid].model.cid === model.cid) {
collectionView.children[cid].destroy();
delete collectionView.children[cid];
break;
}
}
handleChangeFromNotEmptyToEmpty.call(collectionView);
},
reset: function(collectionView, collection) {
collectionView.reset();
},
error: function(collectionView, message) {
if (collectionView.options.errors) {
collectionView.trigger('error', message);
this.trigger('error', message);
}
}
}
});
Handlebars.registerViewHelper('collection', Thorax.CollectionView, function(collection, view) {
if (arguments.length === 1) {
view = collection;
collection = this._view.collection;
}
if (collection) {
//item-view and empty-view may also be passed, but have no defaults
_.extend(view.options, {
'item-template': view.template && view.template !== Handlebars.VM.noop ? view.template : view.options['item-template'],
'empty-template': view.inverse && view.inverse !== Handlebars.VM.noop ? view.inverse : view.options['empty-template'],
'item-context': view.options['item-context'] || view.parent.itemContext,
'empty-context': view.options['empty-context'] || view.parent.emptyContext,
filter: view.options['filter']
});
view.setCollection(collection);
}
});
//empty helper
Handlebars.registerViewHelper('empty', function(collection, view) {
var empty, noArgument;
if (arguments.length === 1) {
view = collection;
collection = false;
noArgument = true;
}
var _render = view.render;
view.render = function() {
if (noArgument) {
empty = !this.parent.model || (this.parent.model && !this.parent.model.isEmpty());
} else if (!collection) {
empty = true;
} else {
empty = collection.isEmpty();
}
if (empty) {
this.parent.trigger('rendered:empty', this, collection);
return _render.call(this, this.template);
} else {
return _render.call(this, this.inverse);
}
};
//no model binding is necessary as model.set() will cause re-render
if (collection) {
function collectionRemoveCallback() {
if (collection.length === 0) {
view.render();
}
}
function collectionAddCallback() {
if (collection.length === 1) {
view.render();
}
}
function collectionResetCallback() {
view.render();
}
view.on(collection, 'remove', collectionRemoveCallback);
view.on(collection, 'add', collectionAddCallback);
view.on(collection, 'reset', collectionResetCallback);
}
view.render();
});
//$(selector).collection() helper
$.fn.collection = function(view) {
var $this = $(this),
collectionElement = $this.closest('[' + collectionCidAttributeName + ']'),
collectionCid = collectionElement && collectionElement.attr(collectionCidAttributeName);
if (collectionCid) {
view = view || $this.view();
if (view) {
return view.collection;
}
}
return false;
};
var paramMatcher = /:(\w+)/g,
callMethodAttributeName = 'data-call-method';
Handlebars.registerHelper('url', function(url) {
var matches = url.match(paramMatcher),
context = this;
if (matches) {
url = url.replace(paramMatcher, function(match, key) {
return context[key] ? Thorax.Util.getValue(context, key) : match;
});
}
url = Thorax.Util.expandToken(url, context);
return (Backbone.history._hasPushState ? Backbone.history.options.root : '#') + url;
});
Handlebars.registerHelper('button', function(method, options) {
options.hash.tag = options.hash.tag || options.hash.tagName || 'button';
options.hash[callMethodAttributeName] = method;
return new Handlebars.SafeString(Thorax.Util.tag.call(this, options.hash, options.fn ? options.fn(this) : '', this));
});
Handlebars.registerHelper('link', function(url, options) {
options.hash.tag = options.hash.tag || options.hash.tagName || 'a';
options.hash.href = Handlebars.helpers.url.call(this, url);
options.hash[callMethodAttributeName] = '_anchorClick';
return new Handlebars.SafeString(Thorax.Util.tag.call(this, options.hash, options.fn ? options.fn(this) : '', this));
});
$(function() {
$(document).on('click', '[' + callMethodAttributeName + ']', function(event) {
var target = $(event.target),
view = target.view({helper: false}),
methodName = target.attr(callMethodAttributeName);
view[methodName].call(view, event);
});
});
Thorax.View.prototype._anchorClick = function(event) {
var target = $(event.currentTarget),
href = target.attr('href');
// Route anything that starts with # or / (excluding //domain urls)
if (href && (href[0] === '#' || (href[0] === '/' && href[1] !== '/'))) {
Backbone.history.navigate(href, {
trigger: true
});
event.preventDefault();
}
};
if (Thorax.View.prototype._setModelOptions) {
(function() {
var _onModelChange = Thorax.View.prototype._onModelChange,
_setModelOptions = Thorax.View.prototype._setModelOptions;
_.extend(Thorax.View.prototype, {
_onModelChange: function() {
var response = _onModelChange.call(this);
if (this._modelOptions.populate) {
this.populate(this.model.attributes);
}
return response;
},
_setModelOptions: function(options) {
if (!options) {
options = {};
}
if (!('populate' in options)) {
options.populate = true;
}
return _setModelOptions.call(this, options);
}
});
})();
}
_.extend(Thorax.View.prototype, {
//serializes a form present in the view, returning the serialized data
//as an object
//pass {set:false} to not update this.model if present
//can pass options, callback or event in any order
serialize: function() {
var callback, options, event;
//ignore undefined arguments in case event was null
for (var i = 0; i < arguments.length; ++i) {
if (typeof arguments[i] === 'function') {
callback = arguments[i];
} else if (typeof arguments[i] === 'object') {
if ('stopPropagation' in arguments[i] && 'preventDefault' in arguments[i]) {
event = arguments[i];
} else {
options = arguments[i];
}
}
}
if (event && !this._preventDuplicateSubmission(event)) {
return;
}
options = _.extend({
set: true,
validate: true
},options || {});
var attributes = options.attributes || {};
//callback has context of element
var view = this;
var errors = [];
eachNamedInput.call(this, options, function() {
var value = view._getInputValue(this, options, errors);
if (typeof value !== 'undefined') {
objectAndKeyFromAttributesAndName.call(this, attributes, this.name, {mode: 'serialize'}, function(object, key) {
if (!object[key]) {
object[key] = value;
} else if (_.isArray(object[key])) {
object[key].push(value);
} else {
object[key] = [object[key], value];
}
});
}
});
this.trigger('serialize', attributes, options);
if (options.validate) {
var validateInputErrors = this.validateInput(attributes);
if (validateInputErrors && validateInputErrors.length) {
errors = errors.concat(validateInputErrors);
}
this.trigger('validate', attributes, errors, options);
if (errors.length) {
this.trigger('error', errors);
return;
}
}
if (options.set && this.model) {
if (!this.model.set(attributes, {silent: true})) {
return false;
};
}
callback && callback.call(this, attributes, _.bind(resetSubmitState, this));
return attributes;
},
_preventDuplicateSubmission: function(event, callback) {
event.preventDefault();
var form = $(event.target);
if ((event.target.tagName || '').toLowerCase() !== 'form') {
// Handle non-submit events by gating on the form
form = $(event.target).closest('form');
}
if (!form.attr('data-submit-wait')) {
form.attr('data-submit-wait', 'true');
if (callback) {
callback.call(this, event);
}
return true;
} else {
return false;
}
},
//populate a form from the passed attributes or this.model if present
populate: function(attributes) {
var value, attributes = attributes || this._getContext(this.model);
//callback has context of element
eachNamedInput.call(this, {}, function() {
objectAndKeyFromAttributesAndName.call(this, attributes, this.name, {mode: 'populate'}, function(object, key) {
if (object && typeof (value = object[key]) !== 'undefined') {
//will only execute if we have a name that matches the structure in attributes
if (this.type === 'checkbox' && _.isBoolean(value)) {
this.checked = value;
} else if (this.type === 'checkbox' || this.type === 'radio') {
this.checked = value == this.value;
} else {
this.value = value;
}
}
});
});
this.trigger('populate', attributes);
},
//perform form validation, implemented by child class
validateInput: function(attributes, options, errors) {},
_getInputValue: function(input, options, errors) {
if (input.type === 'checkbox' || input.type === 'radio') {
if (input.checked) {
return input.value;
}
} else if (input.multiple === true) {
var values = [];
$('option',input).each(function(){
if (this.selected) {
values.push(this.value);
}
});
return values;
} else {
return input.value;
}
}
});
Thorax.View.on({
error: function() {
resetSubmitState.call(this);
// If we errored with a model we want to reset the content but leave the UI
// intact. If the user updates the data and serializes any overwritten data
// will be restored.
if (this.model && this.model.previousAttributes) {
this.model.set(this.model.previousAttributes(), {
silent: true
});
}
},
deactivated: function() {
resetSubmitState.call(this);
}
})
function eachNamedInput(options, iterator, context) {
var i = 0;
this.$('select,input,textarea', options.root || this.el).each(function() {
if (this.type !== 'button' && this.type !== 'cancel' && this.type !== 'submit' && this.name && this.name !== '') {
iterator.call(context || this, i, this);
++i;
}
});
}
//calls a callback with the correct object fragment and key from a compound name
function objectAndKeyFromAttributesAndName(attributes, name, options, callback) {
var key, i, object = attributes, keys = name.split('['), mode = options.mode;
for(i = 0; i < keys.length - 1; ++i) {
key = keys[i].replace(']','');
if (!object[key]) {
if (mode == 'serialize') {
object[key] = {};
} else {
return callback.call(this, false, key);
}
}
object = object[key];
}
key = keys[keys.length - 1].replace(']', '');
callback.call(this, object, key);
}
function resetSubmitState() {
this.$('form').removeAttr('data-submit-wait');
}
//Router
function initializeRouter() {
Backbone.history || (Backbone.history = new Backbone.History);
Backbone.history.on('route', onRoute, this);
//router does not have a built in destroy event
//but ViewController does
this.on('destroyed', function() {
Backbone.history.off('route', onRoute, this);
});
}
Thorax.Router = Backbone.Router.extend({
constructor: function() {
var response = Thorax.Router.__super__.constructor.apply(this, arguments);
initializeRouter.call(this);
return response;
},
route: function(route, name, callback) {
//add a route:before event that is fired before the callback is called
return Backbone.Router.prototype.route.call(this, route, name, function() {
this.trigger.apply(this, ['route:before', name].concat(Array.prototype.slice.call(arguments)));
return callback.apply(this, arguments);
});
}
});
Thorax.Routers = {};
Thorax.Util.createRegistryWrapper(Thorax.Router, Thorax.Routers);
function onRoute(router, name) {
if (this === router) {
this.trigger.apply(this, ['route'].concat(Array.prototype.slice.call(arguments, 1)));
}
}
//layout
var layoutCidAttributeName = 'data-layout-cid';
Thorax.LayoutView = Thorax.View.extend({
render: function(output) {
//TODO: fixme, lumbar inserts templates after JS, most of the time this is fine
//but Application will be created in init.js (unlike most views)
//so need to put this here so the template will be picked up
var layoutTemplate;
if (this.name) {
layoutTemplate = Thorax.Util.registryGet(Thorax, 'templates', this.name, true);
}
//a template is optional in a layout
if (output || this.template || layoutTemplate) {
//but if present, it must have embedded an element containing layoutCidAttributeName
var response = Thorax.View.prototype.render.call(this, output || this.template || layoutTemplate);
ensureLayoutViewsTargetElement.call(this);
return response;
} else {
ensureLayoutCid.call(this);
}
},
setView: function(view, options) {
options = _.extend({
scroll: true,
destroy: true
}, options || {});
if (typeof view === 'string') {
view = new (Thorax.Util.registryGet(Thorax, 'Views', view, false));
}
this.ensureRendered();
var oldView = this._view;
if (view == oldView){
return false;
}
if (options.destroy && view) {
view._shouldDestroyOnNextSetView = true;
}
this.trigger('change:view:start', view, oldView, options);
oldView && oldView.trigger('deactivated', options);
view && view.trigger('activated', options);
if (oldView && oldView.el && oldView.el.parentNode) {
oldView.$el.remove();
}
//make sure the view has been rendered at least once
view && this._addChild(view);
view && view.ensureRendered();
view && getLayoutViewsTargetElement.call(this).appendChild(view.el);
this._view = view;
oldView && (delete this.children[oldView.cid]);
oldView && oldView._shouldDestroyOnNextSetView && oldView.destroy();
this._view && this._view.trigger('ready', options);
this.trigger('change:view:end', view, oldView, options);
return view;
},
getView: function() {
return this._view;
}
});
Handlebars.registerHelper('layout', function(options) {
options.hash[layoutCidAttributeName] = this._view.cid;
return new Handlebars.SafeString(Thorax.Util.tag.call(this, options.hash, '', this));
});
function ensureLayoutCid() {
++this._renderCount;
//set the layoutCidAttributeName on this.$el if there was no template
this.$el.attr(layoutCidAttributeName, this.cid);
}
function ensureLayoutViewsTargetElement() {
if (!this.$('[' + layoutCidAttributeName + '="' + this.cid + '"]')[0]) {
throw new Error('No layout element found in ' + (this.name || this.cid));
}
}
function getLayoutViewsTargetElement() {
return this.$('[' + layoutCidAttributeName + '="' + this.cid + '"]')[0] || this.el[0] || this.el;
}
//ViewController
Thorax.ViewController = Thorax.LayoutView.extend({
constructor: function() {
var response = Thorax.ViewController.__super__.constructor.apply(this, arguments);
this._bindRoutes();
initializeRouter.call(this);
//set the ViewController as the view on the parent
//if a parent was specified
this.on('route:before', function(router, name) {
if (this.parent && this.parent.getView) {
if (this.parent.getView() !== this) {
this.parent.setView(this, {
destroy: false
});
}
}
}, this);
return response;
}
});
_.extend(Thorax.ViewController.prototype, Thorax.Router.prototype);
var loadStart = 'load:start',
loadEnd = 'load:end',
rootObject;
Thorax.setRootObject = function(obj) {
rootObject = obj;
};
Thorax.loadHandler = function(start, end) {
return function(message, background, object) {
var self = this;
function startLoadTimeout() {
clearTimeout(self._loadStart.timeout);
self._loadStart.timeout = setTimeout(function() {
try {
self._loadStart.run = true;
start.call(self, self._loadStart.message, self._loadStart.background, self._loadStart);
} catch(e) {
Thorax.onException('loadStart', e);
}
},
loadingTimeout*1000);
}
if (!self._loadStart) {
var loadingTimeout = self._loadingTimeoutDuration;
if (loadingTimeout === void 0) {
// If we are running on a non-view object pull the default timeout
loadingTimeout = Thorax.View.prototype._loadingTimeoutDuration;
}
self._loadStart = _.extend({
events: [],
timeout: 0,
message: message,
background: !!background
}, Backbone.Events);
startLoadTimeout();
} else {
clearTimeout(self._loadStart.endTimeout);
self._loadStart.message = message;
if (!background && self._loadStart.background) {
self._loadStart.background = false;
startLoadTimeout();
}
}
self._loadStart.events.push(object);
object.bind(loadEnd, function endCallback() {
object.off(loadEnd, endCallback);
var loadingEndTimeout = self._loadingTimeoutEndDuration;
if (loadingEndTimeout === void 0) {
// If we are running on a non-view object pull the default timeout
loadingEndTimeout = Thorax.View.prototype._loadingTimeoutEndDuration;
}
var events = self._loadStart.events,
index = events.indexOf(object);
if (index >= 0) {
events.splice(index, 1);
}
if (!events.length) {
self._loadStart.endTimeout = setTimeout(function() {
try {
if (!events.length) {
var run = self._loadStart.run;
if (run) {
// Emit the end behavior, but only if there is a paired start
end.call(self, self._loadStart.background, self._loadStart);
self._loadStart.trigger(loadEnd, self._loadStart);
}
// If stopping make sure we don't run a start
clearTimeout(self._loadStart.timeout);
self._loadStart = undefined;
}
} catch(e) {
Thorax.onException('loadEnd', e);
}
}, loadingEndTimeout * 1000);
}
});
};
};
/**
* Helper method for propagating load:start events to other objects.
*
* Forwards load:start events that occur on `source` to `dest`.
*/
Thorax.forwardLoadEvents = function(source, dest, once) {
function load(message, backgound, object) {
if (once) {
source.off(loadStart, load);
}
dest.trigger(loadStart, message, backgound, object);
}
source.on(loadStart, load);
return {
off: function() {
source.off(loadStart, load);
}
};
};
//
// Data load event generation
//
/**
* Mixing for generating load:start and load:end events.
*/
Thorax.mixinLoadable = function(target, useParent) {
_.extend(target, {
//loading config
_loadingClassName: 'loading',
_loadingTimeoutDuration: 0.33,
_loadingTimeoutEndDuration: 0.10,
// Propagates loading view parameters to the AJAX layer
onLoadStart: function(message, background, object) {
var that = useParent ? this.parent : this;
if (!that.nonBlockingLoad && !background && rootObject) {
rootObject.trigger(loadStart, message, background, object);
}
$(that.el).addClass(that._loadingClassName);
//used by loading helpers
if (that._loadingCallbacks) {
that._loadingCallbacks.forEach(function(callback) {
callback();
});
}
},
onLoadEnd: function(background, object) {
var that = useParent ? this.parent : this;
$(that.el).removeClass(that._loadingClassName);
//used by loading helpers
if (that._loadingCallbacks) {
that._loadingCallbacks.forEach(function(callback) {
callback();
});
}
}
});
};
Thorax.mixinLoadableEvents = function(target, useParent) {
_.extend(target, {
loadStart: function(message, background) {
var that = useParent ? this.parent : this;
that.trigger(loadStart, message, background, that);
},
loadEnd: function() {
var that = useParent ? this.parent : this;
that.trigger(loadEnd, that);
}
});
};
Thorax.mixinLoadable(Thorax.View.prototype);
Thorax.mixinLoadableEvents(Thorax.View.prototype);
Thorax.sync = function(method, dataObj, options) {
var self = this,
complete = options.complete;
options.complete = function() {
self._request = undefined;
self._aborted = false;
complete && complete.apply(this, arguments);
};
this._request = Backbone.sync.apply(this, arguments);
// TODO : Reevaluate this event... Seems too indepth to expose as an API
this.trigger('request', this._request);
return this._request;
};
function bindToRoute(callback, failback) {
var fragment = Backbone.history.getFragment(),
completed;
function finalizer(isCanceled) {
var same = fragment === Backbone.history.getFragment();
if (completed) {
// Prevent multiple execution, i.e. we were canceled but the success callback still runs
return;
}
if (isCanceled && same) {
// Ignore the first route event if we are running in newer versions of backbone
// where the route operation is a postfix operation.
return;
}
completed = true;
Backbone.history.off('route', resetLoader);
var args = Array.prototype.slice.call(arguments, 1);
if (!isCanceled && same) {
callback.apply(this, args);
} else {
failback && failback.apply(this, args);
}
}
var resetLoader = _.bind(finalizer, this, true);
Backbone.history.on('route', resetLoader);
return _.bind(finalizer, this, false);
}
function loadData(callback, failback, options) {
if (this.isPopulated()) {
return callback(this);
}
if (arguments.length === 2 && typeof failback !== 'function' && _.isObject(failback)) {
options = failback;
failback = false;
}
this.fetch(_.defaults({
success: bindToRoute(callback, failback && _.bind(failback, this, false)),
error: failback && _.bind(failback, this, true)
}, options));
}
function fetchQueue(options, $super) {
if (options.resetQueue) {
// WARN: Should ensure that loaders are protected from out of band data
// when using this option
this.fetchQueue = undefined;
}
if (!this.fetchQueue) {
// Kick off the request
this.fetchQueue = [options];
options = _.defaults({
success: flushQueue(this, this.fetchQueue, 'success'),
error: flushQueue(this, this.fetchQueue, 'error'),
complete: flushQueue(this, this.fetchQueue, 'complete')
}, options);
$super.call(this, options);
} else {
// Currently fetching. Queue and process once complete
this.fetchQueue.push(options);
}
}
function flushQueue(self, fetchQueue, handler) {
return function() {
var args = arguments;
// Flush the queue. Executes any callback handlers that
// may have been passed in the fetch options.
fetchQueue.forEach(function(options) {
if (options[handler]) {
options[handler].apply(this, args);
}
}, this);
// Reset the queue if we are still the active request
if (self.fetchQueue === fetchQueue) {
self.fetchQueue = undefined;
}
}
}
var klasses = [];
Thorax.Model && klasses.push(Thorax.Model);
Thorax.Collection && klasses.push(Thorax.Collection);
_.each(klasses, function(DataClass) {
var $fetch = DataClass.prototype.fetch;
Thorax.mixinLoadableEvents(DataClass.prototype, false);
_.extend(DataClass.prototype, {
sync: Thorax.sync,
fetch: function(options) {
options = options || {};
var self = this,
complete = options.complete;
options.complete = function() {
complete && complete.apply(this, arguments);
self.loadEnd();
};
self.loadStart(undefined, options.background);
return fetchQueue.call(this, options || {}, $fetch);
},
load: function(callback, failback, options) {
if (arguments.length === 2 && typeof failback !== 'function') {
options = failback;
failback = false;
}
options = options || {};
if (!options.background && !this.isPopulated() && rootObject) {
// Make sure that the global scope sees the proper load events here
// if we are loading in standalone mode
Thorax.forwardLoadEvents(this, rootObject, true);
}
var self = this;
loadData.call(this, callback,
function(isError) {
// Route changed, kill it
if (!isError) {
if (self._request) {
self._aborted = true;
self._request.abort();
}
}
failback && failback.apply && failback.apply(this, arguments);
},
options);
}
});
});
Thorax.Util.bindToRoute = bindToRoute;
if (Thorax.Router) {
Thorax.Router.bindToRoute = Thorax.Router.prototype.bindToRoute = bindToRoute;
}
//
// View load event handling
//
if (Thorax.Model) {
(function() {
// Propagates loading view parameters to the AJAX layer
var _setModelOptions = Thorax.View.prototype._setModelOptions;
Thorax.View.prototype._setModelOptions = function(options) {
return _setModelOptions.call(this, _.defaults({
ignoreErrors: this.ignoreFetchError,
background: this.nonBlockingLoad
}, options || {}));
};
})();
Thorax.View.prototype._loadModel = function(model, options) {
if (model.load) {
model.load(function() {
options.success && options.success(model);
}, options);
} else {
model.fetch(options);
}
};
}
if (Thorax.Collection) {
Thorax.mixinLoadable(Thorax.CollectionView.prototype);
Thorax.mixinLoadableEvents(Thorax.CollectionView.prototype);
// Propagates loading view parameters to the AJAX layer
var _setCollectionOptions = Thorax.CollectionView.prototype._setCollectionOptions;
Thorax.CollectionView.prototype._setCollectionOptions = function(collection, options) {
return _setCollectionOptions.call(this, collection, _.defaults({
ignoreErrors: this.ignoreFetchError,
background: this.nonBlockingLoad
}, options || {}));
};
Thorax.CollectionView.prototype._loadCollection = function(collection, options) {
if (collection.load) {
collection.load(function(){
options.success && options.success(collection);
}, options);
} else {
collection.fetch(options);
}
};
}
Thorax.View.on({
'load:start': Thorax.loadHandler(
function(message, background, object) {
this.onLoadStart(message, background, object);
},
function(background, object) {
this.onLoadEnd(object);
}),
collection: {
'load:start': function(collectionView, message, background, object) {
//this refers to the collection view, we want to trigger on
//the parent view which originally bound the collection
this.trigger(loadStart, message, background, object);
}
},
model: {
'load:start': function(message, background, object) {
this.trigger(loadStart, message, background, object);
}
}
});
// Helpers
Handlebars.registerViewHelper('loading', function(view) {
_render = view.render;
view.render = function() {
if (view.parent.$el.hasClass(view.parent._loadingClassName)) {
return _render.call(this, view.fn);
} else {
return _render.call(this, view.inverse);
}
};
var callback = _.bind(view.render, view);
view.parent._loadingCallbacks = view.parent._loadingCallbacks || [];
view.parent._loadingCallbacks.push(callback);
view.on('freeze', function() {
view.parent._loadingCallbacks = _.without(view.parent._loadingCallbacks, callback);
});
view.render();
});
//add "loading-view" and "loading-template" options to collection helper
Thorax.View.on('helper:collection', function(view) {
if (arguments.length === 2) {
view = arguments[1];
}
if (!view.collection) {
view.collection = view.parent.collection;
}
if (view.options['loading-view'] || view.options['loading-template']) {
var item;
var callback = Thorax.loadHandler(_.bind(function() {
if (view.collection.length === 0) {
view.$el.empty();
}
if (view.options['loading-view']) {
var instance = Thorax.Util.getViewInstance(view.options['loading-view'], {
collection: view.collection
});
view._addChild(instance);
if (view.options['loading-template']) {
instance.render(view.options['loading-template']);
} else {
instance.render();
}
item = instance;
} else {
item = view.renderTemplate(view.options['loading-template'], {
collection: view.collection
});
}
view.appendItem(item, view.collection.length);
view.$el.children().last().attr('data-loading-element', view.collection.cid);
}, this), _.bind(function() {
view.$el.find('[data-loading-element="' + view.collection.cid + '"]').remove();
}, this));
view.on(view.collection, 'load:start', callback);
}
});
if (Thorax.CollectionView) {
Thorax.CollectionView._optionNames.push('loading-template');
Thorax.CollectionView._optionNames.push('loading-view');
}
})();
(function() {
'use strict';
// Todo Model
// ----------
// Our basic **Todo** model has `title`, `order`, and `completed` attributes.
window.app.Todo = Backbone.Model.extend({
// Default attributes for the todo
// and ensure that each todo created has `title` and `completed` keys.
defaults: {
title: '',
completed: false
},
// Toggle the `completed` state of this todo item.
toggle: function() {
this.save({
completed: !this.get('completed')
});
},
isVisible: function () {
var isCompleted = this.get('completed');
if (window.app.TodoFilter === '') {
return true;
} else if (window.app.TodoFilter === 'completed') {
return isCompleted;
} else if (window.app.TodoFilter === 'active') {
return !isCompleted;
}
}
});
}());
(function() {
'use strict';
// Todo Router
// ----------
new (Thorax.Router.extend({
// The module variable is set inside of the file
// generated by Lumbar
name: module.name,
routes: module.routes,
setFilter: function( param ) {
// Set the current filter to be used
window.app.TodoFilter = param ? param.trim().replace(/^\//, '') : '';
// Thorax listens for a `filter` event which will
// force the collection to re-filter
window.app.Todos.trigger('filter');
}
}));
}());
$(function( $ ) {
'use strict';
// The Application
// ---------------
// Our overall **AppView** is the top-level piece of UI.
Thorax.View.extend({
// This will assign the template Thorax.templates['app'] to the view and
// create a view class at Thorax.Views['app']
name: 'app',
// Delegated events for creating new items, and clearing completed ones.
events: {
'keypress #new-todo': 'createOnEnter',
'click #toggle-all': 'toggleAllComplete',
// The collection helper in the template will bind the collection
// to the view. Any events in this hash will be bound to the
// collection.
collection: {
all: 'toggleToggleAllButton'
},
rendered: 'toggleToggleAllButton'
},
// Unless the "context" method is overriden any attributes on the view
// will be availble to the context / scope of the template, make the
// global Todos collection available to the template.
// Load any preexisting todos that might be saved in *localStorage*.
initialize: function() {
this.todosCollection = window.app.Todos;
this.todosCollection.fetch();
this.render();
},
toggleToggleAllButton: function() {
this.$('#toggle-all').attr('checked', !this.todosCollection.remaining().length);
},
// This function is specified in the collection helper as the filter
// and will be called each time a model changes, or for each item
// when the collection is rendered
filterTodoItem: function(model) {
return model.isVisible();
},
// Generate the attributes for a new Todo item.
newAttributes: function() {
return {
title: this.$('#new-todo').val().trim(),
order: window.app.Todos.nextOrder(),
completed: false
};
},
// If you hit return in the main input field, create new **Todo** model,
// persisting it to *localStorage*.
createOnEnter: function( e ) {
if ( e.which !== ENTER_KEY || !this.$('#new-todo').val().trim() ) {
return;
}
window.app.Todos.create( this.newAttributes() );
this.$('#new-todo').val('');
},
toggleAllComplete: function() {
var completed = this.$('#toggle-all')[0].checked;
window.app.Todos.each(function( todo ) {
todo.save({
'completed': completed
});
});
}
});
});
Thorax.View.extend({
name: 'stats',
events: {
'click #clear-completed': 'clearCompleted',
// The "rendered" event is triggered by Thorax each time render()
// is called and the result of the template has been appended
// to the View's $el
rendered: 'highlightFilter'
},
initialize: function() {
// Whenever the Todos collection changes re-render the stats
// render() needs to be called with no arguments, otherwise calling
// it with arguments will insert the arguments as content
window.app.Todos.on('all', _.debounce(function() {
this.render();
}), this);
},
// Clear all completed todo items, destroying their models.
clearCompleted: function() {
_.each( window.app.Todos.completed(), function( todo ) {
todo.destroy();
});
return false;
},
// Each time the stats view is rendered this function will
// be called to generate the context / scope that the template
// will be called with. "context" defaults to "return this"
context: function() {
var remaining = window.app.Todos.remaining().length;
return {
itemText: remaining === 1 ? 'item' : 'items',
completed: window.app.Todos.completed().length,
remaining: remaining
};
},
// Highlight which filter will appear to be active
highlightFilter: function() {
this.$('#filters li a')
.removeClass('selected')
.filter('[href="#/' + ( window.app.TodoFilter || '' ) + '"]')
.addClass('selected');
}
});
\ No newline at end of file
$(function() {
'use strict';
// Todo Item View
// --------------
// The DOM element for a todo item...
Thorax.View.extend({
//... is a list tag.
tagName: 'li',
// Cache the template function for a single item.
name: 'todo-item',
// The DOM events specific to an item.
events: {
'click .toggle': 'toggleCompleted',
'dblclick label': 'edit',
'click .destroy': 'clear',
'keypress .edit': 'updateOnEnter',
'blur .edit': 'close',
// The "rendered" event is triggered by Thorax each time render()
// is called and the result of the template has been appended
// to the View's $el
rendered: function() {
this.$el.toggleClass( 'completed', this.model.get('completed') );
}
},
// Toggle the `"completed"` state of the model.
toggleCompleted: function() {
this.model.toggle();
},
// Switch this view into `"editing"` mode, displaying the input field.
edit: function() {
this.$el.addClass('editing');
this.$('.edit').focus();
},
// Close the `"editing"` mode, saving changes to the todo.
close: function() {
var value = this.$('.edit').val().trim();
if ( value ) {
this.model.save({ title: value });
} else {
this.clear();
}
this.$el.removeClass('editing');
},
// If you hit `enter`, we're through editing the item.
updateOnEnter: function( e ) {
if ( e.which === ENTER_KEY ) {
this.close();
}
},
// Remove the item, destroy the model from *localStorage* and delete its view.
clear: function() {
this.model.destroy();
}
});
});
<section id="todoapp">
<header id="header">
<h1>todos</h1>
<input id="new-todo" placeholder="What needs to be done?" autofocus>
</header>
{{^empty todosCollection}}
<section id="main">
<input id="toggle-all" type="checkbox">
<label for="toggle-all">Mark all as complete</label>
{{#collection todosCollection filter="filterTodoItem" item-view="todo-item" tag="ul" id="todo-list"}}
<div class="view">
<input class="toggle" type="checkbox" {{#if completed}}checked{{/if}}>
<label>{{title}}</label>
<button class="destroy"></button>
</div>
<input class="edit" value="{{title}}">
{{/collection}}
</section>
{{view "stats" tag="footer" id="footer"}}
{{/empty}}
</section>
<div id="info">
<p>Double-click to edit a todo</p>
<p>Written by <a href="https://github.com/addyosmani">Addy Osmani</a> &amp; <a href="https://github.com/beastridge">Ryan Eastridge</a></p>
<p>Part of <a href="http://todomvc.com">TodoMVC</a></p>
</div>
<span id="todo-count"><strong>{{remaining}}</strong> {{itemText}} left</span>
<ul id="filters">
<li>
{{#link "/" class="selected"}}All{{/link}}
</li>
<li>
{{#link "/active"}}Active{{/link}}
</li>
<li>
{{#link "/completed"}}Completed{{/link}}
</li>
</ul>
{{#if completed}}
<button id="clear-completed">Clear completed ({{completed}})</button>
{{/if}}
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment