• Jump To … +
    modules/Behavior.js modules/Cell.js modules/Collection.js modules/Events.js modules/FormModel.js modules/FormView.js modules/ListView.js modules/Model.js modules/NestedCell.js modules/NestedModel.js modules/Router.js modules/ServiceCell.js modules/View.js modules/behaviors/DataBehavior.js modules/configure.js modules/handlebarsUtils.js modules/history.js modules/mixins/cacheMixin.js modules/mixins/cellMixin.js modules/mixins/loadingMixin.js modules/mixins/modelMixin.js modules/mixins/pollingMixin.js modules/registry.js modules/stickitUtils.js modules/templateRenderer.js modules/torso.js modules/validation.js
  • ListView.js

  • ¶
    (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 {
  • ¶

    Shouldn’t get here. Item view is missing…

            }
          }, this);
        },
    
        /**
         * Loops through children views and renders them
         */
        renderChildViews: function() {
          _.each(this.getTrackedViews({child: true}), function(childView) {
            childView.render();
          });
        },
    
        /**
         * Takes existing item views and moves them into correct order defined by
         * this.modelsToRender(). NOTE: As this method doesn't generate or remove views,
         * this method takes advantage of jquery's ability to move elements already attached to the DOM.
         */
        reorder: function() {
          var firstItemView, sameOrder,
            elements = [],
            models = this.modelsToRender(),
            newOrderOfIds = _.pluck(models, this.__modelId),
            sizeOfNewModels = _.size(newOrderOfIds),
            sizeOfOldModels = _.size(this.__orderedModelIdList),
            sameSize = sizeOfNewModels === sizeOfOldModels;
    
          if (sameSize) {
  • ¶

    is order the same?

            sameOrder = _.reduce(this.__orderedModelIdList, function(result, oldId, index) {
              return result && newOrderOfIds[index] == oldId;
            }, true);
          } else {
            throw 'Reorder should not be invoked if the number of models have changed';
          }
          if (!sizeOfNewModels || sameOrder) {
  • ¶

    stop early if there are no models to reorder or the models are the same

            return;
          }
          _.each(models, function(model, index) {
            var itemView = this.getItemViewFromModel(model);
            if (itemView) {
              elements.push(itemView.$el);
            }
            if (index === 0) {
              firstItemView = itemView;
            }
          }, this);
  • ¶

    elements that are already connected to the DOM will be moved instead of re-attached meaning that detach, delegate events, and attach are not needed

          if (!this.itemContainer) {
            this.$el.append(elements);
          } else if (firstItemView) {
            var injectionSite = $("<span>");
            firstItemView.$el.before(injectionSite);
            injectionSite.after(elements);
            injectionSite.remove();
          }
          this.__updateOrderedModelIdList(newOrderOfIds);
          this.trigger('reorder-complete');
        },
    
        /**
         * Override if you want a different context for your empty template. Defaults to this.prepare()
         * @return a context that can be used by the empty list template
         */
        prepareEmpty: function() {
          return this.prepare();
        },
    
        /**
         * Returns an array of which models should be rendered.
         * By default, all models in the input collection will be
         * shown.  Extensions of this class may override this
         * method to apply collection filters.
         */
        modelsToRender: function() {
          return this.collection ? this.collection.models : [];
        },
    
        /**
         * Builds any new views, removes stale ones, and re-renders
         */
        update: function() {
          var oldViews = this.getItemViews();
          var newViews = this.__createItemViews();
          var staleViews = this.__getStaleItemViews();
          var sizeOfOldViews = _.size(oldViews);
          var sizeOfNewViews = _.size(newViews);
          var sizeOfStaleViews = _.size(staleViews);
          var sizeOfFinalViews = sizeOfOldViews - sizeOfStaleViews + sizeOfNewViews;
          var changes = sizeOfNewViews + sizeOfStaleViews;
          var percentChange = changes / Math.max(sizeOfFinalViews, 1);
          var fromEmptyToNotEmpty = !sizeOfOldViews && sizeOfNewViews;
          var fromNotEmptyToEmpty = sizeOfOldViews && sizeOfOldViews === sizeOfStaleViews && !sizeOfNewViews;
          var threshold = this.updateThreshold || 0.5;
          var signficantChanges = percentChange >= threshold;
          if (changes <= 0) {
            return this.reorder();
          }
  • ¶

    A switch from empty to not empty or vise versa, needs a new render

          var renderNeeded = fromEmptyToNotEmpty || fromNotEmptyToEmpty || signficantChanges;
          if (renderNeeded) {
            this.__removeStaleItemViews(staleViews);
            this.__delayedRender();
          } else {
            this.__updateByAddingRemoving(oldViews, newViews, staleViews);
          }
        },
    
        /**
         * Returns the view that corresponds to the model if one exists
         * @param {Model} model the model
         * @return the item view corresponding to the model
         */
        getItemViewFromModel: function(model) {
          return model ? this.getTrackedView(this.__modelToViewMap[model[this.__modelId]]) : undefined;
        },
    
        /**
         * @return {boolean} returns true if there exists any generated item views
         */
        hasItemViews: function() {
          return !_.isEmpty(this.getItemViews());
        },
    
        /**
         * @return {View[]} Returns unordered list of views generated by this list view
         */
        getItemViews: function() {
          var view = this;
          var orderedViewIds = _.map(this.__orderedModelIdList, this.__getViewIdFromModelId, this);
          return _.map(orderedViewIds, this.getTrackedView, this);
        },
  • ¶

    ** Private methods **//

        /**
         * Creates all needed item views that don't exist from modelsToRender()
         * @private
         * @return {Array} each object in array contains a 'view' and 'indexOfModel' field
         */
        __createItemViews: function() {
          var newItemViews = [];
          _.each(this.modelsToRender(), function(model, indexOfModel) {
            var itemView = this.getItemViewFromModel(model);
            if (!itemView) {
              newItemViews.push({
                view: this.__createItemView(model, true),
                indexOfModel: indexOfModel
              });
            }
          }, this);
          this.__updateOrderedModelIdList();
          return newItemViews;
        },
    
        /**
         * Creates an item view and stores a reference to it
         * @private
         * @param {external:Backbone-Model} model the model to create the view from
         * @param [noUpdateToIdList=false] if true, the internal order of model ids are not updated
         * @return {external:Backbone-View} the new item view
         */
        __createItemView: function(model, noUpdateToIdList) {
          var itemView,
            ItemViewClass = this.itemView;
          if (!_.isFunction(this.itemView.extend)) {
            ItemViewClass = this.itemView(model);
          }
          itemView = new ItemViewClass(this.__generateItemViewArgs(model));
          this.registerTrackedView(itemView, { shared: false });
          this.__modelToViewMap[model[this.__modelId]] = itemView.cid;
          if (!noUpdateToIdList) {
            this.__updateOrderedModelIdList();
          }
          this.trigger('child-view-added', {model: model, view: itemView});
          this.trigger('item-view-added', {model: model, view: itemView});
          return itemView;
        },
    
        /**
         * Gets all item views that have models that are no longer tracked by modelsToRender
         * @return {Object[]} An array of information about stale items. Each object has a 'view' and 'modelId' field
         * @private
         */
        __getStaleItemViews: function() {
          var staleItemViews = [];
          var modelsWithViews = _.clone(this.__modelToViewMap);
          _.each(this.modelsToRender(), function(model) {
            var itemView = this.getItemViewFromModel(model);
            if (itemView) {
              delete modelsWithViews[model[this.__modelId]];
            }
          }, this);
          _.each(modelsWithViews, function(viewId, modelId) {
            var itemView = this.getTrackedView(viewId);
            if (itemView) {
              staleItemViews.push({ view: itemView, modelId: modelId });
            }
          }, this);
          return staleItemViews;
        },
    
        /**
         * Removes the item views that no longer have models returned by modelsToRender()
         * @param {Object[]} [staleItemViewInfo] Array of objects:
         *   [{
         *     view: stale item view,
         *     modelId: id of model item
         *   }] If provided, stale items will not be found, but this array will be used instead.
         * @private
         */
        __removeStaleItemViews: function(staleItemViewInfo) {
          var view = this;
          staleItemViewInfo = staleItemViewInfo || this.__getStaleItemViews();
          _.each(staleItemViewInfo, function(staleViewInfo) {
            _removeItemView.call(view, staleViewInfo.view, staleViewInfo.modelId);
          });
        },
    
        /**
         * Creates a DOM fragment with each item view appended in the order defined by
         * modelsToRender(). This will clear the List View's DOM and invoke the necessary
         * detach, register and render logic on each item view.
         * @return a DOM fragment with item view elements appended
         * @private
         */
        __emptyAndRebuildItemViewsFragment: function() {
          var injectionFragment = document.createDocumentFragment();
  • ¶

    Clearing the DOM will reduce the repaints needed as we detach each item view.

          this.$el.empty();
    
         _.each(this.modelsToRender(), function(model) {
            var itemView = this.getItemViewFromModel(model);
            if (itemView) {
  • ¶

    detach to be safe, but during a render, the item views will already be detached.

              itemView.detach();
              this.registerTrackedView(itemView, { shared: false });
              itemView.attachTo(null, {
                replaceMethod: function($el) {
                  injectionFragment.appendChild($el[0]);
                },
                discardInjectionSite: true
              });
            }
          }, this);
          this.__updateOrderedModelIdList();
          return $(injectionFragment);
        },
    
        /**
         * Attempts to insert new views and remove stale views individually and correctly reorder all views in an
         * attempt to be faster then a full view re-render
         *
         * @private
         * @param {View[]} oldViews - correctly ordered list of views before making changes to models to render
         * @param {View[]} newViews - the new views created that will be inserted
         * @param {View[]} staleViews - the stale views that will be removed
         */
        __updateByAddingRemoving: function(oldViews, newViews, staleViews) {
          var firstItemViewLeft, injectionSite,
            view = this,
            sizeOfOldViews = _.size(oldViews),
            sizeOfNewViews = _.size(newViews),
            sizeOfStaleViews = _.size(staleViews);
          if (view.itemContainer && sizeOfOldViews && sizeOfOldViews == sizeOfStaleViews) {
  • ¶

    we removed all the views!

            injectionSite = $('<span>');
            _.first(oldViews).$el.before(injectionSite);
          }
          view.__removeStaleItemViews(staleViews);
          _.each(newViews, function(createdViewInfo, indexOfView) {
            if (createdViewInfo.indexOfModel === 0) {
  • ¶

    need to handle this case uniquely.

              var replaceMethod;
              if (!view.itemContainer) {
                replaceMethod = _.bind(view.$el.prepend, view.$el);
              } else {
                if (injectionSite) {
                  replaceMethod = _.bind(injectionSite.replaceWith, injectionSite);
                } else {
                  var staleModelIdMap = _.indexBy(staleViews, 'modelId');
                  var firstModelIdLeft = _.find(view.__orderedModelIdList, function(modelId) {
                    return !staleModelIdMap[modelId];
                  });
                  firstItemViewLeft = view.getTrackedView(view.__modelToViewMap[firstModelIdLeft]);
                  replaceMethod = _.bind(firstItemViewLeft.$el.prepend, firstItemViewLeft.$el);
                }
              }
              view.attachView(null, createdViewInfo.view, {
                replaceMethod: replaceMethod,
                discardInjectionSite: true
              });
            } else {
  • ¶

    There will always the view before this one because we are adding new views in order and we took care of the initial case.

              _addItemView.call(view, createdViewInfo.view, createdViewInfo.indexOfModel);
            }
          });
          this.reorder();
        },
    
        /**
         * Updates the internal list of model ids that correspond to the models used for the current
         * list of item views. The order is the same order of the item views.
         * @param {string[]} [newIdsList] - array of ids: if passed the array, it will use that instead of finding the list.
         * @private
         */
        __updateOrderedModelIdList: function(newIdsList) {
          this.__orderedModelIdList = newIdsList || _.pluck(this.modelsToRender(), this.__modelId);
        },
    
        /**
         * Method to generate arguments when creating an item view. Override this method
         * to change the arguments for a given item view.
         * The format of the subview's arguments is:
         * {
         *   context: {
         *     ... inherited from parent ...
         *   },
         *   <modelName>: <modelObject>,
         *   listView: the parent list view
         * }
         * @private
         * @param model the model for an item view
         * @return a context to be used by an item view
         */
        __generateItemViewArgs: function(model) {
          var args = {
            'context': _.extend({}, _.result(this, '__itemContext')),
            'listView': this
          };
          args[this.__modelName] = model;
          return args;
        },
    
        /**
         * Alias method for {@link ListView#__generateItemViewArgs}
         * @private
         */
        __generateChildArgs: function() {
          return this.__generateItemViewArgs.apply(this, arguments);
        },
    
        /**
         * @private
         * @param {(string|Number)} modelId id of model
         * @return {(string|Number)} view cid that was built from corresponding model
         */
        __getViewIdFromModelId: function(modelId) {
          return this.__modelToViewMap[modelId];
        }
      });
    
      return ListView;
    }));