Source: itemcollection.js

var _ = require("lodash");
var hasEmitter = require("./events").hasEmitter;
var binder = require("./events").binder;
var DecksEvent = require("./events").DecksEvent;
var Item = require("./item");
var validate = require("./utils/validate");

/**
 * Represents a collection of Items
 *
 * @class
 * @mixes binder
 * @param {(Item[]|Object[])} items items with which to initialize collection
 */
function ItemCollection(items, options) {
  if (!(this instanceof ItemCollection)) {
    return new ItemCollection(items);
  }

  items = items || [];
  options = _.merge({}, this.defaultOptions, options);

  this.setEmitter(options.emitter || {});
  this.items = {};
  this.addItems(items);

  this.emit(DecksEvent("item:collection:ready", this));
}

_.extend(ItemCollection.prototype, binder, hasEmitter, /** @lends ItemCollection.prototype */ {
  /**
   * Default options for ItemCollection instances
   */
  defaultOptions: {
  },

  /**
   * Item events to which to bind for each Item added to the collection.
   *
   * @return {undefined}
   */
  getItemEvents: function() {
    return {
      "*": "onAnyItemEvent"
    };
  },

  /**
   * Binds to all {@link Item} events.
   *
   * It's not necessary to call this directly, unless you called this.unbind at some point.
   *
   * @return {undefined}
   */
  bind: function bind() {
    _.each(this.getItems(), function(item) {
      this.bindEvents(item, this.getItemEvents());
    }, this);
  },

  /**
   * Unbinds from all {@link Item} events.
   *
   * @return {undefined}
   */
  unbind: function bind() {
    _.each(this.getItems(), function(item) {
      this.unbindEvents(item, this.getItemEvents());
    }, this);
  },

  /**
   * Destroys the {@link ItemCollection}
   *
   * @return {undefined}
   */
  destroy: function() {
    this.unbind();

    _.each(this.getItems(), function(item) {
      item.destroy();
    }, this);
  },

  /**
   * Gets a single item from the collection
   *
   * @param {!String} id - Item id to find
   * @return {Item} - Item or null if not found
   */
  getItem: function getItem(id) {
    if (_.isNumber(id)) {
      id = "" + id;
    }

    validate(id, "id", { isString: true });

    return this.items[id];
  },

  /**
   * Gets all {@link Item}s from the collection, or a subset of {@link Item}s based on an optional filter function.
   *
   * @param {?Function} filter - the filter predicate function (takes an item and should return whether Item passes the filter)
   * @return {Item[]} - Array of {@link Item}s (not an {@link ItemCollection} instance)
   */
  getItems: function getItems(filter) {
    var items = _.values(this.items);

    if (filter) {
      items = _.filter(items, filter);
    }

    return items;
  },

  /**
   * Adds an {@link Item} to the {@link ItemCollection}.
   *
   * An event is emitted just before adding the {@link Item} and just after.
   *
   * After adding the {@link Item} the {@link ItemCollection} is re-indexed, based on the current
   * filter function, sortBy function, and reversed flag.
   *
   * @param {!(Item|Object)} item - item instance or options to add
   * @param {?Object} [options={}] - additional options
   * @param {?boolean} [options.silent=false] - if true, the {@link ItemCollection} will not emit events
   * @param {?boolean} [options.noIndex=false] - if true, the {@link ItemCollection} will not be re-indexed after adding the {@link Item}
   * @return {Item} - the {@link Item} instance that was added
   */
  addItem: function addItem(item, options) {
    validate(item, "item", { isRequired: true });

    options = options || {};

    if (!(item instanceof Item)) {
      item = new Item(item);
    }

    if (this.items[item.id]) {
      throw new Error("ItemCollection#addItem: collection already contains an item with id " + item.id);
    }

    if (!options.silent) {
      this.emit(DecksEvent("item:collection:item:adding", this, item));
    }

    this.items[item.id] = item;
    this.bindEvents(item, this.getItemEvents());

    if (!options.noIndex) {
      this.index({ isAddItem: true }, { silent: options.silent });
    }

    if (!options.silent) {
      this.emit(DecksEvent("item:collection:item:added", this, item));
    }

    return item;
  },

  /**
   * Adds an array of items to the collection.
   *
   * Events and indexing will be suppressed for each individual {@link Item} added.
   *
   * @param {!(Item[]|Object[])} items - Array of {@link Item} instances or options objects to add
   * @param {?Object} [options={}] - additional options
   * @param {?boolean} [options.silent=false] - if true, no events will be emitted
   * @param {?boolean} [options.noIndex=false] - if true, the {@link ItemCollection} will not be re-indexed
   * @return {Item[]} - Array of {@link Item} instances that were added.
   */
  addItems: function addItems(items, options) {
    validate(items, "items", { isArray: true });

    options = options || {};

    if (_.isEmpty(items)) {
      return;
    }

    if (!options.silent) {
      this.emit(DecksEvent("item:collection:items:adding", this));
    }

    items = _.map(items, function(item) {
      return this.addItem(item, { silent: true, noIndex: true });
    }, this);

    if (!options.noIndex) {
      this.index({ isAddItems: true }, { silent: options.silent });
    }

    if (!options.silent) {
      this.emit(DecksEvent("item:collection:items:added", this, items));
    }

    return items;
  },

  /**
   * Removes an item from the collection
   *
   * @param {Item} item item to remove
   * @param {?Object} options additional options
   * @return {undefined}
   */
  removeItem: function removeItem(item, options) {
    validate(item, "item", { isInstanceOf: Item });

    options = options || {};

    if (!_.has(this.items, item.id)) {
      return;
    }

    if (!options.silent) {
      this.emit(DecksEvent("item:collection:item:removing", this, item));
    }

    this.unbindEvents(item, this.getItemEvents());
    delete this.items[item.id];

    if (!options.noIndex) {
      this.index({ isRemoveItem: true }, { silent: options.silent });
    }

    if (!options.silent) {
      this.emit(DecksEvent("item:collection:item:removed", this, item));
    }

    return item;
  },

  /**
   * Clears the collection by removing all the {@link Item}s one-by-one.
   *
   * Events and indexing will be suppressed for each individual {@link Item} removed.
   *
   * @param {?Object} options additional options
   * @return {undefined}
   */
  clear: function clear(options) {
    options = options || {};

    if (_.isEmpty(this.items)) {
      return;
    }

    if (!options.silent) {
      this.emit(DecksEvent("item:collection:clearing", this));
    }

    var items = _.map(this.items, function(item) {
      return this.removeItem(item, { silent: true, noIndex: true });
    }, this);

    // There's not really a need to re-index the collection, but some things rely
    // on the indexing events being emitted
    if (!options.noIndex) {
      this.index({ isClear: true }, { silent: options.silent });
    }

    if (!options.silent) {
      this.emit(DecksEvent("item:collection:cleared", this, items));
    }

    return items;
  },

  /**
   * Sets or clears the current filter function.
   *
   * The filter function is used to filter {@link Item}s out of the {@link ItemCollection} based
   * on a predicate.  The {@link Item}s are marked as filtered-out by setting the {@link Item} index
   * to -1 (they are not actually removed from the collection, just flagged in this way).
   *
   * @param {?Function} [filter=null] - the filter function (or null to clear the current filter)
   * @param {?Object} [options={}] - additional options
   * @param {?boolean} [options.silent=false] - if true, events will not be emitted
   * @param {?boolean} [options.noIndex=false] - if true, the collection will not be re-indexed with the new filter
   * @return {undefined}
   */
  setFilter: function setFilter(filter, options) {
    if (!_.isFunction(filter)) {
      filter = null;
    }

    options = options || {};

    if (this.filter === filter) {
      return;
    }

    if (!options.silent) {
      this.emit(DecksEvent("item:collection:filter:setting", this, { oldFilter: this.filter, newFilter: filter }));
    }

    this.filter = filter;

    if (!options.noIndex) {
      this.index({ isSetFilter: true }, { silent: options.silent });
    }

    if (!options.silent) {
      this.emit(DecksEvent("item:collection:filter:set", this, this.filter));
    }
  },

  /**
   * Sets or clears the current sort by function.
   *
   * The sort by function is used to sort the {@link Item}s in the collection.
   * The sortBy function should return a sortable value for each {@link Item}.
   * The sort is applied by updating the {@link Item} index values.
   *
   * @param {?Function} [sortBy=null] - the sort by function (or null to clear the current sort)
   * @param {?Object} [options={}] - additional options
   * @param {?boolean} [options.silent=false] - if true, events will not be emitted
   * @param {?boolean} [options.noIndex=false] - if true, the collection will not be re-indexed with the new sort
   * @return {undefined}
   */
  setSortBy: function setSortBy(sortBy, options) {
    if (!_.isFunction(sortBy)) {
      sortBy = null;
    }

    options = options || {};

    if (this.sortBy === sortBy) {
      return;
    }

    if (!options.silent) {
      this.emit(DecksEvent("item:collection:sort:by:setting", this, { oldSortBy: this.sortBy, newSortBy: sortBy }));
    }

    this.sortBy = sortBy;

    if (!options.noIndex) {
      this.index({ isSetSortBy: true }, { silent: options.silent });
    }

    if (!options.silent) {
      this.emit(DecksEvent("item:collection:sort:by:set", this, this.sortBy));
    }
  },

  /**
   * Reverses the sort on the collection.
   *
   * The reversing is applied by re-indexing all the {@link Item}s in the {@link ItemCollection}.
   *
   * @param {?boolean} [isReversed=false] - whether the collection should be reversed.
   * @param {?Object} [options={}] - additional options
   * @param {?boolean} [options.silent=false] - if true, events will not be emitted
   * @param {?boolean} [options.noIndex=false] - if true, the collection will not be re-indexed with the new reverse value.
   * @return {undefined}
   */
  setReversed: function setReversed(isReversed, options) {
    isReversed = !!isReversed;
    options = options || {};

    if (this.isReversed === isReversed) {
      return;
    }

    if (!options.silent) {
      this.emit(DecksEvent("item:collection:reversed:setting", this, { oldIsReversed: this.isReversed, newIsReversed: isReversed }));
    }

    this.isReversed = isReversed;

    if (!options.noIndex) {
      this.index({ isSetReversed: true }, { silent: options.silent });
    }

    if (!options.silent) {
      this.emit(DecksEvent("item:collection:reversed:set", this, this.isReversed));
    }
  },

  /**
   * Indexes the {@link ItemCollection} based on the current filter function, sortBy function, and
   * reversed flag.  Each {@link Item} in the collection will have it's index value set based on
   * the filter/sortBy/reversed representation of the {@link ItemCollection} items.
   *
   * The sorting and filtering is applied to the {@link Item}s by updating the {@link Item} index values.
   * {@link Item}s are not physically moved or removed from the {@link ItemCollection}.
   *
   * If {@link Item}s are filtered out via the filter function, the {@link Item} index will be set to
   * -1.  The {@link Layout#getRenders} method can check if item.index is -1 to decide if the {@link Item}
   *  should be displayed.
   *
   * @param {!Object} reason - an object which indicates why the collection is being indexed
   * @param {?Object} options - additional options
   * @param {?boolean} [options.silent=false] - if true, no events will be emitted
   * @return {undefined}
   */
  index: function index(reason, options) {
    validate(reason, "reason", { isRequired: true });
    options = options || {};

    // Create a list of filtered, sorted, and reversed items, then find the index of each item
    // in the reference list.  If the item is not in the ref list, it will get an index of -1,
    // which means it should not be drawn (or should be hidden).
    var referenceItems = _(this.getItems());

    if (this.filter) {
      referenceItems = referenceItems.filter(this.filter);
    }

    if (this.sortBy) {
      referenceItems = referenceItems.sortBy(this.sortBy);
    }

    if (this.isReversed) {
      referenceItems = referenceItems.reverse();
    }

    referenceItems = referenceItems.value();

    var items = this.getItems();

    var totalCount = items.length;
    var referenceCount = referenceItems.length;

    if (!options.silent) {
      this.emit(DecksEvent("item:collection:indexing", this, {
        reason: reason,
        totalCount: totalCount,
        referenceCount: referenceCount
      }));
    }

    var changedCount = _.reduce(items, function indexItem(count, item) {
      var index = _.indexOf(referenceItems, item);
      if (item.setIndex(index, { silent: options.silent })) {
        count++;
      }
      return count;
    }, 0);

    if (!options.silent) {
      this.emit(DecksEvent("item:collection:indexed", this, {
        reason: reason,
        totalCount: totalCount,
        referenceCount: referenceCount,
        changedCount: changedCount
      }));
    }
  },

  /**
   * Called for {@link Item} events.  Forwards the {@link Item} events via the {@ItemCollection}
   * emitter.
   *
   * @param e
   * @return {undefined}
   */
  onAnyItemEvent: function onAnyItemEvent(e) {
    this.emit(e);
  }
});

module.exports = ItemCollection;