todomvc
Version:
> Helping you select an MV\* framework
1,157 lines (1,010 loc) • 39.4 kB
JavaScript
/*
Agility.js
Licensed under the MIT license
Copyright (c) Artur B. Adib, 2011
http://agilityjs.com
*/
// Sandboxed, so kids don't get hurt. Inspired by jQuery's code:
// Creates local ref to window for performance reasons (as JS looks up local vars first)
// Redefines undefined as it could have been tampered with
(function(window, undefined){
if (!window.jQuery) {
throw "agility.js: jQuery not found";
}
// Local references
var document = window.document,
location = window.location,
// In case $ is being used by another lib
$ = jQuery,
// Main agility object builder
agility,
// Internal utility functions
util = {},
// Default object prototype
defaultPrototype = {},
// Global object counter
idCounter = 0,
// Constant
ROOT_SELECTOR = '&';
//////////////////////////////////////////////////////////////////////////
//
// Modernizing old JS
//
// Modified from Douglas Crockford's Object.create()
// The condition below ensures we override other manual implementations (most are not adequate)
if (!Object.create || Object.create.toString().search(/native code/i)<0) {
Object.create = function(obj){
var Aux = function(){};
$.extend(Aux.prototype, obj); // simply setting Aux.prototype = obj somehow messes with constructor, so getPrototypeOf wouldn't work in IE
return new Aux();
};
}
// Modified from John Resig's Object.getPrototypeOf()
// The condition below ensures we override other manual implementations (most are not adequate)
if (!Object.getPrototypeOf || Object.getPrototypeOf.toString().search(/native code/i)<0) {
if ( typeof "test".__proto__ === "object" ) {
Object.getPrototypeOf = function(object){
return object.__proto__;
};
} else {
Object.getPrototypeOf = function(object){
// May break if the constructor has been tampered with
return object.constructor.prototype;
};
}
}
//////////////////////////////////////////////////////////////////////////
//
// util.*
//
// Checks if provided obj is an agility object
util.isAgility = function(obj){
return obj._agility === true;
};
// Scans object for functions (depth=2) and proxies their 'this' to dest.
// * To ensure it works with previously proxied objects, we save the original function as
// a '._preProxy' method and when available always use that as the proxy source.
// * To skip a given method, create a sub-method called '_noProxy'.
util.proxyAll = function(obj, dest){
if (!obj || !dest) {
throw "agility.js: util.proxyAll needs two arguments";
}
for (var attr1 in obj) {
var proxied = obj[attr1];
// Proxy root methods
if (typeof obj[attr1] === 'function') {
proxied = obj[attr1]._noProxy ? obj[attr1] : $.proxy(obj[attr1]._preProxy || obj[attr1], dest);
proxied._preProxy = obj[attr1]._noProxy ? undefined : (obj[attr1]._preProxy || obj[attr1]); // save original
obj[attr1] = proxied;
}
// Proxy sub-methods (model.*, view.*, etc)
else if (typeof obj[attr1] === 'object') {
for (var attr2 in obj[attr1]) {
var proxied2 = obj[attr1][attr2];
if (typeof obj[attr1][attr2] === 'function') {
proxied2 = obj[attr1][attr2]._noProxy ? obj[attr1][attr2] : $.proxy(obj[attr1][attr2]._preProxy || obj[attr1][attr2], dest);
proxied2._preProxy = obj[attr1][attr2]._noProxy ? undefined : (obj[attr1][attr2]._preProxy || obj[attr1][attr2]); // save original
proxied[attr2] = proxied2;
}
} // for attr2
obj[attr1] = proxied;
} // if not func
} // for attr1
}; // proxyAll
// Reverses the order of events attached to an object
util.reverseEvents = function(obj, eventType){
var events = $(obj).data('events');
if (events !== undefined && events[eventType] !== undefined){
// can't reverse what's not there
var reverseEvents = [];
for (var e in events[eventType]){
if (!events[eventType].hasOwnProperty(e)) continue;
reverseEvents.unshift(events[eventType][e]);
}
events[eventType] = reverseEvents;
}
}; //reverseEvents
// Determines # of attributes of given object (prototype inclusive)
util.size = function(obj){
var size = 0, key;
for (key in obj) {
size++;
}
return size;
};
// Find controllers to be extended (with syntax '~'), redefine those to encompass previously defined controllers
// Example:
// var a = $$({}, '<button>A</button>', {'click &': function(){ alert('A'); }});
// var b = $$(a, {}, '<button>B</button>', {'~click &': function(){ alert('B'); }});
// Clicking on button B will alert both 'A' and 'B'.
util.extendController = function(object) {
for (var controllerName in object.controller) {
(function(){ // new scope as we need one new function handler per controller
var matches, extend, eventName,
previousHandler, currentHandler, newHandler;
if (typeof object.controller[controllerName] === 'function') {
matches = controllerName.match(/^(\~)*(.+)/); // 'click button', '~click button', '_create', etc
extend = matches[1];
eventName = matches[2];
if (!extend) return; // nothing to do
// Redefine controller:
// '~click button' ---> 'click button' = previousHandler + currentHandler
previousHandler = object.controller[eventName] ? (object.controller[eventName]._preProxy || object.controller[eventName]) : undefined;
currentHandler = object.controller[controllerName];
newHandler = function() {
if (previousHandler) previousHandler.apply(this, arguments);
if (currentHandler) currentHandler.apply(this, arguments);
};
object.controller[eventName] = newHandler;
delete object.controller[controllerName]; // delete '~click button'
} // if function
})();
} // for controllerName
};
//////////////////////////////////////////////////////////////////////////
//
// Default object prototype
//
defaultPrototype = {
_agility: true,
//////////////////////////////////////////////////////////////////////////
//
// _container
//
// API and related auxiliary functions for storing child Agility objects.
// Not all methods are exposed. See 'shortcuts' below for exposed methods.
//
_container: {
// Adds child object to container, appends/prepends/etc view, listens for child removal
_insertObject: function(obj, selector, method){
var self = this;
if (!util.isAgility(obj)) {
throw "agility.js: append argument is not an agility object";
}
this._container.children[obj._id] = obj; // children is *not* an array; this is for simpler lookups by global object id
this.trigger(method, [obj, selector]);
obj._parent = this;
// ensures object is removed from container when destroyed:
obj.bind('destroy', function(event, id){
self._container.remove(id);
});
return this;
},
append: function(obj, selector) {
return this._container._insertObject.call(this, obj, selector, 'append');
},
prepend: function(obj, selector) {
return this._container._insertObject.call(this, obj, selector, 'prepend');
},
after: function(obj, selector) {
return this._container._insertObject.call(this, obj, selector, 'after');
},
before: function(obj, selector) {
return this._container._insertObject.call(this, obj, selector, 'before');
},
// Removes child object from container
remove: function(id){
delete this._container.children[id];
this.trigger('remove', id);
return this;
},
// Iterates over all child objects in container
each: function(fn){
$.each(this._container.children, fn);
return this; // for chainable calls
},
// Removes all objects in container
empty: function(){
this.each(function(){
this.destroy();
});
return this;
},
// Number of children
size: function() {
return util.size(this._container.children);
}
},
//////////////////////////////////////////////////////////////////////////
//
// _events
//
// API and auxiliary functions for handling events. Not all methods are exposed.
// See 'shortcuts' below for exposed methods.
//
_events: {
// Parses event string like:
// 'event' : custom event
// 'event selector' : DOM event using 'selector'
// Returns { type:'event' [, selector:'selector'] }
parseEventStr: function(eventStr){
var eventObj = { type:eventStr },
spacePos = eventStr.search(/\s/);
// DOM event 'event selector', e.g. 'click button'
if (spacePos > -1) {
eventObj.type = eventStr.substr(0, spacePos);
eventObj.selector = eventStr.substr(spacePos+1);
}
return eventObj;
},
// Binds eventStr to fn. eventStr is parsed as per parseEventStr()
bind: function(eventStr, fn){
var eventObj = this._events.parseEventStr(eventStr);
// DOM event 'event selector', e.g. 'click button'
if (eventObj.selector) {
// Manually override root selector, as jQuery selectors can't select self object
if (eventObj.selector === ROOT_SELECTOR) {
this.view.$().bind(eventObj.type, fn);
}
else {
this.view.$().delegate(eventObj.selector, eventObj.type, fn);
}
}
// Custom event
else {
$(this._events.data).bind(eventObj.type, fn);
}
return this; // for chainable calls
}, // bind
// Triggers eventStr. Syntax for eventStr is same as that for bind()
trigger: function(eventStr, params){
var eventObj = this._events.parseEventStr(eventStr);
// DOM event 'event selector', e.g. 'click button'
if (eventObj.selector) {
// Manually override root selector, as jQuery selectors can't select self object
if (eventObj.selector === ROOT_SELECTOR) {
this.view.$().trigger(eventObj.type, params);
}
else {
this.view.$().find(eventObj.selector).trigger(eventObj.type, params);
}
}
// Custom event
else {
$(this._events.data).trigger('_'+eventObj.type, params);
// fire 'pre' hooks in reverse attachment order ( last first )
util.reverseEvents(this._events.data, 'pre:' + eventObj.type);
$(this._events.data).trigger('pre:' + eventObj.type, params);
// put the order of events back
util.reverseEvents(this._events.data, 'pre:' + eventObj.type);
$(this._events.data).trigger(eventObj.type, params);
if(this.parent())
this.parent().trigger((eventObj.type.match(/^child:/) ? '' : 'child:') + eventObj.type, params);
$(this._events.data).trigger('post:' + eventObj.type, params);
}
return this; // for chainable calls
} // trigger
}, // _events
//////////////////////////////////////////////////////////////////////////
//
// Model
//
// Main model API. All methods are exposed, but methods starting with '_'
// are meant to be used internally only.
//
model: {
// Setter
set: function(arg, params) {
var self = this;
var modified = []; // list of modified model attributes
if (typeof arg === 'object') {
var _clone = false;
if (params && params.reset) {
_clone = this.model._data; // hold on to data for change events
this.model._data = $.extend({}, arg); // erases previous model attributes without pointing to object
}
else {
$.extend(this.model._data, arg); // default is extend
}
for (var key in arg) {
delete _clone[ key ]; // no need to fire change twice
modified.push(key);
}
for (key in _clone) {
modified.push(key);
}
}
else {
throw "agility.js: unknown argument type in model.set()";
}
// Events
if (params && params.silent===true) return this; // do not fire events
this.trigger('change');
$.each(modified, function(index, val){
self.trigger('change:'+val);
});
return this; // for chainable calls
},
// Getter
get: function(arg){
// Full model getter
if (arg === undefined) {
return this.model._data;
}
// Attribute getter
if (typeof arg === 'string') {
return this.model._data[arg];
}
throw 'agility.js: unknown argument for getter';
},
// Resetter (to initial model upon object initialization)
reset: function(){
this.model.set(this.model._initData, {reset:true});
return this; // for chainable calls
},
// Number of model properties
size: function(){
return util.size(this.model._data);
},
// Convenience function - loops over each model property
each: function(fn){
$.each(this.model._data, fn);
return this; // for chainable calls
}
}, // model prototype
//////////////////////////////////////////////////////////////////////////
//
// View
//
// Main view API. All methods are exposed, but methods starting with '_'
// are meant to be used internally only.
//
view: {
// Defaults
format: '<div/>',
style: '',
// Shortcut to view.$root or view.$root.find(), depending on selector presence
$: function(selector){
return (!selector || selector === ROOT_SELECTOR) ? this.view.$root : this.view.$root.find(selector);
},
// Render $root
// Only function to access $root directly other than $()
render: function(){
// Without format there is no view
if (this.view.format.length === 0) {
throw "agility.js: empty format in view.render()";
}
if (this.view.$root.size() === 0) {
this.view.$root = $(this.view.format);
}
else {
this.view.$root.html( $(this.view.format).html() ); // can't overwrite $root as this would reset its presence in the DOM and all events already bound, and
}
// Ensure we have a valid (non-empty) $root
if (this.view.$root.size() === 0) {
throw 'agility.js: could not generate html from format';
}
return this;
}, // render
// Parse data-bind string of the type '[attribute][=] variable[, [attribute][=] variable ]...'
// If the variable is not an attribute, it must occur by itself
// all pairs in the list are assumed to be attributes
// Returns { key:'model key', attr: [ {attr : 'attribute', attrVar : 'variable' }... ] }
_parseBindStr: function(str){
var obj = {key:null, attr:[]},
pairs = str.split(','),
regex = /([a-zA-Z0-9_\-]+)(?:[\s=]+([a-zA-Z0-9_\-]+))?/,
keyAssigned = false,
matched;
if (pairs.length > 0) {
for (var i = 0; i < pairs.length; i++) {
matched = pairs[i].match(regex);
// [ "attribute variable", "attribute", "variable" ]
// or [ "attribute=variable", "attribute", "variable" ]
// or
// [ "variable", "variable", undefined ]
// in some IE it will be [ "variable", "variable", "" ]
// or
// null
if (matched) {
if (typeof(matched[2]) === "undefined" || matched[2] === "") {
if (keyAssigned) {
throw new Error("You may specify only one key (" +
keyAssigned + " has already been specified in data-bind=" +
str + ")");
} else {
keyAssigned = matched[1];
obj.key = matched[1];
}
} else {
obj.attr.push({attr: matched[1], attrVar: matched[2]});
}
} // if (matched)
} // for (pairs.length)
} // if (pairs.length > 0)
return obj;
},
// Apply two-way (DOM <--> Model) bindings to elements with 'data-bind' attributes
bindings: function(){
var self = this;
var $rootNode = this.view.$().filter('[data-bind]');
var $childNodes = this.view.$('[data-bind]');
var createAttributePairClosure = function(bindData, node, i) {
var attrPair = bindData.attr[i]; // capture the attribute pair in closure
return function() {
node.attr(attrPair.attr, self.model.get(attrPair.attrVar));
};
};
$rootNode.add($childNodes).each(function(){
var $node = $(this);
var bindData = self.view._parseBindStr( $node.data('bind') );
var bindAttributesOneWay = function() {
// 1-way attribute binding
if (bindData.attr) {
for (var i = 0; i < bindData.attr.length; i++) {
self.bind('_change:'+bindData.attr[i].attrVar,
createAttributePairClosure(bindData, $node, i));
} // for (bindData.attr)
} // if (bindData.attr)
}; // bindAttributesOneWay()
// <input type="checkbox">: 2-way binding
if ($node.is('input:checkbox')) {
// Model --> DOM
self.bind('_change:'+bindData.key, function(){
$node.prop("checked", self.model.get(bindData.key)); // this won't fire a DOM 'change' event, saving us from an infinite event loop (Model <--> DOM)
});
// DOM --> Model
$node.change(function(){
var obj = {};
obj[bindData.key] = $(this).prop("checked");
self.model.set(obj); // not silent as user might be listening to change events
});
// 1-way attribute binding
bindAttributesOneWay();
}
// <select>: 2-way binding
else if ($node.is('select')) {
// Model --> DOM
self.bind('_change:'+bindData.key, function(){
var nodeName = $node.attr('name');
var modelValue = self.model.get(bindData.key);
$node.val(modelValue);
});
// DOM --> Model
$node.change(function(){
var obj = {};
obj[bindData.key] = $node.val();
self.model.set(obj); // not silent as user might be listening to change events
});
// 1-way attribute binding
bindAttributesOneWay();
}
// <input type="radio">: 2-way binding
else if ($node.is('input:radio')) {
// Model --> DOM
self.bind('_change:'+bindData.key, function(){
var nodeName = $node.attr('name');
var modelValue = self.model.get(bindData.key);
$node.siblings('input[name="'+nodeName+'"]').filter('[value="'+modelValue+'"]').prop("checked", true); // this won't fire a DOM 'change' event, saving us from an infinite event loop (Model <--> DOM)
});
// DOM --> Model
$node.change(function(){
if (!$node.prop("checked")) return; // only handles check=true events
var obj = {};
obj[bindData.key] = $node.val();
self.model.set(obj); // not silent as user might be listening to change events
});
// 1-way attribute binding
bindAttributesOneWay();
}
// <input type="search"> (model is updated after every keypress event)
else if ($node.is('input[type="search"]')) {
// Model --> DOM
self.bind('_change:'+bindData.key, function(){
$node.val(self.model.get(bindData.key)); // this won't fire a DOM 'change' event, saving us from an infinite event loop (Model <--> DOM)
});
// Model <-- DOM
$node.keypress(function(){
// Without timeout $node.val() misses the last entered character
setTimeout(function(){
var obj = {};
obj[bindData.key] = $node.val();
self.model.set(obj); // not silent as user might be listening to change events
}, 50);
});
// 1-way attribute binding
bindAttributesOneWay();
}
// <input type="text">, <input>, and <textarea>: 2-way binding
else if ($node.is('input:text, textarea')) {
// Model --> DOM
self.bind('_change:'+bindData.key, function(){
$node.val(self.model.get(bindData.key)); // this won't fire a DOM 'change' event, saving us from an infinite event loop (Model <--> DOM)
});
// Model <-- DOM
$node.change(function(){
var obj = {};
obj[bindData.key] = $(this).val();
self.model.set(obj); // not silent as user might be listening to change events
});
// 1-way attribute binding
bindAttributesOneWay();
}
// all other <tag>s: 1-way binding
else {
if (bindData.key) {
self.bind('_change:'+bindData.key, function(){
if (self.model.get(bindData.key)) {
$node.text(self.model.get(bindData.key).toString());
} else {
$node.text('');
}
});
}
bindAttributesOneWay();
}
}); // nodes.each()
return this;
}, // bindings()
// Triggers _change and _change:* events so that view is updated as per view.bindings()
sync: function(){
var self = this;
// Trigger change events so that view is updated according to model
this.model.each(function(key, val){
self.trigger('_change:'+key);
});
if (this.model.size() > 0) {
this.trigger('_change');
}
return this;
},
// Applies style dynamically
stylize: function(){
var objClass,
regex = new RegExp(ROOT_SELECTOR, 'g');
if (this.view.style.length === 0 || this.view.$().size() === 0) {
return;
}
// Own style
// Object gets own class name ".agility_123", and <head> gets a corresponding <style>
if (this.view.hasOwnProperty('style')) {
objClass = 'agility_' + this._id;
var styleStr = this.view.style.replace(regex, '.'+objClass);
$('head', window.document).append('<style type="text/css">'+styleStr+'</style>');
this.view.$().addClass(objClass);
}
// Inherited style
// Object inherits CSS class name from first ancestor to have own view.style
else {
// Returns id of first ancestor to have 'own' view.style
var ancestorWithStyle = function(object) {
while (object !== null) {
object = Object.getPrototypeOf(object);
if (object.view.hasOwnProperty('style'))
return object._id;
}
return undefined;
}; // ancestorWithStyle
var ancestorId = ancestorWithStyle(this);
objClass = 'agility_' + ancestorId;
this.view.$().addClass(objClass);
}
return this;
}
}, // view prototype
//////////////////////////////////////////////////////////////////////////
//
// Controller
//
// Default controllers, i.e. event handlers. Event handlers that start
// with '_' are of internal use only, and take precedence over any other
// handler without that prefix. (See trigger()).
//
controller: {
// Triggered after self creation
_create: function(event){
this.view.stylize();
this.view.bindings(); // Model-View bindings
this.view.sync(); // syncs View with Model
},
// Triggered upon removing self
_destroy: function(event){
// destroy any appended agility objects
this._container.empty();
// destroy self
this.view.$().remove();
},
// Triggered after child obj is appended to container
_append: function(event, obj, selector){
this.view.$(selector).append(obj.view.$());
},
// Triggered after child obj is prepended to container
_prepend: function(event, obj, selector){
this.view.$(selector).prepend(obj.view.$());
},
// Triggered after child obj is inserted in the container
_before: function(event, obj, selector){
if (!selector) throw 'agility.js: _before needs a selector';
this.view.$(selector).before(obj.view.$());
},
// Triggered after child obj is inserted in the container
_after: function(event, obj, selector){
if (!selector) throw 'agility.js: _after needs a selector';
this.view.$(selector).after(obj.view.$());
},
// Triggered after a child obj is removed from container (or self-removed)
_remove: function(event, id){
},
// Triggered after model is changed
'_change': function(event){
}
}, // controller prototype
//////////////////////////////////////////////////////////////////////////
//
// Shortcuts
//
//
// Self
//
destroy: function() {
this.trigger('destroy', this._id); // parent must listen to 'remove' event and handle container removal!
// can't return this as it might not exist anymore!
},
parent: function(){
return this._parent;
},
//
// _container shortcuts
//
append: function(){
this._container.append.apply(this, arguments);
return this; // for chainable calls
},
prepend: function(){
this._container.prepend.apply(this, arguments);
return this; // for chainable calls
},
after: function(){
this._container.after.apply(this, arguments);
return this; // for chainable calls
},
before: function(){
this._container.before.apply(this, arguments);
return this; // for chainable calls
},
remove: function(){
this._container.remove.apply(this, arguments);
return this; // for chainable calls
},
size: function(){
return this._container.size.apply(this, arguments);
},
each: function(){
return this._container.each.apply(this, arguments);
},
empty: function(){
return this._container.empty.apply(this, arguments);
},
//
// _events shortcuts
//
bind: function(){
this._events.bind.apply(this, arguments);
return this; // for chainable calls
},
trigger: function(){
this._events.trigger.apply(this, arguments);
return this; // for chainable calls
}
}; // prototype
//////////////////////////////////////////////////////////////////////////
//
// Main object builder
//
// Main agility object builder
agility = function(){
// Real array of arguments
var args = Array.prototype.slice.call(arguments, 0),
// Object to be returned by builder
object = {},
prototype = defaultPrototype;
//////////////////////////////////////////////////////////////////////////
//
// Define object prototype
//
// Inherit object prototype
if (typeof args[0] === "object" && util.isAgility(args[0])) {
prototype = args[0];
args.shift(); // remaining args now work as though object wasn't specified
} // build from agility object
// Build object from prototype as well as the individual prototype parts
// This enables differential inheritance at the sub-object level, e.g. object.view.format
object = Object.create(prototype);
object.model = Object.create(prototype.model);
object.view = Object.create(prototype.view);
object.controller = Object.create(prototype.controller);
object._container = Object.create(prototype._container);
object._events = Object.create(prototype._events);
// Fresh 'own' properties (i.e. properties that are not inherited at all)
object._id = idCounter++;
object._parent = null;
object._events.data = {}; // event bindings will happen below
object._container.children = {};
object.view.$root = $(); // empty jQuery object
// Cloned own properties (i.e. properties that are inherited by direct copy instead of by prototype chain)
// This prevents children from altering parents models
object.model._data = prototype.model._data ? $.extend(true, {}, prototype.model._data) : {};
object._data = prototype._data ? $.extend(true, {}, prototype._data) : {};
//////////////////////////////////////////////////////////////////////////
//
// Extend model, view, controller
//
// Just the default prototype
if (args.length === 0) {
}
// Prototype differential from single {model,view,controller} object
else if (args.length === 1 && typeof args[0] === 'object' && (args[0].model || args[0].view || args[0].controller) ) {
for (var prop in args[0]) {
if (prop === 'model') {
$.extend(object.model._data, args[0].model);
}
else if (prop === 'view') {
$.extend(object.view, args[0].view);
}
else if (prop === 'controller') {
$.extend(object.controller, args[0].controller);
util.extendController(object);
}
// User-defined methods
else {
object[prop] = args[0][prop];
}
}
} // {model, view, controller} arg
// Prototype differential from separate {model}, {view}, {controller} arguments
else {
// Model from string
if (typeof args[0] === 'object') {
$.extend(object.model._data, args[0]);
}
else if (args[0]) {
throw "agility.js: unknown argument type (model)";
}
// View format from shorthand string (..., '<div>whatever</div>', ...)
if (typeof args[1] === 'string') {
object.view.format = args[1]; // extend view with .format
}
// View from object (..., {format:'<div>whatever</div>'}, ...)
else if (typeof args[1] === 'object') {
$.extend(object.view, args[1]);
}
else if (args[1]) {
throw "agility.js: unknown argument type (view)";
}
// View style from shorthand string (..., ..., 'p {color:red}', ...)
if (typeof args[2] === 'string') {
object.view.style = args[2];
args.splice(2, 1); // so that controller code below works
}
// Controller from object (..., ..., {method:function(){}})
if (typeof args[2] === 'object') {
$.extend(object.controller, args[2]);
util.extendController(object);
}
else if (args[2]) {
throw "agility.js: unknown argument type (controller)";
}
} // ({model}, {view}, {controller}) args
//////////////////////////////////////////////////////////////////////////
//
// Bootstrap: Bindings, initializations, etc
//
// Save model's initial state (so it can be .reset() later)
object.model._initData = $.extend({}, object.model._data);
// object.* will have their 'this' === object. This should come before call to object.* below.
util.proxyAll(object, object);
// Initialize $root, needed for DOM events binding below
object.view.render();
// Bind all controllers to their events
var bindEvent = function(ev, handler){
if (typeof handler === 'function') {
object.bind(ev, handler);
}
};
for (var eventStr in object.controller) {
var events = eventStr.split(';');
var handler = object.controller[eventStr];
$.each(events, function(i, ev){
ev = ev.trim();
bindEvent(ev, handler);
});
}
// Auto-triggers create event
object.trigger('create');
return object;
}; // agility
//////////////////////////////////////////////////////////////////////////
//
// Global objects
//
// $$.document is a special Agility object, whose view is attached to <body>
// This object is the main entry point for all DOM operations
agility.document = agility({
view: {
$: function(selector){ return selector ? $(selector, 'body') : $('body'); }
},
controller: {
// Override default controller
// (don't render, don't stylize, etc)
_create: function(){}
}
});
// Shortcut to prototype for plugins
agility.fn = defaultPrototype;
// isAgility test
agility.isAgility = function(obj) {
if (typeof obj !== 'object') return false;
return util.isAgility(obj);
};
// Globals
window.agility = window.$$ = agility;
//////////////////////////////////////////////////////////////////////////
//
// Bundled plugin: persist
//
// Main initializer
agility.fn.persist = function(adapter, params){
var id = 'id'; // name of id attribute
this._data.persist = $.extend({adapter:adapter}, params);
this._data.persist.openRequests = 0;
if (params && params.id) {
id = params.id;
}
// Creates persist methods
// .save()
// Creates new model or update existing one, depending on whether model has 'id' property
this.save = function(){
var self = this;
if (this._data.persist.openRequests === 0) {
this.trigger('persist:start');
}
this._data.persist.openRequests++;
this._data.persist.adapter.call(this, {
type: this.model.get(id) ? 'PUT' : 'POST', // update vs. create
id: this.model.get(id),
data: this.model.get(),
complete: function(){
self._data.persist.openRequests--;
if (self._data.persist.openRequests === 0) {
self.trigger('persist:stop');
}
},
success: function(data, textStatus, jqXHR){
if (data[id]) {
// id in body
self.model.set({id:data[id]}, {silent:true});
}
else if (jqXHR.getResponseHeader('Location')) {
// parse id from Location
self.model.set({ id: jqXHR.getResponseHeader('Location').match(/\/([0-9]+)$/)[1] }, {silent:true});
}
self.trigger('persist:save:success');
},
error: function(){
self.trigger('persist:error');
self.trigger('persist:save:error');
}
});
return this; // for chainable calls
}; // save()
// .load()
// Loads model with given id
this.load = function(){
var self = this;
if (this.model.get(id) === undefined) throw 'agility.js: load() needs model id';
if (this._data.persist.openRequests === 0) {
this.trigger('persist:start');
}
this._data.persist.openRequests++;
this._data.persist.adapter.call(this, {
type: 'GET',
id: this.model.get(id),
complete: function(){
self._data.persist.openRequests--;
if (self._data.persist.openRequests === 0) {
self.trigger('persist:stop');
}
},
success: function(data, textStatus, jqXHR){
self.model.set(data);
self.trigger('persist:load:success');
},
error: function(){
self.trigger('persist:error');
self.trigger('persist:load:error');
}
});
return this; // for chainable calls
}; // load()
// .erase()
// Erases model with given id
this.erase = function(){
var self = this;
if (this.model.get(id) === undefined) throw 'agility.js: erase() needs model id';
if (this._data.persist.openRequests === 0) {
this.trigger('persist:start');
}
this._data.persist.openRequests++;
this._data.persist.adapter.call(this, {
type: 'DELETE',
id: this.model.get(id),
complete: function(){
self._data.persist.openRequests--;
if (self._data.persist.openRequests === 0) {
self.trigger('persist:stop');
}
},
success: function(data, textStatus, jqXHR){
self.destroy();
self.trigger('persist:erase:success');
},
error: function(){
self.trigger('persist:error');
self.trigger('persist:erase:error');
}
});
return this; // for chainable calls
}; // erase()
// .gather()
// Loads collection and appends/prepends (depending on method) at selector. All persistence data including adapter comes from proto, not self
this.gather = function(proto, method, selectorOrQuery, query){
var selector, self = this;
if (!proto) throw "agility.js plugin persist: gather() needs object prototype";
if (!proto._data.persist) throw "agility.js plugin persist: prototype doesn't seem to contain persist() data";
// Determines arguments
if (query) {
selector = selectorOrQuery;
}
else {
if (typeof selectorOrQuery === 'string') {
selector = selectorOrQuery;
}
else {
selector = undefined;
query = selectorOrQuery;
}
}
if (this._data.persist.openRequests === 0) {
this.trigger('persist:start');
}
this._data.persist.openRequests++;
proto._data.persist.adapter.call(proto, {
type: 'GET',
data: query,
complete: function(){
self._data.persist.openRequests--;
if (self._data.persist.openRequests === 0) {
self.trigger('persist:stop');
}
},
success: function(data){
$.each(data, function(index, entry){
var obj = $$(proto, entry);
if (typeof method === 'string') {
self[method](obj, selector);
}
});
self.trigger('persist:gather:success', {data:data});
},
error: function(){
self.trigger('persist:error');
self.trigger('persist:gather:error');
}
});
return this; // for chainable calls
}; // gather()
return this; // for chainable calls
}; // fn.persist()
// Persistence adapters
// These are functions. Required parameters:
// {type: 'GET' || 'POST' || 'PUT' || 'DELETE'}
agility.adapter = {};
// RESTful JSON adapter using jQuery's ajax()
agility.adapter.restful = function(_params){
var params = $.extend({
dataType: 'json',
url: (this._data.persist.baseUrl || 'api/') + this._data.persist.collection + (_params.id ? '/'+_params.id : '')
}, _params);
$.ajax(params);
};
})(window);