Home Reference Source

src/routers/ArvaRouter.js

/**


 @author: Hans van den Akker (mysim1)
 @license NPOSL-3.0
 @copyright Bizboard, 2015

 */

import isEqual                      from 'lodash/isEqual';
import {Router}                     from '../core/Router.js';
import {provide}                    from '../utils/di/Decorators.js';
import Easing                       from 'famous/transitions/Easing.js';
import AnimationController          from 'famous-flex/AnimationController.js';

@provide(Router)
export class ArvaRouter extends Router {

    routes = {};
    history = [];
    decode = decodeURIComponent;
    defaultController = 'Home';
    defaultMethod = 'Index';

    constructor() {
        super();
        if (window == null) {
            return;
        }
        window.addEventListener('hashchange', this.run);

        if (window == null) {
            return;
        }

        this.routes = {};
        this.history = [];
        this.decode = decodeURIComponent;


        window.addEventListener('hashchange', this.run);
        this._setupNativeBackButtonListener();
    }

    /**
     * Sets the initial controller and method to be activated whenever the controllers are activated.
     * @param {Controller|Function|String} controller Default controller instance, controller constructor, or controller name to go to.
     * @param {String} method Default method to call in given controller.
     * @returns {void}
     */
    setDefault(controller, method = null) {

        this.defaultController = this._getControllerName(controller);

        if (method != null) {
            this.defaultMethod = method;
        }
    }

    /**
     * Sets the animation specs object for use by the famous-flex AnimationController.
     * @param {Object} specs Animation specs, keyed by target controller.
     * @returns {void}
     */
    setControllerSpecs(specs) {
        this.specs = specs;
    }

    /**
     * Triggers navigation to one of the controllers
     * @param {Controller|Function|String} controller The controller instance, controller constructor, or controller name to go to.
     * @param {String} method The method to call in given controller.
     * @param {Object} params Dictonary of key-value pairs containing named arguments (i.e. {id: 1, test: "yes"})
     * @returns {void}
     */
    go(controller, method, params = null) {

        let controllerName = this._getControllerName(controller);

        let routeRoot = controllerName
            .replace(this.defaultController, '')
            .replace('Controller', '');

        let hash = '#' + (routeRoot.length > 0 ? '/' + routeRoot : '') + ('/' + method);
        if (params !== null) {
            for (let i = 0; i < Object.keys(params).length; i++) {
                var key = Object.keys(params)[i];
                hash += i == 0 ? '?' : '&';
                hash += (key + '=' + params[key]);
            }
        }

        if (history.pushState) {
            history.pushState(null, null, hash);
        }

        this.run();
    }


    /**
     * Registers a single controller.
     * @param {String} route Route to trigger handler on.
     * @param {Object} handlers
     * @param {Function} handler.enter Method to call on entering a route.
     * @param {Function} handler.leave Method to call on when leaving a route.
     * @returns {void}
     */
    add(route, {enter, leave}, controller) {
        let pieces = route.split('/'),
            rules = this.routes;

        for (let i = 0; i < pieces.length; ++i) {
            let piece = pieces[i],
                name = piece[0] === ':' ? ':' : piece;

            rules = rules[name] || (rules[name] = {});

            if (name === ':') {
                rules['@name'] = piece.slice(1);
            }
        }

        rules['enter'] = enter;
        rules['leave'] = leave;
        rules['controller'] = controller;

    }

    /**
     * On a route change, calls the corresponding controller method with the given parameter values.
     * @returns {Boolean} Whether the current route was successfully ran.
     */
    run() {
        let url = window.location.hash.replace('#', '');

        if (url !== '') {
            url = url.replace('/?', '?');
            url[0] === '/' && (url = url.slice(1));
            url.slice(-1) === '/' && (url = url.slice(0, -1));
        }

        let rules = this.routes,
            querySplit = url.split('?'),
            pieces = querySplit[0].split('/'),
            values = [],
            keys = [],
            method = '';
        for (let piece in pieces) {
            if (pieces[piece].indexOf('=') > -1) {
                let splitted = pieces[piece].split('=');
                pieces[piece] = splitted[0];
                querySplit.push(pieces[piece] + '=' + splitted[1]);
            }
        }

        let rule = null;
        let controller;

        // if there is no controller reference, assume we have hit the default Controller
        if (pieces.length === 1 && pieces[0].length === 0) {
            pieces[0] = this.defaultController;
            pieces.push(this.defaultMethod);
        } else if (pieces.length === 1 && pieces[0].length > 0) {
            pieces.unshift(this.defaultController);
        }

        controller = pieces[0];

        // Parse the non-query portion of the URL...
        for (let i = 0; i < pieces.length && rules; ++i) {
            let piece = this.decode(pieces[i]);
            rule = rules[piece];

            if (!rule && (rule = rules[':'])) {
                method = piece;
            }

            rules = rules[piece];
        }

        (function parseQuery(q) {
            let query = q.split('&');

            for (let i = 0; i < query.length; ++i) {
                let nameValue = query[i].split('=');

                if (nameValue.length > 1) {
                    keys.push(nameValue[0]);
                    values.push(this.decode(nameValue[1]));
                }
            }
        }).call(this, querySplit.length > 1 ? querySplit[1] : '');

        if (rule && rule['enter']) {

            /* Push current route to the history stack for later use */
            let previousRoute = this.history.length ? this.history[this.history.length - 1] : undefined;
            let currentRoute = {
                url: url,
                controller: controller,
                controllerObject: rule['controller'],
                method: method,
                keys: keys,
                values: values
            };

            if(previousRoute){
                if(currentRoute.controllerObject !== previousRoute.controllerObject){
                    this.routes[previousRoute.controller][':']['leave'](currentRoute);
                }
            }
            currentRoute.spec = previousRoute ? this._getAnimationSpec(previousRoute, currentRoute) : (this._initialSpec || {});
            this._setHistory(currentRoute);

            this._executeRoute(rule, currentRoute);

            return true;
        } else {
            console.log('Controller doesn\'t exist!');
        }

        return false;
    }

    setInitialSpec(spec) {
        this._initialSpec = spec;
    }

    setBackButtonEnabled(enabled) {
        this._backButtonEnabled = enabled;
    }

    isBackButtonEnabled() {
        return this._backButtonEnabled;
    }

    goBackInHistory() {
        /* Default behaviour: go back in history in the arva router */
        let {history} = this;
        if (history.length > 1) {
            let {controller, method, keys, values} = history[history.length - 2];
            let inputObject = {};
            for (let i = 0; i < keys.length; i++) {
                inputObject[keys[i]] = values[i];
            }
            this.go(controller, method, inputObject);
        } else {
            this.go(this.defaultController, this.defaultMethod);
        }
    }

    _setupNativeBackButtonListener() {
        this._backButtonEnabled = true;
        document.addEventListener("backbutton", (e) => {
            if (!this._backButtonEnabled) {
                e.preventDefault();
            } else {
                this.goBackInHistory();
            }
        }, false);
    }


    /**
     * Executes the controller handler associated with a given route, passing the route as a parameter.
     * @param {Object} rule Rule handler to execute.
     * @param {Object} route Route object to pass as parameter.
     * @returns {void}
     * @private
     */
    _executeRoute(rule, route) {
        /* Make the controller active for current scope */
        if (rule['enter'](route)) {
            this.emit('routechange', route);
        }
    }

    /**
     * Checks if the current route is already present in the history stack, and if so removes all entries after
     * and including the first occurrence. It will then append the current route to the history stack.
     * @param {Object} currentRoute Route object containing url, controller, method, keys, and values.
     * @returns {void}
     * @private
     */
    _setHistory(currentRoute) {
        for (let i = 0; i < this.history.length; i++) {
            let previousRoute = this.history[i];
            if (currentRoute.controller === previousRoute.controller &&
                currentRoute.method === previousRoute.method &&
                isEqual(currentRoute.values, previousRoute.values)) {
                this.history.splice(i, this.history.length - i);
                break;
            }
        }

        this.history.push(currentRoute);
    }

    /**
     * CheckS whether a route is already present in the history stack.
     * @param {Object} currentRoute Route object containing url, controller, method, keys, and values.
     * @returns {Boolean} Whether the route has been visited previously.
     * @private
     */
    _hasVisited(currentRoute) {
        for (let i = 0; i < this.history.length; i++) {
            let previousRoute = this.history[i];
            if (currentRoute.controller === previousRoute.controller &&
                currentRoute.method === previousRoute.method &&
                isEqual(currentRoute.values, previousRoute.values)) {
                return true;
            }
        }

        return false;
    }

    /**
     * Returns the Famous-Flex animation spec for two given routes. Takes its spec inputs from the specs set in
     * router.setControllerSpecs(), which is called from the app constructor.
     * @param {Object} previousRoute Previous route object containing url, controller, method, keys, and values.
     * @param {Object} currentRoute Current route object containing url, controller, method, keys, and values.
     * @returns {Object} A spec object if one is found, or an empty object otherwise.
     * @private
     */
    _getAnimationSpec(previousRoute, currentRoute) {
        let fromController = previousRoute.controller;
        let toController = currentRoute.controller;

        if (fromController.indexOf('Controller') === -1) {
            fromController += 'Controller';
        }
        if (toController.indexOf('Controller') === -1) {
            toController += 'Controller';
        }

        /* We're on exactly the same page as before */
        if (currentRoute.controller === previousRoute.controller &&
            currentRoute.method === previousRoute.method &&
            isEqual(currentRoute.values, previousRoute.values)) {
            return {};
        }

        /* Same controller, different method or different parameters */
        if (currentRoute.controller === previousRoute.controller) {

            let direction = this._hasVisited(currentRoute) ? 'previous' : 'next';
            if (this.specs && this.specs[fromController] && this.specs[fromController].methods) {
                return this.specs[fromController].methods[direction];
            }

            /* Default method-to-method animations, used only if not overridden in app's controllers spec. */
            let defaults = {
                'previous': {
                    transition: {duration: 400, curve: Easing.outBack},
                    animation: AnimationController.Animation.Slide.Right
                },
                'next': {
                    transition: {duration: 400, curve: Easing.outBack},
                    animation: AnimationController.Animation.Slide.Left
                }
            };
            return defaults[direction];
        }

        /* Different controller */
        if (this.specs && this.specs.hasOwnProperty(toController) && this.specs[toController].controllers) {
            let controllerSpecs = this.specs[toController].controllers;
            for (let specIndex in controllerSpecs) {
                let spec = controllerSpecs[specIndex];
                if (spec.activeFrom && spec.activeFrom.indexOf(fromController) !== -1) {
                    return spec;
                }
            }
        }

        console.log('No spec defined from ' + fromController + ' to ' + toController + '. Please check router.setControllerSpecs() in your app constructor.');
    }

    /**
     * Extracts a controller name from a given string, constructor, or controller instance. 'Controller' part is not included in the returned name.
     * E.g. _getControllerName(HomeController) -> 'Home'.
     * @param {Function|Object|String} controller String, constructor, or controller instance.
     * @returns {String} Name of the controller
     * @private
     */
    _getControllerName(controller) {
        if (typeof controller === 'string') {
            return controller.replace('Controller', '');
        } else if (typeof controller === 'function' && Object.getPrototypeOf(controller).constructor.name == 'Function') {
            /* The _name property is set by babel-plugin-transform-runtime-constructor-name.
             * This is done so Controller class names remain available in minimised code. */
            let controllerName = controller._name || controller.name;
            return controllerName.replace('Controller', '');
        } else {
            return typeof controller === 'object' ?
                Object.getPrototypeOf(controller).constructor.name.replace('Controller', '') : typeof controller;
        }
    }

}