UNPKG

angular-state-router

Version:

An AngularJS state-based router designed for flexibility and ease of use.

682 lines (567 loc) 18.1 kB
'use strict'; 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; }]; }];