Controller.js

import Action from './Action'
import _Symbol from './utils/_Symbol'

export const __store = _Symbol('store')
export const __mountPath = _Symbol('mountPath')
export const __mountPathString = _Symbol('mountPathString')

const __selectors = _Symbol('selectors')
const __selectorsByKey = _Symbol('selectorsByKey')
const __actions = _Symbol('actions')

const getAllClassMethods = (obj) => {
  let keys = []
  let topObject = obj

  const onlyOriginalMethods = (p, i, arr) =>
    typeof topObject[p] === 'function' &&  // only the methods
    p !== 'constructor' && // not the constructor
    (i === 0 || p !== arr[i - 1]) && // not overriding in this prototype
    keys.indexOf(p) === -1 // not overridden in a child

  do {
    const l = Object.getOwnPropertyNames(obj)
      .sort()
      .filter(onlyOriginalMethods)
    keys = keys.concat(l)

    // walk-up the prototype chain
    obj = Object.getPrototypeOf(obj)
  } while (
    // not the the Object prototype methods (hasOwnProperty, etc...)
    obj && Object.getPrototypeOf(obj)
  )

  return keys
}

/**
 * Base class for controllers. Controllers are responsible for managing the certain part of the
 * **state**, which includes:
 *
 *  - *Sending* relevant **actions** in response to certain related events.
 *  - *Modifying* the state by providing the relevant **reducer**.
 *  - *Providing access* and if necessary *transforming* the data in the managed part of the
 *    **state**.
 *
 * Controller is intended to be subclassed. It won't work as is, because it doesn't provide
 * a reducer function ([reducer()]{@link Controller#reducer} returns `undefined`).
 *
 * The only function mandatory for overriding is:
 *
 *  - [reducer()]{@link Controller#reducer} - to provide the reducer function.
 *
 * You may also need to override:
 *
 *  - [afterCreateStore()]{@link Controller#afterCreateStore} - will be called for all Controllers
 *    after Redux store was created and all Controllers were mounted.
 *  - [areStatesEqual()]{@link Controller#areStatesEqual} - to control the optimizations done by
 *    {@link Container}. If you follow Redux recommendation of immutability and your Controller
 *    selector methods only transform the global state (which is also highly recommended), you do
 *    **not need** to override this method.
 *
 * In order to utilize the full power of the framework, subclasses define two types of functions:
 *
 *  - **Selectors** transform the part of the Controller-managed state into different structure.
 *    Any method that starts with `$` symbol is considered to be a selector.
 *  - **Dispatches** are special functions that are expected to dispatch some actions either
 *    synchronously or asynchronously. Any method that starts with `dispatch` is considered to be
 *    a dispatch method.
 *
 * @example
 * class ToDoController extends Controller {
 *   constructor() {
 *     super()
 *     this.createAction('add')
 *   }
 *
 *   // Dispatch function. Will be mapped as `add(text)` in Container
 *   dispatchAdd(text) {
 *     this.dispatchAction('add', text)
 *   }
 *
 *   // Selector function. Will be used to collect data for `props.text` in Container
 *   $texts(state) {
 *     return (this.$$(state)._items || []).map((item) => item.text)
 *   }
 *
 *   reducer() {
 *     const { add } = this.actions
 *     return this.createReducer(
 *       add.on((state, text) => ({
 *         _items: (state._items || []).concat({ text })
 *       }))
 *     )
 *   }
 * }
 */
class Controller {
  /**
   * Constructor is a good place to create all [actions]{@link Action} that Controller needs.
   * And of course to pass and store any external dependencies and parameters Controller needs
   * to function.
   */
  constructor () {
    this[__actions] = { }
  }

  /**
   * Checks if provided value *looks like* an instance of the Controller class. It does pretty
   * minimal check and should not be relied to actually detect the fact of being Controller's
   * subclass if needed.
   *
   * @param instance Value to be checked.
   */
  static is (value) {
    // Enough to consider instance to be a Controller for most cases
    return value && typeof value.reducer === 'function'
  }

  /**
   * Redux store object this controller is mounted to.
   * @type {Store}
   */
  get store () {
    return this[__store]
  }

  /**
   * Path under which this controller is mounted as the array of keys.
   * @type {string[]}
   */
  get mountPath () {
    return this[__mountPath]
  }

  /**
   * Path under which this controller is mounted as the single string. Path components are joined
   * with `.` (dot) symbol.
   * @type {string}
   */
  get mountPathString () {
    return this[__mountPathString]
  }

  /**
   * Array of [Actions]{@link Action} previously created by
   * [Controller.createAction()]{@link Controller#createAction} calls.
   * @type {Object.<string, Action>}
   */
  get actions () {
    return this[__actions]
  }

  /**
   * Redux store `dispatch()` function.
   * @type {function}
   */
  get dispatch () {
    return this[__store] ? this[__store].dispatch : undefined
  }

  /**
   * Create new {@link Action} and attach it to [Controller.actions]{@link Controller#actions}.
   * Intended to be used from inside the Controller. If provided a string key the {@link Action}
   * will be created with type equal to `${Controller.mountPathString}/${action}`.
   *
   * @param {string|Action} action String to be used as action key in {@link Controller#actions}
   *   and as a part of [Action.baseType]{@link Action#baseType}. Alternatively the ready made
   *   {@link Action} can be specified to be attached to the Controller.
   * @param {(string)} key If {@link Action} object was passed as first argument, this defines
   *    a key to be used in [Controller.actions]{@link Controller#actions}.
   *
   * @example
   * // Create new action with key "update" and attach it to the Controller
   * this.createAction("update")
   *
   * // Attach existing Action to the Controller using key "load"
   * this.createAction(loadAction, "load")
   *
   * // Later on these actions are available in this.actions:
   * const { update, load } = this.actions
   */
  createAction (action, key) {
    if (typeof action === 'string') {
      const baseType = () => (this[__mountPathString] + '/' + action)
      this[__actions][action] = new Action(baseType)
    } else {
      this[__actions][key || action.type()] = action
    }
  }

  /**
   * Dispatch the {@link Action} into the store by key and optionally a stage with the provided
   * payload. This is a shortcut method provided for convenience. Is it intended to be used from
   * inside the Controller.
   *
   * @param {string} actionType Action key string with optional stage (see {@link Action}).
   * @param payload Any object that should be sent as action payload.
   *
   * @example
   * // Dispatch action with key "update"
   * dispatchAction("update", { objectId: "1" })
   *
   * // Dispatch action with key "update" and stage "started"
   * dispatchAction("update.started", { objectId: "1" })
   */
  dispatchAction (actionType, payload) {
    // TODO: error processing if action was not found.

    const dotI = actionType.indexOf('.')
    const actionBaseType = dotI === -1
        ? actionType : actionType.substring(0, dotI)
    const actionStage = dotI === -1
        ? undefined : actionType.substring(dotI + 1)

    if (actionStage === 'error') {
      this.store.dispatch(this.actions[actionBaseType].error(payload))
    } else {
      this.store.dispatch(this.actions[actionBaseType].action(
          actionStage, payload))
    }
  }

  /**
   * This a convenience function, which simply calls
   * [Action.createReducer()]{@link Action#createReducer} passing through all of the arguments.
   */
  createReducer (...args) {
    return Action.createReducer(...args)
  }

  /**
   * Get the raw part of the stored state, managed by the controller. No selectors
   * will be called and no dispatches to be added to the result.
   *
   * @param {Object} state The root of the state tree managed by the Redux
   *    store. If ommitted, the function will operate on current state of the store.
   */
  $$ (state) {
    if (arguments.length === 0) {
      state = this.store.getState()
    }

    let innerState = state
    this[__mountPath].forEach((key) => {
      innerState = innerState ? innerState[key] : undefined
    })
    return innerState
  }

  /**
   * Select the value at specified path of the stored state. If no path is specified
   * (any falsey value or `"*"`), the full state of the tree is returned. All the
   * required selector functions are called in both cases, first level keys in the state that
   * start with underscore symbol (`_`) are considered "private" and ommitted.
   *
   * @param {Object=} state The root of the state tree managed by the Redux
   *    store. If ommitted, the function will operate on current state of the store.
   *
   * @param {(string|string[])=} path The path of the sub tree to obtain from
   *    the state, relative to the controller mount path. It should either be a
   *    string of dot separated keys or an array of strings. Falsey value as
   *    well as not specifying this parameter makes the function to return the
   *    full state managed by the controller.
   *
   * @returns Value selected from the specified path or `undefined` if nothing found
   *    at the specified path.
   */
  $ (state, path) {
    let _state, _path
    if (arguments.length === 1) {
      // either state or path
      if (Array.isArray(arguments[0]) || typeof arguments[0] === 'string') {
        _path = arguments[0]
        _state = this.store.getState()
      } else {
        _state = arguments[0]
      }
    } else if (arguments.length > 1) {
      _state = arguments[0]
      _path = arguments[1]
    } else {
      _state = this.store.getState()
    }

    const all = !_path || (_path.length === 0) || _path === '*'

    if (!this[__selectors]) {
      // cache selectors
      this[__selectors] = this
          .getAllSelectKeys()
          .map((key) => ({
            key: key.substr('$'.length),
            selector: this[key].bind(this)
          }))

      this[__selectorsByKey] = this[__selectors].reduce(
          (res, s) => Object.assign(res, {[s.key]: s.selector}), {})
    }

    const $$state = this.$$(_state)
    if (all) {
      const selectedState = Object.keys($$state)
          .filter((key) => key[0] !== '_')
          .reduce((res, key) => Object.assign(res, {
            [key]: $$state[key]
          }), {})

      this[__selectors]
          .reduce((res, s) => Object.assign(res, {
            [s.key]: s.selector(_state)
          }), selectedState)

      return selectedState
    } else {
      if (typeof _path === 'string') {
        _path = _path.split('.').filter((el) => el.length > 0)
      }

      let selectedState = $$state
      for (let i = 0; i < _path.length; ++i) {
        if (i === 0 && this[__selectorsByKey][_path[i]]) {
          selectedState = this[__selectorsByKey][_path[i]](_state)
        } else {
          selectedState = selectedState[_path[i]]
        }
      }
      return selectedState
    }
  }

  /**
   * Passed as a callback to [Controller.subscribe()]{@link Controller#subscribe}.
   * @callback Controller~SubscribeListener
   * @param value Current value at the subscribed path.
   * @param prevValue Previous value at the subscribed path.
   */

  /**
   * Subscribes to changes of some value at path relative to the controller.
   *
   * @param {string|Array.<string>}
   * @param {Controller~SubscribeListener}
   */
  subscribe (path, listener, isEqual = (value, prevValue) => (value === prevValue)) {
    let value = this.$(path)

    return this.store.subscribe(() => {
      let prevValue = value
      value = this.$(path)

      if (!isEqual(prevValue, value)) {
        listener(value, prevValue)
      }
    })
  }

  /**
   * Called when Controller reducer is needed for the first time. Override this method and return
   * the reducer function. Reducer function is executed on the part state where Controller
   * was mounted. It is recommended to utilize {@link Action} and convenience functions
   * [Controller.createReducer]{@link Controller#createReducer},
   * [Controller.createAction]{@link Controller#createAction} and
   * [Controller.dispatchAction]{@link Controller#dispatchAction}, but is not mandatory. A regular
   * Redux reducer function will also work just fine.
   *
   * @returns {function} Reducer function.
   *
   * @example
   * reducer() {
   *   const { update } = this.actions
   *
   *   return this.createReducer(
   *     update.onStarted((state, payload) => ({...state, isUpdating: true })),
   *     update.onSuccess((state, items) => ({...state, items, isUpdating: false }))
   *   )
   * }
   */
  reducer () {
    // to be overriden in children
  }

  /**
   * Executed for all controllers after createStore() was called.
   * At this point all of the controllers are created and store is initialized.
   */
  afterCreateStore () {
    // to be overriden in children
  }

  /**
   * Returns array of all dispatch* function names defined in the Controller.
   *
   * @private
   */
  getAllDispatchKeys () {
    return getAllClassMethods(this).filter((key) => /^dispatch./.test(key) &&
      key !== 'dispatchAction' && typeof this[key] === 'function')
  }

  /**
   * Returns array of all $* function names (selectors) defined in the Controller.
   *
   * @private
   */
  getAllSelectKeys () {
    return getAllClassMethods(this)
      .filter((key) => /^\$[^$]+/.test(key) && typeof this[key] === 'function')
  }

  /**
   * This method is used by [Controller.hasChanges]{@link Controller#hasChanges} by default.
   * It checks if the state was changed comparing to an old state, so selectors need to be
   * reevaluated. By default it compares state objects by reference (`===`). This should
   * be fine if your state is immutable, which is highly recommended. Otherwise
   * you are responsible for overriding this check according to your needs or
   * just return false if you want reevaluate all selectors each time the state
   * tree is updated.
   *
   * Its purpose is basically the same as of `options.areStatesEqual` argument
   * to `connect` function from `react-redux` library.
   *
   * If you need to check the parts of the state, not managed by the controller,
   * override [Controller.hasChanges]{@link Controller#hasChanges} instead.
   *
   * @param $$prev Previous value of part of the state managed by the Controller.
   * @param $$next Next value part of the state managed by the Controller to be compared.
   */
  areStatesEqual ($$prev, $$next) {
    return $$prev === $$next
  }

  /**
   * This method is used by {@link Container} for optimizations. It checks if the state
   * was changed comparing to an old state, so selectors need to be reevaluated.
   * By default it calls [Controller.areStatesEqual]{@link Controller#areStatesEqual}
   * and returns the opposite boolean value.
   *
   * It is useful, if controller selects parts of the state, not managed by itself.
   *
   * @param prevState Previous Redux state value.
   * @param next Next Redux state value.
   */
  hasChanges (prevState, nextState) {
    return !this.areStatesEqual(this.$$(prevState), this.$$(nextState))
  }
}

export default Controller