diff --git a/src/common.js b/src/common.js index 1dc0172ee..f3010ccd5 100644 --- a/src/common.js +++ b/src/common.js @@ -61,6 +61,20 @@ function objectKeys(object) { return result; } +/** + * like objectKeys, but includes keys from prototype chain. + * @param object the object whose prototypal keys will be returned + * @param ignoreKeys an array of keys to ignore + */ +function protoKeys(object, ignoreKeys) { + var result = []; + for (var key in object) { + if (!ignoreKeys || ignoreKeys.indexOf(key) === -1) + result.push(key); + } + return result; +} + /** * IE8-safe wrapper for `Array.prototype.indexOf()`. * diff --git a/src/state.js b/src/state.js index b5b56da9c..3257d3244 100644 --- a/src/state.js +++ b/src/state.js @@ -64,12 +64,19 @@ function $StateProvider( $urlRouterProvider, $urlMatcherFactory) { return state.url ? state : (state.parent ? state.parent.navigable : null); }, + // Own parameters for this state. state.url.params is already built at this point. Create and add non-url params + ownParams: function(state) { + var params = state.url && state.url.params || new $$UrlMatcherFactoryProvider.ParamSet(); + forEach(state.params || {}, function(config, id) { + if (!params[id]) params[id] = new $$UrlMatcherFactoryProvider.Param(id, null, config); + }); + return params; + }, + // Derive parameters for this state and ensure they're a super-set of parent's parameters params: function(state) { - if (!state.params) { - return state.url ? state.url.params : state.parent.params; - } - return state.params; + var parentParams = state.parent && state.parent.params || new $$UrlMatcherFactoryProvider.ParamSet(); + return inherit(parentParams, state.ownParams); }, // If there is no explicit multi-view configuration, make one up so we don't have @@ -87,28 +94,6 @@ function $StateProvider( $urlRouterProvider, $urlMatcherFactory) { return views; }, - ownParams: function(state) { - state.params = state.params || {}; - - if (!state.parent) { - return objectKeys(state.params); - } - var paramNames = {}; forEach(state.params, function (v, k) { paramNames[k] = true; }); - - forEach(state.parent.params, function (v, k) { - if (!paramNames[k]) { - throw new Error("Missing required parameter '" + k + "' in state '" + state.name + "'"); - } - paramNames[k] = false; - }); - var ownParams = []; - - forEach(paramNames, function (own, p) { - if (own) ownParams.push(p); - }); - return ownParams; - }, - // Keep a full path from the root down to this state as this is needed for state activation. path: function(state) { return state.parent ? state.parent.path.concat(state) : []; // exclude root from path @@ -801,7 +786,7 @@ function $StateProvider( $urlRouterProvider, $urlMatcherFactory) { var keep = 0, state = toPath[keep], locals = root.locals, toLocals = []; if (!options.reload) { - while (state && state === fromPath[keep] && equalForKeys(toParams, fromParams, state.ownParams)) { + while (state && state === fromPath[keep] && equalForKeys(toParams, fromParams, state.ownParams.$$keys())) { locals = toLocals[keep] = state.locals; keep++; state = toPath[keep]; @@ -820,7 +805,7 @@ function $StateProvider( $urlRouterProvider, $urlMatcherFactory) { } // Filter parameters before we pass them to event handlers etc. - toParams = filterByKeys(objectKeys(to.params), toParams || {}); + toParams = filterByKeys(to.params.$$keys(), toParams || {}); // Broadcast start event and cancel the transition if requested if (options.notify) { @@ -1133,7 +1118,7 @@ function $StateProvider( $urlRouterProvider, $urlMatcherFactory) { if (!nav || nav.url === undefined || nav.url === null) { return null; } - return $urlRouter.href(nav.url, filterByKeys(objectKeys(state.params), params || {}), { + return $urlRouter.href(nav.url, filterByKeys(state.params.$$keys(), params || {}), { absolute: options.absolute }); }; @@ -1162,7 +1147,7 @@ function $StateProvider( $urlRouterProvider, $urlMatcherFactory) { // necessary. In addition to being available to the controller and onEnter/onExit callbacks, // we also need $stateParams to be available for any $injector calls we make during the // dependency resolution process. - var $stateParams = (paramsAreFiltered) ? params : filterByKeys(objectKeys(state.params), params); + var $stateParams = (paramsAreFiltered) ? params : filterByKeys(state.params.$$keys(), params); var locals = { $stateParams: $stateParams }; // Resolve 'global' dependencies for the state, i.e. those not specific to a view. diff --git a/src/urlMatcherFactory.js b/src/urlMatcherFactory.js index 31d9d41ad..524c2db81 100644 --- a/src/urlMatcherFactory.js +++ b/src/urlMatcherFactory.js @@ -60,7 +60,7 @@ * @returns {Object} New `UrlMatcher` object */ function UrlMatcher(pattern, config) { - config = angular.isObject(config) ? config : {}; + config = extend({ params: {} }, isObject(config) ? config : {}); // Find all placeholders and create a compiled pattern, using either classic or curly syntax: // '*' name @@ -78,21 +78,13 @@ function UrlMatcher(pattern, config) { var placeholder = /([:*])(\w+)|\{(\w+)(?:\:((?:[^{}\\]+|\\.|\{(?:[^{}\\]+|\\.)*\})+))?\}/g, compiled = '^', last = 0, m, segments = this.segments = [], - params = this.params = {}; - - /** - * [Internal] Gets the decoded representation of a value if the value is defined, otherwise, returns the - * default value, which may be the result of an injectable function. - */ - function $value(value) { - /*jshint validthis: true */ - return isDefined(value) ? this.type.decode(value) : $UrlMatcherFactory.$$getDefaultValue(this); - } + params = this.params = new $$UrlMatcherFactoryProvider.ParamSet(); function addParameter(id, type, config) { if (!/^\w+(-+\w+)*$/.test(id)) throw new Error("Invalid parameter name '" + id + "' in pattern '" + pattern + "'"); if (params[id]) throw new Error("Duplicate parameter name '" + id + "' in pattern '" + pattern + "'"); - params[id] = extend({ type: type || new Type(), $value: $value }, config); + params[id] = new $$UrlMatcherFactoryProvider.Param(id, type, config); + return params[id]; } function quoteRegExp(string, pattern, isOptional) { @@ -102,12 +94,6 @@ function UrlMatcher(pattern, config) { return result + flag + '(' + pattern + ')' + flag; } - function paramConfig(param) { - if (!config.params || !config.params[param]) return {}; - var cfg = config.params[param]; - return isObject(cfg) ? cfg : { value: cfg }; - } - this.source = pattern; // Split into static segments separated by path parameter placeholders. @@ -119,12 +105,12 @@ function UrlMatcher(pattern, config) { regexp = m[4] || (m[1] == '*' ? '.*' : '[^/]*'); segment = pattern.substring(last, m.index); type = this.$types[regexp] || new Type({ pattern: new RegExp(regexp) }); - cfg = paramConfig(id); + cfg = config.params[id]; if (segment.indexOf('?') >= 0) break; // we're into the search part - compiled += quoteRegExp(segment, type.$subPattern(), isDefined(cfg.value)); - addParameter(id, type, cfg); + var param = addParameter(id, type, cfg); + compiled += quoteRegExp(segment, type.$subPattern(), param.isOptional); segments.push(segment); last = placeholder.lastIndex; } @@ -140,7 +126,7 @@ function UrlMatcher(pattern, config) { // Allow parameters to be separated by '?' as well as '&' to make concat() easier forEach(search.substring(1).split(/[&?]/), function(key) { - addParameter(key, null, paramConfig(key)); + addParameter(key, null, config.params[key]); }); } else { this.sourcePath = pattern; @@ -180,7 +166,7 @@ UrlMatcher.prototype.concat = function (pattern, config) { // Because order of search parameters is irrelevant, we can add our own search // parameters to the end of the new pattern. Parse the new pattern by itself // and then join the bits together, but it's much easier to do this on a string level. - return new $$UrlMatcherFactoryProvider.compile(this.sourcePath + pattern + this.sourceSearch, config); + return $$UrlMatcherFactoryProvider.compile(this.sourcePath + pattern + this.sourceSearch, config); }; UrlMatcher.prototype.toString = function () { @@ -216,21 +202,19 @@ UrlMatcher.prototype.exec = function (path, searchParams) { if (!m) return null; searchParams = searchParams || {}; - var params = this.parameters(), nTotal = params.length, + var paramNames = this.parameters(), nTotal = paramNames.length, nPath = this.segments.length - 1, - values = {}, i, cfg, param; + values = {}, i, cfg, paramName; if (nPath !== m.length - 1) throw new Error("Unbalanced capture group in route '" + this.source + "'"); for (i = 0; i < nPath; i++) { - param = params[i]; - cfg = this.params[param]; - values[param] = cfg.$value(m[i + 1]); + paramName = paramNames[i]; + values[paramName] = this.params[paramName].value(m[i + 1]); } for (/**/; i < nTotal; i++) { - param = params[i]; - cfg = this.params[param]; - values[param] = cfg.$value(searchParams[param]); + paramName = paramNames[i]; + values[paramName] = this.params[paramName].value(searchParams[paramName]); } return values; @@ -265,15 +249,7 @@ UrlMatcher.prototype.parameters = function (param) { * @returns {boolean} Returns `true` if `params` validates, otherwise `false`. */ UrlMatcher.prototype.validates = function (params) { - var result = true, isOptional, cfg, self = this; - - forEach(params, function(val, key) { - if (!self.params[key]) return; - cfg = self.params[key]; - isOptional = !val && isDefined(cfg.value); - result = result && (isOptional || cfg.type.is(val)); - }); - return result; + return this.params.$$validates(params); }; /** @@ -717,7 +693,94 @@ function $UrlMatcherFactory() { UrlMatcher.prototype.$types[type.name] = def; }); } + + this.Param = function Param(id, type, config) { + var self = this; + var defaultValueConfig = getDefaultValueConfig(config); + config = config || {}; + type = getType(config, type); + + function getDefaultValueConfig(config) { + var keys = isObject(config) ? objectKeys(config) : []; + var isShorthand = keys.indexOf("value") === -1 && keys.indexOf("type") === -1; + var configValue = isShorthand ? config : config.value; + return { + fn: isInjectable(configValue) ? configValue : function () { return configValue; }, + value: configValue + }; + } + + function getType(config, urlType) { + if (config.type && urlType) throw new Error("Param '"+id+"' has two type configurations."); + if (urlType && !config.type) return urlType; + return config.type instanceof Type ? config.type : new Type(config.type || {}); + } + + /** + * [Internal] Get the default value of a parameter, which may be an injectable function. + */ + function $$getDefaultValue() { + if (!injector) throw new Error("Injectable functions cannot be called at configuration time"); + return injector.invoke(defaultValueConfig.fn); + } + + /** + * [Internal] Gets the decoded representation of a value if the value is defined, otherwise, returns the + * default value, which may be the result of an injectable function. + */ + function $value(value) { + return isDefined(value) ? self.type.decode(value) : $$getDefaultValue(); + } + + extend(this, { + id: id, + type: type, + config: config, + dynamic: undefined, + isOptional: defaultValueConfig.value !== undefined, + value: $value + }); + }; + + function ParamSet(params) { + extend(this, params || {}); + } + + ParamSet.prototype = { + $$keys: function () { + return protoKeys(this, ["$$keys", "$$values", "$$equals", "$$validates"]); + }, + $$values: function(paramValues) { + var values = {}, self = this; + forEach(self.$$keys(), function(key) { + values[key] = self[key].value(paramValues && paramValues[key]); + }); + return values; + }, + $$equals: function(paramValues1, paramValues2) { + var equal = true; self = this; + forEach(self.$$keys(), function(key) { + var left = paramValues1 && paramValues1[key], right = paramValues2 && paramValues2[key]; + if (!self[key].type.equals(left, right)) equal = false; + }); + return equal; + }, + $$validates: function $$validate(paramValues) { + var result = true, isOptional, val, param, self = this; + + forEach(this.$$keys(), function(key) { + param = self[key]; + val = paramValues[key]; + isOptional = !val && param.isOptional; + result = result && (isOptional || param.type.is(val)); + }); + return result; + } + }; + + this.ParamSet = ParamSet; } // Register as a provider so it's available to other providers angular.module('ui.router.util').provider('$urlMatcherFactory', $UrlMatcherFactory); +angular.module('ui.router.util').run(['$urlMatcherFactory', function($urlMatcherFactory) { }]);