diff --git a/src/common.js b/src/common.js index 9f73bacda..1dc0172ee 100644 --- a/src/common.js +++ b/src/common.js @@ -49,7 +49,7 @@ function ancestors(first, second) { * @param {Object} object A JavaScript object. * @return {Array} Returns the keys of the object as an array. */ -function keys(object) { +function objectKeys(object) { if (Object.keys) { return Object.keys(object); } @@ -96,8 +96,9 @@ function inheritParams(currentParams, newParams, $current, $to) { var parents = ancestors($current, $to), parentParams, inherited = {}, inheritList = []; for (var i in parents) { - if (!parents[i].params || !parents[i].params.length) continue; - parentParams = parents[i].params; + if (!parents[i].params) continue; + parentParams = objectKeys(parents[i].params); + if (!parentParams.length) continue; for (var j in parentParams) { if (arraySearch(inheritList, parentParams[j]) >= 0) continue; @@ -108,23 +109,6 @@ function inheritParams(currentParams, newParams, $current, $to) { return extend({}, inherited, newParams); } -/** - * Normalizes a set of values to string or `null`, filtering them by a list of keys. - * - * @param {Array} keys The list of keys to normalize/return. - * @param {Object} values An object hash of values to normalize. - * @return {Object} Returns an object hash of normalized string values. - */ -function normalize(keys, values) { - var normalized = {}; - - forEach(keys, function (name) { - var value = values[name]; - normalized[name] = (value != null) ? String(value) : null; - }); - return normalized; -} - /** * Performs a non-strict comparison of the subset of two objects, defined by a list of keys. * diff --git a/src/state.js b/src/state.js index 905b89e8f..cf32681e3 100644 --- a/src/state.js +++ b/src/state.js @@ -48,18 +48,14 @@ function $StateProvider( $urlRouterProvider, $urlMatcherFactory) { // Build a URLMatcher if necessary, either via a relative or absolute URL url: function(state) { - var url = state.url; + var url = state.url, config = { params: state.params || {} }; if (isString(url)) { - if (url.charAt(0) == '^') { - return $urlMatcherFactory.compile(url.substring(1)); - } - return (state.parent.navigable || root).url.concat(url); + if (url.charAt(0) == '^') return $urlMatcherFactory.compile(url.substring(1), config); + return (state.parent.navigable || root).url.concat(url, config); } - if ($urlMatcherFactory.isMatcher(url) || url == null) { - return url; - } + if (!url || $urlMatcherFactory.isMatcher(url)) return url; throw new Error("Invalid url '" + url + "' in state '" + state + "'"); }, @@ -71,10 +67,8 @@ function $StateProvider( $urlRouterProvider, $urlMatcherFactory) { // 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.parameters() : state.parent.params; + return state.url ? state.url.params : state.parent.params; } - if (!isArray(state.params)) throw new Error("Invalid params in state '" + state + "'"); - if (state.url) throw new Error("Both params and url specicified in state '" + state + "'"); return state.params; }, @@ -94,16 +88,18 @@ function $StateProvider( $urlRouterProvider, $urlMatcherFactory) { }, ownParams: function(state) { + state.params = state.params || {}; + if (!state.parent) { - return state.params; + return objectKeys(state.params); } - var paramNames = {}; forEach(state.params, function (p) { paramNames[p] = true; }); + var paramNames = {}; forEach(state.params, function (v, k) { paramNames[k] = true; }); - forEach(state.parent.params, function (p) { - if (!paramNames[p]) { - throw new Error("Missing required parameter '" + p + "' in state '" + state.name + "'"); + forEach(state.parent.params, function (v, k) { + if (!paramNames[k]) { + throw new Error("Missing required parameter '" + k + "' in state '" + state.name + "'"); } - paramNames[p] = false; + paramNames[k] = false; }); var ownParams = []; @@ -782,8 +778,8 @@ function $StateProvider( $urlRouterProvider, $urlMatcherFactory) { toState = findState(to, options.relative); if (!isDefined(toState)) { - if (options.relative) throw new Error("Could not resolve '" + to + "' from state '" + options.relative + "'"); - throw new Error("No such state '" + to + "'"); + if (!options.relative) throw new Error("No such state '" + to + "'"); + throw new Error("Could not resolve '" + to + "' from state '" + options.relative + "'"); } } if (toState[abstractKey]) throw new Error("Cannot transition to abstract state '" + to + "'"); @@ -808,14 +804,14 @@ function $StateProvider( $urlRouterProvider, $urlMatcherFactory) { // TODO: We may not want to bump 'transition' if we're called from a location change // that we've initiated ourselves, because we might accidentally abort a legitimate // transition initiated from code? - if (shouldTriggerReload(to, from, locals, options) ) { + if (shouldTriggerReload(to, from, locals, options)) { if (to.self.reloadOnSearch !== false) $urlRouter.update(); $state.transition = null; return $q.when($state.current); } - // Normalize/filter parameters before we pass them to event handlers etc. - toParams = normalize(to.params, toParams || {}); + // Filter parameters before we pass them to event handlers etc. + toParams = filterByKeys(objectKeys(to.params), toParams || {}); // Broadcast start event and cancel the transition if requested if (options.notify) { @@ -1090,7 +1086,12 @@ function $StateProvider( $urlRouterProvider, $urlMatcherFactory) { * @returns {string} compiled state url */ $state.href = function href(stateOrName, params, options) { - options = extend({ lossy: true, inherit: false, absolute: false, relative: $state.$current }, options || {}); + options = extend({ + lossy: true, + inherit: false, + absolute: false, + relative: $state.$current + }, options || {}); var state = findState(stateOrName, options.relative); @@ -1102,7 +1103,9 @@ function $StateProvider( $urlRouterProvider, $urlMatcherFactory) { if (!nav || !nav.url) { return null; } - return $urlRouter.href(nav.url, normalize(state.params, params || {}), { absolute: options.absolute }); + return $urlRouter.href(nav.url, filterByKeys(objectKeys(state.params), params || {}), { + absolute: options.absolute + }); }; /** @@ -1132,7 +1135,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(state.params, params); + var $stateParams = (paramsAreFiltered) ? params : filterByKeys(objectKeys(state.params), 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 542c75a03..83044bf28 100644 --- a/src/urlMatcherFactory.js +++ b/src/urlMatcherFactory.js @@ -38,8 +38,11 @@ * path into the parameter 'path'. * * `'/files/*path'` - ditto. * - * @param {string} pattern the pattern to compile into a matcher. - * @param {bool} caseInsensitiveMatch true if url matching should be case insensitive, otherwise false, the default value (for backward compatibility) is false. + * @param {string} pattern The pattern to compile into a matcher. + * @param {Object} config A configuration object hash: + * + * * `caseInsensitive` - `true` if URL matching should be case insensitive, otherwise `false`, the default value (for backward compatibility) is `false`. + * * `strict` - `false` if matching against a URL with a trailing slash should be treated as equivalent to a URL without a trailing slash, the default value is `true`. * * @property {string} prefix A static prefix of this pattern. The matcher guarantees that any * URL matching this matcher (i.e. any string for which {@link ui.router.util.type:UrlMatcher#methods_exec exec()} returns @@ -54,9 +57,10 @@ * @property {string} regex The constructed regex that will be used to match against the url when * it is time to determine which url will match. * - * @returns {Object} New UrlMatcher object + * @returns {Object} New `UrlMatcher` object */ -function UrlMatcher(pattern, caseInsensitiveMatch) { +function UrlMatcher(pattern, config) { + config = angular.isObject(config) ? config : {}; // Find all placeholders and create a compiled pattern, using either classic or curly syntax: // '*' name @@ -72,33 +76,55 @@ function UrlMatcher(pattern, caseInsensitiveMatch) { // \\. - a backslash escape // \{(?:[^{}\\]+|\\.)*\} - a matched set of curly braces containing other atoms var placeholder = /([:*])(\w+)|\{(\w+)(?:\:((?:[^{}\\]+|\\.|\{(?:[^{}\\]+|\\.)*\})+))?\}/g, - names = {}, compiled = '^', last = 0, m, + compiled = '^', last = 0, m, segments = this.segments = [], - params = this.params = []; + 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); + } - function addParameter(id) { + function addParameter(id, type, config) { if (!/^\w+(-+\w+)*$/.test(id)) throw new Error("Invalid parameter name '" + id + "' in pattern '" + pattern + "'"); - if (names[id]) throw new Error("Duplicate parameter name '" + id + "' in pattern '" + pattern + "'"); - names[id] = true; - params.push(id); + if (params[id]) throw new Error("Duplicate parameter name '" + id + "' in pattern '" + pattern + "'"); + params[id] = extend({ type: type || new Type(), $value: $value }, config); + } + + function quoteRegExp(string, pattern, isOptional) { + var result = string.replace(/[\\\[\]\^$*+?.()|{}]/g, "\\$&"); + if (!pattern) return result; + var flag = isOptional ? '?' : ''; + return result + flag + '(' + pattern + ')' + flag; } - function quoteRegExp(string) { - return string.replace(/[\\\[\]\^$*+?.()|{}]/g, "\\$&"); + 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. // The number of segments is always 1 more than the number of parameters. - var id, regexp, segment; + var id, regexp, segment, type, cfg; + while ((m = placeholder.exec(pattern))) { - id = m[2] || m[3]; // IE[78] returns '' for unmatched groups instead of null - regexp = m[4] || (m[1] == '*' ? '.*' : '[^/]*'); + id = m[2] || m[3]; // IE[78] returns '' for unmatched groups instead of null + regexp = m[4] || (m[1] == '*' ? '.*' : '[^/]*'); segment = pattern.substring(last, m.index); + type = this.$types[regexp] || new Type({ pattern: new RegExp(regexp) }); + cfg = paramConfig(id); + if (segment.indexOf('?') >= 0) break; // we're into the search part - compiled += quoteRegExp(segment) + '(' + regexp + ')'; - addParameter(id); + + compiled += quoteRegExp(segment, type.$subPattern(), isDefined(cfg.value)); + addParameter(id, type, cfg); segments.push(segment); last = placeholder.lastIndex; } @@ -106,26 +132,25 @@ function UrlMatcher(pattern, caseInsensitiveMatch) { // Find any search parameter names and remove them from the last segment var i = segment.indexOf('?'); + if (i >= 0) { var search = this.sourceSearch = segment.substring(i); segment = segment.substring(0, i); - this.sourcePath = pattern.substring(0, last+i); + this.sourcePath = pattern.substring(0, last + i); // Allow parameters to be separated by '?' as well as '&' to make concat() easier - forEach(search.substring(1).split(/[&?]/), addParameter); + forEach(search.substring(1).split(/[&?]/), function(key) { + addParameter(key, null, paramConfig(key)); + }); } else { this.sourcePath = pattern; this.sourceSearch = ''; } - compiled += quoteRegExp(segment) + '$'; + compiled += quoteRegExp(segment) + (config.strict === false ? '\/?' : '') + '$'; segments.push(segment); - if(caseInsensitiveMatch){ - this.regexp = new RegExp(compiled, 'i'); - }else{ - this.regexp = new RegExp(compiled); - } - + + this.regexp = new RegExp(compiled, config.caseInsensitive ? 'i' : undefined); this.prefix = segments[0]; } @@ -142,19 +167,20 @@ function UrlMatcher(pattern, caseInsensitiveMatch) { * * @example * The following two matchers are equivalent: - * ``` + *
* new UrlMatcher('/user/{id}?q').concat('/details?date'); * new UrlMatcher('/user/{id}/details?q&date'); - * ``` + ** * @param {string} pattern The pattern to append. - * @returns {ui.router.util.type:UrlMatcher} A matcher for the concatenated pattern. + * @param {Object} config An object hash of the configuration for the matcher. + * @returns {UrlMatcher} A matcher for the concatenated pattern. */ -UrlMatcher.prototype.concat = function (pattern) { +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 UrlMatcher(this.sourcePath + pattern + this.sourceSearch); + return new UrlMatcher(this.sourcePath + pattern + this.sourceSearch, config); }; UrlMatcher.prototype.toString = function () { @@ -174,10 +200,12 @@ UrlMatcher.prototype.toString = function () { * as optional. * * @example - * ``` - * new UrlMatcher('/user/{id}?q&r').exec('/user/bob', { x:'1', q:'hello' }); - * // returns { id:'bob', q:'hello', r:null } - * ``` + *
+ * new UrlMatcher('/user/{id}?q&r').exec('/user/bob', { + * x: '1', q: 'hello' + * }); + * // returns { id: 'bob', q: 'hello', r: null } + ** * @param {string} path The URL path to match, e.g. `$location.path()`. * @param {Object} searchParams URL search parameters, e.g. `$location.search()`. @@ -186,15 +214,24 @@ UrlMatcher.prototype.toString = function () { UrlMatcher.prototype.exec = function (path, searchParams) { var m = this.regexp.exec(path); if (!m) return null; + searchParams = searchParams || {}; - var params = this.params, nTotal = params.length, - nPath = this.segments.length-1, - values = {}, i; + var params = this.parameters(), nTotal = params.length, + nPath = this.segments.length - 1, + values = {}, i, cfg, param; if (nPath !== m.length - 1) throw new Error("Unbalanced capture group in route '" + this.source + "'"); - for (i=0; i
* new UrlMatcher('/user/{id}?q').format({ id:'bob', q:'yes' }); * // returns '/user/bob?q=yes' - * ``` + ** * @param {Object} values the values to substitute for the parameters in this pattern. * @returns {string} the formatted URL (path and optionally search part). */ UrlMatcher.prototype.format = function (values) { - var segments = this.segments, params = this.params; - if (!values) return segments.join(''); + var segments = this.segments, params = this.parameters(); - var nPath = segments.length-1, nTotal = params.length, - result = segments[0], i, search, value; + if (!values) return segments.join('').replace('//', '/'); - for (i=0; i
+ * // Defines a custom type that gets a value from a service, + * // where each service gets different types of values from + * // a backend API: + * $urlMatcherFactoryProvider.type('dbObject', function(Users, Posts) { + * + * // Matches up services to URL parameter names + * var services = { + * user: Users, + * post: Posts + * }; + * + * return { + * encode: function(object) { + * // Represent the object in the URL using its unique ID + * return object.id; + * }, + * decode: function(value, key) { + * // Look up the object by ID, using the parameter + * // name (key) to call the correct service + * return services[key].findById(value); + * }, + * is: function(object, key) { + * // Check that object is a valid dbObject + * return angular.isObject(object) && object.id && services[key]; + * } + * equals: function(a, b) { + * // Check the equality of decoded objects by comparing + * // their unique IDs + * return a.id === b.id; + * } + * }; + * }); + * + * // In a config() block, you can then attach URLs with + * // type-annotated parameters: + * $stateProvider.state('users', { + * url: "/users", + * // ... + * }).state('users.item', { + * url: "/{user:dbObject}", + * controller: function($scope, $stateParams) { + * // $stateParams.user will now be an object returned from + * // the Users service + * }, + * // ... + * }); + *+ */ + this.type = function (name, def) { + if (!isDefined(def)) return UrlMatcher.prototype.$types[name]; + typeQueue.push({ name: name, def: def }); + if (!enqueue) flushTypeQueue(); return this; }; + + /* No need to document $get, since it returns this */ + this.$get = ['$injector', function ($injector) { + injector = $injector; + enqueue = false; + UrlMatcher.prototype.$types = {}; + flushTypeQueue(); + + forEach(defaultTypes, function(type, name) { + if (!UrlMatcher.prototype.$types[name]) UrlMatcher.prototype.$types[name] = new Type(type); + }); + return this; + }]; + + // To ensure proper order of operations in object configuration, and to allow internal + // types to be overridden, `flushTypeQueue()` waits until `$urlMatcherFactory` is injected + // before actually wiring up and assigning type definitions + function flushTypeQueue() { + forEach(typeQueue, function(type) { + if (UrlMatcher.prototype.$types[type.name]) { + throw new Error("A type named '" + type.name + "' has already been defined."); + } + var def = new Type(isInjectable(type.def) ? injector.invoke(type.def) : type.def); + UrlMatcher.prototype.$types[type.name] = def; + }); + } } // Register as a provider so it's available to other providers diff --git a/src/urlRouter.js b/src/urlRouter.js index 0ad472e6f..e4a76524f 100644 --- a/src/urlRouter.js +++ b/src/urlRouter.js @@ -16,8 +16,7 @@ */ $UrlRouterProvider.$inject = ['$locationProvider', '$urlMatcherFactoryProvider']; function $UrlRouterProvider( $locationProvider, $urlMatcherFactory) { - var rules = [], - otherwise = null; + var rules = [], otherwise = null, interceptDeferred = false, listener; // Returns a string that is a prefix of all strings matching the RegExp function regExpPrefix(re) { @@ -38,7 +37,7 @@ function $UrlRouterProvider( $locationProvider, $urlMatcherFactory) { * @methodOf ui.router.router.$urlRouterProvider * * @description - * Defines rules that are used by `$urlRouterProvider to find matches for + * Defines rules that are used by `$urlRouterProvider` to find matches for * specific URLs. * * @example @@ -61,7 +60,7 @@ function $UrlRouterProvider( $locationProvider, $urlMatcherFactory) { * @param {object} rule Handler function that takes `$injector` and `$location` * services as arguments. You can use them to return a valid path as a string. * - * @return {object} $urlRouterProvider - $urlRouterProvider instance + * @return {object} `$urlRouterProvider` - `$urlRouterProvider` instance */ this.rule = function (rule) { if (!isFunction(rule)) throw new Error("'rule' must be a function"); @@ -75,7 +74,7 @@ function $UrlRouterProvider( $locationProvider, $urlMatcherFactory) { * @methodOf ui.router.router.$urlRouterProvider * * @description - * Defines a path that is used when an invalied route is requested. + * Defines a path that is used when an invalid route is requested. * * @example *
@@ -98,7 +97,7 @@ function $UrlRouterProvider( $locationProvider, $urlMatcherFactory) { * rule that returns the url path. The function version is passed two params: * `$injector` and `$location` services. * - * @return {object} $urlRouterProvider - $urlRouterProvider instance + * @return {object} `$urlRouterProvider` - `$urlRouterProvider` instance */ this.otherwise = function (rule) { if (isString(rule)) { @@ -124,8 +123,8 @@ function $UrlRouterProvider( $locationProvider, $urlMatcherFactory) { * * @description * Registers a handler for a given url matching. if handle is a string, it is - * treated as a redirect, and is interpolated according to the syyntax of match - * (i.e. like String.replace() for RegExp, or like a UrlMatcher pattern otherwise). + * treated as a redirect, and is interpolated according to the syntax of match + * (i.e. like `String.replace()` for `RegExp`, or like a `UrlMatcher` pattern otherwise). * * If the handler is a function, it is injectable. It gets invoked if `$location` * matches. You have the option of inject the match object as `$match`. @@ -197,6 +196,59 @@ function $UrlRouterProvider( $locationProvider, $urlMatcherFactory) { throw new Error("invalid 'what' in when()"); }; + /** + * @ngdoc function + * @name ui.router.router.$urlRouterProvider#deferIntercept + * @methodOf ui.router.router.$urlRouterProvider + * + * @description + * Disables (or enables) deferring location change interception. + * + * If you wish to customize the behavior of syncing the URL (for example, if you wish to + * defer a transition but maintain the current URL), call this method at configuration time. + * Then, at run time, call `$urlRouter.listen()` after you have configured your own + * `$locationChangeSuccess` event handler. + * + * @example + *+ * var app = angular.module('app', ['ui.router.router']); + * + * app.config(function ($urlRouterProvider) { + * + * // Prevent $urlRouter from automatically intercepting URL changes; + * // this allows you to configure custom behavior in between + * // location changes and route synchronization: + * $urlRouterProvider.deferIntercept(); + * + * }).run(function ($rootScope, $urlRouter, UserService) { + * + * $rootScope.$on('$locationChangeSuccess', function(e) { + * // UserService is an example service for managing user state + * if (UserService.isLoggedIn()) return; + * + * // Prevent $urlRouter's default handler from firing + * e.preventDefault(); + * + * UserService.handleLogin().then(function() { + * // Once the user has logged in, sync the current URL + * // to the router: + * $urlRouter.sync(); + * }); + * }); + * + * // Configures $urlRouter's listener *after* your custom listener + * $urlRouter.listen(); + * }); + *+ * + * @param {boolean} defer Indicates whether to defer location change interception. Passing + no parameter is equivalent to `true`. + */ + this.deferIntercept = function (defer) { + if (defer === undefined) defer = true; + interceptDeferred = defer; + }; + /** * @ngdoc object * @name ui.router.router.$urlRouter @@ -242,7 +294,12 @@ function $UrlRouterProvider( $locationProvider, $urlMatcherFactory) { if (otherwise) check(otherwise); } - $rootScope.$on('$locationChangeSuccess', update); + function listen() { + listener = listener || $rootScope.$on('$locationChangeSuccess', update); + return listener; + } + + if (!interceptDeferred) listen(); return { /** @@ -275,6 +332,10 @@ function $UrlRouterProvider( $locationProvider, $urlMatcherFactory) { update(); }, + listen: function() { + return listen(); + }, + update: function(read) { if (read) { location = $location.url(); @@ -291,9 +352,37 @@ function $UrlRouterProvider( $locationProvider, $urlMatcherFactory) { if (options && options.replace) $location.replace(); }, + /** + * @ngdoc function + * @name ui.router.router.$urlRouter#href + * @methodOf ui.router.router.$urlRouter + * + * @description + * A URL generation method that returns the compiled URL for a given + * {@link ui.router.util.type:UrlMatcher `UrlMatcher`}, populated with the provided parameters. + * + * @example + *+ * $bob = $urlRouter.href(new UrlMatcher("/about/:person"), { + * person: "bob" + * }); + * // $bob == "/about/bob"; + *+ * + * @param {UrlMatcher} urlMatcher The `UrlMatcher` object which is used as the template of the URL to generate. + * @param {object=} params An object of parameter values to fill the matcher's required parameters. + * @param {object=} options Options object. The options are: + * + * - **`absolute`** - {boolean=false}, If true will generate an absolute url, e.g. "http://www.example.com/fullurl". + * + * @returns {string} Returns the fully compiled URL, or `null` if `params` fail validation against `urlMatcher` + */ href: function(urlMatcher, params, options) { + if (!urlMatcher.validates(params)) return null; + var isHtml5 = $locationProvider.html5Mode(); var url = urlMatcher.format(params); + options = options || {}; if (!isHtml5 && url) { url = "#" + $locationProvider.hashPrefix() + url; @@ -304,8 +393,8 @@ function $UrlRouterProvider( $locationProvider, $urlMatcherFactory) { return url; } - var slash = (!isHtml5 && url ? '/' : ''), - port = $location.port() == 80 || $location.port() == 443 ? '' : ':' + $location.port(); + var slash = (!isHtml5 && url ? '/' : ''), port = $location.port(); + port = (port === 80 || port === 443 ? '' : ':' + port); return [$location.protocol(), '://', $location.host(), port, slash, url].join(''); } diff --git a/test/stateDirectivesSpec.js b/test/stateDirectivesSpec.js index 244f0a060..da21806b4 100644 --- a/test/stateDirectivesSpec.js +++ b/test/stateDirectivesSpec.js @@ -117,7 +117,7 @@ describe('uiStateRef', function() { expect(el.attr('href')).toBe('#/contacts/3'); })); - it('should transition states when left-clicked', inject(function($state, $stateParams, $document, $q) { + it('should transition states when left-clicked', inject(function($state, $stateParams, $q) { expect($state.$current.name).toEqual(''); triggerClick(el); @@ -125,10 +125,10 @@ describe('uiStateRef', function() { $q.flush(); expect($state.current.name).toEqual('contacts.item.detail'); - expect($stateParams).toEqual({ id: "5" }); + expect($stateParams).toEqual({ id: 5 }); })); - it('should transition when given a click that contains no data (fake-click)', inject(function($state, $stateParams, $document, $q) { + it('should transition when given a click that contains no data (fake-click)', inject(function($state, $stateParams, $q) { expect($state.current.name).toEqual(''); triggerClick(el, { @@ -142,10 +142,10 @@ describe('uiStateRef', function() { $q.flush(); expect($state.current.name).toEqual('contacts.item.detail'); - expect($stateParams).toEqual({ id: "5" }); + expect($stateParams).toEqual({ id: 5 }); })); - it('should not transition states when ctrl-clicked', inject(function($state, $stateParams, $document, $q) { + it('should not transition states when ctrl-clicked', inject(function($state, $stateParams, $q) { expect($state.$current.name).toEqual(''); triggerClick(el, { ctrlKey: true }); @@ -153,10 +153,10 @@ describe('uiStateRef', function() { $q.flush(); expect($state.current.name).toEqual(''); - expect($stateParams).toEqual({ id: "5" }); + expect($stateParams).toEqual({ id: 5 }); })); - it('should not transition states when meta-clicked', inject(function($state, $stateParams, $document, $q) { + it('should not transition states when meta-clicked', inject(function($state, $stateParams, $q) { expect($state.$current.name).toEqual(''); triggerClick(el, { metaKey: true }); @@ -164,10 +164,10 @@ describe('uiStateRef', function() { $q.flush(); expect($state.current.name).toEqual(''); - expect($stateParams).toEqual({ id: "5" }); + expect($stateParams).toEqual({ id: 5 }); })); - it('should not transition states when shift-clicked', inject(function($state, $stateParams, $document, $q) { + it('should not transition states when shift-clicked', inject(function($state, $stateParams, $q) { expect($state.$current.name).toEqual(''); triggerClick(el, { shiftKey: true }); @@ -175,10 +175,10 @@ describe('uiStateRef', function() { $q.flush(); expect($state.current.name).toEqual(''); - expect($stateParams).toEqual({ id: "5" }); + expect($stateParams).toEqual({ id: 5 }); })); - it('should not transition states when middle-clicked', inject(function($state, $stateParams, $document, $q) { + it('should not transition states when middle-clicked', inject(function($state, $stateParams, $q) { expect($state.$current.name).toEqual(''); triggerClick(el, { button: 1 }); @@ -186,10 +186,10 @@ describe('uiStateRef', function() { $q.flush(); expect($state.current.name).toEqual(''); - expect($stateParams).toEqual({ id: "5" }); + expect($stateParams).toEqual({ id: 5 }); })); - it('should not transition states when element has target specified', inject(function($state, $stateParams, $document, $q) { + it('should not transition states when element has target specified', inject(function($state, $stateParams, $q) { el.attr('target', '_blank'); expect($state.$current.name).toEqual(''); @@ -198,10 +198,10 @@ describe('uiStateRef', function() { $q.flush(); expect($state.current.name).toEqual(''); - expect($stateParams).toEqual({ id: "5" }); + expect($stateParams).toEqual({ id: 5 }); })); - it('should not transition states if preventDefault() is called in click handler', inject(function($state, $stateParams, $document, $q) { + it('should not transition states if preventDefault() is called in click handler', inject(function($state, $stateParams, $q) { expect($state.$current.name).toEqual(''); el.bind('click', function(e) { e.preventDefault(); @@ -212,7 +212,7 @@ describe('uiStateRef', function() { $q.flush(); expect($state.current.name).toEqual(''); - expect($stateParams).toEqual({ id: "5" }); + expect($stateParams).toEqual({ id: 5 }); })); it('should allow passing params to current state', inject(function($compile, $rootScope, $state) { @@ -277,7 +277,7 @@ describe('uiStateRef', function() { $q.flush(); expect($state.$current.name).toBe("contacts.item.detail"); - expect($state.params).toEqual({ id: '5' }); + expect($state.params).toEqual({ id: 5 }); })); it('should resolve states from parent uiView', inject(function ($state, $stateParams, $q, $timeout) { diff --git a/test/stateSpec.js b/test/stateSpec.js index 7dcae9f3f..3d504f538 100644 --- a/test/stateSpec.js +++ b/test/stateSpec.js @@ -20,9 +20,9 @@ describe('state', function () { var A = { data: {} }, B = {}, C = {}, - D = { params: [ 'x', 'y' ] }, - DD = { parent: D, params: [ 'x', 'y', 'z' ] }, - E = { params: [ 'i' ] }, + D = { params: { x: {}, y: {} } }, + DD = { parent: D, params: { x: {}, y: {}, z: {} } }, + E = { params: { i: {} } }, H = { data: {propA: 'propA', propB: 'propB'} }, HH = { parent: H }, HHH = {parent: HH, data: {propA: 'overriddenA', propC: 'propC'} }, @@ -91,6 +91,9 @@ describe('state', function () { } } }) + .state('badParam', { + url: "/bad/{param:int}" + }) .state('first', { url: '^/first/subpath' }) .state('second', { url: '^/second' }) @@ -258,14 +261,14 @@ describe('state', function () { initStateTo(DD, { x: 1, y: 2, z: 3 }); var called; $rootScope.$on('$stateNotFound', function (ev, redirect) { - stateProvider.state(redirect.to, { parent: DD, params: [ 'x', 'y', 'z', 'w' ]}); + stateProvider.state(redirect.to, { parent: DD, params: { x: {}, y: {}, z: {}, w: {} }}); called = true; }); var promise = $state.go('DDD', { w: 4 }); $q.flush(); expect(called).toBeTruthy(); expect($state.current.name).toEqual('DDD'); - expect($state.params).toEqual({ x: '1', y: '2', z: '3', w: '4' }); + expect($state.params).toEqual({ x: 1, y: 2, z: 3, w: 4 }); })); it('can defer a state transition in $stateNotFound', inject(function ($state, $q, $rootScope) { @@ -277,12 +280,12 @@ describe('state', function () { called = true; }); var promise = $state.go('AA', { a: 1 }); - stateProvider.state('AA', { parent: A, params: [ 'a' ]}); + stateProvider.state('AA', { parent: A, params: { a: {} }}); deferred.resolve(); $q.flush(); expect(called).toBeTruthy(); expect($state.current.name).toEqual('AA'); - expect($state.params).toEqual({ a: '1' }); + expect($state.params).toEqual({ a: 1 }); })); it('can defer and supersede a state transition in $stateNotFound', inject(function ($state, $q, $rootScope) { @@ -295,7 +298,7 @@ describe('state', function () { }); var promise = $state.go('AA', { a: 1 }); $state.go(B); - stateProvider.state('AA', { parent: A, params: [ 'a' ]}); + stateProvider.state('AA', { parent: A, params: { a: {} }}); deferred.resolve(); $q.flush(); expect(called).toBeTruthy(); @@ -436,7 +439,7 @@ describe('state', function () { describe('.go()', function () { it('transitions to a relative state', inject(function ($state, $q) { - $state.transitionTo('about.person.item', { id: 5 }); $q.flush(); + $state.transitionTo('about.person.item', { person: "bob", id: 5 }); $q.flush(); $state.go('^.^.sidebar'); $q.flush(); expect($state.$current.name).toBe('about.sidebar'); @@ -475,7 +478,7 @@ describe('state', function () { $q.flush(); expect($state.$current.name).toBe('about.person.item'); - expect($stateParams).toEqual({ person: 'bob', id: '5' }); + expect($stateParams).toEqual({ person: 'bob', id: 5 }); $state.go('^.^.sidebar'); $q.flush(); @@ -603,7 +606,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: null }); + expect($state.params).toEqual({ x: 'x value', y: undefined }); })); }); @@ -712,6 +715,7 @@ describe('state', function () { 'about.person.item', 'about.sidebar', 'about.sidebar.item', + 'badParam', 'dynamicController', 'first', 'home', @@ -779,6 +783,29 @@ describe('state', function () { expect($state.current.name).toBe(''); })); + describe("typed parameter handling", function() { + + it('should initialize parameters without a hacky empty test', inject(function ($urlMatcherFactory, $state) { + new UrlMatcher(""); + })); + + it('should ignore bad url parameters', inject(function ($state, $rootScope, $location, $urlMatcherFactory) { + $location.path("/bad/5"); + $rootScope.$broadcast("$locationChangeSuccess"); + $rootScope.$apply(); + expect($state.current.name).toBe("badParam"); + + $state.transitionTo("about"); + $rootScope.$apply(); + expect($state.current.name).toBe('about'); + + $location.path("/bad/foo"); + $rootScope.$broadcast("$locationChangeSuccess"); + $rootScope.$apply(); + expect($state.current.name).toBe("about"); + })); + }); + it('should revert to last known working url on state change failure', inject(function ($state, $rootScope, $location, $q) { $state.transitionTo("about"); $q.flush(); @@ -878,16 +905,16 @@ describe('state', function () { describe('substate and stateParams inheritance', function() { it('should inherit the parent param', inject(function ($state, $stateParams, $q) { - initStateTo($state.get('root'), {param1: 1}); - $state.go('root.sub1', {param2: 2}); + initStateTo($state.get('root'), { param1: 1 }); + $state.go('root.sub1', { param2: 2 }); $q.flush(); expect($state.current.name).toEqual('root.sub1'); - expect($stateParams).toEqual({param1: '1', param2: '2'}); + expect($stateParams).toEqual({ param1: 1, param2: 2 }); })); it('should not inherit siblings\' states', inject(function ($state, $stateParams, $q) { - initStateTo($state.get('root'), {param1: 1}); - $state.go('root.sub1', {param2: 2}); + initStateTo($state.get('root'), { param1: 1 }); + $state.go('root.sub1', { param2: 2 }); $q.flush(); expect($state.current.name).toEqual('root.sub1'); @@ -895,7 +922,7 @@ describe('state', function () { $q.flush(); expect($state.current.name).toEqual('root.sub2'); - expect($stateParams).toEqual({param1: '1', param2: null}); + expect($stateParams).toEqual({ param1: 1, param2: undefined }); })); }); diff --git a/test/urlMatcherFactorySpec.js b/test/urlMatcherFactorySpec.js index 67dbae3e1..80b500c23 100644 --- a/test/urlMatcherFactorySpec.js +++ b/test/urlMatcherFactorySpec.js @@ -1,19 +1,59 @@ describe("UrlMatcher", function () { - it("matches static URLs", function () { + + describe("provider", function () { + + var provider; + + beforeEach(function() { + angular.module('ui.router.router.test', function() {}).config(function ($urlMatcherFactoryProvider) { + provider = $urlMatcherFactoryProvider; + }); + + module('ui.router.router', 'ui.router.router.test'); + + inject(function($injector) { + $injector.invoke(provider.$get); + }); + }); + + it("should factory matchers with correct configuration", function () { + provider.caseInsensitive(false); + expect(provider.compile('/hello').exec('/HELLO')).toBeNull(); + + provider.caseInsensitive(true); + expect(provider.compile('/hello').exec('/HELLO')).toEqual({}); + + provider.strictMode(true); + expect(provider.compile('/hello').exec('/hello/')).toBeNull(); + + provider.strictMode(false); + expect(provider.compile('/hello').exec('/hello/')).toEqual({}); + }); + + it("should correctly validate UrlMatcher interface", function () { + var m = new UrlMatcher("/"); + expect(provider.isMatcher(m)).toBe(true); + + m.validates = null; + expect(provider.isMatcher(m)).toBe(false); + }); + }); + + it("should match static URLs", function () { expect(new UrlMatcher('/hello/world').exec('/hello/world')).toEqual({}); }); - it("matches static case insensitive URLs", function () { - expect(new UrlMatcher('/hello/world', true).exec('/heLLo/World')).toEqual({}); + it("should match static case insensitive URLs", function () { + expect(new UrlMatcher('/hello/world', { caseInsensitive: true }).exec('/heLLo/World')).toEqual({}); }); - it("matches against the entire path", function () { + it("should match against the entire path", function () { var matcher = new UrlMatcher('/hello/world'); expect(matcher.exec('/hello/world/')).toBeNull(); expect(matcher.exec('/hello/world/suffix')).toBeNull(); }); - it("parses parameter placeholders", function () { + it("should parse parameter placeholders", function () { var matcher = new UrlMatcher('/users/:id/details/{type}/{repeat:[0-9]+}?from&to'); var params = matcher.parameters(); expect(params.length).toBe(5); @@ -24,105 +64,107 @@ describe("UrlMatcher", function () { expect(params).toContain('to'); }); - it("handles proper snake case parameter names", function(){ - var matcher = new UrlMatcher('/users/?from&to&snake-case&snake-case-triple'); - var params = matcher.parameters(); - expect(params.length).toBe(4); - expect(params).toContain('from'); - expect(params).toContain('to'); - expect(params).toContain('snake-case'); - expect(params).toContain('snake-case-triple'); - }); - - it("handles invalid snake case parameter names", function(){ - expect(function() { new UrlMatcher('/users/?from&to&-snake'); }).toThrow( - "Invalid parameter name '-snake' in pattern '/users/?from&to&-snake'" - ); + describe("snake-case parameters", function() { + it("should match if properly formatted", function() { + var matcher = new UrlMatcher('/users/?from&to&snake-case&snake-case-triple'); + var params = matcher.parameters(); - expect(function() { new UrlMatcher('/users/?from&to&snake-'); }).toThrow( - "Invalid parameter name 'snake-' in pattern '/users/?from&to&snake-'" - ); + expect(params.length).toBe(4); + expect(params).toContain('from'); + expect(params).toContain('to'); + expect(params).toContain('snake-case'); + expect(params).toContain('snake-case-triple'); }); - it(".exec() captures parameter values", function () { - expect( - new UrlMatcher('/users/:id/details/{type}/{repeat:[0-9]+}?from&to') - .exec('/users/123/details//0', {})) - .toEqual({ id:'123', type:'', repeat:'0' }); - }); + it("should not match if invalid", function() { + var err = "Invalid parameter name '-snake' in pattern '/users/?from&to&-snake'"; + expect(function() { new UrlMatcher('/users/?from&to&-snake'); }).toThrow(err); - it(".exec() captures catch-all parameters", function () { - var m = new UrlMatcher('/document/*path'); - expect(m.exec('/document/a/b/c', {})).toEqual({ path: 'a/b/c' }); - expect(m.exec('/document/', {})).toEqual({ path: '' }); + err = "Invalid parameter name 'snake-' in pattern '/users/?from&to&snake-'"; + expect(function() { new UrlMatcher('/users/?from&to&snake-'); }).toThrow(err); + }); }); - it(".exec() uses the optional regexp with curly brace placeholders", function () { - expect( - new UrlMatcher('/users/:id/details/{type}/{repeat:[0-9]+}?from&to') - .exec('/users/123/details/what/thisShouldBeDigits', {})) - .toBeNull(); - }); + describe(".exec()", function() { + it("should capture parameter values", function () { + var m = new UrlMatcher('/users/:id/details/{type}/{repeat:[0-9]+}?from&to'); + expect(m.exec('/users/123/details//0', {})).toEqual({ id:'123', type:'', repeat:'0' }); + }); - it(".exec() treats the URL as already decoded and does not decode it further", function () { - expect(new UrlMatcher('/users/:id').exec('/users/100%25', {})).toEqual({ id: '100%25'}); - }); + it("should capture catch-all parameters", function () { + var m = new UrlMatcher('/document/*path'); + expect(m.exec('/document/a/b/c', {})).toEqual({ path: 'a/b/c' }); + expect(m.exec('/document/', {})).toEqual({ path: '' }); + }); - it('.exec() throws on unbalanced capture list', function () { - var shouldThrow = { - "/url/{matchedParam:([a-z]+)}/child/{childParam}": '/url/someword/child/childParam', - "/url/{matchedParam:([a-z]+)}/child/{childParam}?foo": '/url/someword/child/childParam' - }; + it("should use the optional regexp with curly brace placeholders", function () { + var m = new UrlMatcher('/users/:id/details/{type}/{repeat:[0-9]+}?from&to'); + expect(m.exec('/users/123/details/what/thisShouldBeDigits', {})).toBeNull(); + }); - angular.forEach(shouldThrow, function(url, route) { - expect(function() { new UrlMatcher(route).exec(url, {}); }).toThrow( - "Unbalanced capture group in route '" + route + "'" - ); + it("should treat the URL as already decoded and does not decode it further", function () { + expect(new UrlMatcher('/users/:id').exec('/users/100%25', {})).toEqual({ id: '100%25'}); }); - var shouldPass = { - "/url/{matchedParam:[a-z]+}/child/{childParam}": '/url/someword/child/childParam', - "/url/{matchedParam:[a-z]+}/child/{childParam}?foo": '/url/someword/child/childParam' - }; + it('should throw on unbalanced capture list', function () { + var shouldThrow = { + "/url/{matchedParam:([a-z]+)}/child/{childParam}": '/url/someword/child/childParam', + "/url/{matchedParam:([a-z]+)}/child/{childParam}?foo": '/url/someword/child/childParam' + }; + + angular.forEach(shouldThrow, function(url, route) { + expect(function() { new UrlMatcher(route).exec(url, {}); }).toThrow( + "Unbalanced capture group in route '" + route + "'" + ); + }); - angular.forEach(shouldPass, function(url, route) { - expect(function() { new UrlMatcher(route).exec(url, {}); }).not.toThrow(); + var shouldPass = { + "/url/{matchedParam:[a-z]+}/child/{childParam}": '/url/someword/child/childParam', + "/url/{matchedParam:[a-z]+}/child/{childParam}?foo": '/url/someword/child/childParam' + }; + + angular.forEach(shouldPass, function(url, route) { + expect(function() { new UrlMatcher(route).exec(url, {}); }).not.toThrow(); + }); }); }); - it(".format() reconstitutes the URL", function () { - expect( - new UrlMatcher('/users/:id/details/{type}/{repeat:[0-9]+}?from') - .format({ id:'123', type:'default', repeat:444, ignored:'value', from:'1970' })) - .toEqual('/users/123/details/default/444?from=1970'); - }); + describe(".format()", function() { + it("should reconstitute the URL", function () { + var m = new UrlMatcher('/users/:id/details/{type}/{repeat:[0-9]+}?from'), + params = { id:'123', type:'default', repeat:444, ignored:'value', from:'1970' }; - it(".format() encodes URL parameters", function () { - expect(new UrlMatcher('/users/:id').format({ id:'100%'})).toEqual('/users/100%25'); - }); + expect(m.format(params)).toEqual('/users/123/details/default/444?from=1970'); + }); - it(".format() encodes URL parameters with hashes", function () { - expect( - new UrlMatcher('/users/:id#:section') - .format({ id: 'bob', section: 'contact-details' })) - .toEqual('/users/bob#contact-details'); - }); + it("should encode URL parameters", function () { + expect(new UrlMatcher('/users/:id').format({ id:'100%'})).toEqual('/users/100%25'); + }); - it(".concat() concatenates matchers", function () { - var matcher = new UrlMatcher('/users/:id/details/{type}?from').concat('/{repeat:[0-9]+}?to'); - var params = matcher.parameters(); - expect(params.length).toBe(5); - expect(params).toContain('id'); - expect(params).toContain('type'); - expect(params).toContain('repeat'); - expect(params).toContain('from'); - expect(params).toContain('to'); + it("encodes URL parameters with hashes", function () { + var m = new UrlMatcher('/users/:id#:section'), + params = { id: 'bob', section: 'contact-details' }; + expect(m.format(params)).toEqual('/users/bob#contact-details'); + }); }); - it(".concat() returns a new matcher", function () { - var base = new UrlMatcher('/users/:id/details/{type}?from'); - var matcher = base.concat('/{repeat:[0-9]+}?to'); - expect(matcher).toNotBe(base); + describe(".concat()", function() { + it("should concatenate matchers", function () { + var matcher = new UrlMatcher('/users/:id/details/{type}?from').concat('/{repeat:[0-9]+}?to'); + var params = matcher.parameters(); + expect(params.length).toBe(5); + expect(params).toContain('id'); + expect(params).toContain('type'); + expect(params).toContain('repeat'); + expect(params).toContain('from'); + expect(params).toContain('to'); + }); + + it("should return a new matcher", function () { + var base = new UrlMatcher('/users/:id/details/{type}?from'); + var matcher = base.concat('/{repeat:[0-9]+}?to'); + expect(matcher).toNotBe(base); + }); }); }); @@ -142,14 +184,216 @@ describe("urlMatcherFactory", function () { it("recognizes matchers", function () { expect($umf.isMatcher(new UrlMatcher('/'))).toBe(true); + + var custom = { + format: angular.noop, + exec: angular.noop, + concat: angular.noop, + validates: angular.noop, + parameters: angular.noop + }; + expect($umf.isMatcher(custom)).toBe(true); }); - it("should handle case sensistive URL by default", function () { + it("should handle case sensitive URL by default", function () { expect($umf.compile('/hello/world').exec('/heLLo/WORLD')).toBeNull(); }); it("should handle case insensistive URL", function () { - $umf.caseInsensitiveMatch(true); + $umf.caseInsensitive(true); expect($umf.compile('/hello/world').exec('/heLLo/WORLD')).toEqual({}); }); + + describe("typed parameters", function() { + it("should accept object definitions", function () { + var type = { encode: function() {}, decode: function() {} }; + $umf.type("myType", type); + expect($umf.type("myType").encode).toBe(type.encode); + }); + + it("should reject duplicate definitions", function () { + $umf.type("myType", { encode: function () {}, decode: function () {} }); + expect(function() { $umf.type("myType", {}); }).toThrow("A type named 'myType' has already been defined."); + }); + + it("should accept injected function definitions", inject(function ($stateParams) { + $umf.type("myType", function($stateParams) { + return { + decode: function() { + return $stateParams; + } + }; + }); + expect($umf.type("myType").decode()).toBe($stateParams); + })); + + it("should accept annotated function definitions", inject(function ($stateParams) { + $umf.type("myAnnotatedType", ['$stateParams', function(s) { + return { + decode: function() { + return s; + } + }; + }]); + expect($umf.type("myAnnotatedType").decode()).toBe($stateParams); + })); + + it("should match built-in types", function () { + var m = new UrlMatcher("/{foo:int}/{flag:bool}"); + expect(m.exec("/1138/1")).toEqual({ foo: 1138, flag: true }); + expect(m.format({ foo: 5, flag: true })).toBe("/5/1"); + }); + + it("should encode/decode dates", function () { + var m = new UrlMatcher("/calendar/{date:date}"), + result = m.exec("/calendar/2014-03-26"); + + expect(result.date instanceof Date).toBe(true); + expect(result.date.toUTCString()).toEqual('Wed, 26 Mar 2014 00:00:00 GMT'); + expect(m.format({ date: new Date(2014, 2, 26) })).toBe("/calendar/2014-03-26"); + }); + + it("should not match invalid typed parameter values", function() { + var m = new UrlMatcher('/users/{id:int}'); + + expect(m.exec('/users/1138').id).toBe(1138); + expect(m.exec('/users/alpha')).toBeNull(); + + expect(m.format({ id: 1138 })).toBe("/users/1138"); + expect(m.format({ id: "alpha" })).toBeNull(); + }); + }); + + describe("optional parameters", function() { + it("should match with or without values", function () { + var m = new UrlMatcher('/users/{id:int}', { + params: { id: { value: null } } + }); + expect(m.exec('/users/1138')).toEqual({ id: 1138 }); + expect(m.exec('/users/').id).toBeNull(); + expect(m.exec('/users').id).toBeNull(); + }); + + it("should correctly match multiple", function() { + var m = new UrlMatcher('/users/{id:int}/{state:[A-Z]+}', { + params: { id: { value: null }, state: { value: null } } + }); + expect(m.exec('/users/1138')).toEqual({ id: 1138, state: null }); + expect(m.exec('/users/1138/NY')).toEqual({ id: 1138, state: "NY" }); + + expect(m.exec('/users/').id).toBeNull(); + expect(m.exec('/users/').state).toBeNull(); + + expect(m.exec('/users').id).toBeNull(); + expect(m.exec('/users').state).toBeNull(); + + expect(m.exec('/users/NY').state).toBe("NY"); + expect(m.exec('/users/NY').id).toBeNull(); + }); + + it("should correctly format with or without values", function() { + var m = new UrlMatcher('/users/{id:int}', { + params: { id: { value: null } } + }); + expect(m.format()).toBe('/users/'); + expect(m.format({ id: 1138 })).toBe('/users/1138'); + }); + + it("should correctly format multiple", function() { + var m = new UrlMatcher('/users/{id:int}/{state:[A-Z]+}', { + params: { id: { value: null }, state: { value: null } } + }); + + expect(m.format()).toBe("/users/"); + expect(m.format({ id: 1138 })).toBe("/users/1138/"); + expect(m.format({ state: "NY" })).toBe("/users/NY"); + expect(m.format({ id: 1138, state: "NY" })).toBe("/users/1138/NY"); + }); + + it("should match in between static segments", function() { + var m = new UrlMatcher('/users/{user:int}/photos', { + params: { user: 5 } + }); + expect(m.exec('/users/photos').user).toBe(5); + expect(m.exec('/users/6/photos').user).toBe(6); + expect(m.format()).toBe("/users/photos"); + expect(m.format({ user: 1138 })).toBe("/users/1138/photos"); + }); + + describe("default values", function() { + it("should populate if not supplied in URL", function() { + var m = new UrlMatcher('/users/{id:int}/{test}', { + params: { id: { value: 0 }, test: { value: "foo" } } + }); + expect(m.exec('/users')).toEqual({ id: 0, test: "foo" }); + expect(m.exec('/users/2')).toEqual({ id: 2, test: "foo" }); + expect(m.exec('/users/bar')).toEqual({ id: 0, test: "bar" }); + expect(m.exec('/users/2/bar')).toEqual({ id: 2, test: "bar" }); + expect(m.exec('/users/bar/2')).toBeNull(); + }); + + it("should allow shorthand definitions", function() { + var m = new UrlMatcher('/foo/:foo', { + params: { foo: "bar" } + }); + expect(m.exec("/foo")).toEqual({ foo: "bar" }); + }); + + it("should populate query params", function() { + var defaults = { order: "name", limit: 25, page: 1 }; + var m = new UrlMatcher('/foo?order&limit&page', { + params: defaults + }); + expect(m.exec("/foo")).toEqual(defaults); + }); + + it("should allow function-calculated values", function() { + var m = new UrlMatcher('/foo/:bar', { + params: { + bar: function() { + return "Value from bar()"; + } + } + }); + expect(m.exec('/foo').bar).toBe("Value from bar()"); + }); + + it("should allow injectable functions", inject(function($stateParams) { + var m = new UrlMatcher('/users/:user', { + params: { + user: function($stateParams) { + return $stateParams.user; + } + } + }); + var user = { name: "Bob" }; + + $stateParams.user = user; + expect(m.exec('/users').user).toBe(user); + })); + }); + }); + + describe("strict matching", function() { + it("should match with or without trailing slash", function() { + var m = new UrlMatcher('/users', { strict: false }); + expect(m.exec('/users')).toEqual({}); + expect(m.exec('/users/')).toEqual({}); + }); + + it("should not match multiple trailing slashes", function() { + var m = new UrlMatcher('/users', { strict: false }); + expect(m.exec('/users//')).toBeNull(); + }); + + it("should match when defined with parameters", function() { + var m = new UrlMatcher('/users/{name}', { strict: false, params: { + name: { value: null } + }}); + expect(m.exec('/users/')).toEqual({ name: null }); + expect(m.exec('/users/bob')).toEqual({ name: "bob" }); + expect(m.exec('/users/bob/')).toEqual({ name: "bob" }); + expect(m.exec('/users/bob//')).toBeNull(); + }); + }); }); diff --git a/test/urlRouterSpec.js b/test/urlRouterSpec.js index 9cd7de370..8cc41698f 100644 --- a/test/urlRouterSpec.js +++ b/test/urlRouterSpec.js @@ -2,40 +2,73 @@ describe("UrlRouter", function () { var $urp, $ur, location, match, scope; - beforeEach(function() { - angular.module('ui.router.router.test', function() {}).config(function ($urlRouterProvider) { - $urp = $urlRouterProvider; + describe("provider", function () { - $urp.rule(function ($injector, $location) { - var path = $location.path(); - if (!/baz/.test(path)) return false; - return path.replace('baz', 'b4z'); - }).when('/foo/:param', function($match) { - match = ['/foo/:param', $match]; - }).when('/bar', function($match) { - match = ['/bar', $match]; + beforeEach(function() { + angular.module('ui.router.router.test', function() {}).config(function ($urlRouterProvider) { + $urlRouterProvider.deferIntercept(); + $urp = $urlRouterProvider; }); - }); - module('ui.router.router', 'ui.router.router.test'); + module('ui.router.router', 'ui.router.router.test'); - inject(function($rootScope, $location, $injector) { - scope = $rootScope.$new(); - location = $location; - $ur = $injector.invoke($urp.$get); + inject(function($rootScope, $location, $injector) { + scope = $rootScope.$new(); + location = $location; + $ur = $injector.invoke($urp.$get); + }); }); - }); - - describe("provider", function () { it("should throw on non-function rules", function () { expect(function() { $urp.rule(null); }).toThrow("'rule' must be a function") expect(function() { $urp.otherwise(null); }).toThrow("'rule' must be a function") }); + it("should allow location changes to be deferred", inject(function ($urlRouter, $location, $rootScope) { + var log = []; + + $urp.rule(function ($injector, $location) { + log.push($location.path()); + }); + + $location.path("/foo"); + $rootScope.$broadcast("$locationChangeSuccess"); + + expect(log).toEqual([]); + + $urlRouter.listen(); + $rootScope.$broadcast("$locationChangeSuccess"); + + expect(log).toEqual(["/foo"]); + })); }); describe("service", function() { + + beforeEach(function() { + angular.module('ui.router.router.test', function() {}).config(function ($urlRouterProvider) { + $urp = $urlRouterProvider; + + $urp.rule(function ($injector, $location) { + var path = $location.path(); + if (!/baz/.test(path)) return false; + return path.replace('baz', 'b4z'); + }).when('/foo/:param', function($match) { + match = ['/foo/:param', $match]; + }).when('/bar', function($match) { + match = ['/bar', $match]; + }); + }); + + module('ui.router.router', 'ui.router.router.test'); + + inject(function($rootScope, $location, $injector) { + scope = $rootScope.$new(); + location = $location; + $ur = $injector.invoke($urp.$get); + }); + }); + it("should execute rewrite rules", function () { location.path("/foo"); scope.$emit("$locationChangeSuccess"); @@ -64,7 +97,13 @@ describe("UrlRouter", function () { it("should allow custom URL matchers", function () { var custom = { - url: { exec: function() {}, format: function() {}, concat: function() {} }, + url: { + exec: function() {}, + format: function() {}, + concat: function() {}, + validates: function() {}, + parameters: function() {} + }, handler: function() {} }; @@ -146,6 +185,20 @@ describe("UrlRouter", function () { expect($location.url()).toBe('/old'); })); }); + + describe("URL generation", function() { + it("should return null when UrlMatcher rejects parameters", inject(function($urlRouter, $urlMatcherFactory) { + $urlMatcherFactory.type("custom", { + is: function(val) { + return val === 1138; + } + }); + var matcher = new UrlMatcher("/foo/{param:custom}"); + + expect($urlRouter.href(matcher, { param: 1138 })).toBe('#/foo/1138'); + expect($urlRouter.href(matcher, { param: 5 })).toBeNull(); + })); + }); }); }); \ No newline at end of file