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..146cce228 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 @@ -793,6 +778,8 @@ function $StateProvider( $urlRouterProvider, $urlMatcherFactory) { } if (toState[abstractKey]) throw new Error("Cannot transition to abstract state '" + to + "'"); if (options.inherit) toParams = inheritParams($stateParams, toParams || {}, $state.$current, toState); + var defaultParams = toState.params.$$values(); + toParams = extend(defaultParams, toParams); to = toState; var toPath = to.path; @@ -801,7 +788,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 +807,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 +1120,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 +1149,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..0cbe09e0a 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,86 @@ 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", "$$validates"]); + }, + $$values: function(paramValues) { + var values = {}, self = this; + forEach(self.$$keys(), function(key) { + values[key] = self[key].value(paramValues && paramValues[key]); + }); + return values; + }, + $$validates: function $$validate(paramValues) { + var result = true, isOptional, param, self = this; + + forEach(paramValues, function (val, key) { + if (!self[key]) return; + param = self[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) { }]); diff --git a/test/stateSpec.js b/test/stateSpec.js index 7dbbfff5a..ed7c3a3a0 100644 --- a/test/stateSpec.js +++ b/test/stateSpec.js @@ -20,13 +20,14 @@ describe('state', function () { var A = { data: {} }, B = {}, C = {}, - D = { params: { x: {}, y: {} } }, - DD = { parent: D, params: { x: {}, y: {}, z: {} } }, + D = { params: { x: null, y: null } }, + DD = { parent: D, params: { x: null, y: null, z: null } }, E = { params: { i: {} } }, H = { data: {propA: 'propA', propB: 'propB'} }, HH = { parent: H }, HHH = {parent: HH, data: {propA: 'overriddenA', propC: 'propC'} }, RS = { url: '^/search?term', reloadOnSearch: false }, + OPT = { url: '/opt/:param', params: { param: 100 } }, AppInjectable = {}; beforeEach(module(function ($stateProvider, $provide) { @@ -46,6 +47,7 @@ describe('state', function () { .state('H', H) .state('HH', HH) .state('HHH', HHH) + .state('OPT', OPT) .state('RS', RS) .state('home', { url: "/" }) @@ -101,8 +103,8 @@ describe('state', function () { // State param inheritance tests. param1 is inherited by sub1 & sub2; // param2 should not be transferred (unless explicitly set). .state('root', { url: '^/root?param1' }) - .state('root.sub1', {url: '/1?param2' }) - .state('root.sub2', {url: '/2?param2' }); + .state('root.sub1', {url: '/1?param2' }); + $stateProvider.state('root.sub2', {url: '/2?param2' }); $provide.value('AppInjectable', AppInjectable); })); @@ -642,7 +644,7 @@ describe('state', function () { it('contains the parameter values for the current state', inject(function ($state, $q) { initStateTo(D, { x: 'x value', z: 'invalid value' }); - expect($state.params).toEqual({ x: 'x value', y: undefined }); + expect($state.params).toEqual({ x: 'x value', y: null }); })); }); @@ -745,6 +747,7 @@ describe('state', function () { 'H', 'HH', 'HHH', + 'OPT', 'RS', 'about', 'about.person', @@ -786,6 +789,26 @@ describe('state', function () { })); }); + describe('optional parameters', function() { + it("should be populated during transition, if unspecified", inject(function($state, $q) { + var stateParams; + $state.get("OPT").onEnter = function($stateParams) { stateParams = $stateParams; }; + $state.go("OPT"); $q.flush(); + expect($state.current.name).toBe("OPT"); + expect($state.params).toEqual({ param: 100 }); + expect(stateParams).toEqual({ param: 100 }); + })); + + it("should be populated during primary transition, if unspecified", inject(function($state, $q) { + var count = 0; + $state.get("OPT").onEnter = function($stateParams) { count++; }; + $state.go("OPT"); $q.flush(); + expect($state.current.name).toBe("OPT"); + expect($state.params).toEqual({ param: 100 }); + expect(count).toEqual(1); + })); + }); + describe('url handling', function () { it('should transition to the same state with different parameters', inject(function ($state, $rootScope, $location) { $location.path("/about/bob");