(function(root, factory) {
if (typeof define === 'function' && define.amd) {
define(['underscore', 'backbone', './View', './templateRenderer'], factory);
} else if (typeof exports === 'object') {
module.exports = factory(require('underscore'), require('backbone'), require('./View'), require('./templateRenderer'));
} else {
root.Torso = root.Torso || {};
root.Torso.ListView = factory(root._, root.Backbone, root.Torso.View, root.Torso.Utils.templateRenderer);
}
}(this, function(_, Backbone, View, templateRenderer) {
'use strict';
var removeItemView, _removeItemView, addItemView, _addItemView, aggregateRenders, breakDelayedRender;
var $ = Backbone.$;
/**
* If one exists, this method will clear the delayed render timeout and invoke render
* @param {ListView} view the list view
* @private
*/
breakDelayedRender = function(view) {
if (view.__delayedRenderTimeout) {
clearTimeout(view.__delayedRenderTimeout);
view.__delayedRenderTimeout = null;
if (!view.isDisposed()) {
view.render();
}
}
};
/**
* Aggregates calls to render by waiting a certain amount of time and then rendering.
* Calls that happen while it is waiting, will be swallowed. Useful for when you want to
* batch render calls
* @private
* @param {number} wait the number of milliseconds to wait before rendering
* @param {ListView} view the list view
*/
aggregateRenders = function(wait, view) {
var postpone = function() {
view.__delayedRenderTimeout = null;
if (!view.isDisposed()) {
view.render();
}
};
return function() {
if (!view.__delayedRenderTimeout && wait > 0) {
view.__delayedRenderTimeout = setTimeout(postpone, wait);
} else if (wait <= 0 && !view.isDisposed()) {
view.render();
}
};
};
/**
* Handles the removal of an item view if a model has been removed from the collection
* @private
* @param {external:Backbone-Model} model the model that has been removed
*/
removeItemView = function(model) {
var itemView = this.getItemViewFromModel(model);
if (itemView) {
_removeItemView.call(this, itemView, model[this.__modelId], model);
if (!this.hasItemViews()) {
this.__delayedRender();
}
}
};
/**
* Disposes of an item view, unregisters, stops tracking and triggers a 'item-view-removed' event
* with the model and an item view as the payload.
* @private
* @param {external:Backbone-View} itemView the view being removed
* @param {(string|Number)} modelId the id used for the model
* @param {external:Backbone-Model} [model] the model
*/
_removeItemView = function(itemView, modelId, model) {
itemView.dispose();
this.unregisterTrackedView(itemView, { shared: false });
delete this.__modelToViewMap[modelId];
this.__updateOrderedModelIdList();
this.trigger('item-view-removed', {model: model || itemView.model, view: itemView});
this.trigger('child-view-removed', {model: model || itemView.model, view: itemView});
};
/**
* Handles the addition of an item view if a model has been added to the collection.
* When possible, it will append the view instead of causing a rerender
* @private
* @param model the model being added
*/
addItemView = function(model) {
var itemView,
models = this.modelsToRender(),
indexOfModel = models.indexOf(model);
if (indexOfModel > -1) {
itemView = this.__createItemView(model);
_addItemView.call(this, itemView, indexOfModel);
}
};
/**
* Adds the new item view before or after a sibling view. If no sibling view exists
* or if this item view is the first, it will cause a re-render. This method will break
* any delayed renders and force a re-render before continuing.
* @private
* @param {View} itemView the view being added
* @param {number} indexOfModel - the index of the model into the array of models to render
*/
_addItemView = function(itemView, indexOfModel) {
var viewAfter, viewBefore, replaceMethod,
models = this.modelsToRender();
if (!this.hasItemViews()) {
this.__delayedRender();
} else {
breakDelayedRender(this);
viewAfter = this.getItemViewFromModel(models[indexOfModel + 1]);
viewBefore = this.getItemViewFromModel(models[indexOfModel - 1]);
if (viewAfter) {
replaceMethod = _.bind(viewAfter.$el.before, viewAfter.$el);
} else if (viewBefore) {
replaceMethod = _.bind(viewBefore.$el.after, viewBefore.$el);
} else {
this.__delayedRender();
}
if (replaceMethod) {
this.attachView(null, itemView, {
replaceMethod: replaceMethod,
discardInjectionSite: true
});
}
}
};
/**
* A view that is backed by a collection that managers views per model in the collection.
*
* @class ListView
* @extends View
*
* @author ariel.wexler@vecna.com, kent.willis@vecna.com
*
* @see <a href="../annotated/modules/ListView.html">ListView Annotated Source</a>
*/
var ListView = View.extend(/** @lends ListView# */{
/**
* The collection that holds the models that this list view will track
* @property collection
* @type Collection
*/
collection: null,
/**
* The item view class definition that will be instantiated for each model in the list.
* itemView can also be a function that takes a model and returns a view class. This allows
* for different view classes depending on the model.
* @property itemView
* @type {(View|Function)}
*/
itemView: null,
/**
* The template that allows a list view to hold it's own HTML like filter buttons, etc.
* @property template
* @type external:Handlebars-Template
*/
template: null,
/**
* If provided, this template that will be shown if the modelsToRender() method returns
* an empty list. If an itemContainer is provided, the empty template will be rendered there.
* @property emptyTemplate
* @type external:Handlebars-Template
*/
emptyTemplate: null,
/**
* (Required if 'template' is provided, ignored otherwise) name of injection site for list of item views
* @property itemContainer
* @type String
*/
itemContainer: null,
__modelName: '',
__modelId: '',
__modelToViewMap: null,
__itemContext: null,
__renderWait: 0,
__delayedRender: null,
/**
* @property __delayedRenderTimeout
* @private
* @type {number}
*/
__delayedRenderTimeout: null,
/**
* Constructor for the list view object.
* @constructs
* @param {Object} args - options argument
* @param {(external:Backbone-View|function)} args.itemView - the class definition of the item view. This view will be instantiated for every model returned by modelsToRender(). If a function is passed in, then for each model, this function will be invoked to find the appropriate view class. It takes the model as the only parameter.
* @param {external:Backbone-Collection} args.collection - The collection that will back this list view. A subclass of list view might provide a default collection. Can be private or public collection
* @param {(Object|Function)} [args.itemContext] - object or function that's passed to the item view's during initialization under the name "context". Can be used by the item view during their prepare method.
* @param {external:Handlebars-Template} [args.template] - allows a list view to hold it's own HTML like filter buttons, etc.
* @param {string} [args.itemContainer] - (Required if 'template' is provided, ignored otherwise) name of injection site for list of item views
* @param {external:Handlebars-Template} [args.emptyTemplate] - if provided, this template will be shown if the modelsToRender() method returns an empty list. If a itemContainer is provided, the empty template will be rendered there.
* @param {function} [args.modelsToRender] - If provided, this function will override the modelsToRender() method with custom functionality.
* @param {number} [args.renderWait=0] - If provided, will collect any internally invoked renders (typically through collection events like reset) for a duration specified by renderWait in milliseconds and then calls a single render instead. Helps to remove unnecessary render calls when modifying the collection often.
* @param {string} [args.modelId='cid'] - one of ('cid' or 'id'): model property used as identifier for a given model. This property is saved and used to find the corresponding view.
* @param {string} [args.modelName='model'] - name of the model argument passed to the item view during initialization
*/
constructor: function(args) {
View.apply(this, arguments);
args = args || {};
var collection = args.collection || this.collection;
this.template = args.template || this.template;
this.emptyTemplate = args.emptyTemplate || this.emptyTemplate;
this.itemView = args.itemView || this.itemView;
this.itemContainer = args.itemContainer || this.itemContainer;
if (this.template && !this.itemContainer) {
throw 'Item container is required when using a template';
}
this.modelsToRender = args.modelsToRender || this.modelsToRender;
this.__itemContext = args.itemContext || this.__itemContext;
this.__modelToViewMap = {};
this.__renderWait = args.renderWait || this.__renderWait;
this.__modelId = args.modelId || this.modelId || 'cid';
this.__modelName = args.modelName || this.modelName || 'model';
this.__orderedModelIdList = [];
this.__createItemViews();
this.__delayedRender = aggregateRenders(this.__renderWait, this);
if (collection) {
this.setCollection(collection, true);
}
this.on('render:after-dom-update', this.__cleanupItemViewsAfterAttachedToParent);
},
/**
* Sets the collection from which this view generates item views.
* This method will attach all necessary event listeners to the new collection to auto-generate item views
* and has the option of removing listeners on a previous collection. It will immediately update child
* views and re-render if it is necessary - this behavior can be prevented with preventUpdate argument
*
* @param {external:Backbone-Collection} collection the new collection that this list view should use.
* @param {boolean} preventUpdate if true, the list view will not update the child views nor rerender.
*/
setCollection: function(collection, preventUpdate) {
this.stopListening(this.collection, 'remove', removeItemView);
this.stopListening(this.collection, 'add', addItemView);
this.stopListening(this.collection, 'sort', this.reorder);
this.stopListening(this.collection, 'reset', this.update);
this.collection = collection;
this.listenTo(this.collection, 'remove', removeItemView);
this.listenTo(this.collection, 'add', addItemView);
this.listenTo(this.collection, 'sort', this.reorder);
this.listenTo(this.collection, 'reset', this.update);
if (!preventUpdate) {
this.update();
}
},
/**
* Builds a single DOM fragment from the item views and attaches it at once.
*/
updateDOM: function() {
var injectionSite,
newDOM = $(templateRenderer.copyTopElement(this.el));
if (this.template) {
newDOM.html(this.template(this.prepare()));
injectionSite = newDOM.find('[inject=' + this.itemContainer + ']');
} else {
injectionSite = $('<span>');
newDOM.append(injectionSite);
}
if (this.hasItemViews()) {
injectionSite.replaceWith(this.__emptyAndRebuildItemViewsFragment());
} else if (this.emptyTemplate) {
injectionSite.replaceWith(this.emptyTemplate(this.prepareEmpty()));
}
this.$el.html(newDOM.contents());
},
/**
* Completes each item view's lifecycle of being attached to a parent.
* Because the item views are attached in a non-standard way, it's important to make sure
* that the item views are in the appropriate state after being attached as one fragment.
* @private
*/
__cleanupItemViewsAfterAttachedToParent: function() {
_.each(this.modelsToRender(), function(model) {
var itemView = this.getItemViewFromModel(model);
if (itemView) {
itemView.delegateEvents();
if (!itemView.__attachedCallbackInvoked && itemView.isAttached()) {
itemView.__invokeAttached();
}
itemView.activate();
} else {