lateralus.mixins.js

import _ from 'lodash-compat';

/**
 * These method are mixed into `{@link Lateralus}`,
 * `{@link Lateralus.Component}`, and `{@link Lateralus.Component.View}`.
 * @mixin Lateralus.mixins
 * @requires http://backbonejs.org/#Events
 */
const mixins = {};

/**
 * Event namespace for `{@link Lateralus.mixins.provide}` handlers.
 * @type {string}
 * @memberof Lateralus.mixins
 * @name PROVIDE_PREFIX
 * @final
 * @private
 */
mixins.PROVIDE_PREFIX = '_provide:';
const PROVIDE_PREFIX = mixins.PROVIDE_PREFIX;

/**
 * @param {Object} obj
 * @return {boolean}
 * @private
 */
function isLateralus (obj) {
  return obj.toString() === 'lateralus';
}

/**
 * Add a subcomponent to a `{@link Lateralus}` or `{@link Lateralus.Component}`
 * instance.
 *
 *     const App = Lateralus.beget(function () {
 *       Lateralus.apply(this, arguments);
 *     });
 *
 *     const app = new App(document.getElementById('app'));
 *     const component = app.addComponent(Lateralus.Component);
 *     const subcomponent = component.addComponent(Lateralus.Component);
 *
 * @method Lateralus.mixins#addComponent
 * @param {Lateralus.Component} Component A constructor, not an instance.
 * @param {Object} [viewOptions] The `options` object to be passed along to
 * the `Component` parameter's {@link Lateralus.Component.View} instance.
 * @param {Object} [options] Gets passed to the new {@link Lateralus.Component}
 * instance.
 * @param {Object} [options.modelAttributes] Any attributes to pre-populate
 * the `{@link Lateralus.Component.Model}`
 * instance with, if there is one.
 * @param {Object} [options.modelOptions] Any parameters to pass to the
 * `{@link Lateralus.Component.Model}`
 * instance, if there is one.
 * @return {Lateralus.Component} The component that was added.
 */
mixins.addComponent = function (Component, viewOptions, options) {
  options = options || {};

  // If this object belongs to a Lateralus.Component (such as a
  // Lateralus.Component.View or Lateralus.Component.Model), add the new
  // subcomponent to that containing Lateralus.Component.
  if (typeof this.component !== 'undefined') {
    return this.component.addComponent.apply(this.component, arguments);
  }

  // If this object is a Lateralus.Model, add the new subcomponent to the
  // central Lateralus instance.
  if (this.toString() === 'lateralus-model') {
    return this.lateralus.addComponent.apply(this.lateralus, arguments);
  }

  if (!this.components) {
    /**
     * The subcomponents belonging to this object.  Do not modify this
     * property directly, it is managed by Lateralus.
     * @memberof Lateralus.mixins
     * @type {Object.<Lateralus.Component>}
     * @name components
     */
    this.components = {};

    /**
     * An internal counter registry of the subcomponents belonging to this
     * object.
     * @memberof Lateralus.mixins
     * @type {Object.<number>}
     * @private
     */
    this.componentCounters = {};
  }

  // If thisIsLateralus is false, `this` is a Lateralus.Component instance.
  const thisIsLateralus = isLateralus(this);

  const lateralusReference = thisIsLateralus ? this : this.lateralus;
  const component = new Component(
    lateralusReference,
    options,
    viewOptions || {},
    thisIsLateralus ? null : this
  );

  if (thisIsLateralus && component.view) {
    this.$el.append(component.view.$el);
  }

  const componentType = component.toString();
  if (this.componentCounters.hasOwnProperty(componentType)) {
    this.componentCounters[componentType]++;
  } else {
    this.componentCounters[componentType] = 0;
  }

  const componentInstanceName =
      componentType + this.componentCounters[componentType];
  this.components[componentInstanceName] = component;

  return component;
};

/**
 * Components should never communicate directly with one another in order to
 * maintain a loosely-coupled architecture.  Instead, they should just
 * broadcast general messages with the [`Backbone.Events`
 * API](http://backbonejs.org/#Events).  `emit` facilitates this loose
 * coupling by firing an event that bubbles throughout the app, depending on
 * what calls it:
 *
 * * If this is called by `{@link Lateralus}`, this just triggers an event on
 * that `{@link Lateralus}` instance.
 * * If this is called by a `{@link Lateralus.Component}`, this triggers an
 * event on that `{@link Lateralus.Component}` as well as the central `{@link
 * Lateralus}` instance.
 * * If this is called by a `{@link Lateralus.Component.View}`, this triggers
 * an event on that `{@link Lateralus.Component.View}`, the `{@link
 * Lateralus.Component}` to which it belongs, and the central `{@link
 * Lateralus}` instance.
 *
 * This method has the same method signature as
 * [`Backbone.Events.trigger`](http://backbonejs.org/#Events-trigger).
 * @method Lateralus.mixins#emit
 * @param {string} eventName The name of the event.
 * @param {...any} [args] Any arguments to pass along to the listeners.
 */
mixins.emit = function () {
  const args = _.toArray(arguments);
  this.trigger.apply(this, args);

  if (isLateralus(this)) {
    return;
  }

  if (this.component) {
    this.component.trigger.apply(this.component, args);
  }

  this.lateralus.trigger.apply(this.lateralus, args);
};

/**
 * Listen to an event-emitting Object and amplify one of its events across the
 * {@link Lateralus} application.  Useful for making plain Backbone Objects
 * (i.e., non-Lateralus Objects) communicate important information in a broader
 * way.
 * @method Lateralus.mixins#amplify
 * @param {Backbone.Events} emitter The object that `trigger`s events that
 * should be amplified globally across the app.
 * @param {string} eventName The event to amplify globally across the app.
 */
mixins.amplify = function (emitter, eventName) {
  this.listenTo(emitter, eventName, _.bind(this.emit, this, eventName));
};

/**
 * Listen for an event that is triggered on the central {@link Lateralus}
 * instance and bind a function handler.
 * @method Lateralus.mixins#listenFor
 * @param {string} event The name of the event to listen for.
 * @param {Function} callback The function handler to bind.
 */
mixins.listenFor = function (event, callback) {
  const thisIsLateralus = isLateralus(this);
  if (thisIsLateralus) {
    this.on(event, callback);
  } else {
    this.listenTo(this.lateralus, event, callback);
  }
};

mixins.setupProviders = function () {
  _.each(this.provide, (fn, key) => {
    // The `provide` Object may have already been processed by setupProviders
    // from a previous class instantiation (it is a shared prototype Object)
    // so check for that and don't namespace the keys again.
    if (key.match(PROVIDE_PREFIX)) {
      return;
    }

    this.provide[PROVIDE_PREFIX + key] = function (callback, args) {
      callback(fn.apply(this, args));
    };

    delete this.provide[key];
  });
};

/**
 * Execute any `{@link Lateralus.mixins.provide}` handlers that have
 * been set up in the app and return an array of the returned values.
 *
 * Values that are `undefined` are excluded from the returned Array.
 * @method Lateralus.mixins#collect
 * @param {string} key The name of the `{@link Lateralus.mixins.provide}`
 * methods to run.
 * @param {...any} [args] Any parameters to pass along to `{@link
 * Lateralus.mixins.provide}` methods.
 * @return {Array<any>}
 */
mixins.collect = function (key, ...args) {
  const collectedValues = [];

  this.emit(PROVIDE_PREFIX + key,
      _.bind(collectedValues.push, collectedValues), args);

  return _.reject(collectedValues, _.isUndefined);
};

/**
 * Execute any `{@link Lateralus.mixins.provide}` handlers that have been set
 * up in the app and return the first value.
 * @method Lateralus.mixins#collectOne
 * @param {string} key The name of the `{@link Lateralus.mixins.provide}`
 * methods to run.
 * @param {...any} [args] Any parameters to pass along to `{@link
 * Lateralus.mixins.provide}` methods.
 * @return {any}
 */
mixins.collectOne = function () {
  return this.collect.apply(this, arguments)[0];
};

const delegateEventSplitter = /^(\S+)\s*(.*)$/;

/**
 * Bind `{@link Lateralus.mixins.lateralusEvents}`, if it is
 * defined.
 * @method Lateralus.mixins#delegateLateralusEvents
 * @chainable
 */
mixins.delegateLateralusEvents = function () {
  this.setupProviders();

  _.each({
      /**
       * A map of functions or string references to functions that will
       * handle [events](http://backbonejs.org/#Events) dispatched to the
       * central `{@link Lateralus}` instance.
       *
       *     const ExtendedComponent = Lateralus.Component.extend({
       *       name: 'extended',
       *
       *       lateralusEvents: {
       *         anotherComponentChanged: 'onAnotherComponentChanged',
       *
       *         anotherComponentDestroyed: function () {
       *           // ...
       *         }
       *       },
       *
       *       onAnotherComponentChanged: function () {
       *         // ...
       *       }
       *     });
       * @memberof Lateralus.mixins
       * @name lateralusEvents
       * @type {Object|undefined}
       * @default undefined
       */
      lateralusEvents: this.lateralus || this,

      /**
       * A map of functions that will handle `{@link Lateralus.mixins#collect}`
       * calls.  Each of the functions attached to this Object should return a
       * value.  These functions **must** be completely synchronous.
       *
       *     const App = Lateralus.beget(function () {
       *       Lateralus.apply(this, arguments);
       *     });
       *
       *     _.extend(App.prototype, {
       *       provide: {
       *         demoData: function () {
       *           return 1;
       *         }
       *       }
       *     });
       *
       *     const app = new App();
       *     const ComponentSubclass = Lateralus.Component.extend({
       *       name: 'provider',
       *       provide: {
       *         demoData: function () {
       *           return 2;
       *         }
       *       }
       *     });
       *
       *     app.addComponent(ComponentSubclass);
       *     console.log(app.collect('demoData')); // [1, 2]
       * @memberof Lateralus.mixins
       * @name provide
       * @type {Object|undefined}
       */
      provide: this.lateralus || this,

      /**
       * A map of functions or string references to functions that will
       * handle [events](http://backbonejs.org/#Events) emitted by
       * `this.model`.
       *
       *     const ExtendedComponent = Lateralus.View.extend({
       *       modelEvents: {
       *         changed:someProperty: function (model, someProperty) {
       *           // ...
       *         }
       *       }
       *     });
       * @memberof Lateralus.mixins
       * @name modelEvents
       * @type {Object|undefined}
       * @default undefined
       */
      modelEvents: this.model
    }, (subject, mapName) => {

    if (!subject) {
      return;
    }

    const eventMap = this[mapName];

    if (eventMap) {
      // Inherit the parent object's event map, if there is one.
      const childEventMap = eventMap;
      const ctorProto = this.constructor.prototype;

      if (ctorProto[mapName]) {
        // Temporarily delete the key so the next analogous key on the
        // prototype chain is accessible.
        delete ctorProto[mapName];

        // Grab the inherited map.
        const baseEventMap = this[mapName];

        // Augment the child's map with the parent's.
        ctorProto[mapName] = _.defaults(childEventMap, baseEventMap);
      }
    }

    for (const key in eventMap) {
      let method = eventMap[key];
      if (!_.isFunction(method)) {
        method = this[eventMap[key]];
      }

      if (!method) {
        new Error('Method "' + method + '" not found for ' + this.toString());
      }

      const match = key.match(delegateEventSplitter);
      const eventName = match[1];
      const boundMethod = _.bind(method, this);

      if (isLateralus(this) && isLateralus(subject)) {
        this.on(eventName, boundMethod);
      } else {
        this.listenTo(subject, eventName, boundMethod);
      }
    }
  });

  return this;
};

/**
 * Helper function for initModel and initCollection.
 * @param {Object} [initialObject]
 * @return {{ lateralus: Lateralus, component: Lateralus.Component= }}
 * `component` is not defined if `this` is the Lateralus instance.
 * @private
 */
function getAugmentedOptionsObject (initialObject) {
  // jshint validthis:true
  const thisIsLateralus = isLateralus(this);
  const augmentedOptions = _.extend(initialObject || {}, {
    lateralus: thisIsLateralus ? this : this.lateralus
  });

  if (!thisIsLateralus) {
    augmentedOptions.component = this.component || this;
  }

  return augmentedOptions;
}

/**
 * @param {Lateralus.Component.Model} Model A constructor, not an instance.
 * @param {Object} [attributes]
 * @param {Object} [options]
 * @return {Lateralus.Component.Model} An instance of the provided Model
 * constructor.
 * @method Lateralus.mixins#initModel
 */
mixins.initModel = function (Model, attributes, options) {
  if (isLateralus(this)) {
    return new Model(this, attributes, options);
  }

  const augmentedOptions = getAugmentedOptionsObject.call(this, options);
  return new Model(attributes, augmentedOptions);
};

/**
 * @param {Lateralus.Component.Collection} Collection A constructor, not an
 * instance.
 * @param {Array.<Lateralus.Model>} [models]
 * @param {Object} [options]
 * @return {Lateralus.Component.Collection} Am instance of the provided
 * Collection constructor.
 * @method Lateralus.mixins#initCollection
 */
mixins.initCollection = function (Collection, models, options) {
  const augmentedOptions = getAugmentedOptionsObject.call(this, options);
  return new Collection(models, augmentedOptions);
};

/**
 * Merge the properties of another object into this object.  If the `mixin`
 * configuration object has a method called `initialize`, it is called in the
 * context of the object calling this function.
 * @method Lateralus.mixins#mixin
 * @param {Object} mixin The object to mix in to this one.
 */
mixins.mixin = function (mixin) {
  _.extend(this, _.omit(mixin, 'initialize'));

  if (typeof mixin.initialize === 'function') {
    mixin.initialize.call(this);
  }
};

export default mixins;