angular-state-router
Version:
An AngularJS state-based router designed for flexibility and ease of use.
682 lines (567 loc) • 18.1 kB
JavaScript
;
var UrlDictionary = require('../utils/url-dictionary');
var Parameters = require('../utils/parameters');
module.exports = [function StateRouterProvider() {
// Provider
var _provider = this;
// Configuration, global options
var _configuration = {
historyLength: 5
};
// State definition library
var _stateLibrary = {};
var _stateCache = {};
// URL to state dictionary
var _urlDictionary = new UrlDictionary();
// Middleware layers
var _layerList = [];
/**
* Parse state notation name-params.
*
* Assume all parameter values are strings
*
* @param {String} nameParams A name-params string
* @return {Object} A name string and param Object
*/
var _parseName = function(nameParams) {
if(nameParams && nameParams.match(/^[a-zA-Z0-9_\.]*\(.*\)$/)) {
var npart = nameParams.substring(0, nameParams.indexOf('('));
var ppart = Parameters( nameParams.substring(nameParams.indexOf('(')+1, nameParams.lastIndexOf(')')) );
return {
name: npart,
params: ppart
};
} else {
return {
name: nameParams,
params: null
};
}
};
/**
* Add default values to a state
*
* @param {Object} data An Object
* @return {Object} An Object
*/
var _setStateDefaults = function(data) {
// Default values
data.inherit = (typeof data.inherit === 'undefined') ? true : data.inherit;
return data;
};
/**
* Validate state name
*
* @param {String} name A unique identifier for the state; using dot-notation
* @return {Boolean} True if name is valid, false if not
*/
var _validateStateName = function(name) {
name = name || '';
// TODO optimize with RegExp
var nameChain = name.split('.');
for(var i=0; i<nameChain.length; i++) {
if(!nameChain[i].match(/[a-zA-Z0-9_]+/)) {
return false;
}
}
return true;
};
/**
* Validate state query
*
* @param {String} query A query for the state; using dot-notation
* @return {Boolean} True if name is valid, false if not
*/
var _validateStateQuery = function(query) {
query = query || '';
// TODO optimize with RegExp
var nameChain = query.split('.');
for(var i=0; i<nameChain.length; i++) {
if(!nameChain[i].match(/(\*(\*)?|[a-zA-Z0-9_]+)/)) {
return false;
}
}
return true;
};
/**
* Compare two states, compares values.
*
* @return {Boolean} True if states are the same, false if states are different
*/
var _compareStates = function(a, b) {
a = a || {};
b = b || {};
return a.name === b.name && angular.equals(a.params, b.params);
};
/**
* Get a list of parent states
*
* @param {String} name A unique identifier for the state; using dot-notation
* @return {Array} An Array of parent states
*/
var _getNameChain = function(name) {
var nameList = name.split('.');
return nameList
.map(function(item, i, list) {
return list.slice(0, i+1).join('.');
})
.filter(function(item) {
return item !== null;
});
};
/**
* Internal method to crawl library heirarchy
*
* @param {String} name A unique identifier for the state; using state-notation
* @return {Object} A state data Object
*/
var _getState = function(name) {
name = name || '';
var state = null;
// Only use valid state queries
if(!_validateStateName(name)) {
return null;
// Use cache if exists
} else if(_stateCache[name]) {
return _stateCache[name];
}
var nameChain = _getNameChain(name);
var stateChain = nameChain
.map(function(name, i) {
var item = angular.copy(_stateLibrary[name]);
return item;
})
.filter(function(parent) {
return !!parent;
});
// Walk up checking inheritance
for(var i=stateChain.length-1; i>=0; i--) {
if(stateChain[i]) {
var nextState = stateChain[i];
state = angular.merge(nextState, state || {});
}
if(state && state.inherit === false) break;
}
// Store in cache
_stateCache[name] = state;
return state;
};
/**
* Internal method to store a state definition. Parameters should be included in data Object not state name.
*
* @param {String} name A unique identifier for the state; using state-notation
* @param {Object} data A state definition data Object
* @return {Object} A state data Object
*/
var _defineState = function(name, data) {
if(name === null || typeof name === 'undefined') {
throw new Error('Name cannot be null.');
// Only use valid state names
} else if(!_validateStateName(name)) {
throw new Error('Invalid state name.');
}
// Create state
var state = angular.copy(data);
// Use defaults
_setStateDefaults(state);
// Named state
state.name = name;
// Set definition
_stateLibrary[name] = state;
// Reset cache
_stateCache = {};
// URL mapping
if(state.url) {
_urlDictionary.add(state.url, state);
}
return data;
};
/**
* Set configuration data parameters for StateRouter
*
* Including parameters:
*
* - historyLength {Number} Defaults to 5
* - initialLocation {Object} An Object{name:String, params:Object} for initial state transition
*
* @param {Object} options A data Object
* @return {$stateProvider} Itself; chainable
*/
this.options = function(options) {
angular.extend(_configuration, options || {});
return _provider;
};
/**
* Set/get state
*
* @param {String} name A unique identifier for the state; using state-notation
* @param {Object} data A state definition data Object
* @return {$stateProvider} Itself; chainable
*/
this.state = function(name, state) {
// Get
if(!state) {
return _getState(name);
}
// Set
_defineState(name, state);
return _provider;
};
/**
* Set initialization parameters; deferred to $ready()
*
* @param {String} name A iniital state
* @param {Object} params A data object of params
* @return {$stateProvider} Itself; chainable
*/
this.init = function(name, params) {
_configuration.initialLocation = {
name: name,
params: params
};
return _provider;
};
/**
* Get instance
*/
this.$get = ['$rootScope', '$location', '$q', '$queueHandler', function StateRouterFactory($rootScope, $location, $q, $queueHandler) {
// State
var _current;
var _transitionQueue = [];
var _isReady = true;
var _options;
var _initalLocation;
var _history = [];
var _isInit = false;
/**
* Internal method to add history and correct length
*
* @param {Object} data An Object
*/
var _pushHistory = function(data) {
// Keep the last n states (e.g. - defaults 5)
var historyLength = _options.historyLength || 5;
if(data) {
_history.push(data);
}
// Update length
if(_history.length > historyLength) {
_history.splice(0, _history.length - historyLength);
}
};
/**
* Internal method to fulfill change state request. Parameters in `params` takes precedence over state-notation `name` expression.
*
* @param {String} name A unique identifier for the state; using state-notation including optional parameters
* @param {Object} params A data object of params
* @return {Promise} A promise fulfilled when state change occurs
*/
var _changeState = function(name, params) {
var deferred = $q.defer();
$rootScope.$evalAsync(function() {
params = params || {};
// Parse state-notation expression
var nameExpr = _parseName(name);
name = nameExpr.name;
params = angular.extend(nameExpr.params || {}, params);
// Special name notation
if(name === '.' && _current) {
name = _current.name;
}
var error = null;
var request = {
name: name,
params: params,
locals: {},
promise: deferred.promise
};
// Compile execution phases
var queue = $queueHandler.create().data(request);
var nextState = angular.copy(_getState(name));
var prevState = _current;
if(nextState) {
// Set locals
nextState.locals = request.locals;
// Set parameters
nextState.params = angular.extend(nextState.params || {}, params);
}
// Does not exist
if(nextState === null) {
queue.add(function(data, next) {
error = new Error('Requested state was not defined.');
error.code = 'notfound';
$rootScope.$broadcast('$stateChangeErrorNotFound', error, request);
next(error);
}, 200);
// State not changed
} else if(_compareStates(prevState, nextState)) {
queue.add(function(data, next) {
_current = nextState;
next();
}, 200);
// Valid state exists
} else {
// Process started
queue.add(function(data, next) {
$rootScope.$broadcast('$stateChangeBegin', request);
next();
}, 201);
// Make state change
queue.add(function(data, next) {
if(prevState) _pushHistory(prevState);
_current = nextState;
next();
}, 200);
// Add middleware
queue.add(_layerList);
// Process ended
queue.add(function(data, next) {
$rootScope.$broadcast('$stateChangeEnd', request);
next();
}, -200);
}
// Run
queue.execute(function(err) {
if(err) {
$rootScope.$broadcast('$stateChangeError', err, request);
deferred.reject(err);
} else {
deferred.resolve();
}
});
});
return deferred.promise;
};
/**
* Internal method to change to state and broadcast completion
*
* @param {String} name A unique identifier for the state; using state-notation including optional parameters
* @param {Object} params A data object of params
* @return {Promise} A promise fulfilled when state change occurs
*/
var _changeStateAndBroadcastComplete = function(name, params) {
return _changeState(name, params).then(function() {
$rootScope.$broadcast('$stateChangeComplete', null, _current);
}, function(err) {
$rootScope.$broadcast('$stateChangeComplete', err, _current);
});
};
/**
* Reloads the current state
*
* @return {Promise} A promise fulfilled when state change occurs
*/
var _reloadState = function() {
var deferred = $q.defer();
$rootScope.$evalAsync(function() {
var n = _current.name;
var p = angular.copy(_current.params);
if(!_current.params) {
_current.params = {};
}
_current.params.deprecated = true;
// Notify
$rootScope.$broadcast('$stateReload', null, _current);
_changeStateAndBroadcastComplete(n, p).then(function() {
deferred.resolve();
}, function(err) {
deferred.reject(err);
});
});
return deferred.promise;
};
// Instance
var _inst;
_inst = {
/**
* Get options
*
* @return {Object} A configured options
*/
options: function() {
// Hasn't been initialized
if(!_options) {
_options = angular.copy(_configuration);
}
return _options;
},
/**
* Set/get state. Reloads state if current state is affected by defined
* state (when redefining parent or current state)
*
* @param {String} name A unique identifier for the state; using state-notation
* @param {Object} data A state definition data Object
* @return {$state} Itself; chainable
*/
state: function(name, state) {
// Get
if(!state) {
return _getState(name);
}
// Set
_defineState(name, state);
return _inst;
},
/**
* Internal method to add middleware; called during state transition
*
* @param {Function} handler A callback, function(request, next)
* @param {Number} priority A number denoting priority
* @return {$state} Itself; chainable
*/
$use: function(handler, priority) {
if(typeof handler !== 'function') {
throw new Error('Middleware must be a function.');
}
if(typeof priority !== 'undefined') handler.priority = priority;
_layerList.push(handler);
return _inst;
},
/**
* Internal method to perform initialization
*
* @return {$state} Itself; chainable
*/
$ready: function() {
$rootScope.$evalAsync(function() {
if(!_isInit) {
_isInit = true;
// Configuration
if(!_options) {
_options = angular.copy(_configuration);
}
// Initial location
if(_options.hasOwnProperty('initialLocation')) {
_initalLocation = angular.copy(_options.initialLocation);
}
var readyDeferred = null;
// Initial location
if($location.url() !== '') {
readyDeferred = _inst.$location($location.url());
// Initialize with state
} else if(_initalLocation) {
readyDeferred = _changeStateAndBroadcastComplete(_initalLocation.name, _initalLocation.params);
}
$q.when(readyDeferred).then(function() {
$rootScope.$broadcast('$stateInit');
});
}
});
return _inst;
},
// Parse state notation name-params.
parse: _parseName,
/**
* Retrieve definition of states
*
* @return {Object} A hash of all defined states
*/
library: function() {
return _stateLibrary;
},
// Validation
validate: {
name: _validateStateName,
query: _validateStateQuery
},
/**
* Retrieve history
*
* @return {[type]} [description]
*/
history: function() {
return _history;
},
/**
* Request state transition, asynchronous operation
*
* @param {String} name A unique identifier for the state; using dot-notation
* @param {Object} [params] A parameters data object
* @return {Promise} A promise fulfilled when state change complete
*/
change: function(name, params) {
return _changeStateAndBroadcastComplete(name, params);
},
/**
* Reloads the current state
*
* @return {Promise} A promise fulfilled when state change occurs
*/
reload: _reloadState,
/**
* Internal method to change state based on $location.url(), asynchronous operation using internal methods, quiet fallback.
*
* @param {String} url A url matching defind states
* @param {Function} [callback] A callback, function(err)
* @return {$state} Itself; chainable
*/
$location: function(url) {
var data = _urlDictionary.lookup(url);
if(data) {
var state = data.ref;
if(state) {
// Parse params from url
return _changeStateAndBroadcastComplete(state.name, data.params);
}
} else if(!!url && url !== '') {
var error = new Error('Requested state was not defined.');
error.code = 'notfound';
$rootScope.$broadcast('$stateChangeErrorNotFound', error, {
url: url
});
}
return $q.reject(new Error('Unable to find location in library'));
},
/**
* Retrieve copy of current state
*
* @return {Object} A copy of current state
*/
current: function() {
return (!_current) ? null : angular.copy(_current);
},
/**
* Check query against current state
*
* @param {Mixed} query A string using state notation or a RegExp
* @param {Object} params A parameters data object
* @return {Boolean} A true if state is parent to current state
*/
active: function(query, params) {
query = query || '';
// No state
if(!_current) {
return false;
// Use RegExp matching
} else if(query instanceof RegExp) {
return !!_current.name.match(query);
// String; state dot-notation
} else if(typeof query === 'string') {
// Cast string to RegExp
if(query.match(/^\/.*\/$/)) {
var casted = query.substr(1, query.length-2);
return !!_current.name.match(new RegExp(casted));
// Transform to state notation
} else {
var transformed = query
.split('.')
.map(function(item) {
if(item === '*') {
return '[a-zA-Z0-9_]*';
} else if(item === '**') {
return '[a-zA-Z0-9_\\.]*';
} else {
return item;
}
})
.join('\\.');
return !!_current.name.match(new RegExp(transformed));
}
}
// Non-matching
return false;
}
};
return _inst;
}];
}];