StructureJS

0.15.2

A class based utility library for building modular and scalable web platform applications. Features opt-in classes and utilities which provide a solid foundation and toolset to build your next project.

File: ts/service/Router.ts

import StringUtil from '../util/StringUtil';
import RouterEvent from '../event/RouterEvent';
import Route from '../model/Route';

/**
 * The **Router** class is a static class allows you to add different route patterns that can be matched to help control your application. Look at the Router.{{#crossLink "Router/add:method"}}{{/crossLink}} method for more details and examples.
 *
 * @class Router
 * @module StructureJS
 * @submodule controller
 * @requires Route
 * @requires RouterEvent
 * @requires StringUtil
 * @static
 * @author Robert S. (www.codeBelt.com)
 */
class Router
{
    /**
     * A reference to the browser Window Object.
     *
     * @property _window
     * @type {Window}
     * @private
     * @static
     */
    private static _window:any = window;

    /**
     * A list of the added Route objects.
     *
     * @property _routes
     * @type {Array<Route>}
     * @private
     * @static
     */
    private static _routes:Array<Route> = [];

    /**
     * TODO: YUIDoc_comment
     *
     * @property _validators
     * @type {Array<Function>}
     * @private
     * @static
     */
    private static _validators:Array<Function> = [];

    /**
     * TODO: YUIDoc_comment
     *
     * @property _validatorFunc
     * @type {Function}
     * @private
     * @static
     */
    private static _validatorFunc:Function = null;

    /**
     * A reference to default route object.
     *
     * @property _defaultRoute
     * @type {Route}
     * @private
     * @static
     */
    private static _defaultRoute:Route = null;

    /**
     * A reference to the hash change event that was sent from the Window Object.
     *
     * @property _hashChangeEvent
     * @type {any}
     * @private
     * @static
     */
    private static _hashChangeEvent:any = null;

    /**
     * A reference to the current {{#crossLink "RouterEvent"}}{{/crossLink}} that was triggered.
     *
     * @property _currentRoute
     * @type {RouterEvent}
     * @private
     * @static
     */
    private static _currentRoute:RouterEvent = null;

    /**
     * A reference to the last state object this {{#crossLink "Router"}}{{/crossLink}} creates when this
     * using the HTML5 History API.
     *
     * @property _lastHistoryState
     * @type {RouterEvent}
     * @private
     * @static
     */
    private static _lastHistoryState:{ route:string } = null;

    /**
     * Determines if the {{#crossLink "Router"}}{{/crossLink}} should use hash or history routing.
     *
     * @property forceHashRouting
     * @type {boolean}
     * @default false
     * @public
     * @static
     * @example
     *     Router.forceHashRouting = true;
     */
    public static forceHashRouting:boolean = false;

    /**
     * Determines if the Router class is enabled or disabled.
     *
     * @property isEnabled
     * @type {boolean}
     * @readOnly
     * @public
     * @static
     * @example
     *     // Read only.
     *     console.log(Router.isEnabled);
     */
    public static isEnabled:boolean = false;

    /**
     * The **Router.useDeepLinking** property tells the Router class weather it should change the hash url or not.
     * By **default** this property is set to **true**. If you set the property to **false** and using the **Router.navigateTo**
     * method the hash url will not change. This can be useful if you are making an application or game and you don't want the user
     * to know how to jump to other sections directly. See the **Router.{{#crossLink "Router/allowManualDeepLinking:property"}}{{/crossLink}}** to fully change the Router class
     * from relying on the hash url to an internal state service.
     *
     * @property useDeepLinking
     * @type {boolean}
     * @default true
     * @public
     * @static
     * @example
     *     Router.useDeepLinking = true;
     */
    public static useDeepLinking:boolean = true;

    /**
     * The **Router.allowManualDeepLinking** property tells the Router class weather it should check for route matches if the
     * hash url changes in the browser. This property only works if the **Router. {{#crossLink "Router/useDeepLinking:property"}}{{/crossLink}}** is set to **false**.
     * This is useful if want to use your added routes but don't want any external forces trigger your routes.
     *
     * Typically what I do for games is during development/testing I allow the hash url to change the states so testers can jump
     * to sections or levels easily but then when it is ready for production I set the property to **false** so users cannot jump
     * around if they figure out the url schema.
     *
     * @property allowManualDeepLinking
     * @type {boolean}
     * @default true
     * @public
     * @static
     * @example
     *     Router.useDeepLinking = false;
     *     Router.allowManualDeepLinking = false;
     */
    public static allowManualDeepLinking:boolean = true;

    /**
     * The **Router.forceSlash** property tells the Router class if the **Router.{{#crossLink "Router/navigateTo:method"}}{{/crossLink}}** method is called to
     * make sure the hash url has a forward slash after the **#** character like this **#/**.
     *
     * @property forceSlash
     * @type {boolean}
     * @default false
     * @public
     * @static
     * @example
     *     // To turn on forcing the forward slash
     *     Router.forceSlash = true;
     *
     *     // If forceSlash is set to true it will change the url from #contact/bob/ to #/contact/bob/
     *     // when using the navigateTo method.
     */
    public static forceSlash:boolean = false;

    /**
     * The **Router.allowMultipleMatches** property tells the Router class if it should trigger one or all routes that match a route pattern.
     *
     * @property allowMultipleMatches
     * @type {boolean}
     * @default true
     * @public
     * @static
     * @example
     *     // Only allow the first route matched to be triggered.
     *     Router.allowMultipleMatches = false;
     */
    public static allowMultipleMatches:boolean = true;

    constructor()
    {
        throw new Error('[Router] Do not instantiate the Router class because it is a static class.');
    }

    /**
     * The **Router.add** method allows you to listen for route patterns to be matched. When a match is found the callback will be executed passing a {{#crossLink "RouterEvent"}}{{/crossLink}}.
     *
     * @method add
     * @param routePattern {string} The string pattern you want to have match, which can be any of the following combinations {}, ::, *, ?, ''. See the examples below for more details.
     * @param callback {Function} The function that should be executed when a request matches the routePattern. It will receive a {{#crossLink "RouterEvent"}}{{/crossLink}} object.
     * @param callbackScope {any} The scope of the callback function that should be executed.
     * @public
     * @static
     * @example
     *     // Example of adding a route listener and the function callback below.
     *     Router.add('/games/{gameName}/:level:/', this._method, this);
     *
     *     // The above route listener would match the below url:
     *     // www.site.com/#/games/asteroids/2/
     *
     *     // The Call back receives a RouterEvent object.
     *     _onRouteHandler(routerEvent) {
     *         console.log(routerEvent.params);
     *     }
     *
     * Route Pattern Options:
     * ----------------------
     * **:optional:** The two colons **::** means a part of the hash url is optional for the match. The text between can be anything you want it to be.
     *
     *     Router.add('/contact/:name:/', this._method, this);
     *
     *     // Will match one of the following:
     *     // www.site.com/#/contact/
     *     // www.site.com/#/contact/heather/
     *     // www.site.com/#/contact/john/
     *
     *
     * **{required}** The two curly brackets **{}** means a part of the hash url is required for the match. The text between can be anything you want it to be.
     *
     *     Router.add('/product/{productName}/', this._method, this);
     *
     *     // Will match one of the following:
     *     // www.site.com/#/product/shoes/
     *     // www.site.com/#/product/jackets/
     *
     *
     * **\*** The asterisk character means it will match all or part of part the hash url.
     *
     *     Router.add('*', this._method, this);
     *
     *     // Will match one of the following:
     *     // www.site.com/#/anything/
     *     // www.site.com/#/matches/any/hash/url/
     *     // www.site.com/#/really/it/matches/any/and/all/hash/urls/
     *
     *
     * **?** The question mark character means it will match a query string for the hash url.
     *
     *     Router.add('?', this._method, this);
     *
     *     // Will match one of the following:
     *     // www.site.com/#/?one=1&two=2&three=3
     *     // www.site.com/#?one=1&two=2&three=3
     *
     *
     * **''** The empty string means it will match when there are no hash url.
     *
     *     Router.add('', this._method, this);
     *     Router.add('/', this._method, this);
     *
     *     // Will match one of the following:
     *     // www.site.com/
     *     // www.site.com/#/
     *
     *
     * Other possible combinations but not limited too:
     *
     *     Router.add('/games/{gameName}/:level:/', this._method1, this);
     *     Router.add('/{category}/blog/', this._method2, this);
     *     Router.add('/home/?', this._method3, this);
     *     Router.add('/about/*', this._method4, this);
     *
     */
    public static add(routePattern:string, callback:Function, callbackScope:any):void
    {
        Router.enable();

        const route:Route = new Route(routePattern, callback, callbackScope);

        Router._routes.push(route);
    }

    /**
     * The **Router.remove** method will remove one of the added routes.
     *
     * @method remove
     * @param routePattern {string} Must be the same string pattern you pasted into the {{#crossLink "Router/add:method"}}{{/crossLink}} method.
     * @param callback {Function} Must be the same function you pasted into the {{#crossLink "Router/add:method"}}{{/crossLink}} method.
     * @param callbackScope {any} Must be the same scope off the callback pattern you pasted into the {{#crossLink "Router/add:method"}}{{/crossLink}} method.
     * @public
     * @static
     * @example
     *     // Example of adding a route listener.
     *     Router.add('/games/{gameName}/:level:/', this._method, this);
     *
     *     // Example of removing the same added route listener above.
     *     Router.remove('/games/{gameName}/:level:/', this._method, this);
     */
    public static remove(routePattern:string, callback:Function, callbackScope:any):void
    {
        let route:Route;
        // Since we are removing (splice) from routes we need to check the length every iteration.
        for (let i = Router._routes.length - 1; i >= 0; i--)
        {
            route = Router._routes[i];
            if (route.routePattern === routePattern && route.callback === callback && route.callbackScope === callbackScope)
            {
                Router._routes.splice(i, 1);
            }
        }
    }

    /**
     * The **Router.addDefault** method is meant to trigger a callback function if there are no route matches are found.
     *
     * @method addDefault
     * @param callback {Function}
     * @param callbackScope {any}
     * @public
     * @static
     * @example
     *     Router.addDefault(this._noRoutesFoundHandler, this);
     */
    public static addDefault(callback:Function, callbackScope:any):void
    {
        Router._defaultRoute = new Route('', callback, callbackScope);
    }

    /**
     * The **Router.removeDefault** method will remove the default callback that was added by the **Router.addDefault** method.
     *
     * @method removeDefault
     * @public
     * @static
     * @example
     *     Router.removeDefault();
     */
    public static removeDefault():void
    {
        Router._defaultRoute = null;
    }

    /**
     * Gets the current hash url minus the # or #! symbol(s).
     *
     * @method getHash
     * @public
     * @static
     * @return {string} Returns current hash url minus the # or #! symbol(s).
     * @example
     *     let str = Router.getHash();
     */
    public static getHash():string
    {
        const hash:string = Router._window.location.hash;
        const strIndex:number = (hash.substr(0, 2) === '#!') ? 2 : 1;

        return hash.substring(strIndex); // Return everything after # or #!
    }

    /**
     * The **Router.enable** method will allow the Router class to listen for the hashchange event. By default the Router class is enabled.
     *
     * @method enable
     * @public
     * @static
     * @example
     *     Router.enable();
     */
    public static enable():void
    {
        if (Router.isEnabled === true)
        {
            return;
        }

        if (Router._window.addEventListener)
        {
            Router._window.addEventListener('hashchange', Router._onHashChange, false);
            Router._window.addEventListener('popstate', Router._onHistoryChange, false);
        }
        else
        {
            Router._window.attachEvent('onhashchange', Router._onHashChange);
            Router._window.attachEvent('onpopstate', Router._onHistoryChange);
        }

        Router.isEnabled = true;
    }

    /**
     * The **Router.disable** method will stop the Router class from listening for the hashchange event.
     *
     * @method disable
     * @public
     * @static
     * @example
     *     Router.disable();
     */
    public static disable():void
    {
        if (Router.isEnabled === false)
        {
            return;
        }

        if (Router._window.removeEventListener)
        {
            Router._window.removeEventListener('hashchange', Router._onHashChange);
            Router._window.removeEventListener('popstate', Router._onHistoryChange);
        }
        else
        {
            Router._window.detachEvent('onhashchange', Router._onHashChange);
            Router._window.detachEvent('onpopstate', Router._onHistoryChange);
        }

        Router.isEnabled = false;
    }

    /**
     * The **Router.start** method is meant to trigger or check the hash url on page load.
     * Either you can call this method after you add all your routers or after all data is loaded.
     * It is recommend you only call this once per page or application instantiation.
     *
     * @method start
     * @public
     * @static
     * @example
     *     // Example of adding routes and calling the start method.
     *     Router.add('/games/{gameName}/:level:/', this._method1, this);
     *     Router.add('/{category}/blog/', this._method2, this);
     *
     *     Router.start();
     */
    public static start():void
    {
        Router.forceHashRouting = (window.history && window.history.pushState) ? Router.forceHashRouting : true;

        if (Router.forceHashRouting === true) {
            setTimeout(Router._onHashChange, 1);
        } else {
            setTimeout(Router._onHistoryChange, 1);
        }
    }

    /**
     * The **Router.navigateTo** method allows you to change the hash url and to trigger a route
     * that matches the string value. The second parameter is **silent** and is **false** by
     * default. This allows you to update the hash url without causing a route callback to be
     * executed.
     *
     * @method navigateTo
     * @param route {String}
     * @param [silent=false] {Boolean}
     * @param [disableHistory=false] {Boolean}
     * @public
     * @static
     * @example
     *     // This will update the hash url and trigger the matching route.
     *     Router.navigateTo('/games/asteroids/2/');
     *
     *     // This will update the hash url but will not trigger the matching route.
     *     Router.navigateTo('/games/asteroids/2/', true);
     *
     *     // This will not update the hash url but will trigger the matching route.
     *     Router.navigateTo('/games/asteroids/2/', true, true);
     */
    public static navigateTo(route, silent:boolean = false, disableHistory:boolean = false):void
    {
        if (Router.isEnabled === false)
        {
            return;
        }

        if (route.charAt(0) === '#')
        {
            const strIndex = (route.substr(0, 2) === '#!') ? 2 : 1;
            route = route.substring(strIndex);
        }

        if (Router.forceHashRouting === true)
        {
            // Enforce starting slash
            if (route.charAt(0) !== '/' && Router.forceSlash === true)
            {
                route = '/' + route;
            }

            if (disableHistory === true)
            {
                Router._changeRoute(route);
                return;
            }

            if (Router.useDeepLinking === true)
            {
                if (silent === true)
                {
                    Router.disable();
                    setTimeout(function ()
                    {
                        window.location.hash = route;
                        setTimeout(Router.enable, 1);
                    }, 1);
                }
                else
                {
                    setTimeout(function ()
                    {
                        window.location.hash = route;
                    }, 1);
                }
            }
            else
            {
                Router._changeRoute(route);
            }
        }
        else
        {
            Router._lastHistoryState = window.history.state;

            if (Router.useDeepLinking === true)
            {
                window.history.pushState({ route: route }, null, route);
            }

            Router._changeRoute(route);
        }
    }

    /**
     * The **Router.clear** will remove all route's and the default route from the Router class.
     *
     * @method clear
     * @public
     * @static
     * @example
     *     Router.clear();
     */
    public clear():void
    {
        Router._routes = [];
        Router._defaultRoute = null;
        Router._hashChangeEvent = null;
    }

    /**
     * The **Router.destroy** method will null out all references to other objects in the Router class.
     *
     * @method destroy
     * @public
     * @static
     * @example
     *     Router.destroy();
     */
    public destroy():void
    {
        Router._window = null;
        Router._routes = null;
        Router._defaultRoute = null;
        Router._hashChangeEvent = null;
    }

    /**
     * A simple helper method to create a url route from an unlimited number of arguments.
     *
     * @method buildRoute
     * @param ...rest {...rest}
     * @return {string}
     * @public
     * @static
     * @example
     *      const someProperty = 'api/endpoint';
     *      const queryObject = {type: 'car', name: encodeURIComponent('Telsa Motors')};
     *
     *      Router.buildRoute(someProperty, 'path', 7, queryObject);
     *
     *      //Creates 'api/endpoint/path/7?type=car&name=Telsa%20Motors'
     */
    public static buildRoute(...rest):string
    {
        let clone = rest.slice(0);

        clone.forEach((value, index) => {
            if (typeof value === 'object')
            {
                clone[index] = `?${StringUtil.toQueryString(value)}`;
            }
        });

        // Remove any empty strings from the array
        clone = clone.filter(Boolean);

        let route = clone.join('/');
        // Remove extra back slashes
        route = route.replace(/\/\//g, '/');
        // Add back slash since we remove it from the "http://"
        route = route.replace(':/', '://');
        // Remove the back slash in front of a question mark
        route = route.replace('/?', '?');

        return route
    }

    /**
     * Returns the current router event that was last triggered.
     *
     * @method getCurrentRoute
     * @public
     * @static
     * @example
     *      Router.getCurrentRoute();
     */
    public static getCurrentRoute():RouterEvent
    {
        return this._currentRoute;
    }

    /**
     * TODO: YUIDoc_comment
     *
     * @method validate
     * @param func {Function} The function you wanted called if the validation failed.
     * @public
     * @static
     * @example
     *         Router.validate((routerEvent, next) => {
     *              const allowRouteChange = this._someMethodCheck();
     *
     *              if (allowRouteChange == false) {
     *                  next(() => {
     *                      // Do something here.
     *                      // For example you can call Router.navigateTo to change the route.
     *                  });
     *              } else {
     *                  next();
     *              }
     *         });
     */
    public static validate(func:Function):void
    {
        Router._validators.push(func);
    }

    /**
     * This method will be called if the Window object dispatches a HashChangeEvent.
     * This method will not be called if the Router is disabled.
     *
     * @method _onHashChange
     * @param event {HashChangeEvent}
     * @private
     * @static
     */
    private static _onHashChange(event):void
    {
        if (Router.allowManualDeepLinking !== false && Router.useDeepLinking !== false)
        {
            Router._hashChangeEvent = event;
            var hash = Router.getHash();
            Router._changeRoute(hash);
        }
        else
        {
            Router._changeRoute('');
        }
    }

    /**
     * This method will be called if the Window object dispatches a popstate event.
     * This method will not be called if the Router is disabled.
     *
     * @method _onHistoryChange
     * @param event {HashChangeEvent}
     * @private
     * @static
     */
    private static _onHistoryChange(event)
    {
        if (Router.forceHashRouting === true)
        {
            return;
        }

        if (Router.allowManualDeepLinking !== false && Router.useDeepLinking !== false)
        {
            if (event != null)
            {
                const state:any = event.state;
                Router._changeRoute(state.route);
            }
            else
            {
                const route = location.pathname + location.search + location.hash;

                if (Router.useDeepLinking === true)
                {
                    window.history.replaceState({ route: route }, null, null);
                }

                Router._changeRoute(route);
            }
        }
        else
        {
            Router._changeRoute('');
        }
    }

    /**
     * The method is responsible for check if one of the routes matches the string value passed in.
     *
     * @method _changeRoute
     * @param hash {string}
     * @private
     * @static
     */
    private static _changeRoute(hash:string):void
    {
        let route:Route;
        let match:any;
        let routerEvent:RouterEvent = null;

        // Loop through all the route's. Note: we need to check the length every loop in case one was removed.
        for (let i = 0; i < Router._routes.length; i++)
        {
            route = Router._routes[i];
            match = route.match(hash);

            // If there is a match.
            if (match !== null)
            {
                routerEvent = new RouterEvent();
                routerEvent.route = match.shift();
                routerEvent.params = match.slice(0, match.length);
                routerEvent.routePattern = route.routePattern;
                routerEvent.query = (hash.indexOf('?') > -1) ? StringUtil.queryStringToObject(hash) : null;
                routerEvent.target = Router;
                routerEvent.currentTarget = Router;

                // Remove any empty strings in the array due to the :optional: route pattern.
                // Since we are removing (splice) from params we need to check the length every iteration.
                for (let j = routerEvent.params.length - 1; j >= 0; j--)
                {
                    if (routerEvent.params[j] === '')
                    {
                        routerEvent.params.splice(j, 1);
                    }
                }

                // If there was a hash change event then set the info we want to send.
                if (Router._hashChangeEvent != null)
                {
                    routerEvent.newURL = Router._hashChangeEvent.newURL;
                    routerEvent.oldURL = Router._hashChangeEvent.oldURL;
                }
                else if (window.history && window.history.state)
                {
                    routerEvent.newURL = hash;
                    routerEvent.oldURL = (Router._lastHistoryState === null) ? null : Router._lastHistoryState.route;

                    Router._lastHistoryState = { route: routerEvent.newURL }
                }
                else
                {
                    routerEvent.newURL = window.location.href;
                }

                const allowRouteChange:boolean = Router._allowRouteChange(routerEvent);

                if (allowRouteChange === true)
                {
                    Router._currentRoute = routerEvent;

                    // Execute the callback function and pass the route event.
                    route.callback.call(route.callbackScope, routerEvent);

                    // Only trigger the first route and stop checking.
                    if (Router.allowMultipleMatches === false)
                    {
                        break;
                    }
                }
                else
                {
                    break;
                }
            }
        }

        // If there are no route's matched and there is a default route. Then call that default route.
        if (routerEvent === null)
        {
            routerEvent = new RouterEvent();
            routerEvent.route = hash;
            routerEvent.query = (hash.indexOf('?') > -1) ? StringUtil.queryStringToObject(hash) : null;
            routerEvent.target = Router;
            routerEvent.currentTarget = Router;

            if (Router._hashChangeEvent != null)
            {
                routerEvent.newURL = Router._hashChangeEvent.newURL;
                routerEvent.oldURL = Router._hashChangeEvent.oldURL;
            }
            else
            {
                routerEvent.newURL = window.location.href;
            }

            const allowRouteChange:boolean = Router._allowRouteChange(routerEvent);

            if (allowRouteChange === true)
            {
                Router._currentRoute = routerEvent;

                if (Router._defaultRoute !== null)
                {
                    Router._defaultRoute.callback.call(Router._defaultRoute.callbackScope, routerEvent);
                }
            }
        }

        Router._hashChangeEvent = null;
        if (Router._validatorFunc != null)
        {
            Router._validatorFunc();
        }
    }

    /**
     * TODO: YUIDoc_comment
     *
     * @method _allowRouteChange
     * @private
     * @static
     */
    private static _allowRouteChange(routerEvent:RouterEvent):boolean
    {
        Router._validatorFunc = null;

        for (let i:number = 0; i < Router._validators.length; i++)
        {
            const func:Function = Router._validators[i];

            if (Router._validatorFunc != null)
            {
                break;
            }

            const callback:Function = (back:Function = null) =>
            {
                Router._validatorFunc = back;
            };

            func(routerEvent, callback);
        }

        return Router._validatorFunc == null;
    }

}

export default Router;