diff --git a/VERSIONS b/VERSIONS index 2b68b760eb71..150aa4bf779a 100644 --- a/VERSIONS +++ b/VERSIONS @@ -99,6 +99,7 @@ Snappy ?? X11 https://github.com/knplabs/snappy Backbone 0.9.9 X11/MIT http://backbonejs.org/ Backone Forms c6920b3c89 X11/MIT https://github.com/powmedia/backbone-forms Backbon.Collectionsubset d3de0d6804 X11/MIT https://github.com/anthonyshort/backbone.collectionsubset +Backbone.ModelBinder 448472f X11/MIT https://github.com/theironcook/Backbone.ModelBinder git-footnote 2013-03-27 LGPL 3 https://github.com/totten/git-footnote json2 2012-10-08 PUBDOM https://github.com/douglascrockford/JSON-js Marionette 1.0.0-rc2 X11/MIT http://marionettejs.com/ diff --git a/backbone/backbone.modelbinder.js b/backbone/backbone.modelbinder.js new file mode 100644 index 000000000000..8dde7b05508f --- /dev/null +++ b/backbone/backbone.modelbinder.js @@ -0,0 +1,576 @@ +// Backbone.ModelBinder v1.0.2 +// (c) 2013 Bart Wood +// Distributed Under MIT License + +(function (factory) { + if (typeof define === 'function' && define.amd) { + // AMD. Register as an anonymous module. + define(['underscore', 'jquery', 'backbone'], factory); + } else { + // Browser globals + factory(_, $, Backbone); + } +}(function(_, $, Backbone){ + + if(!Backbone){ + throw 'Please include Backbone.js before Backbone.ModelBinder.js'; + } + + Backbone.ModelBinder = function(){ + _.bindAll.apply(_, [this].concat(_.functions(this))); + }; + + // Static setter for class level options + Backbone.ModelBinder.SetOptions = function(options){ + Backbone.ModelBinder.options = options; + }; + + // Current version of the library. + Backbone.ModelBinder.VERSION = '1.0.2'; + Backbone.ModelBinder.Constants = {}; + Backbone.ModelBinder.Constants.ModelToView = 'ModelToView'; + Backbone.ModelBinder.Constants.ViewToModel = 'ViewToModel'; + + _.extend(Backbone.ModelBinder.prototype, { + + bind:function (model, rootEl, attributeBindings, options) { + this.unbind(); + + this._model = model; + this._rootEl = rootEl; + this._setOptions(options); + + if (!this._model) this._throwException('model must be specified'); + if (!this._rootEl) this._throwException('rootEl must be specified'); + + if(attributeBindings){ + // Create a deep clone of the attribute bindings + this._attributeBindings = $.extend(true, {}, attributeBindings); + + this._initializeAttributeBindings(); + this._initializeElBindings(); + } + else { + this._initializeDefaultBindings(); + } + + this._bindModelToView(); + this._bindViewToModel(); + }, + + bindCustomTriggers: function (model, rootEl, triggers, attributeBindings, modelSetOptions) { + this._triggers = triggers; + this.bind(model, rootEl, attributeBindings, modelSetOptions) + }, + + unbind:function () { + this._unbindModelToView(); + this._unbindViewToModel(); + + if(this._attributeBindings){ + delete this._attributeBindings; + this._attributeBindings = undefined; + } + }, + + _setOptions: function(options){ + this._options = _.extend({ + boundAttribute: 'name' + }, Backbone.ModelBinder.options, options); + + // initialize default options + if(!this._options['modelSetOptions']){ + this._options['modelSetOptions'] = {}; + } + this._options['modelSetOptions'].changeSource = 'ModelBinder'; + + if(!this._options['changeTriggers']){ + this._options['changeTriggers'] = {'': 'change', '[contenteditable]': 'blur'}; + } + + if(!this._options['initialCopyDirection']){ + this._options['initialCopyDirection'] = Backbone.ModelBinder.Constants.ModelToView; + } + }, + + // Converts the input bindings, which might just be empty or strings, to binding objects + _initializeAttributeBindings:function () { + var attributeBindingKey, inputBinding, attributeBinding, elementBindingCount, elementBinding; + + for (attributeBindingKey in this._attributeBindings) { + inputBinding = this._attributeBindings[attributeBindingKey]; + + if (_.isString(inputBinding)) { + attributeBinding = {elementBindings: [{selector: inputBinding}]}; + } + else if (_.isArray(inputBinding)) { + attributeBinding = {elementBindings: inputBinding}; + } + else if(_.isObject(inputBinding)){ + attributeBinding = {elementBindings: [inputBinding]}; + } + else { + this._throwException('Unsupported type passed to Model Binder ' + attributeBinding); + } + + // Add a linkage from the element binding back to the attribute binding + for(elementBindingCount = 0; elementBindingCount < attributeBinding.elementBindings.length; elementBindingCount++){ + elementBinding = attributeBinding.elementBindings[elementBindingCount]; + elementBinding.attributeBinding = attributeBinding; + } + + attributeBinding.attributeName = attributeBindingKey; + this._attributeBindings[attributeBindingKey] = attributeBinding; + } + }, + + // If the bindings are not specified, the default binding is performed on the specified attribute, name by default + _initializeDefaultBindings: function(){ + var elCount, elsWithAttribute, matchedEl, name, attributeBinding; + + this._attributeBindings = {}; + elsWithAttribute = $('[' + this._options['boundAttribute'] + ']', this._rootEl); + + for(elCount = 0; elCount < elsWithAttribute.length; elCount++){ + matchedEl = elsWithAttribute[elCount]; + name = $(matchedEl).attr(this._options['boundAttribute']); + + // For elements like radio buttons we only want a single attribute binding with possibly multiple element bindings + if(!this._attributeBindings[name]){ + attributeBinding = {attributeName: name}; + attributeBinding.elementBindings = [{attributeBinding: attributeBinding, boundEls: [matchedEl]}]; + this._attributeBindings[name] = attributeBinding; + } + else{ + this._attributeBindings[name].elementBindings.push({attributeBinding: this._attributeBindings[name], boundEls: [matchedEl]}); + } + } + }, + + _initializeElBindings:function () { + var bindingKey, attributeBinding, bindingCount, elementBinding, foundEls, elCount, el; + for (bindingKey in this._attributeBindings) { + attributeBinding = this._attributeBindings[bindingKey]; + + for (bindingCount = 0; bindingCount < attributeBinding.elementBindings.length; bindingCount++) { + elementBinding = attributeBinding.elementBindings[bindingCount]; + if (elementBinding.selector === '') { + foundEls = $(this._rootEl); + } + else { + foundEls = $(elementBinding.selector, this._rootEl); + } + + if (foundEls.length === 0) { + this._throwException('Bad binding found. No elements returned for binding selector ' + elementBinding.selector); + } + else { + elementBinding.boundEls = []; + for (elCount = 0; elCount < foundEls.length; elCount++) { + el = foundEls[elCount]; + elementBinding.boundEls.push(el); + } + } + } + } + }, + + _bindModelToView: function () { + this._model.on('change', this._onModelChange, this); + + if(this._options['initialCopyDirection'] === Backbone.ModelBinder.Constants.ModelToView){ + this.copyModelAttributesToView(); + } + }, + + // attributesToCopy is an optional parameter - if empty, all attributes + // that are bound will be copied. Otherwise, only attributeBindings specified + // in the attributesToCopy are copied. + copyModelAttributesToView: function(attributesToCopy){ + var attributeName, attributeBinding; + + for (attributeName in this._attributeBindings) { + if(attributesToCopy === undefined || _.indexOf(attributesToCopy, attributeName) !== -1){ + attributeBinding = this._attributeBindings[attributeName]; + this._copyModelToView(attributeBinding); + } + } + }, + + copyViewValuesToModel: function(){ + var bindingKey, attributeBinding, bindingCount, elementBinding, elCount, el; + for (bindingKey in this._attributeBindings) { + attributeBinding = this._attributeBindings[bindingKey]; + + for (bindingCount = 0; bindingCount < attributeBinding.elementBindings.length; bindingCount++) { + elementBinding = attributeBinding.elementBindings[bindingCount]; + + if(this._isBindingUserEditable(elementBinding)){ + if(this._isBindingRadioGroup(elementBinding)){ + el = this._getRadioButtonGroupCheckedEl(elementBinding); + if(el){ + this._copyViewToModel(elementBinding, el); + } + } + else { + for(elCount = 0; elCount < elementBinding.boundEls.length; elCount++){ + el = $(elementBinding.boundEls[elCount]); + if(this._isElUserEditable(el)){ + this._copyViewToModel(elementBinding, el); + } + } + } + } + } + } + }, + + _unbindModelToView: function(){ + if(this._model){ + this._model.off('change', this._onModelChange); + this._model = undefined; + } + }, + + _bindViewToModel: function () { + _.each(this._options['changeTriggers'], function (event, selector) { + $(this._rootEl).delegate(selector, event, this._onElChanged); + }, this); + + if(this._options['initialCopyDirection'] === Backbone.ModelBinder.Constants.ViewToModel){ + this.copyViewValuesToModel(); + } + }, + + _unbindViewToModel: function () { + if(this._options && this._options['changeTriggers']){ + _.each(this._options['changeTriggers'], function (event, selector) { + $(this._rootEl).undelegate(selector, event, this._onElChanged); + }, this); + } + }, + + _onElChanged:function (event) { + var el, elBindings, elBindingCount, elBinding; + + el = $(event.target)[0]; + elBindings = this._getElBindings(el); + + for(elBindingCount = 0; elBindingCount < elBindings.length; elBindingCount++){ + elBinding = elBindings[elBindingCount]; + if (this._isBindingUserEditable(elBinding)) { + this._copyViewToModel(elBinding, el); + } + } + }, + + _isBindingUserEditable: function(elBinding){ + return elBinding.elAttribute === undefined || + elBinding.elAttribute === 'text' || + elBinding.elAttribute === 'html'; + }, + + _isElUserEditable: function(el){ + var isContentEditable = el.attr('contenteditable'); + return isContentEditable || el.is('input') || el.is('select') || el.is('textarea'); + }, + + _isBindingRadioGroup: function(elBinding){ + var elCount, el; + var isAllRadioButtons = elBinding.boundEls.length > 0; + for(elCount = 0; elCount < elBinding.boundEls.length; elCount++){ + el = $(elBinding.boundEls[elCount]); + if(el.attr('type') !== 'radio'){ + isAllRadioButtons = false; + break; + } + } + + return isAllRadioButtons; + }, + + _getRadioButtonGroupCheckedEl: function(elBinding){ + var elCount, el; + for(elCount = 0; elCount < elBinding.boundEls.length; elCount++){ + el = $(elBinding.boundEls[elCount]); + if(el.attr('type') === 'radio' && el.attr('checked')){ + return el; + } + } + + return undefined; + }, + + _getElBindings:function (findEl) { + var attributeName, attributeBinding, elementBindingCount, elementBinding, boundElCount, boundEl; + var elBindings = []; + + for (attributeName in this._attributeBindings) { + attributeBinding = this._attributeBindings[attributeName]; + + for (elementBindingCount = 0; elementBindingCount < attributeBinding.elementBindings.length; elementBindingCount++) { + elementBinding = attributeBinding.elementBindings[elementBindingCount]; + + for (boundElCount = 0; boundElCount < elementBinding.boundEls.length; boundElCount++) { + boundEl = elementBinding.boundEls[boundElCount]; + + if (boundEl === findEl) { + elBindings.push(elementBinding); + } + } + } + } + + return elBindings; + }, + + _onModelChange:function () { + var changedAttribute, attributeBinding; + + for (changedAttribute in this._model.changedAttributes()) { + attributeBinding = this._attributeBindings[changedAttribute]; + + if (attributeBinding) { + this._copyModelToView(attributeBinding); + } + } + }, + + _copyModelToView:function (attributeBinding) { + var elementBindingCount, elementBinding, boundElCount, boundEl, value, convertedValue; + + value = this._model.get(attributeBinding.attributeName); + + for (elementBindingCount = 0; elementBindingCount < attributeBinding.elementBindings.length; elementBindingCount++) { + elementBinding = attributeBinding.elementBindings[elementBindingCount]; + + for (boundElCount = 0; boundElCount < elementBinding.boundEls.length; boundElCount++) { + boundEl = elementBinding.boundEls[boundElCount]; + + if(!boundEl._isSetting){ + convertedValue = this._getConvertedValue(Backbone.ModelBinder.Constants.ModelToView, elementBinding, value); + this._setEl($(boundEl), elementBinding, convertedValue); + } + } + } + }, + + _setEl: function (el, elementBinding, convertedValue) { + if (elementBinding.elAttribute) { + this._setElAttribute(el, elementBinding, convertedValue); + } + else { + this._setElValue(el, convertedValue); + } + }, + + _setElAttribute:function (el, elementBinding, convertedValue) { + switch (elementBinding.elAttribute) { + case 'html': + el.html(convertedValue); + break; + case 'text': + el.text(convertedValue); + break; + case 'enabled': + el.prop('disabled', !convertedValue); + break; + case 'displayed': + el[convertedValue ? 'show' : 'hide'](); + break; + case 'hidden': + el[convertedValue ? 'hide' : 'show'](); + break; + case 'css': + el.css(elementBinding.cssAttribute, convertedValue); + break; + case 'class': + var previousValue = this._model.previous(elementBinding.attributeBinding.attributeName); + var currentValue = this._model.get(elementBinding.attributeBinding.attributeName); + // is current value is now defined then remove the class the may have been set for the undefined value + if(!_.isUndefined(previousValue) || !_.isUndefined(currentValue)){ + previousValue = this._getConvertedValue(Backbone.ModelBinder.Constants.ModelToView, elementBinding, previousValue); + el.removeClass(previousValue); + } + + if(convertedValue){ + el.addClass(convertedValue); + } + break; + default: + el.attr(elementBinding.elAttribute, convertedValue); + } + }, + + _setElValue:function (el, convertedValue) { + if(el.attr('type')){ + switch (el.attr('type')) { + case 'radio': + if (el.val() === convertedValue) { + // must defer the change trigger or the change will actually fire with the old value + el.prop('checked') || _.defer(function() { el.trigger('change'); }); + el.prop('checked', true); + } + else { + // must defer the change trigger or the change will actually fire with the old value + el.prop('checked', false); + } + break; + case 'checkbox': + // must defer the change trigger or the change will actually fire with the old value + el.prop('checked') === !!convertedValue || _.defer(function() { el.trigger('change') }); + el.prop('checked', !!convertedValue); + break; + case 'file': + break; + default: + el.val(convertedValue); + } + } + else if(el.is('input') || el.is('select') || el.is('textarea')){ + el.val(convertedValue || (convertedValue === 0 ? '0' : '')); + } + else { + el.text(convertedValue || (convertedValue === 0 ? '0' : '')); + } + }, + + _copyViewToModel: function (elementBinding, el) { + var result, value, convertedValue; + + if (!el._isSetting) { + + el._isSetting = true; + result = this._setModel(elementBinding, $(el)); + el._isSetting = false; + + if(result && elementBinding.converter){ + value = this._model.get(elementBinding.attributeBinding.attributeName); + convertedValue = this._getConvertedValue(Backbone.ModelBinder.Constants.ModelToView, elementBinding, value); + this._setEl($(el), elementBinding, convertedValue); + } + } + }, + + _getElValue: function(elementBinding, el){ + switch (el.attr('type')) { + case 'checkbox': + return el.prop('checked') ? true : false; + default: + if(el.attr('contenteditable') !== undefined){ + return el.html(); + } + else { + return el.val(); + } + } + }, + + _setModel: function (elementBinding, el) { + var data = {}; + var elVal = this._getElValue(elementBinding, el); + elVal = this._getConvertedValue(Backbone.ModelBinder.Constants.ViewToModel, elementBinding, elVal); + data[elementBinding.attributeBinding.attributeName] = elVal; + return this._model.set(data, this._options['modelSetOptions']); + }, + + _getConvertedValue: function (direction, elementBinding, value) { + if (elementBinding.converter) { + value = elementBinding.converter(direction, value, elementBinding.attributeBinding.attributeName, this._model, elementBinding.boundEls); + } + + return value; + }, + + _throwException: function(message){ + if(this._options.suppressThrows){ + if(console && console.error){ + console.error(message); + } + } + else { + throw message; + } + } + }); + + Backbone.ModelBinder.CollectionConverter = function(collection){ + this._collection = collection; + + if(!this._collection){ + throw 'Collection must be defined'; + } + _.bindAll(this, 'convert'); + }; + + _.extend(Backbone.ModelBinder.CollectionConverter.prototype, { + convert: function(direction, value){ + if (direction === Backbone.ModelBinder.Constants.ModelToView) { + return value ? value.id : undefined; + } + else { + return this._collection.get(value); + } + } + }); + + // A static helper function to create a default set of bindings that you can customize before calling the bind() function + // rootEl - where to find all of the bound elements + // attributeType - probably 'name' or 'id' in most cases + // converter(optional) - the default converter you want applied to all your bindings + // elAttribute(optional) - the default elAttribute you want applied to all your bindings + Backbone.ModelBinder.createDefaultBindings = function(rootEl, attributeType, converter, elAttribute){ + var foundEls, elCount, foundEl, attributeName; + var bindings = {}; + + foundEls = $('[' + attributeType + ']', rootEl); + + for(elCount = 0; elCount < foundEls.length; elCount++){ + foundEl = foundEls[elCount]; + attributeName = $(foundEl).attr(attributeType); + + if(!bindings[attributeName]){ + var attributeBinding = {selector: '[' + attributeType + '="' + attributeName + '"]'}; + bindings[attributeName] = attributeBinding; + + if(converter){ + bindings[attributeName].converter = converter; + } + + if(elAttribute){ + bindings[attributeName].elAttribute = elAttribute; + } + } + } + + return bindings; + }; + + // Helps you to combine 2 sets of bindings + Backbone.ModelBinder.combineBindings = function(destination, source){ + _.each(source, function(value, key){ + var elementBinding = {selector: value.selector}; + + if(value.converter){ + elementBinding.converter = value.converter; + } + + if(value.elAttribute){ + elementBinding.elAttribute = value.elAttribute; + } + + if(!destination[key]){ + destination[key] = elementBinding; + } + else { + destination[key] = [destination[key], elementBinding]; + } + }); + + return destination; + }; + + + return Backbone.ModelBinder; + +})); \ No newline at end of file