UNPKG

bonmot

Version:

A nicely scalable web ui framework.

894 lines (796 loc) 29.7 kB
define([ 'underscore', 'backbone', 'jquery', 'backbone.stickit', 'dw-backbone' ], function( _, Backbone, jQuery, Stickit, DWBackbone ) { var exports = _.clone(DWBackbone), CollectionView, Model, View, vTemp, //here because pointers are a terrible thing to waste uniques = {}; var fnTemplateCompiler = function(tpl) { return function() { return tpl; }; }; /** * *Magic* * AttributeRenderer is a super simple stand-in for a BonMot.View * It allows BonMot to have the same code for Child Views and simple templates of 'fill it in' html * * @param options * @constructor */ var AttributeRenderer = function(options) { this.$el = jQuery(options.el); this.tpl = options.tpl; //options.model = (options.model)? options.model : {}; this.setModel(options.model); }; AttributeRenderer.prototype.needsModel = false; AttributeRenderer.prototype.setModel = function(object) { object = (!!object)? object : {}; var html = ''; object = (object instanceof Backbone.Collection || object instanceof Backbone.Model)? object.toJSON() : object; if(object.constructor === Array) { for(var i = 0; i < object.length; i++) { html += this.tpl(object[i]); } } else { html = this.tpl(object); } this.$el.html(html); }; /** * Stub functions for parent view */ AttributeRenderer.prototype.remove = function() { delete this.$el; delete this.tpl; }; AttributeRenderer.prototype.on = function() { //no-op }; //ROOT VIEW View = Backbone.View.extend({ /** * This is the declaration of the Model 'type' to be used with this view. * It is strongly recommended if this view has child-views. * It is recommended that the model have BonMot.Model in its prototype chain... * IE, the result of a BonMot.Model.extend() operation. */ Model: false, /** * This is the prefix for all classes that wish to bind events, etc. * It will be followed by a '-atr-<attributeName>' or '-ctrl-<functionName>' */ bindPrefix: '.w', bindAtrPrefix: '.w-atr-', bindCtrlPrefix: '.w-ctrl-', /** * This View's parent view. */ parentView: false, /** * Template string or function that generates html. Called when the view is instantiated. * Result is inserted into the dom and replaces el.innerHTML. * * If model is present on construction, will also be passed a toJSON representation of the model, * mixed with tplData. * * If is string, templateCompiler must be set. * * **if .tpl property exists in constructor argument, it overrides this declaration */ tpl: false, /** * {} used as a mix-in to populate tpl. */ tplData: {}, /** * template engine used to compile template files. * By default it returns the string defined by .tpl */ templateCompiler: fnTemplateCompiler, /** * Used to locate properly suffixed attribute or control elements within the views root DOM node. * It may be left as an empty string, but class name collisions could result if there are child-views. * And before you complain - performance comes at a price. Typing out a tiny string is no big deal compared * with the horrors of Angular. */ classSuffix: '', /** * This is a unique string or function that identifies this type of view. * You can think of it as a code-accessible class name. Leave undeclared if you don't care. * * String Definition: may be mixed case and contain dots or dashes. * * If a function, function should return a string conforming to String Definition * * String will be injected into the view's root node as a * css class. Before injection, dots are transformed to dashes. */ unique: '', /** * If true and the view's model's 'dispose' function is called, * this view's 'remove' function will be called, removing it from the DOM. */ needsModel: false, /** * If view persists on .setModel(undefined) ... * Clears all inputs and content as defined in .uiBindings & .bindings. * Does not affect child-views. * * **does this by creating a new BonMot.Model instance, calling * .stickit() and .unstickit(), then destroying the model. * The only negative side-effect of this is if something is listening * for 'stickit' events; additional ones will be generated. */ clearUIOnUndefinedModel: true, /** * If true, always deletes View.el when .remove() is called. * If false and .el is passed to the constructor, will preserve the original node and even put back the html that * was within it. (classes and attributes on the original node may have been modified) */ //You need to do the above!!!! deleteNodeOnRemoveAlways:false, elOriginalInnerHTML:'', /** * convenience function for finding elements within this view. Use it! * @param cssExpr * @returns {*} */ $elf: function(cssExpr) { return this.$el.find(cssExpr); }, constructor: function(options) { var $el, tpl = (options.tpl)? options.tpl : this.tpl, tplData = {}; options = (options)? options : {}; this.childViews = {}; if(options.model) { if(!this.Model) { if(! (options.model instanceof DWBackbone.Model) ) { throw new Error('No Model declared on this View, and passed in .model is not an instance of DWBackbone.Model '); } } else if(! (options.model instanceof this.Model) ) { if(options.model instanceof Backbone.Model) { throw new Error('Attempting to pass a model that is not an instance or instance child of this View.Model'); } options.model = new this.Model(options.model); } } if(this.needsModel && (typeof options !== 'object' || !options.hasOwnProperty('model'))) { throw new Error('This View requires a model on instantiation. (.needsModel == true in View declaration )'); } if(options.hasOwnProperty('parentView')) { this.parentView = options.parentView; } if (typeof tpl === 'string') { tpl = this.templateCompiler(tpl); } else if(!tpl) { tpl = function() { return ''; }; } if(this.tplData) { if (true === this.tplData && options.model) { tplData = options.model.toJSON(); } else { tplData = this.tplData; } } $el = jQuery(tpl(tplData)); if(options.el) { this.el = options.el; this.$el = jQuery(options.el); if(!this.deleteNodeOnRemoveAlways) { this.elOriginalInnerHTML = '+' + $el.html(); } if($el.html()) { this.$el.html($el); } } else { this.el = options.el = $el[0]; this.$el = $el; } this.findControlElements(); Backbone.View.apply(this, arguments); this.injectUnique(); if(options.model) { delete this.model; //because eliminating backbone side-effects this.setModel(options.model); } }, injectModelCid: function(cid) { if(arguments.length !== 1) { cid = this.model.cid; } this.$el.attr('data-m-cid', cid); this.$el.data('m-cid', cid); //because caching. }, injectUnique: function() { this.$el.attr('data-v-cid', this.cid); if(this.unique) { switch (DWBackbone.isA(this.unique)) { case 'function' : this.$el.addClass(this.unique().split('.').join('-')); break; case 'string' : this.$el.addClass(this.unique.split('.').join('-')); break; default : throw new Error('If set, this.unique must be either a string or a function! data-v-cid:' + this.cid); } } }, findControlElements: function() { this.$ctrl = {}; _.each(this.ctrlElementClasses, function(cssExpr,key) { this.$ctrl[key] = this.$elf(cssExpr); }, this); }, /** * Should be callable multiple times. */ initChildViews: function() { var atrs = (this.hasOwnProperty('model'))? this.model.attributes : {}; _.each(this.atrViews, function(init, atrName) { if(!this.childViews.hasOwnProperty(atrName)) { if(atrs.hasOwnProperty(atrName) || init.view.prototype.needsModel === false) { this.newChildView(atrName, init, atrs[atrName]); } } else { this.childViews[atrName].setModel(atrs[atrName]); } }, this); }, newChildView: function(atrName, init, model) { var options = { el: this.$elf(init.find)[0], parentView:this }; if(model) { options.model = model; } if(!init.primitiveRender && this.Model && this.Model.prototype._setCollections[atrName] ) { options.childView = init.view; this.childViews[atrName] = new CollectionView(options); } else { this.childViews[atrName] = new init.view(options); } this.childViews[atrName].on('remove', function() { this.childViews[atrName].off(null, null, this); delete this.childViews[atrName]; }, this); }, /** * Sets a model on this view and binds the model's attributes to the view. * If a model was already set on the view, it is unset, and unbound. * ** Nested views and models must be handled manually at this time via the 'unsetModel' and 'setModel' events. * @param model * @returns {exports.View} */ setModel: function(model) { if(model === this.model) { return this; } this._unsetModel(model); if(model) { this.model = model; this.initChildViews(); //This is outside initChildViews because we want initChildViews to be callable multiple times with no addl side //effects. _.each(this.childViews, function(view, atrName) { view.setModel(this.model.get(atrName)); this.model.on('change:' + atrName, function(x, model){ this.childViews[atrName].setModel(model); }, this); }, this); this.model.on('destroy', function() { this.setModel(); }, this); this.injectModelCid(); this.trigger('setModel', model, this); this.stickit(); } else { if(this.needsModel) { return this.remove(); } else if(this.clearUIOnUndefinedModel) { this.model = new Model(); this.stickit(); this.unstickit(); delete this.model; } } return this; }, /** * Internal function. Use setModel(undefined) instead! * * Unbinds and unsets the current model from this view. May also trigger removal of * view from DOM. * @param newModel - used only to test if the model should call .setModel(undefined) on child models. * @returns {boolean/Model} */ _unsetModel: function(newModel) { var model = false; if(this.model) { this.injectModelCid(''); this.model.off(null, null, this); this.unstickit(); if(this.model.views) { for (var i = 0; i < this.model.views.length; i++) { if (this === this.model.views[i]) { this.model.views.splice(i, 1); break; } } } if(!newModel) { _.each(this.childViews, function(view) { view.setModel(); }); } model = this.model; delete this.model; this.trigger('unsetModel', model, this); } return model; }, remove: function() { this._unsetModel(); this.trigger('remove', this); this.off(null, null, null); _.each(this.childViews, function(view) { view.off(null,null,this); view.remove(); }, this); delete this.options; delete this.parentView; //console.log('Test deleteNodeOnRemoveAlways!! '); if(!this.deleteNodeOnRemoveAlways && this.elOriginalInnerHTML.indexOf('+') === 0) { this.$el.html(this.elOriginalInnerHTML.substring(1)); this.setElement(null); } return Backbone.View.prototype.remove.call(this); } }); var ViewExtend = View.extend; /** * Preprocessor to support Bon-Mot functionality. * 1. processes 'ctrl' prefixed functions and adds them to the events binding object * 2. Takes .uiBindings list and translates it into 'stickit' style bindings * @param childView * @returns {*} */ View.extend = function(subView) { var classSuffix = (subView.classSuffix) ? '-' + subView.classSuffix : '', parentClassSuffix = (this.prototype.classSuffix) ? '-' + subView.classSuffix : ''; if(this.prototype instanceof View) { if(!subView.Model && !this.prototype.Model) { throw new Error('This view must define a .Model attribute that is an extension of BonMot or DWBackbone'); } } if(subView.hasOwnProperty('bindPrefix')) { if(subView.bindPrefix.indexOf('.') !== 0) { throw new Error('.bindPrefix must being with a dot "." It begins with a "' + subView.bindPrefix[0] + '"'); } } else { subView.bindPrefix = this.prototype.bindPrefix; } subView.bindAtrPrefix = subView.bindPrefix + '-atr-'; subView.bindCtrlPrefix = subView.bindPrefix + '-ctrl-'; //declared here to make use of classSuffix var ctrlEvents = function(fn, fnName) { var ctrlNameFragment, event, eventExpression, fnNameFragments; if((typeof fn === 'function') && (fnName.indexOf('ctrl') === 0)) { event = 'click'; fnNameFragments = DWBackbone.toUnderscored(fnName.substring(4)).substring(1).split('_'); switch(fnNameFragments[0].toLowerCase()) { //todo: support these different event types case 'change' : case 'mouseout' : case 'mouseover' : case 'keypressed' : case 'keydown' : case 'keyup' : case 'click' : event = fnNameFragments.shift(); } ctrlNameFragment = DWBackbone.toCamel(fnNameFragments.join('_')); //this is a bit of a hack this.ctrlElementClasses[ctrlNameFragment] = subView.bindCtrlPrefix + ctrlNameFragment + classSuffix; eventExpression = event + ' ' + this.ctrlElementClasses[ctrlNameFragment]; if(!this.events.hasOwnProperty(eventExpression)) { this.events[eventExpression] = fnName; } } }; if(subView.tpl && typeof subView.tpl !== 'function') { if(subView.templateCompiler) { subView.tpl = subView.templateCompiler(subView.tpl); } else { subView.tpl = this.prototype.templateCompiler(subView.tpl); } } /** * atrViews defines the which attributes get their own views automagically. * * if it exists, format it with appropriate declarations * else - create a blank, and inherit any atrViews from this view's parent, correcting the css find as appropriate. * @type {{}} */ if(subView.hasOwnProperty('atrViews')) { _.each(subView.atrViews, function (viewDeclaration, atrName) { if (viewDeclaration.prototype instanceof Backbone.View) { subView.atrViews[atrName] = { find: subView.bindAtrPrefix + atrName + classSuffix, view: viewDeclaration }; } else if(typeof viewDeclaration === 'string' || typeof viewDeclaration === 'function') { if(typeof viewDeclaration === 'string') { viewDeclaration = (subView.hasOwnProperty('templateCompiler'))? subView.templateCompiler(viewDeclaration) : this.prototype.templateCompiler(viewDeclaration); } subView.atrViews[atrName] = { find: subView.bindAtrPrefix + atrName + classSuffix, view: function(options) { options.tpl = viewDeclaration; return new AttributeRenderer(options); } }; subView.atrViews[atrName].primitiveRender = true; } else if (viewDeclaration.hasOwnProperty('find') === false || viewDeclaration.hasOwnProperty('view') === false) { throw new Error('atrViews has bad declaration', viewDeclaration); } }); } else { subView.atrViews = {}; if(this.prototype.atrViews) { _.each(this.prototype.atrViews, function (viewDeclaration, atrName) { subView.atrViews[atrName] = _.clone(viewDeclaration); if(subView.atrViews[atrName].find === subView.bindAtrPrefix + atrName + parentClassSuffix) { subView.atrViews[atrName].find = subView.bindAtrPrefix + atrName + classSuffix; } }, this); } } if(!subView.events) { subView.events = {}; } subView.ctrlElementClasses = {}; _.each(subView, ctrlEvents, subView); _.each(this.prototype, ctrlEvents, subView); /** * Take .uiBindings list and translate into 'stickit' style bindings */ if(!subView.hasOwnProperty('bindings')) { subView.bindings = {}; } if(!subView.hasOwnProperty('uiBindings')) { if(this.prototype.uiBindings) { subView.uiBindings = _.clone(this.prototype.uiBindings); } else { subView.uiBindings = []; } } if(subView.uiBindings.constructor !== Array) { throw new Error('uiBindings for view must be Array', subView); } _.each(subView.uiBindings, function(binder) { if(typeof binder === 'string') { binder = { observe:binder }; } else { binder = _.clone(binder); } if(!binder.hasOwnProperty('observe')) { console.log('Error when constructing bindings. Cannot locate .observe:', binder, 'on view declaration:', subView); throw new Error('Error when constructing bindings. Cannot locate attribute name to observe on view declaration. See log statement'); } if(!binder.hasOwnProperty('find')) { binder.find = subView.bindAtrPrefix + binder.observe + classSuffix; } else if(binder.find === subView.bindAtrPrefix + binder.observe + parentClassSuffix) { binder.find = subView.bindAtrPrefix + binder.observe + classSuffix; } if(!subView.bindings.hasOwnProperty(binder.find)) { subView.bindings[binder.find] = binder; delete binder.find; } }); vTemp = ViewExtend.call(this, subView); /** * A unique check. Is this thing really unique within the BonMot Universe? */ if(subView.unique) { if(uniques.hasOwnProperty(subView.unique)) { throw new Error('BonMot error when extending subView.unique ' + subView.unique + '. A subview with that name already exists:', uniques[subView.unique]); } uniques[subView.unique] = vTemp; } return vTemp; }; Model = DWBackbone.Model.extend({ /** * This should get into DW-Backbone @ some point. Fingers crossed. */ dispose: function() { for(var i in this.attributes) if(this.hasOwnProperty(i)) { if(((this.attributes[i] instanceof Backbone.Model) === true) || ((this.attributes[i] instanceof DWBackbone.Collection) === true)) { try { this.attributes[i].dispose(); } catch (e) { console.log('When disposing of', this, 'child model .' + i, this.attributes[i], 'did not have dispose function'); } } } this.removeParent(); this.trigger('dispose', this); //likely this should be eliminated in favor of everything listening for destroy. this.trigger('destroy', this); }, }); /** * Manages a set of child-views created from a collection of models. * More specifically it is intended to be auto-invoked by a parent-View * to manage a collection of models that is an attribute of a parent-view's Model. * * It can be used on its own, but you'll need to refer to source to understand how to use it. * * Some information on its operation and declaration. Because we expect it to work within a parent * view that doesn't make distinctions between Model and Collection (nor should it need to) * "setModel" could be considered to mean "set the item of interest".. which (we hope) is a collection * BUT CollectionView also uses a Model to bind controls and store values, and needs to keep that * Model instance at .model so that event and attribute bindings are created and handled correctly. * This model is instantiated in the constructor, and NEVER changes. * * So .setModel() takes a collection, or undefined * * * There's a lot of code in this contstructor's class that's been duplicated from the primary View * constructor... that's because we can't call it directly, as it calls 'setModel' at the end * this would be disasterous for CollectionView. So we copy. * * @type {any} */ CollectionView = View.extend({ Model:DWBackbone.Collection, firstPage: 1, bindings: { //here for documentation mostly //'.w-atr-page':'page', //'.w-atr-pageLength':'pageLength' }, templateCompiler: fnTemplateCompiler, constructor: function(options) { var $el, collection = false, tpl = (options.tpl)? options.tpl : this.tpl, tplData = {}, model; this.firstPage = (this.firstPage === 0)? 0 : 1; this.childViews = {}; if(!options.el) { throw new Error('CollectionView must be passed an element on construction!'); } if(!options.hasOwnProperty('parentView')) { throw new Error('CollectionView must be passed a parentView on construction!'); } this.parentView = options.parentView; this.bindings[options.parentView.bindAtrPrefix + 'page'] = 'page'; this.bindings[options.parentView.bindAtrPrefix + 'pageLength'] = 'pageLength'; if(!this.atrViews) { this.atrViews = {}; } if(options.childView) { this.ChildView = options.childView; } if(!this.ChildView) { throw new Error('CollectionView: must have a this.ChildView or be passed .childView at construction.'); } if(options.model) { if(!(options.model instanceof Backbone.Collection)) { throw new Error('CollectionView: .model must be instanceof Backbone.Collection'); } collection = options.model; delete options.model; } else if (this.Model && this.Model.prototype instanceof Backbone.Collection) { collection = new this.Model(); } else { throw new Error("CollectionView: must have a this.Model, or have .model instance passed on construction. These must be instanceof Backbone.Collection"); } if (typeof tpl === 'string') { //while the default template compiler may be needed elsewhere in this view, //because in most cases CollectionView acts like magic wiring it will not need to be extended //Therefor use the ChildView's compiler if(this.templateCompiler !== fnTemplateCompiler) { tpl = this.templateCompiler(tpl); } else { tpl = this.ChildView.templateCompiler(tpl); } } else if(!tpl) { tpl = function() { return ''; }; } if(this.tplData) { tplData = this.tplData; } $el = jQuery(tpl(tplData)); if(options.el) { this.el = options.el; this.$el = jQuery(options.el); if($el.html()) { this.$el.html($el); } } else { this.el = options.el = $el[0]; this.$el = $el; } this.findControlElements(); this.$collection = this.$elf(options.parentView.bindPrefix + '-collection:first'); if(this.$collection.length === 0) { this.$collection = this.$el; } model = new DWBackbone.Model({ page:1, pageLength: (this.$collection.data('page-length'))? parseInt(this.$collection.data('page-length')) : 0, sortOn:false, searchOn:'', search:'' }); options.model = model; this.listenTo(model, 'change:page change:pageLength change:search change:searchBy', this.renderChildViews); Backbone.View.apply(this, arguments); this.injectUnique(); this.stickit(); this.setModel(collection); }, setModel: function(collection) { if(this.collection === collection) { return this; } if(collection && !(collection instanceof Backbone.Collection)) { throw new Error('Bonmot.CollectionView.setModel(collection) was not passed a collection!'); } if(this.collection && !collection) { _.each(this.childViews,function(view, key) { view.remove(); delete this.childViews[key]; }, this); this.collection.off(null, null, this); return this; } this.collection = collection; this.renderChildViews(); if(this.collection) { this.collection.on('add', this.renderChildViews, this); this.collection.on('remove', this.renderChildViews, this); this.collection.on('reset', this.renderChildViews, this); this.collection.on('sort', this.renderChildViews, this); } }, /** * removes and adds child views as needed, then orders them. * assumes sort, etc have already been called; */ renderChildViews: function() { var page = this.model.get('page') - this.firstPage, pageLength = parseInt(this.model.get('pageLength')), collection = this.collection, search = this.model.get('search'), searchBy = this.model.get('searchBy'); if(search.length > 0 ) { collection = new this.collection.constructor(this.collection.filter(function(model) { return (model.get(searchBy).indexOf(search) > -1); }, this)); } if((pageLength > 0) && (collection.length > 0)) { if((page * pageLength) > collection.length) { this.model.set('page', ((page - 1) + this.firstPage)); return; } collection = new this.collection.constructor(collection.slice(page * pageLength, (page + 1) * pageLength)); } _.each(this.childViews, function(view, cid) { if(!collection.get(cid)) { this.removeChildView(cid); } }, this); collection.each(function(model) { if(!this.childViews.hasOwnProperty(model.cid)) { this.newChildView(model); } }, this); this.sortChildViews(collection); }, sortChildViews: function(collection) { var $lastEl = false, page, pageLength; if(!collection) { pageLength = this.model.get('pageLength'); if(pageLength === 0) { collection = this.collection; } else { page = this.model.get('page'); collection = new this.collection.constructor(this.collection.slice((page - 1) * pageLength, page * pageLength)); } } collection.each(function(model, i) { if(i === 0) { this.$collection.prepend(this.childViews[model.cid].$el); } else { $lastEl.after(this.childViews[model.cid].$el); } $lastEl = this.childViews[model.cid].$el; }, this); }, newChildView: function(model) { this.childViews[model.cid] = new this.ChildView({ model:model, parentView:this.parentView }); this.$collection.append(this.childViews[model.cid].$el); }, removeChildView: function(cid) { this.childViews[cid].off(null, null, this); this.childViews[cid].remove(); delete this.childViews[cid]; }, remove: function() { _.each(this.childViews, function(view) { view.off(null,null,this); view.remove(); }, this); if(this.collection) { this.collection.off(null,null,this); } delete this.options; delete this.parentView; return Backbone.View.prototype.remove.call(this); }, ctrlKeyupSearch: function(evt) { var $search = $(evt.currentTarget); this.model.set('searchBy', $search.data('search-by')); this.model.set('search', $search.val()); }, ctrlSortBy: function(evt) { this.model.set('sortBy', $(evt.currentTarget).data('sort-by')); }, ctrlFirst: function() { this.model.set({'page': this.firstPage}); }, ctrlPrev: function() { var page = this.model.get('page'); if(page > this.firstPage) { this.model.set({'page': (page-1)}); } else { this.ctrlFirst(); } }, ctrlNext: function() { var page = this.model.get('page'); var minus = (this.firstPage === 1)? 0 : 1; if(page < (Math.ceil(this.collection.length / this.model.get('pageLength')) - minus)) { this.model.set({'page': (page+1)}); } else { this.ctrlLast(); } }, ctrlLast: function() { var minus = (this.firstPage === 1)? 0 : 1, page = (Math.ceil(this.collection.length / this.model.get('pageLength')) - minus); if(page < this.firstPage) { page = this.firstPage; } this.model.set({'page': page}); } }); exports.Model = Model; exports.View = View; exports.CollectionView = CollectionView; return exports; });