lateralus.component.js

import _ from 'lodash-compat';
import Backbone from 'backbone';
import mixins from './lateralus.mixins';
import ComponentView from './lateralus.component.view';
import ComponentModel from './lateralus.component.model';
import ComponentCollection from './lateralus.component.collection';

/**
 * The constructor for this method should not be called directly.  Instead, use
 * the `{@link Lateralus.mixins#addComponent}` mixin method:
 *
 *     const App = Lateralus.beget(function () {
 *       Lateralus.apply(this, arguments);
 *     });
 *
 *     const app = new App(document.getElementById('app'));
 *     const component = app.addComponent(Lateralus.Component);
 *
 *     console.log(component instanceof Lateralus.Component); // true
 * @class Lateralus.Component
 * @param {Lateralus} lateralus
 * @param {Object} options Values to attach to this `{@link
 * Lateralus.Component}` instance.  This object also get passed to the `{@link
 * Lateralus.Component.initialize}` method, if one is defined.
 * @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.
 * @param {Object} viewOptions The `options` Object to pass to the
 * `{@link Lateralus.Component.View}`
 * constructor.
 * @param {Lateralus.Component} [opt_parentComponent] The parent component of
 * this component, if any.
 * @mixes http://backbonejs.org/#Events
 * @mixes Lateralus.mixins
 * @constructs Lateralus.Component
 */
function Component (lateralus, options, viewOptions, opt_parentComponent) {

  /**
   * A reference to the central {@link Lateralus} instance.
   * @member Lateralus.Component#lateralus
   * @type {Lateralus}
   * @final
   */
  this.lateralus = lateralus;

  /**
   * If a `{@link Lateralus.Component}` has `mixins` defined on its `prototype`
   * before it is instantiated, the objects within `mixins` will be merged into
   * this `{@link  "Lateralus.Component}` at instantiation-time with `{@link
   * Lateralus.Component/mixin}`.
   * @property mixins
   * @type {Object|Array<Object>|undefined}
   * @default undefined
   */
  if (this.mixins) {
    _.each(this.mixins, _.bind(this.mixin, this));
  }

  if (opt_parentComponent) {
    /**
     * If this is a subcomponent of another `{@link Lateralus.Component}`, this
     * property is a reference to the parent `{@link Lateralus.Component}`.
     * @member Lateralus.Component#parentComponent
     * @type {Lateralus.Component|undefined}
     * @default undefined
     */
    this.parentComponent = opt_parentComponent;
  }

  /**
   * The `{@link Lateralus.Component.View}` constructor to use, if any.  If
   * this `{@link Lateralus.Component}` is intended to render to the DOM,
   * `View` should be defined on the `prototype` before instantiating:
   *
   *     const ExtendedComponent = Lateralus.Component.extend({
   *       name: 'extended',
   *       View: Lateralus.Component.View,
   *       template: '<div></div>'
   *     });
   * @member Lateralus.Component#View
   * @type {Lateralus.Component.View|undefined}
   * @default undefined
   */
  if (this.View) {
    const augmentedViewOptions = viewOptions;

    // A model instance provided to addComponent takes precendence over the
    // prototype property.
    if (this.Model && !viewOptions.model) {

      options.modelOptions = _.extend(options.modelOptions || {}, {
        lateralus: lateralus,
        component: this
      });

      this.model = new this.Model(
        options.modelAttributes,
        options.modelOptions
      );

      augmentedViewOptions.model = this.model;
    }

    /**
     * If `{@link Lateralus.Component.View}` is defined, this is an instance of
     * that constructor.
     * @member Lateralus.Component#view
     * @type {Lateralus.Component.View|undefined}
     * @default undefined
     */
    this.view = new this.View(
        lateralus,
        this,
        augmentedViewOptions
      );
  }

  _.extend(this, options);

  /**
   * A method to be called when this `{@link Lateralus.Component}` has been set
   * up.
   * @member Lateralus.Component#initialize
   * @type {Function|undefined}
   * @default undefined
   */
  if (this.initialize) {
    this.initialize(options);
  }

  this.delegateLateralusEvents();
}

const fn = Component.prototype;

Component.View = ComponentView;
Component.Model = ComponentModel;
Component.Collection = ComponentCollection;

/**
 * Create a `{@link Lateralus.Component}` subclass.
 * @method Lateralus.Component#extend
 * @param {Object} protoProps
 * @param {string} protoProps.name The name of this component.  It should have
 * no whitespace.
 * @param {Lateralus.Component.View} [protoProps.View] The `{@link
 * Lateralus.Component.View}` to render this component with.
 * @param {Lateralus.Component.Model} [protoProps.Model] The optional `{@link
 * Lateralus.Component.Model}` to be provided to `protoProps.View` when it is
 * instantiated.  This does nothing if `protoProps.View` is not defined.
 */
Component.extend = function (protoProps) {
  const extendedComponent = Backbone.Model.extend.call(this, protoProps);

  if (!protoProps.name) {
    throw new Error('A name was not provided to Component.extend.');
  }

  _.extend(extendedComponent, protoProps);

  return extendedComponent;
};

// Prototype members
//
_.extend(fn, Backbone.Events, mixins);

/**
 * The name of this component.  This is used internally by Lateralus.
 * @protected
 * @property name
 * @type string
 */
fn.name = 'component';

/**
 * @param {any} property
 * @param {Object} object
 * @private
 */
function removePropertyFromObject (property, object) {
  for (const propertyName in object) {
    if (object[propertyName] === property) {
      delete object[propertyName];
    }
  }
}

/**
 * Remove this `{@link Lateralus.Component}` from memory.  Also remove any
 * nested components added by `{@link Lateralus.mixins#addComponent}`.
 * @method Lateralus.Component#dispose
 */
fn.dispose = function () {
  _(this).lateralusDispose(() => {
    if (this.view) {
      this.view.dispose();
    }

    if (this.components) {
      _.invoke(this.components, 'dispose');
    }

    const parentComponent = this.parentComponent;
    if (parentComponent) {
      removePropertyFromObject(this, parentComponent.components);
    }

    if (_.contains(this.lateralus.components, this)) {
      removePropertyFromObject(this, this.lateralus);
    }
  });
};

/**
 * Meant to be overridden by subclasses.
 * @method Lateralus.Component#toJSON
 * @return {Object}
 */
fn.toJSON = function () {
  return {};
};

/**
 * @method Lateralus.Component#toString
 * @return {string} The name of this Component.  This is used internally by
 * Lateralus.
 */
fn.toString = function () {
  return this.name;
};

export default Component;