Source: modules/state_model.js

/**
 * StateModel reduces the complexibility of making the UI and a module state
 * synchronized while keeping the UI responsive at all times. A typical use case
 * would be bound a toggle to a hardware feature that requires time for turning
 * on and off.
 *
 * StateModel was designed for the following goals:
 * - Immediate response
 *   The UI is not blocked even the target module can not respond to the user
 *   request immediately.
 * - Displaying the target state
 *   The final state should always be the last state requested from users unless
 *   the module fails to fufill the request.
 * - Displaying the real state
 *   If the module fails to be set to the target state or it changes the state
 *   by itself, the final state is the real module state.
 *
 * StateModel needs to be created with an object used for performing the
 * state change operations. The object should define the following three
 * functinos:
 * - onInit
 *   It is called only once when the model is being initialized. The function
 *   takes a function as the parameter used for reporting the state changes
 *   from the module. It can be omitted if your module won't change the state
 *   by itself. onInit should also return an initial state immediately.
 *
 * - onSetState
 *   It is called with a target state. Do the operation required for switching
 *   to the target state. Always return a promise resolved/rejected with a
 *   state.
 *   If the promise resolves with a state that does not equal to the
 *   latest target state, StateModel will call to this function again with the
 *   target state. If the promise rejects, StateModel simply changes the
 *   target state to the reported state because the module fails to be changed
 *   to the desired state.
 *
 * - onGetState
 *   It is called for requesting the current state. Return a promise wrapping
 *   the current state. StateModel fails to be initialized if the returning
 *   promise rejects.
 *
 * StateModel provides three observable properties:
 * - targetState
 *   This is a property representing the user desired state and it gets updated
 *   to the real module state when required. Binding this property to the UI
 *   should be sufficient for most of the use cases.
 * - currentState
 *   This is a readonly property reporting the latest stable state.
 * - transitioning
 *   This is a readonly property showing if it is in transition between states.
 *
 * @example
 *   var stateModel = StateModel({
 *     _state: null,
 *     onInit: function(stateChange) {
 *       return this._state;
 *     },
 *     onSetState: function(state) {
 *       // A fake time consuming operation
 *       return new Promise((resolve) => {
 *         setTimeout(() => {
 *           this._state = state;
 *           resolve(this._state);
 *         }, 10000);
 *       });
 *     },
 *     onGetState: function() {
 *       return Promise.resolve(this._state);
 *     }
 *   });
 *
 *   // Do the binding
 *   checkbox.addEventListener('change', function() {
 *     stateModel.targetState = checkbox.checked;
 *   });
 *   stateModel.observe('targetState', function(newState) {
 *     checkbox.checked = newState;
 *   });
 */
define(function(require) {
  'use strict';

  var Module = require('modules/base/module');
  var Observable = require('modules/mvvm/observable');

  /**
   * @class StateModel
   * @param {Function} onInit
   * @param {Function} onSetState
   * @param {Function} onGetState
   * @returns {StateModel}
   */
  var StateModel = Module.create(
    function StateModel({onInit, onGetState, onSetState}) {
      if (typeof onInit !== 'function' ||
          typeof onGetState !== 'function' ||
          typeof onSetState !== 'function') {
        this.throw('invalid arguments');
      }
      this.super(Observable).call(this);

      var argument = arguments[0];
      this._init(onInit.bind(argument), onGetState.bind(argument),
        onSetState.bind(argument));
  }).extend(Observable);

  /**
   * An observable property indicating the desired state.
   *
   * @access public
   * @memberOf StateModel.prototype
   * @type {Object}
   */
  Observable.defineObservableProperty(StateModel.prototype, 'targetState', {
    value: null
  });

  /**
   * An observable property indicating the current state.
   *
   * @access public
   * @readonly
   * @memberOf StateModel.prototype
   * @type {Object}
   */
  Observable.defineObservableProperty(StateModel.prototype, 'currentState', {
    readonly: true,
    value: null
  });

  /**
   * An observable property indicating if it is in transition.
   *
   * @access public
   * @readonly
   * @memberOf StateModel.prototype
   * @type {Boolean}
   */
  Observable.defineObservableProperty(StateModel.prototype, 'transitioning', {
    readonly: true,
    value: false
  });

  StateModel.prototype._init = function(onInit, onGetState, onSetState) {
    this._onInit = onInit;
    this._onGetState = onGetState;
    this._onSetState = onSetState;

    this.targetState = this._onInit((realState) => {
      if (!this._transitioning) {
        this._currentState = realState;
        this.targetState = realState;
      }
    });
    this._currentState = this.targetState;

    // Do the initialization
    this._onGetState().then((realState) => {
      this._currentState = realState;
      if (this.targetState !== realState) {
        // If the current target state is not the real state, do set again.
        this._setState(this.targetState);
      }

      this.observe('targetState', (newState) => {
        if (this._transitioning || newState === this._currentState) {
          return;
        }
        this._setState(newState);
      });
    }).catch((err) => {
      this.error('initialization error ' + err);
    });
  };

  StateModel.prototype._setState = function(state) {
    this.debug('set state: ' + state);
    this._transitioning = true;
    return this._onSetState(state).then((realState) => {
      this.debug('set state completed: ' + state +
        ', real state: ' + realState);
      this._currentState = realState;
      if (this.targetState !== realState) {
        // If the current target state is not the real state, do set again.
        this._setState(this.targetState);
      } else {
        this._transitioning = false;
      }
    }, (realState) => {
      this.debug('set state failed: ' + state + ', real state: ' + realState);
      this._currentState = realState;
      this.targetState = realState;
      this._transitioning = false;
    });
  };

  return StateModel;
});