StructureJS
0.15.2A 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.
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;