component.js

import Symbol from 'es6-symbol';
import EventEmitter from 'eventemitter3';
import get from 'lodash/get';
import set from 'lodash/set';
import assign from 'lodash/assign';
import values from 'lodash/values';
import isPlainObject from 'lodash/isPlainObject';
import isArray from 'lodash/isArray';
import isNil from 'lodash/isNil';
import {
  requires,
  assertDisposable,
  assertString,
} from './asserts';
import createClass from './util/create-class';
import decorateObject from './util/decorate-object';
import subscribableMixin from './mixins/subscribable';
import disposableMixin from './mixins/disposable';
import disposableDecorator from './mixins/decorator/disposable-decorator';

const FIELDS = {
  type: Symbol('type'),
  state: Symbol('state'),
  emitter: Symbol('emitter'),
};

function onDispose() {
  this[FIELDS.emitter].emit('component:dispose', this);
  this[FIELDS.emitter].removeAllListeners();
}

function transition(...args) {
  let previous = args[0];
  const path = args.length >= 3 ? args[1] : null;
  const value = JSON.parse(JSON.stringify(args.length >= 3 ? args[2] : args[1]));

  if (isNil(previous)) {
    if (isNil(value)) {
      return value;
    } else if (isArray(value)) {
      previous = [];
    } else if (isPlainObject(value)) {
      previous = {};
    }
  }

  const current = JSON.parse(JSON.stringify(previous));

  if (path) {
    assertString(path);
    return set(current, path, value);
  }

  return value;
}
/**
 * @namespace Component
 * @class
 * A component is simply a structured set of data defined by a type.
 * @examples
 * const component = new Component('health', { hp: 100 });
 * const componentType = component.getType();
 * @param {!string} type - The component type.
 * @param {*} [state] - The initial state.
 * @mixes Subscribable
 * @mixes Disposable
 */
const Component = createClass({

  /**
   * A change event occurs when the state of a component changes.
   * @event component:change
   * @param {Component} component - The component that changed.
   * @param {*} newState - The new value of the component.
   * @param {*} oldState - The old value of the component.
   * @instance
   * @memberof Component
   */

  /**
   * A dispose event occurs when a component is being disposed.
   * @event component:dispose
   * @param {Component} component - The component that is being disposed.
   * @listens Component#Component: change
   * @instance
   * @memberof Component
   */

  mixins: [
    decorateObject(subscribableMixin(FIELDS.emitter), disposableDecorator),
    disposableMixin(values(FIELDS), onDispose),
  ],

  constructor(type, state = null) {
    requires('type', type);

    /**
     * A literal that identifies the type of data in the component.
     * @member {string} type
     * @readonly
     * @instance
     * @memberof Component
     * @private
     */
    this[FIELDS.type] = type;

    /**
     * The data contained in the component.
     * @member {*} state
     * @instance
     * @memberof Component
     * @private
     */
    this[FIELDS.state] = transition(null, state);

    /**
     * The event emitter.
     * @member {EventEmitter} emitter
     * @instance
     * @memberof Component
     * @private
     * @ignore
     */
    this[FIELDS.emitter] = new EventEmitter();
  },

  /**
   * Get the component type.
   * @returns {string} The component type.
   * @throws {ReferenceError} Will throw this error if invoked after being disposed.
   * @instance
   * @memberof Component
   */
  getType() {
    assertDisposable(this);
    return this[FIELDS.type];
  },

  /**
   * Get data from the component state. Returns the entire state if no path is specified.
   * @param {string} [path] - The path to a value in the state.
   * @returns {*} The requested value from the state.
   * @examples
   * # Will set handle equal to 'TheChimo'
   * const component = new Component('player', { user: { id: 1337, handle: 'TheChimo' } };
   * const handle = component.get('user.handle');
   * console.log(handle == 'TheChimo');
   * @throws {TypeError} Will throw this error if the path argument is not a string.
   * @throws {ReferenceError} Will throw this error if invoked after being disposed.
   * @instance
   * @memberof Component
   */
  getState(path) {
    assertDisposable(this);

    if (path) {
      assertString(path);
    }

    const value = !path ? this[FIELDS.state] : get(this[FIELDS.state], path);

    if (isArray(value)) {
      return assign([], value);
    } else if (isPlainObject(value)) {
      return assign({}, value);
    }

    return value;
  },

  /**
   * Set data into the component state. Replaces the entire state if no path is specified.
   * @param {string} [path] - The path in the state where to set the data.
   * @param {*} data - The data to set.
   * @returns {Component} Itself
   * @examples
   * # Will set user.handle equal to 'somethingElse'
   * const component = new Component('player', { user: { id: 1337, handle: 'TheChimo' } };
   * component.set('user.handle', 'somethingElse');
   * @emits Component#component:change
   * @throws {TypeError} Will throw this error if the path argument is not a string.
   * @throws {ReferenceError} Will throw this error if invoked after being disposed.
   * @instance
   * @memberof Component
   */
  setState(...args) {
    assertDisposable(this);

    const previous = this[FIELDS.state];
    this[FIELDS.state] = transition(previous, ...args);
    this[FIELDS.emitter].emit('component:change', this, this.getState(), previous);
    return this;
  },

});

export default Component;