statemachine-adapter.js 9.72 KB
/*
 * ADOBE CONFIDENTIAL
 *
 * Copyright 2016 Adobe Systems Incorporated
 * All Rights Reserved.
 *
 * NOTICE:  All information contained herein is, and remains
 * the property of Adobe Systems Incorporated and its suppliers,
 * if any.  The intellectual and technical concepts contained
 * herein are proprietary to Adobe Systems Incorporated and its
 * suppliers and may be covered by U.S. and Foreign Patents,
 * patents in process, and are protected by trade secret or copyright law.
 * Dissemination of this information or reproduction of this material
 * is strictly forbidden unless prior written permission is obtained
 * from Adobe Systems Incorporated.
 */
/* globals Promise */
define('screens/player/store/statemachine-adapter', [
    'underscore',
    'screens/player/shared/serviceadmin',
    'screens/player/store/store'
], function(_, ServiceAdmin, Store) {
    'use strict';

    /**
     * @memberof StateMachineAdapter
     */
    var EVENTS = Object.freeze({
        CHANGE: 'statemachine-change',

        CLEAR: 'statemachine-clear'
    });

    /**
     * @enum {StateMachineAdapter.LISTENER}
     * @memberof StateMachineAdapter
     * @readonly
     */
     var LISTENER = Object.freeze({

        /**
         * entered the state
         * @memberof StateMachineAdapter.LISTENER
         */
        ENTER: '@enter',

        /**
         * exited the state
         * @memberof StateMachineAdapter.LISTENER
         */
        EXIT: '@exit'
    });

    /**
     * Creates a new object based on a given dot-notated path
     * @param {string} path in dot-notation
     * @param {*} value the value to be set on the lowest level
     * @private
     * @returns {Object} a nested object with the set value
     */
    function createNestedObject(path, value) {
        return path.split('.').reverse().reduce(function(tree, prop) {
            var obj = {};
            obj[prop] = tree;
            return obj;
        }, value);
    }

    /**
     * Statemachine factory to support a valid transition flow
     * for states in the store
     *
     * @param {string} path full path in the state of the store
     * @param {string} initialState the initial state which will be set during activation
     * @param {Object} config state flow description and listeners
     * @constructor
     */
    var StateMachineAdapter = function(path, initialState, config) {
        this._config = config;

        var pathParts = path.split('.');
        this._namespace = pathParts[0];
        pathParts.shift();
        this._path = pathParts.join('.');
        this._initialState = initialState;
        this._throttledTransitions = {};

        if (!this._path.match(/^[\w.]+$/)) {
            throw new Error('Invalid path');
        }

        function fetchFromObject(obj, prop) {
            return prop.split('.').reduce(function(object, property) {
                return object ? object[property] : null;
            }, obj);
        }

        this._getStateValue = function(obj) {
            try {
                return fetchFromObject(obj, this._path);
            }
            catch (ex) {
                return null;
            }
        };

        this._isInitialized = false;
    };

    /**
     * Listener options for state transitions
     * @type {Object}
     */
    StateMachineAdapter.LISTENER = LISTENER;

    /**
     * Dispatching events for the store
     * @type {Object}
     */
    StateMachineAdapter.EVENTS = EVENTS;

    /**
     * Initializes the configured state machine
     * @returns {Promise} a promise that the state machine was initialized
     */
    StateMachineAdapter.prototype.initialize = function() {
        var self = this;

        if (this._isInitialized) {
            return Promise.reject('StateMachineAdapter already activated');
        }

        this._isInitialized = true;

        return new Promise(function(resolve, reject) {
            ServiceAdmin.onServiceHighestRankedStart(Store.serviceName, function(store) {
                // add reducer
                store.addReducer(self.reducer.bind(self), self._namespace);

                // subscribe to changes
                self._stateChangeListener = store.subscribe(self._onStateChange.bind(self), self._namespace);

                // propagate initial state
                self.transition();
                resolve();
            });
        });
    };

    /**
     * Tears down the state machine
     * @returns {Promise} a promise that the state machine was destroyed
     */
    StateMachineAdapter.prototype.destroy = function() {
        var self = this;
        return new Promise(function(resolve, reject) {
            ServiceAdmin.onServiceHighestRankedStart(Store.serviceName, function(store) {
                store.dispatch({
                    type: EVENTS.CLEAR,
                    payload: {
                        namespace: self._namespace,
                        path: self._path
                    }
                });
                store.removeReducer(self.reducer);
                if (self._stateChangeListener) {
                    store.unsubscribe(self._stateChangeListener);
                    self._stateChangeListener = null;
                }
                self._throttledTransitions = {};
                self._isInitialized = false;
                resolve();
            });
        });
    };

    /**
     * Looks up the possible transition states for the current state
     * @param {string} currentState of the machine
     * @returns {Object} the possible options for further transitions
     */
    StateMachineAdapter.prototype.validNextStates = function(currentState) {
        var ret = _.assign({}, this._config.states[currentState]);
        delete ret[LISTENER.ENTER];
        delete ret[LISTENER.EXIT];
        return ret;
    };

    StateMachineAdapter.prototype._throttleTransition = function(nextState, interval) {
        if (!_.isFunction(this._throttledTransitions[nextState])) {
            this._throttledTransitions[nextState] = _.throttle(function() {
                this._throttledTransitions[nextState] = null;
                this.transition(nextState);
            }.bind(this), interval, {
                trailing: true,
                leading: false
            });
        }
        this._throttledTransitions[nextState]();
    };

    /**
     * Requests a transition to a state
     * @param {string} nextState the desired state
     * @param {Number?} interval specifies the value by what we throttle
     */
    StateMachineAdapter.prototype.transition = function(nextState, interval) {

        if (_.isNumber(interval)) {
            this._throttleTransition(nextState, interval);
            return;
        }

        ServiceAdmin.getService(Store.serviceName).dispatch({
            type: EVENTS.CHANGE,
            payload: {
                transitionTo: nextState,
                namespace: this._namespace,
                path: this._path
            }
        });
    };

    /**
     * Reducer to handle transition changes
     * @param {Object} state of the store
     * @param {string} action the dispatched action
     * @returns {object} state
     */
    StateMachineAdapter.prototype.reducer = function(state, action) {
        var data = action.payload;

        if (action.type === EVENTS.CHANGE &&
            data.namespace === this._namespace &&
            data.path === this._path) {

            var nextVal;
            if (action.payload.transitionTo) {
                var curVal = this._getStateValue(state);
                if (this._config.states[curVal]) {
                    nextVal = this._config.states[curVal][action.payload.transitionTo];
                    nextVal = _.isString(nextVal) ? nextVal : nextVal && action.payload.transitionTo;
                }

                // @todo this ignores the state machine flow
                // Due to refactoring this will remain until all modules
                // properly describe the flow
                if (!nextVal) {
                    nextVal = this._config.states[action.payload.transitionTo] && action.payload.transitionTo;
                }
            } else { // if no state given then use the initial state
                nextVal = this._initialState;
            }

            if (nextVal) { // TODO: check that this is a valid state to transition to according to the config
                var newValues = createNestedObject(this._path, nextVal);
                return _.assign({}, state, newValues);
            }
        }
        else if (action.type === EVENTS.CLEAR &&
            data.namespace === this._namespace &&
            data.path === this._path) {

            return null;
        }

        return state || {};
    };

    /**
     * Handler for state changes which triggers the state transition callbacks
     * @param {Object} state of the store
     * @param {Object} previousState of the store
     * @private
     */
    StateMachineAdapter.prototype._onStateChange = function(state, previousState) {
        var prevVal = this._getStateValue(previousState);
        var curVal = this._getStateValue(state);

        if (this._config.states[prevVal]) {
            var exitListener = this._config.states[prevVal][LISTENER.EXIT];

            if (_.isFunction(exitListener)) {
                exitListener(curVal);
            }
        }

        if (this._config.states[prevVal] && _.isFunction(this._config.states[prevVal][curVal])) {
            var listener = this._config.states[prevVal][curVal];

            if (_.isFunction(listener)) {
                listener(prevVal);
            }
        }
        else if (this._config.states[curVal]) {
            var enterListener = this._config.states[curVal][LISTENER.ENTER];

            if (_.isFunction(enterListener)) {
                enterListener(prevVal);
            }
        }
    };

    return StateMachineAdapter;

});