wise-web-client
Version:
Based on Spine MVC framework
1,321 lines (1,093 loc) • 41.7 kB
JavaScript
// Backbone.Epoxy
// (c) 2013 Greg MacWilliam
// Freely distributed under the MIT license
// For usage and documentation:
// http://epoxyjs.org
(function(root, factory) {
if (typeof exports !== 'undefined') {
// Define as CommonJS export:
module.exports = factory(require("underscore"), require("backbone"));
} else if (typeof define === 'function' && define.amd) {
// Define as AMD:
define(["underscore", "backbone"], factory);
} else {
// Just run it:
factory(root._, root.Backbone);
}
}(this, function(_, Backbone) {
// Epoxy namespace:
var Epoxy = Backbone.Epoxy = {};
// Object-type utils:
var array = Array.prototype;
var isUndefined = _.isUndefined;
var isFunction = _.isFunction;
var isObject = _.isObject;
var isArray = _.isArray;
var isModel = function(obj) { return obj instanceof Backbone.Model; };
var isCollection = function(obj) { return obj instanceof Backbone.Collection; };
var blankMethod = function() {};
// Static mixins API:
// added as a static member to Epoxy class objects (Model & View);
// generates a set of class attributes for mixin with other objects.
var mixins = {
mixin: function(extend) {
extend = extend || {};
for (var i in this.prototype) {
if (this.prototype.hasOwnProperty(i) && i !== 'constructor') {
extend[i] = this.prototype[i];
}
}
return extend;
}
};
// Calls method implementations of a super-class object:
function _super(instance, method, args) {
return instance._super.prototype[method].apply(instance, args);
}
// Epoxy.Model
// -----------
var modelMap;
var modelProps = ['computeds'];
Epoxy.Model = Backbone.Model.extend({
_super: Backbone.Model,
// Backbone.Model constructor override:
// configures computed model attributes around the underlying native Backbone model.
constructor: function(attributes, options) {
_.extend(this, _.pick(options||{}, modelProps));
_super(this, 'constructor', arguments);
this.initComputeds(attributes, options);
},
// Gets a copy of a model attribute value:
// Array and Object values will return a shallow copy,
// primitive values will be returned directly.
getCopy: function(attribute) {
return _.clone(this.get(attribute));
},
// Backbone.Model.get() override:
// provides access to computed attributes,
// and maps computed dependency references while establishing bindings.
get: function(attribute) {
// Automatically register bindings while building out computed dependency graphs:
modelMap && modelMap.push(['change:'+attribute, this]);
// Return a computed property value, if available:
if (this.hasComputed(attribute)) {
return this.c()[ attribute ].get();
}
// Default to native Backbone.Model get operation:
return _super(this, 'get', arguments);
},
// Backbone.Model.set() override:
// will process any computed attribute setters,
// and then pass along all results to the underlying model.
set: function(key, value, options) {
var params = key;
// Convert key/value arguments into {key:value} format:
if (params && !isObject(params)) {
params = {};
params[ key ] = value;
} else {
options = value;
}
// Default options definition:
options = options || {};
// Attempt to set computed attributes while not unsetting:
if (!options.unset) {
// All param properties are tested against computed setters,
// properties set to computeds will be removed from the params table.
// Optionally, an computed setter may return key/value pairs to be merged into the set.
params = deepModelSet(this, params, {}, []);
}
// Pass all resulting set params along to the underlying Backbone Model.
return _super(this, 'set', [params, options]);
},
// Backbone.Model.toJSON() override:
// adds a 'computed' option, specifying to include computed attributes.
toJSON: function(options) {
var json = _super(this, 'toJSON', arguments);
if (options && options.computed) {
_.each(this.c(), function(computed, attribute) {
json[ attribute ] = computed.value;
});
}
return json;
},
// Backbone.Model.destroy() override:
// clears all computed attributes before destroying.
destroy: function() {
this.clearComputeds();
return _super(this, 'destroy', arguments);
},
// Computed namespace manager:
// Allows the model to operate as a mixin.
c: function() {
return this._c || (this._c = {});
},
// Initializes the Epoxy model:
// called automatically by the native constructor,
// or may be called manually when adding Epoxy as a mixin.
initComputeds: function(attributes, options) {
this.clearComputeds();
// Resolve computeds hash, and extend it with any preset attribute keys:
// TODO: write test.
var computeds = _.result(this, 'computeds')||{};
computeds = _.extend(computeds, _.pick(attributes||{}, _.keys(computeds)));
// Add all computed attributes:
_.each(computeds, function(params, attribute) {
params._init = 1;
this.addComputed(attribute, params);
}, this);
// Initialize all computed attributes:
// all presets have been constructed and may reference each other now.
_.invoke(this.c(), 'init');
},
// Adds a computed attribute to the model:
// computed attribute will assemble and return customized values.
// @param attribute (string)
// @param getter (function) OR params (object)
// @param [setter (function)]
// @param [dependencies ...]
addComputed: function(attribute, getter, setter) {
this.removeComputed(attribute);
var params = getter;
var delayInit = params._init;
// Test if getter and/or setter are provided:
if (isFunction(getter)) {
var depsIndex = 2;
// Add getter param:
params = {};
params._get = getter;
// Test for setter param:
if (isFunction(setter)) {
params._set = setter;
depsIndex++;
}
// Collect all additional arguments as dependency definitions:
params.deps = array.slice.call(arguments, depsIndex);
}
// Create a new computed attribute:
this.c()[ attribute ] = new EpoxyComputedModel(this, attribute, params, delayInit);
return this;
},
// Tests the model for a computed attribute definition:
hasComputed: function(attribute) {
return this.c().hasOwnProperty(attribute);
},
// Removes an computed attribute from the model:
removeComputed: function(attribute) {
if (this.hasComputed(attribute)) {
this.c()[ attribute ].dispose();
delete this.c()[ attribute ];
}
return this;
},
// Removes all computed attributes:
clearComputeds: function() {
for (var attribute in this.c()) {
this.removeComputed(attribute);
}
return this;
},
// Internal array value modifier:
// performs array ops on a stored array value, then fires change.
// No action is taken if the specified attribute value is not an array.
modifyArray: function(attribute, method, options) {
var obj = this.get(attribute);
if (isArray(obj) && isFunction(array[method])) {
var args = array.slice.call(arguments, 2);
var result = array[ method ].apply(obj, args);
options = options || {};
if (!options.silent) {
this.trigger('change:'+attribute+' change', this, array, options);
}
return result;
}
return null;
},
// Internal object value modifier:
// sets new property values on a stored object value, then fires change.
// No action is taken if the specified attribute value is not an object.
modifyObject: function(attribute, property, value, options) {
var obj = this.get(attribute);
var change = false;
// If property is Object:
if (isObject(obj)) {
options = options || {};
// Delete existing property in response to undefined values:
if (isUndefined(value) && obj.hasOwnProperty(property)) {
delete obj[property];
change = true;
}
// Set new and/or changed property values:
else if (obj[ property ] !== value) {
obj[ property ] = value;
change = true;
}
// Trigger model change:
if (change && !options.silent) {
this.trigger('change:'+attribute+' change', this, obj, options);
}
// Return the modified object:
return obj;
}
return null;
}
}, mixins);
// Epoxy.Model -> Private
// ----------------------
// Model deep-setter:
// Attempts to set a collection of key/value attribute pairs to computed attributes.
// Observable setters may digest values, and then return mutated key/value pairs for inclusion into the set operation.
// Values returned from computed setters will be recursively deep-set, allowing computeds to set other computeds.
// The final collection of resolved key/value pairs (after setting all computeds) will be returned to the native model.
// @param model: target Epoxy model on which to operate.
// @param toSet: an object of key/value pairs to attempt to set within the computed model.
// @param toReturn: resolved non-ovservable attribute values to be returned back to the native model.
// @param trace: property stack trace (prevents circular setter loops).
function deepModelSet(model, toSet, toReturn, stack) {
// Loop through all setter properties:
for (var attribute in toSet) {
if (toSet.hasOwnProperty(attribute)) {
// Pull each setter value:
var value = toSet[ attribute ];
if (model.hasComputed(attribute)) {
// Has a computed attribute:
// comfirm attribute does not already exist within the stack trace.
if (!stack.length || !_.contains(stack, attribute)) {
// Non-recursive:
// set and collect value from computed attribute.
value = model.c()[attribute].set(value);
// Recursively set new values for a returned params object:
// creates a new copy of the stack trace for each new search branch.
if (value && isObject(value)) {
toReturn = deepModelSet(model, value, toReturn, stack.concat(attribute));
}
} else {
// Recursive:
// Throw circular reference error.
throw('Recursive setter: '+stack.join(' > '));
}
} else {
// No computed attribute:
// set the value to the keeper values.
toReturn[ attribute ] = value;
}
}
}
return toReturn;
}
// Epoxy.Model -> Computed
// -----------------------
// Computed objects store model values independently from the model's attributes table.
// Computeds define custom getter/setter functions to manage their value.
function EpoxyComputedModel(model, name, params, delayInit) {
params = params || {};
// Rewrite getter param:
if (params.get && isFunction(params.get)) {
params._get = params.get;
}
// Rewrite setter param:
if (params.set && isFunction(params.set)) {
params._set = params.set;
}
// Prohibit override of 'get()' and 'set()', then extend:
delete params.get;
delete params.set;
_.extend(this, params);
// Set model, name, and default dependencies array:
this.model = model;
this.name = name;
this.deps = this.deps || [];
// Skip init while parent model is initializing:
// Model will initialize in two passes...
// the first pass sets up all computed attributes,
// then the second pass initializes all bindings.
if (!delayInit) this.init();
}
_.extend(EpoxyComputedModel.prototype, Backbone.Events, {
// Initializes the computed's value and bindings:
// this method is called independently from the object constructor,
// allowing computeds to build and initialize in two passes by the parent model.
init: function() {
// Configure dependency map, then update the computed's value:
// All Epoxy.Model attributes accessed while getting the initial value
// will automatically register themselves within the model bindings map.
var bindings = {};
var deps = modelMap = [];
this.get(true);
modelMap = null;
// If the computed has dependencies, then proceed to binding it:
if (deps.length) {
// Compile normalized bindings table:
// Ultimately, we want a table of event types, each with an array of their associated targets:
// {'change:name':[<model1>], 'change:status':[<model1>,<model2>]}
// Compile normalized bindings map:
_.each(deps, function(value) {
var attribute = value[0];
var target = value[1];
// Populate event target arrays:
if (!bindings[attribute]) {
bindings[attribute] = [ target ];
} else if (!_.contains(bindings[attribute], target)) {
bindings[attribute].push(target);
}
});
// Bind all event declarations to their respective targets:
_.each(bindings, function(targets, binding) {
for (var i=0, len=targets.length; i < len; i++) {
this.listenTo(targets[i], binding, _.bind(this.get, this, true));
}
}, this);
}
},
// Gets an attribute value from the parent model.
val: function(attribute) {
return this.model.get(attribute);
},
// Gets the computed's current value:
// Computed values flagged as dirty will need to regenerate themselves.
// Note: 'update' is strongly checked as TRUE to prevent unintended arguments (handler events, etc) from qualifying.
get: function(update) {
if (update === true && this._get) {
var val = this._get.apply(this.model, _.map(this.deps, this.val, this));
this.change(val);
}
return this.value;
},
// Sets the computed's current value:
// computed values (have a custom getter method) require a custom setter.
// Custom setters should return an object of key/values pairs;
// key/value pairs returned to the parent model will be merged into its main .set() operation.
set: function(val) {
if (this._get) {
if (this._set) return this._set.apply(this.model, arguments);
else throw('Cannot set read-only computed attribute.');
}
this.change(val);
return null;
},
// Changes the computed's value:
// new values are cached, then fire an update event.
change: function(value) {
if (!_.isEqual(value, this.value)) {
this.value = value;
this.model.trigger('change:'+this.name+' change', this.model);
}
},
// Disposal:
// cleans up events and releases references.
dispose: function() {
this.stopListening();
this.off();
this.model = this.value = null;
}
});
// Epoxy.binding -> Binding API
// ----------------------------
var bindingSettings = {
optionText: 'label',
optionValue: 'value'
};
// Cache for storing binding parser functions:
// Cuts down on redundancy when building repetitive binding views.
var bindingCache = {};
// Reads value from an accessor:
// Accessors come in three potential forms:
// => A function to call for the requested value.
// => An object with a collection of attribute accessors.
// => A primitive (string, number, boolean, etc).
// This function unpacks an accessor and returns its underlying value(s).
function readAccessor(accessor) {
if (isFunction(accessor)) {
// Accessor is function: return invoked value.
return accessor();
}
else if (isObject(accessor)) {
// Accessor is object/array: return copy with all attributes read.
accessor = _.clone(accessor);
_.each(accessor, function(value, key) {
accessor[ key ] = readAccessor(value);
});
}
// return formatted value, or pass through primitives:
return accessor;
}
// Binding Handlers
// ----------------
// Handlers define set/get methods for exchanging data with the DOM.
// Formatting function for defining new handler objects:
function makeHandler(handler) {
return isFunction(handler) ? {set: handler} : handler;
}
var bindingHandlers = {
// Attribute: write-only. Sets element attributes.
attr: makeHandler(function($element, value) {
$element.attr(value);
}),
// Checked: read-write. Toggles the checked status of a form element.
checked: makeHandler({
get: function($element, currentValue) {
var checked = !!$element.prop('checked');
var value = $element.val();
if (this.isRadio($element)) {
// Radio button: return value directly.
return value;
} else if (isArray(currentValue)) {
// Checkbox array: add/remove value from list.
currentValue = currentValue.slice();
var index = _.indexOf(currentValue, value);
if (checked && index < 0) {
currentValue.push(value);
} else if (!checked && index > -1) {
currentValue.splice(index, 1);
}
return currentValue;
}
// Checkbox: return boolean toggle.
return checked;
},
set: function($element, value) {
// Default as loosely-typed boolean:
var checked = !!value;
if (this.isRadio($element)) {
// Radio button: match checked state to radio value.
checked = (value == $element.val());
} else if (isArray(value)) {
// Checkbox array: match checked state to checkbox value in array contents.
checked = _.contains(value, $element.val());
}
// Set checked property to element:
$element.prop('checked', checked);
},
// Is radio button: avoids '.is(":radio");' check for basic Zepto compatibility.
isRadio: function($element) {
return $element.attr('type').toLowerCase() === 'radio';
}
}),
// Class Name: write-only. Toggles a collection of class name definitions.
classes: makeHandler(function($element, value) {
_.each(value, function(enabled, className) {
$element.toggleClass(className, !!enabled);
});
}),
// Collection: write-only. Manages a list of views bound to a Backbone.Collection.
collection: makeHandler({
init: function($element, collection) {
if (!isCollection(collection) || !isFunction(collection.view)) {
throw('Binding "collection" requires a Collection with a "view" constructor.');
}
this.v = {};
},
set: function($element, collection, target) {
var view;
var views = this.v;
var models = collection.models;
// Cache and reset the current dependency graph state:
// sub-views may be created (each with their own dependency graph),
// therefore we need to suspend the working graph map here before making children...
var mapCache = viewMap;
viewMap = null;
// Default target to the bound collection object:
// during init (or failure), the binding will reset.
target = target || collection;
if (isModel(target)) {
// ADD/REMOVE Event (from a Model):
// test if view exists within the binding...
if (!views.hasOwnProperty(target.cid)) {
// Add new view:
views[ target.cid ] = view = new collection.view({model: target});
var index = _.indexOf(models, target);
var $children = $element.children();
// Attempt to add at proper index,
// otherwise just append into the element.
if (index < $children.length) {
$children.eq(index).before(view.$el);
} else {
$element.append(view.$el);
}
} else {
// Remove existing view:
views[ target.cid ].remove();
delete views[ target.cid ];
}
} else if (isCollection(target)) {
// SORT/RESET Event (from a Collection):
// First test if we're sorting...
// (number of models has not changed and all their views are present)
var sort = models.length === _.size(views) && collection.every(function(model) {
return views.hasOwnProperty(model.cid);
});
// Hide element before manipulating:
$element.children().detach();
var frag = document.createDocumentFragment();
if (sort) {
// Sort existing views:
collection.each(function(model) {
frag.appendChild(views[model.cid].el);
});
} else {
// Reset with new views:
this.clean();
collection.each(function(model) {
views[ model.cid ] = view = new collection.view({model: model});
frag.appendChild(view.el);
});
}
$element.append(frag);
}
// Restore cached dependency graph configuration:
viewMap = mapCache;
},
clean: function() {
for (var id in this.v) {
if (this.v.hasOwnProperty(id)) {
this.v[ id ].remove();
delete this.v[ id ];
}
}
}
}),
// CSS: write-only. Sets a collection of CSS styles to an element.
css: makeHandler(function($element, value) {
$element.css(value);
}),
// Disabled: write-only. Sets the 'disabled' status of a form element (true :: disabled).
disabled: makeHandler(function($element, value) {
$element.prop('disabled', !!value);
}),
// Enabled: write-only. Sets the 'disabled' status of a form element (true :: !disabled).
enabled: makeHandler(function($element, value) {
$element.prop('disabled', !value);
}),
// HTML: write-only. Sets the inner HTML value of an element.
html: makeHandler(function($element, value) {
$element.html(value);
}),
// Options: write-only. Sets option items to a <select> element, then updates the value.
options: makeHandler({
init: function($element, value, context, bindings) {
this.e = bindings.optionsEmpty;
this.d = bindings.optionsDefault;
this.v = bindings.value;
},
set: function($element, value) {
// Pre-compile empty and default option values:
// both values MUST be accessed, for two reasons:
// 1) we need to need to guarentee that both values are reached for mapping purposes.
// 2) we'll need their values anyway to determine their defined/undefined status.
var self = this;
var optionsEmpty = readAccessor(self.e);
var optionsDefault = readAccessor(self.d);
var currentValue = readAccessor(self.v);
var options = isCollection(value) ? value.models : value;
var numOptions = options.length;
var enabled = true;
var html = '';
// No options or default, and has an empty options placeholder:
// display placeholder and disable select menu.
if (!numOptions && !optionsDefault && optionsEmpty) {
html += self.opt(optionsEmpty, numOptions);
enabled = false;
} else {
// Try to populate default option and options list:
// Configure list with a default first option, if defined:
if (optionsDefault) {
options = [ optionsDefault ].concat(options);
}
// Create all option items:
_.each(options, function(option, index) {
html += self.opt(option, numOptions);
});
}
// Set new HTML to the element and toggle disabled status:
$element.html(html).prop('disabled', !enabled).val(currentValue);
// Pull revised value with new options selection state:
var revisedValue = $element.val();
// Test if the current value was successfully applied:
// if not, set the new selection state into the model.
if (self.v && !_.isEqual(currentValue, revisedValue)) {
self.v(revisedValue);
}
},
opt: function(option, numOptions) {
// Set both label and value as the raw option object by default:
var label = option;
var value = option;
var textAttr = bindingSettings.optionText;
var valueAttr = bindingSettings.optionValue;
// Dig deeper into label/value settings for non-primitive values:
if (isObject(option)) {
// Extract a label and value from each object:
// a model's 'get' method is used to access potential computed values.
label = isModel(option) ? option.get(textAttr) : option[ textAttr ];
value = isModel(option) ? option.get(valueAttr) : option[ valueAttr ];
}
return ['<option value="', value, '">', label, '</option>'].join('');
},
clean: function() {
this.d = this.e = this.v = 0;
}
}),
// Template: write-only. Renders the bound element with an Underscore template.
template: makeHandler({
init: function($element, value, context) {
var raw = $element.find('script,template');
this.t = _.template(raw.length ? raw.html() : $element.html());
// If an array of template attributes was provided,
// then replace array with a compiled hash of attribute accessors:
if (isArray(value)) {
return _.pick(context, value);
}
},
set: function($element, value) {
value = isModel(value) ? value.toJSON({computed:true}) : value;
$element.html(this.t(value));
},
clean: function() {
this.t = null;
}
}),
// Text: write-only. Sets the text value of an element.
text: makeHandler(function($element, value) {
$element.text(value);
}),
// Toggle: write-only. Toggles the visibility of an element.
toggle: makeHandler(function($element, value) {
$element.toggle(!!value);
}),
// Value: read-write. Gets and sets the value of a form element.
value: makeHandler({
get: function($element) {
return $element.val();
},
set: function($element, value) {
try {
if ($element.val() != value) $element.val(value);
} catch (error) {
// Error setting value: IGNORE.
// This occurs in IE6 while attempting to set an undefined multi-select option.
// unfortuantely, jQuery doesn't gracefully handle this error for us.
// remove this try/catch block when IE6 is officially deprecated.
}
}
})
};
// Binding Filters
// ---------------
// Filters are special binding handlers that may be invoked while binding;
// they will return a wrapper function used to modify how accessors are read.
// Partial application wrapper for creating binding filters:
function makeFilter(handler) {
return function() {
var params = arguments;
var read = isFunction(handler) ? handler : handler.get;
var write = handler.set;
return function(value) {
return isUndefined(value) ?
read.apply(this, _.map(params, readAccessor)) :
params[0]((write ? write : read).call(this, value));
};
};
}
var bindingFilters = {
// Positive collection assessment [read-only]:
// Tests if all of the provided accessors are truthy (and).
all: makeFilter(function() {
var params = arguments;
for (var i=0, len=params.length; i < len; i++) {
if (!params[i]) return false;
}
return true;
}),
// Partial collection assessment [read-only]:
// tests if any of the provided accessors are truthy (or).
any: makeFilter(function() {
var params = arguments;
for (var i=0, len=params.length; i < len; i++) {
if (params[i]) return true;
}
return false;
}),
// Collection length accessor [read-only]:
// assumes accessor value to be an Array or Collection; defaults to 0.
length: makeFilter(function(value) {
return value.length || 0;
}),
// Negative collection assessment [read-only]:
// tests if none of the provided accessors are truthy (and not).
none: makeFilter(function() {
var params = arguments;
for (var i=0, len=params.length; i < len; i++) {
if (params[i]) return false;
}
return true;
}),
// Negation [read-only]:
not: makeFilter(function(value) {
return !value;
}),
// Formats one or more accessors into a text string:
// ('$1 $2 did $3', firstName, lastName, action)
format: makeFilter(function(str) {
var params = arguments;
for (var i=1, len=params.length; i < len; i++) {
// TODO: need to make something like this work: (?<!\\)\$1
str = str.replace(new RegExp('\\$'+i, 'g'), params[i]);
}
return str;
}),
// Provides one of two values based on a ternary condition:
// uses first param (a) as condition, and returns either b (truthy) or c (falsey).
select: makeFilter(function(condition, truthy, falsey) {
return condition ? truthy : falsey;
}),
// CSV array formatting [read-write]:
csv: makeFilter({
get: function(value) {
value = String(value);
return value ? value.split(',') : [];
},
set: function(value) {
return isArray(value) ? value.join(',') : value;
}
}),
// Integer formatting [read-write]:
integer: makeFilter(function(value) {
return value ? parseInt(value, 10) : 0;
}),
// Float formatting [read-write]:
decimal: makeFilter(function(value) {
return value ? parseFloat(value) : 0;
})
};
// Define allowed binding parameters:
// These params may be included in binding handlers without throwing errors.
var allowedParams = {
events: 1,
optionsDefault: 1,
optionsEmpty: 1
};
// Define binding API:
Epoxy.binding = {
allowedParams: allowedParams,
addHandler: function(name, handler) {
bindingHandlers[ name ] = makeHandler(handler);
},
addFilter: function(name, handler) {
bindingFilters[ name ] = makeFilter(handler);
},
config: function(settings) {
_.extend(bindingSettings, settings);
},
emptyCache: function() {
bindingCache = {};
}
};
// Epoxy.View
// ----------
var viewMap;
var viewProps = ['viewModel', 'bindings', 'bindingFilters', 'bindingHandlers', 'bindingSources', 'computeds'];
Epoxy.View = Backbone.View.extend({
_super: Backbone.View,
// Backbone.View constructor override:
// sets up binding controls around call to super.
constructor: function(options) {
_.extend(this, _.pick(options||{}, viewProps));
_super(this, 'constructor', arguments);
this.applyBindings();
},
// Bindings list accessor:
b: function() {
return this._b || (this._b = []);
},
// Bindings definition:
// this setting defines a DOM attribute name used to query for bindings.
// Alternatively, this be replaced with a hash table of key/value pairs,
// where 'key' is a DOM query and 'value' is its binding declaration.
bindings: 'data-bind',
// Setter options:
// Defines an optional hashtable of options to be passed to setter operations.
// Accepts a custom option '{save:true}' that will write to the model via ".save()".
setterOptions: null,
// Compiles a model context, then applies bindings to the view:
// All Model->View relationships will be baked at the time of applying bindings;
// changes in configuration to source attributes or view bindings will require a complete re-bind.
applyBindings: function() {
this.removeBindings();
var self = this;
var sources = _.clone(_.result(self, 'bindingSources'));
var declarations = self.bindings;
var options = self.setterOptions;
var handlers = _.clone(bindingHandlers);
var filters = _.clone(bindingFilters);
var context = self._c = {};
// Compile a complete set of binding handlers for the view:
// mixes all custom handlers into a copy of default handlers.
// Custom handlers defined as plain functions are registered as read-only setters.
_.each(_.result(self, 'bindingHandlers')||{}, function(handler, name) {
handlers[ name ] = makeHandler(handler);
});
// Compile a complete set of binding filters for the view:
// mixes all custom filters into a copy of default filters.
_.each(_.result(self, 'bindingFilters')||{}, function(filter, name) {
filters[ name ] = makeFilter(filter);
});
// Add native 'model' and 'collection' data sources:
self.model = addSourceToViewContext(self, context, options, 'model');
self.viewModel = addSourceToViewContext(self, context, options, 'viewModel');
self.collection = addSourceToViewContext(self, context, options, 'collection');
// Add all additional data sources:
if (sources) {
_.each(sources, function(source, sourceName) {
sources[ sourceName ] = addSourceToViewContext(sources, context, options, sourceName, sourceName);
});
// Reapply resulting sources to view instance.
self.bindingSources = sources;
}
// Add all computed view properties:
_.each(_.result(self, 'computeds')||{}, function(computed, name) {
var getter = isFunction(computed) ? computed : computed.get;
var setter = computed.set;
var deps = computed.deps;
context[ name ] = function(value) {
return (!isUndefined(value) && setter) ?
setter.call(self, value) :
getter.apply(self, getDepsFromViewContext(self._c, deps));
};
});
// Create all bindings:
// bindings are created from an object hash of query/binding declarations,
// OR based on queried DOM attributes.
if (isObject(declarations)) {
// Object declaration method:
// {'span.my-element': 'text:attribute'}
_.each(declarations, function(elementDecs, selector) {
// Get DOM jQuery reference:
var $element = queryViewForSelector(self, selector);
// Ignore empty DOM queries (without errors):
if ($element.length) {
bindElementToView(self, $element, elementDecs, context, handlers, filters);
}
});
} else {
// DOM attributes declaration method:
// <span data-bind='text:attribute'></span>
// Create bindings for each matched element:
queryViewForSelector(self, '['+declarations+']').each(function() {
var $element = Backbone.$(this);
bindElementToView(self, $element, $element.attr(declarations), context, handlers, filters);
});
}
},
// Gets a value from the binding context:
getBinding: function(attribute) {
return accessViewContext(this._c, attribute);
},
// Sets a value to the binding context:
setBinding: function(attribute, value) {
return accessViewContext(this._c, attribute, value);
},
// Disposes of all view bindings:
removeBindings: function() {
this._c = null;
if (this._b) {
while (this._b.length) {
this._b.pop().dispose();
}
}
},
// Backbone.View.remove() override:
// unbinds the view before performing native removal tasks.
remove: function() {
this.removeBindings();
_super(this, 'remove', arguments);
}
}, mixins);
// Epoxy.View -> Private
// ---------------------
// Adds a data source to a view:
// Data sources are Backbone.Model and Backbone.Collection instances.
// @param source: a source instance, or a function that returns a source.
// @param context: the working binding context. All bindings in a view share a context.
function addSourceToViewContext(source, context, options, name, prefix) {
// Resolve source instance:
source = _.result(source, name);
// Ignore missing sources, and invoke non-instances:
if (!source) return;
// Add Backbone.Model source instance:
if (isModel(source)) {
// Establish source prefix:
prefix = prefix ? prefix+'_' : '';
// Create a read-only accessor for the model instance:
context['$'+name] = function() {
viewMap && viewMap.push([source, 'change']);
return source;
};
// Compile all model attributes as accessors within the context:
_.each(source.toJSON({computed:true}), function(value, attribute) {
// Create named accessor functions:
// -> Attributes from 'view.model' use their normal names.
// -> Attributes from additional sources are named as 'source_attribute'.
context[prefix+attribute] = function(value) {
return accessViewDataAttribute(source, attribute, value, options);
};
});
}
// Add Backbone.Collection source instance:
else if (isCollection(source)) {
// Create a read-only accessor for the collection instance:
context['$'+name] = function() {
viewMap && viewMap.push([source, 'reset add remove sort update']);
return source;
};
}
// Return original object, or newly constructed data source:
return source;
}
// Attribute data accessor:
// exchanges individual attribute values with model sources.
// This function is separated out from the accessor creation process for performance.
// @param source: the model data source to interact with.
// @param attribute: the model attribute to read/write.
// @param value: the value to set, or 'undefined' to get the current value.
function accessViewDataAttribute(source, attribute, value, options) {
// Register the attribute to the bindings map, if enabled:
viewMap && viewMap.push([source, 'change:'+attribute]);
// Set attribute value when accessor is invoked with an argument:
if (!isUndefined(value)) {
// Set Object (non-null, non-array) hashtable value:
if (!isObject(value) || isArray(value) || _.isDate(value)) {
var val = value;
value = {};
value[attribute] = val;
}
// Set value:
return options && options.save ? source.save(value, options) : source.set(value, options);
}
// Get the attribute value by default:
return source.get(attribute);
}
// Queries element selectors within a view:
// matches elements within the view, and the view's container element.
function queryViewForSelector(view, selector) {
if (selector === ':el') return view.$el;
var $elements = view.$(selector);
// Include top-level view in bindings search:
if (view.$el.is(selector)) {
$elements = $elements.add(view.$el);
}
return $elements;
}
// Binds an element into a view:
// The element's declarations are parsed, then a binding is created for each declared handler.
// @param view: the parent View to bind into.
// @param $element: the target element (as jQuery) to bind.
// @param declarations: the string of binding declarations provided for the element.
// @param context: a compiled binding context with all availabe view data.
// @param handlers: a compiled handlers table with all native/custom handlers.
function bindElementToView(view, $element, declarations, context, handlers, filters) {
// Parse localized binding context:
// parsing function is invoked with 'filters' and 'context' properties made available,
// yeilds a native context object with element-specific bindings defined.
try {
var parserFunct = bindingCache[declarations] || (bindingCache[declarations] = new Function('$f','$c','with($f){with($c){return{'+ declarations +'}}}'));
var bindings = parserFunct(filters, context);
} catch (error) {
throw('Error parsing bindings: "'+declarations +'"\n>> '+error);
}
// Format the 'events' option:
// include events from the binding declaration along with a default 'change' trigger,
// then format all event names with a '.epoxy' namespace.
var events = _.map(_.union(bindings.events || [], ['change']), function(name) {
return name+'.epoxy';
}).join(' ');
// Apply bindings from native context:
_.each(bindings, function(accessor, handlerName) {
// Validate that each defined handler method exists before binding:
if (handlers.hasOwnProperty(handlerName)) {
// Create and add binding to the view's list of handlers:
view.b().push(new EpoxyBinding($element, handlers[handlerName], accessor, events, context, bindings));
} else if (!allowedParams.hasOwnProperty(handlerName)) {
throw('binding handler "'+ handlerName +'" is not defined.');
}
});
}
// Gets and sets view context data attributes:
// used by the implementations of "getBinding" and "setBinding".
function accessViewContext(context, attribute, value) {
if (context && context.hasOwnProperty(attribute)) {
return isUndefined(value) ? readAccessor(context[attribute]) : context[attribute](value);
}
}
// Accesses an array of dependency properties from a view context:
// used for mapping view dependencies by manual declaration.
function getDepsFromViewContext(context, attributes) {
var values = [];
if (attributes && context) {
for (var i=0, len=attributes.length; i < len; i++) {
values.push(attributes[i] in context ? context[ attributes[i] ]() : null);
}
}
return values;
}
// Epoxy.View -> Binding
// ---------------------
// The binding object connects an element to a bound handler.
// @param $element: the target element (as jQuery) to bind.
// @param handler: the handler object to apply (include all handler methods).
// @param accessor: an accessor method from the binding context that exchanges data with the model.
// @param options: a compiled set of binding options that was pulled from the declaration.
function EpoxyBinding($element, handler, accessor, events, context, bindings) {
var self = this;
var tag = ($element[0].tagName).toLowerCase();
var changable = (tag == 'input' || tag == 'select' || tag == 'textarea' || $element.prop('contenteditable') == 'true');
var triggers = [];
var reset = function(target) {
self.set(self.$el, readAccessor(accessor), target);
};
self.$el = $element;
self.evt = events;
_.extend(self, handler);
// Initialize the binding:
// allow the initializer to redefine/modify the attribute accessor if needed.
accessor = self.init(self.$el, readAccessor(accessor), context, bindings) || accessor;
// Set default binding, then initialize & map bindings:
// each binding handler is invoked to populate its initial value.
// While running a handler, all accessed attributes will be added to the handler's dependency map.
viewMap = triggers;
reset();
viewMap = null;
// Configure READ/GET-able binding. Requires:
// => Form element.
// => Binding handler has a getter method.
// => Value accessor is a function.
if (changable && handler.get && isFunction(accessor)) {
self.$el.on(events, function(evt) {
accessor(self.get(self.$el, readAccessor(accessor), evt));
});
}
// Configure WRITE/SET-able binding. Requires:
// => One or more events triggers.
if (triggers.length) {
for (var i=0, len=triggers.length; i < len; i++) {
self.listenTo(triggers[i][0], triggers[i][1], reset);
}
}
}
_.extend(EpoxyBinding.prototype, Backbone.Events, {
// Pass-through binding methods:
// for override by actual implementations.
init: blankMethod,
get: blankMethod,
set: blankMethod,
clean: blankMethod,
// Destroys the binding:
// all events and managed sub-views are killed.
dispose: function() {
this.clean();
this.stopListening();
this.$el.off(this.evt);
this.$el = null;
}
});
return Epoxy;
}));