diff --git a/Gruntfile.js b/Gruntfile.js index 6b53d341f..589b3d242 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -36,6 +36,7 @@ module.exports = function (grunt) { 'src/urlMatcherFactory.js', 'src/urlRouter.js', 'src/state.js', + 'src/view.js', 'src/viewDirective.js', 'src/stateDirectives.js', 'src/compat.js' diff --git a/README.md b/README.md index 75e53f96c..1be40156e 100644 --- a/README.md +++ b/README.md @@ -3,9 +3,10 @@ # UI-Router ####Finally a de-facto solution to nested views and routing. ->* Latest release 0.0.1: [Compressed](http://angular-ui.github.io/ui-router/release/angular-ui-router.min.js) / [Uncompressed](http://angular-ui.github.io/ui-router/release/angular-ui-router.js) ->* Latest snapshot: [Compressed](http://angular-ui.github.io/ui-router/build/angular-ui-router.min.js) / [Uncompressed](http://angular-ui.github.io/ui-router/build/angular-ui-router.js) +* Latest release 0.0.1: [Compressed](http://angular-ui.github.io/ui-router/release/angular-ui-router.min.js) / [Uncompressed](http://angular-ui.github.io/ui-router/release/angular-ui-router.js) +* Latest snapshot: [Build the Project Locally](https://github.com/angular-ui/ui-router#developing) +**Warning:** UI-Router is in active development. The API is highly subject to change. It is not recommended to use this library on projects that require guaranteed stability. ## Main Goal To evolve the concept of an [angularjs](http://angularjs.org/) [***route***](http://docs.angularjs.org/api/ng.$routeProvider) into a more general concept of a ***state*** for managing complex application UI states. @@ -17,20 +18,21 @@ To evolve the concept of an [angularjs](http://angularjs.org/) [***route***](htt 2. **More Powerful Views** >`ui-view` directive (used in place of `ng-view`) -3. **Named Views** +3. **Nested Views** +>load templates that contain nested `ui-view`s as deep as you'd like. + +4. **Routing** +>States can map to URLs (though it's not required) + +5. **Named Views** >`
` -4. **Multiple Parallel Views** +6. **Multiple Parallel Views** > ```
``` -5. **Nested Views** ->load templates that contain nested `ui-view`s as deep as you'd like. - -6. **Routing** ->States can map to URLs (though it's not required) *Basically, do whatever you want with states and routes.* @@ -63,10 +65,10 @@ To evolve the concept of an [angularjs](http://angularjs.org/) [***route***](htt ``` -2. Set `ui.state` as a dependency in your module +2. Set `ui.router` as a dependency in your module > ```javascript -var myapp = angular.module('myapp', ['ui.state']) +var myapp = angular.module('myapp', ['ui.router']) ``` ### Nested States & Views diff --git a/config/karma.js b/config/karma.js index d9a2945c6..6effb1fb5 100644 --- a/config/karma.js +++ b/config/karma.js @@ -16,6 +16,7 @@ files = [ 'src/templateFactory.js', 'src/urlMatcherFactory.js', 'src/urlRouter.js', + 'src/view.js', 'src/state.js', 'src/viewDirective.js', 'src/stateDirectives.js', diff --git a/sample/index.html b/sample/index.html index 4d8a55588..b694a83cc 100644 --- a/sample/index.html +++ b/sample/index.html @@ -45,7 +45,7 @@ } } -angular.module('sample', ['ui.compat']) +angular.module('sample', ['ui.router.compat']) .config( [ '$stateProvider', '$routeProvider', '$urlRouterProvider', function ($stateProvider, $routeProvider, $urlRouterProvider) { diff --git a/src/common.js b/src/common.js index 0c4b92d6b..acf539ddc 100644 --- a/src/common.js +++ b/src/common.js @@ -15,15 +15,6 @@ function inherit(parent, extra) { return extend(new (extend(function() {}, { prototype: parent }))(), extra); } -/** - * Extends the destination object `dst` by copying all of the properties from the `src` object(s) - * to `dst` if the `dst` object has no own property of the same name. You can specify multiple - * `src` objects. - * - * @param {Object} dst Destination object. - * @param {...Object} src Source object(s). - * @see angular.extend - */ function merge(dst) { forEach(arguments, function(obj) { if (obj !== dst) { @@ -35,7 +26,51 @@ function merge(dst) { return dst; } -angular.module('ui.util', ['ng']); -angular.module('ui.router', ['ui.util']); -angular.module('ui.state', ['ui.router', 'ui.util']); -angular.module('ui.compat', ['ui.state']); +/** + * Finds the common ancestor path between two states. + * + * @param {Object} first The first state. + * @param {Object} second The second state. + * @return {Array} Returns an array of state names in descending order, not including the root. + */ +function ancestors(first, second) { + var path = []; + + for (var n in first.path) { + if (first.path[n] === "") continue; + if (!second.path[n]) break; + path.push(first.path[n]); + } + return path; +} + +/** + * Merges a set of parameters with all parameters inherited between the common parents of the + * current state and a given destination state. + * + * @param {Object} currentParams The value of the current state parameters ($stateParams). + * @param {Object} newParams The set of parameters which will be composited with inherited params. + * @param {Object} $current Internal definition of object representing the current state. + * @param {Object} $to Internal definition of object representing state to transition to. + */ +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; + + for (var j in parentParams) { + if (inheritList.indexOf(parentParams[j]) >= 0) continue; + inheritList.push(parentParams[j]); + inherited[parentParams[j]] = currentParams[parentParams[j]]; + } + } + return extend({}, inherited, newParams); +} + +angular.module('ui.router.util', ['ng']); +angular.module('ui.router.router', ['ui.router.util']); +angular.module('ui.router.state', ['ui.router.router', 'ui.router.util']); +angular.module('ui.router', ['ui.router.state']); +angular.module('ui.router.compat', ['ui.router']); diff --git a/src/compat.js b/src/compat.js index 4adfd5fbb..63d092d93 100644 --- a/src/compat.js +++ b/src/compat.js @@ -79,6 +79,6 @@ function $RouteProvider( $stateProvider, $urlRouterProvider) { } } -angular.module('ui.compat') +angular.module('ui.router.compat') .provider('$route', $RouteProvider) .directive('ngView', $ViewDirective); diff --git a/src/resolve.js b/src/resolve.js new file mode 100644 index 000000000..ea151b722 --- /dev/null +++ b/src/resolve.js @@ -0,0 +1,215 @@ +/** + * Service (`ui-util`). Manages resolution of (acyclic) graphs of promises. + * @module $resolve + * @requires $q + * @requires $injector + */ +$Resolve.$inject = ['$q', '$injector']; +function $Resolve( $q, $injector) { + + var VISIT_IN_PROGRESS = 1, + VISIT_DONE = 2, + NOTHING = {}, + NO_DEPENDENCIES = [], + NO_LOCALS = NOTHING, + NO_PARENT = extend($q.when(NOTHING), { $$promises: NOTHING, $$values: NOTHING }); + + + /** + * Studies a set of invocables that are likely to be used multiple times. + * $resolve.study(invocables)(locals, parent, self) + * is equivalent to + * $resolve.resolve(invocables, locals, parent, self) + * but the former is more efficient (in fact `resolve` just calls `study` internally). + * See {@link module:$resolve/resolve} for details. + * @function + * @param {Object} invocables + * @return {Function} + */ + this.study = function (invocables) { + if (!isObject(invocables)) throw new Error("'invocables' must be an object"); + + // Perform a topological sort of invocables to build an ordered plan + var plan = [], cycle = [], visited = {}; + function visit(value, key) { + if (visited[key] === VISIT_DONE) return; + + cycle.push(key); + if (visited[key] === VISIT_IN_PROGRESS) { + cycle.splice(0, cycle.indexOf(key)); + throw new Error("Cyclic dependency: " + cycle.join(" -> ")); + } + visited[key] = VISIT_IN_PROGRESS; + + if (isString(value)) { + plan.push(key, [ function() { return $injector.get(key); }], NO_DEPENDENCIES); + } else { + var params = $injector.annotate(value); + forEach(params, function (param) { + if (param !== key && invocables.hasOwnProperty(param)) visit(invocables[param], param); + }); + plan.push(key, value, params); + } + + cycle.pop(); + visited[key] = VISIT_DONE; + } + forEach(invocables, visit); + invocables = cycle = visited = null; // plan is all that's required + + function isResolve(value) { + return isObject(value) && value.then && value.$$promises; + } + + return function (locals, parent, self) { + if (isResolve(locals) && self === undefined) { + self = parent; parent = locals; locals = null; + } + if (!locals) locals = NO_LOCALS; + else if (!isObject(locals)) { + throw new Error("'locals' must be an object"); + } + if (!parent) parent = NO_PARENT; + else if (!isResolve(parent)) { + throw new Error("'parent' must be a promise returned by $resolve.resolve()"); + } + + // To complete the overall resolution, we have to wait for the parent + // promise and for the promise for each invokable in our plan. + var resolution = $q.defer(), + result = resolution.promise, + promises = result.$$promises = {}, + values = extend({}, locals), + wait = 1 + plan.length/3, + merged = false; + + function done() { + // Merge parent values we haven't got yet and publish our own $$values + if (!--wait) { + if (!merged) merge(values, parent.$$values); + result.$$values = values; + result.$$promises = true; // keep for isResolve() + resolution.resolve(values); + } + } + + function fail(reason) { + result.$$failure = reason; + resolution.reject(reason); + } + + // Short-circuit if parent has already failed + if (isDefined(parent.$$failure)) { + fail(parent.$$failure); + return result; + } + + // Merge parent values if the parent has already resolved, or merge + // parent promises and wait if the parent resolve is still in progress. + if (parent.$$values) { + merged = merge(values, parent.$$values); + done(); + } else { + extend(promises, parent.$$promises); + parent.then(done, fail); + } + + // Process each invocable in the plan, but ignore any where a local of the same name exists. + for (var i=0, ii=plan.length; i} invocables functions to invoke or `$injector` services to fetch. + * @param {Object.} [locals] values to make available to the injectables + * @param {Promise.} [parent] a promise returned by another call to `$resolve`. + * @param {Object} [self] the `this` for the invoked methods + * @return {Promise.} Promise for an object that contains the resolved return value + * of all invocables, as well as any inherited and local values. + */ + this.resolve = function (invocables, locals, parent, self) { + return this.study(invocables)(locals, parent, self); + }; +} + +angular.module('ui.router.util').service('$resolve', $Resolve); + diff --git a/src/state.js b/src/state.js index 4d4c5ca72..3df7c0adf 100644 --- a/src/state.js +++ b/src/state.js @@ -1,123 +1,173 @@ -$StateProvider.$inject = ['$urlRouterProvider', '$urlMatcherFactoryProvider']; -function $StateProvider( $urlRouterProvider, $urlMatcherFactory) { +$StateProvider.$inject = ['$urlRouterProvider', '$urlMatcherFactoryProvider', '$locationProvider']; +function $StateProvider( $urlRouterProvider, $urlMatcherFactory, $locationProvider) { var root, states = {}, $state; - function findState(stateOrName) { - var state; - if (isString(stateOrName)) { - state = states[stateOrName]; - if (!state) throw new Error("No such state '" + stateOrName + "'"); - } else { - state = states[stateOrName.name]; - if (!state || state !== stateOrName && state.self !== stateOrName) - throw new Error("Invalid or unregistered state"); - } - return state; - } - - function registerState(state) { - // Wrap a new object around the state so we can store our private details easily. - state = inherit(state, { - self: state, - toString: function () { return this.name; } - }); - - var name = state.name; - if (!isString(name) || name.indexOf('@') >= 0) throw new Error("State must have a valid name"); - if (states[name]) throw new Error("State '" + name + "'' is already defined"); + // Builds state properties from definition passed to registerState() + var stateBuilder = { // Derive parent state from a hierarchical name only if 'parent' is not explicitly defined. - var parent = root; - if (!isDefined(state.parent)) { + // state.children = []; + // if (parent) parent.children.push(state); + parent: function(state) { + if (isDefined(state.parent) && state.parent) return findState(state.parent); // regex matches any valid composite state name // would match "contact.list" but not "contacts" - var compositeName = /^(.+)\.[^.]+$/.exec(name); - if (compositeName != null) { - parent = findState(compositeName[1]); + var compositeName = /^(.+)\.[^.]+$/.exec(state.name); + return compositeName ? findState(compositeName[1]) : root; + }, + + // inherit 'data' from parent and override by own values (if any) + data: function(state) { + if (state.parent && state.parent.data) { + state.data = state.self.data = angular.extend({}, state.parent.data, state.data); } - } else if (state.parent != null) { - parent = findState(state.parent); - } - state.parent = parent; - // state.children = []; - // if (parent) parent.children.push(state); + return state.data; + }, // Build a URLMatcher if necessary, either via a relative or absolute URL - var url = state.url; - if (isString(url)) { - if (url.charAt(0) == '^') { - url = state.url = $urlMatcherFactory.compile(url.substring(1)); - } else { - url = state.url = (parent.navigable || root).url.concat(url); + url: function(state) { + var url = state.url; + + if (isString(url)) { + if (url.charAt(0) == '^') { + return $urlMatcherFactory.compile(url.substring(1)); + } + return (state.parent.navigable || root).url.concat(url); + } + + if ($urlMatcherFactory.isMatcher(url) || url == null) { + return url; } - } else if (isObject(url) && - isFunction(url.exec) && isFunction(url.format) && isFunction(url.concat)) { - /* use UrlMatcher (or compatible object) as is */ - } else if (url != null) { throw new Error("Invalid url '" + url + "' in state '" + state + "'"); - } + }, // Keep track of the closest ancestor state that has a URL (i.e. is navigable) - state.navigable = url ? state : parent ? parent.navigable : null; + navigable: function(state) { + return state.url ? state : (state.parent ? state.parent.navigable : null); + }, // Derive parameters for this state and ensure they're a super-set of parent's parameters - var params = state.params; - if (params) { - if (!isArray(params)) throw new Error("Invalid params in state '" + state + "'"); - if (url) throw new Error("Both params and url specicified in state '" + state + "'"); - } else { - params = state.params = url ? url.parameters() : state.parent.params; - } + params: function(state) { + if (!state.params) { + return state.url ? state.url.parameters() : 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; + }, + + // If there is no explicit multi-view configuration, make one up so we don't have + // to handle both cases in the view directive later. Note that having an explicit + // 'views' property will mean the default unnamed view properties are ignored. This + // is also a good time to resolve view names to absolute names, so everything is a + // straight lookup at link time. + views: function(state) { + var views = {}; + + forEach(isDefined(state.views) ? state.views : { '': state }, function (view, name) { + if (name.indexOf('@') < 0) name += '@' + state.parent.name; + views[name] = view; + }); + return views; + }, - var paramNames = {}; forEach(params, function (p) { paramNames[p] = true; }); - if (parent) { - forEach(parent.params, function (p) { + ownParams: function(state) { + if (!state.parent) { + return state.params; + } + var paramNames = {}; forEach(state.params, function (p) { paramNames[p] = true; }); + + forEach(state.parent.params, function (p) { if (!paramNames[p]) { - throw new Error("Missing required parameter '" + p + "' in state '" + name + "'"); + throw new Error("Missing required parameter '" + p + "' in state '" + state.name + "'"); } paramNames[p] = false; }); + var ownParams = []; - var ownParams = state.ownParams = []; forEach(paramNames, function (own, p) { if (own) ownParams.push(p); }); - } else { - state.ownParams = params; - } - - // If there is no explicit multi-view configuration, make one up so we don't have - // to handle both cases in the view directive later. Note that having an explicit - // 'views' property will mean the default unnamed view properties are ignored. This - // is also a good time to resolve view names to absolute names, so everything is a - // straight lookup at link time. - var views = {}; - forEach(isDefined(state.views) ? state.views : { '': state }, function (view, name) { - if (name.indexOf('@') < 0) name = name + '@' + state.parent.name; - views[name] = view; - }); - state.views = views; + return ownParams; + }, // Keep a full path from the root down to this state as this is needed for state activation. - state.path = parent ? parent.path.concat(state) : []; // exclude root from path + path: function(state) { + return state.parent ? state.parent.path.concat(state) : []; // exclude root from path + }, // Speed up $state.contains() as it's used a lot - var includes = state.includes = parent ? extend({}, parent.includes) : {}; - includes[name] = true; + includes: function(state) { + var includes = state.parent ? extend({}, state.parent.includes) : {}; + includes[state.name] = true; + return includes; + } + }; + - if (!state.resolve) state.resolve = {}; // prevent null checks later + function findState(stateOrName, base) { + var isStr = isString(stateOrName), + name = isStr ? stateOrName : stateOrName.name, + path = name.indexOf(".") === 0 || name.indexOf("^") === 0; + + if (path) { + if (!base) throw new Error("No reference point given for path '" + name + "'"); + var rel = name.split("."), i = 0, pathLength = rel.length, current = base; + + for (; i < pathLength; i++) { + if (rel[i] === "" && i === 0) { + current = base; + continue; + } + if (rel[i] === "^") { + if (!current.parent) throw new Error("Path '" + name + "' not valid for state '" + base.name + "'"); + current = current.parent; + continue; + } + break; + } + rel = rel.slice(i).join("."); + name = current.name + (current.name && rel ? "." : "") + rel; + } + var state = states[name]; + + if (state && (isStr || (!isStr && (state === stateOrName || state.self === stateOrName)))) { + return state; + } + throw new Error(isStr ? "No such state '" + name + "'" : "Invalid or unregistered state"); + } + + + function registerState(state) { + // Wrap a new object around the state so we can store our private details easily. + state = inherit(state, { + self: state, + resolve: state.resolve || {}, + toString: function() { return this.name; } + }); + + var name = state.name; + if (!isString(name) || name.indexOf('@') >= 0) throw new Error("State must have a valid name"); + if (states[name]) throw new Error("State '" + name + "'' is already defined"); + + for (var key in stateBuilder) { + state[key] = stateBuilder[key](state); + } + states[name] = state; // Register the state in the global state list and with $urlRouter if necessary. - if (!state['abstract'] && url) { - $urlRouterProvider.when(url, ['$match', function ($match) { - if ($state.$current.navigable != state) $state.transitionTo(state, $match, false); + if (!state['abstract'] && state.url) { + $urlRouterProvider.when(state.url, ['$match', '$stateParams', function ($match, $stateParams) { + if ($state.$current.navigable != state || !equalForKeys($match, $stateParams)) { + $state.transitionTo(state, $match, false); + } }]); } - states[name] = state; return state; } + // Implicit root state that is always active root = registerState({ name: '', @@ -125,7 +175,6 @@ function $StateProvider( $urlRouterProvider, $urlMatcherFactory) { views: null, 'abstract': true }); - root.locals = { globals: { $stateParams: {} } }; root.navigable = null; @@ -142,12 +191,13 @@ function $StateProvider( $urlRouterProvider, $urlMatcherFactory) { // $urlRouter is injected just to ensure it gets instantiated this.$get = $get; - $get.$inject = ['$rootScope', '$q', '$templateFactory', '$injector', '$stateParams', '$location', '$urlRouter']; - function $get( $rootScope, $q, $templateFactory, $injector, $stateParams, $location, $urlRouter) { + $get.$inject = ['$rootScope', '$q', '$view', '$injector', '$resolve', '$stateParams', '$location', '$urlRouter']; + function $get( $rootScope, $q, $view, $injector, $resolve, $stateParams, $location, $urlRouter) { var TransitionSuperseded = $q.reject(new Error('transition superseded')); var TransitionPrevented = $q.reject(new Error('transition prevented')); + root.locals = { resolve: null, globals: { $stateParams: {} } }; $state = { params: {}, current: root.self, @@ -155,14 +205,18 @@ function $StateProvider( $urlRouterProvider, $urlMatcherFactory) { transition: null }; - // $state.go = function go(to, params) { - // }; + $state.go = function go(to, params, options) { + return this.transitionTo(to, params, extend({ inherit: true, relative: $state.$current }, options)); + }; - $state.transitionTo = function transitionTo(to, toParams, updateLocation) { - if (!isDefined(updateLocation)) updateLocation = true; + $state.transitionTo = function transitionTo(to, toParams, options) { + if (!isDefined(options)) options = (options === true || options === false) ? { location: options } : {}; + options = extend({ location: true, inherit: false, relative: null }, options); - to = findState(to); + to = findState(to, options.relative); if (to['abstract']) throw new Error("Cannot transition to abstract state '" + to + "'"); + if (options.inherit) toParams = inheritParams($stateParams, toParams || {}, $state.$current, to); + var toPath = to.path, from = $state.$current, fromParams = $state.params, fromPath = from.path; @@ -187,8 +241,8 @@ function $StateProvider( $urlRouterProvider, $urlMatcherFactory) { toParams = normalize(to.params, toParams || {}); // Broadcast start event and cancel the transition if requested - if ($rootScope.$broadcast('$stateChangeStart', to.self, toParams, from.self, fromParams) - .defaultPrevented) return TransitionPrevented; + var evt = $rootScope.$broadcast('$stateChangeStart', to.self, toParams, from.self, fromParams); + if (evt.defaultPrevented) return TransitionPrevented; // Resolve locals for the remaining states, but don't update any global state just // yet -- if anything fails to resolve the current state needs to remain untouched. @@ -203,7 +257,7 @@ function $StateProvider( $urlRouterProvider, $urlMatcherFactory) { resolved = resolveState(state, toParams, state===to, resolved, locals); } - // Once everything is resolved, we are ready to perform the actual transition + // Once everything is resolved, wer are ready to perform the actual transition // and return a promise for the new state. We also keep track of what the // current promise is, so that we can detect overlapping transitions and // keep only the outcome of the last transition. @@ -217,7 +271,7 @@ function $StateProvider( $urlRouterProvider, $urlMatcherFactory) { exiting = fromPath[l]; if (exiting.self.onExit) { $injector.invoke(exiting.self.onExit, exiting.self, exiting.locals.globals); - } + } exiting.locals = null; } @@ -239,7 +293,7 @@ function $StateProvider( $urlRouterProvider, $urlMatcherFactory) { // Update $location var toNav = to.navigable; - if (updateLocation && toNav) { + if (options.location && toNav) { $location.url(toNav.url.format(toNav.locals.globals.$stateParams)); } @@ -258,116 +312,104 @@ function $StateProvider( $urlRouterProvider, $urlMatcherFactory) { return transition; }; - $state.is = function (stateOrName) { + $state.is = function is(stateOrName) { return $state.$current === findState(stateOrName); }; - $state.includes = function (stateOrName) { + $state.includes = function includes(stateOrName) { return $state.$current.includes[findState(stateOrName).name]; }; - $state.href = function (stateOrName, params, options) { + $state.href = function href(stateOrName, params, options) { options = extend({ lossy: true }, options || {}); var state = findState(stateOrName); var nav = (state && options.lossy) ? state.navigable : state; - return (nav && nav.url) ? nav.url.format(normalize(state.params, params || {})) : null; + var url = (nav && nav.url) ? nav.url.format(normalize(state.params, params || {})) : null; + return !$locationProvider.html5Mode() && url ? "#" + url : url; }; - function resolveState(state, params, paramsAreFiltered, inherited, dst) { - // We need to track all the promises generated during the resolution process. - // The first of these is for the fully resolved parent locals. - var promises = [inherited]; + $state.getConfig = function (stateOrName) { + var state = findState(stateOrName); + return state.self || null; + }; + function resolveState(state, params, paramsAreFiltered, inherited, dst) { // Make a restricted $stateParams with only the parameters that apply to this state if // 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; - if (paramsAreFiltered) $stateParams = params; - else { - $stateParams = {}; - forEach(state.params, function (name) { - $stateParams[name] = params[name]; - }); - } + var $stateParams = (paramsAreFiltered) ? params : filterByKeys(state.params, params); var locals = { $stateParams: $stateParams }; - // Resolves the values from an individual 'resolve' dependency spec - function resolve(deps, dst) { - forEach(deps, function (value, key) { - promises.push($q - .when(isString(value) ? - $injector.get(value) : - $injector.invoke(value, state.self, locals)) - .then(function (result) { - dst[key] = result; - })); - }); - } - // Resolve 'global' dependencies for the state, i.e. those not specific to a view. // We're also including $stateParams in this; that way the parameters are restricted // to the set that should be visible to the state, and are independent of when we update // the global $state and $stateParams values. - var globals = dst.globals = { $stateParams: $stateParams }; - resolve(state.resolve, globals); - globals.$$state = state; // Provide access to the state itself for internal use + dst.resolve = $resolve.resolve(state.resolve, locals, dst.resolve, state); + var promises = [ dst.resolve.then(function (globals) { + dst.globals = globals; + }) ]; + if (inherited) promises.push(inherited); // Resolve template and dependencies for all views. forEach(state.views, function (view, name) { - // References to the controller (only instantiated at link time) - var $view = dst[name] = { - $$controller: view.controller - }; - - // Template - promises.push($q - .when($templateFactory.fromConfig(view, $stateParams, locals) || '') - .then(function (result) { - $view.$template = result; - })); - - // View-local dependencies. If we've reused the state definition as the default - // view definition in .state(), we can end up with state.resolve === view.resolve. - // Avoid resolving everything twice in that case. - if (view.resolve !== state.resolve) resolve(view.resolve, $view); + var injectables = (view.resolve && view.resolve !== state.resolve ? view.resolve : {}); + injectables.$template = [ function () { + return $view.load(name, { view: view, locals: locals, notify: false }) || ''; + }]; + + promises.push($resolve.resolve(injectables, locals, dst.resolve, state).then(function (result) { + // References to the controller (only instantiated at link time) + result.$$controller = view.controller; + // Provide access to the state itself for internal use + result.$$state = state; + dst[name] = result; + })); }); - // Once we've resolved all the dependencies for this state, merge - // in any inherited dependencies, and merge common state dependencies - // into the dependency set for each view. Finally return a promise - // for the fully popuplated state dependencies. + // Wait for all the promises and then return the activation object return $q.all(promises).then(function (values) { - merge(dst.globals, values[0].globals); // promises[0] === inherited - forEach(state.views, function (view, name) { - merge(dst[name], dst.globals); - }); return dst; }); } - function normalize(keys, values) { - var normalized = {}; + return $state; + } - forEach(keys, function (name) { - var value = values[name]; - normalized[name] = (value != null) ? String(value) : null; - }); - return normalized; + function normalize(keys, values) { + var normalized = {}; + + forEach(keys, function (name) { + var value = values[name]; + normalized[name] = (value != null) ? String(value) : null; + }); + return normalized; + } + + function equalForKeys(a, b, keys) { + // If keys not provided, assume keys from object 'a' + if (!keys) { + keys = []; + for (var n in a) keys.push(n); // Used instead of Object.keys() for IE8 compatibility } - function equalForKeys(a, b, keys) { - for (var i=0; i').html(template).contents(); + animate.enter(contents, element); + return contents; + } + }, + "false": { + remove: function(element) { element.html(''); }, + restore: function(compiled, element) { element.append(compiled); }, + populate: function(template, element) { + element.html(template); + return element.contents(); + } + } + })[doAnimate.toString()]; + }; + // Put back the compiled initial view element.append(transclude(scope)); @@ -26,19 +52,28 @@ function $ViewDirective( $state, $compile, $controller, $injector, $an var view = { name: name, state: null }; element.data('$uiView', view); - scope.$on('$stateChangeSuccess', function() { updateView(true); }); + var eventHook = function() { + if (viewIsUpdating) return; + viewIsUpdating = true; + + try { updateView(true); } catch (e) { + viewIsUpdating = false; + throw e; + } + viewIsUpdating = false; + }; + + scope.$on('$stateChangeSuccess', eventHook); + scope.$on('$viewContentLoading', eventHook); updateView(false); function updateView(doAnimate) { var locals = $state.$current && $state.$current.locals[name]; if (locals === viewLocals) return; // nothing to do + var render = renderer(animate && doAnimate); // Remove existing content - if (animate && doAnimate) { - animate.leave(element.contents(), element); - } else { - element.html(''); - } + render.remove(element); // Destroy previous view scope if (viewScope) { @@ -46,45 +81,32 @@ function $ViewDirective( $state, $compile, $controller, $injector, $an viewScope = null; } - if (locals) { - viewLocals = locals; - view.state = locals.$$state; - - var contents; - if (animate && doAnimate) { - contents = angular.element('
').html(locals.$template).contents(); - animate.enter(contents, element); - } else { - element.html(locals.$template); - contents = element.contents(); - } - - var link = $compile(contents); - viewScope = scope.$new(); - if (locals.$$controller) { - locals.$scope = viewScope; - var controller = $controller(locals.$$controller, locals); - element.children().data('$ngControllerController', controller); - } - link(viewScope); - viewScope.$emit('$viewContentLoaded'); - viewScope.$eval(onloadExp); - - // TODO: This seems strange, shouldn't $anchorScroll listen for $viewContentLoaded if necessary? - // $anchorScroll might listen on event... - $anchorScroll(); - } else { + if (!locals) { viewLocals = null; view.state = null; // Restore the initial view - var compiledElem = transclude(scope); - if (animate && doAnimate) { - animate.enter(compiledElem, element); - } else { - element.append(compiledElem); - } + return render.restore(transclude(scope), element); } + + viewLocals = locals; + view.state = locals.$$state; + + var link = $compile(render.populate(locals.$template, element)); + viewScope = scope.$new(); + + if (locals.$$controller) { + locals.$scope = viewScope; + var controller = $controller(locals.$$controller, locals); + element.children().data('$ngControllerController', controller); + } + link(viewScope); + viewScope.$emit('$viewContentLoaded'); + if (onloadExp) viewScope.$eval(onloadExp); + + // TODO: This seems strange, shouldn't $anchorScroll listen for $viewContentLoaded if necessary? + // $anchorScroll might listen on event... + $anchorScroll(); } }; } @@ -92,4 +114,4 @@ function $ViewDirective( $state, $compile, $controller, $injector, $an return directive; } -angular.module('ui.state').directive('uiView', $ViewDirective); +angular.module('ui.router.state').directive('uiView', $ViewDirective); diff --git a/test/resolveSpec.js b/test/resolveSpec.js new file mode 100644 index 000000000..d5c7d745d --- /dev/null +++ b/test/resolveSpec.js @@ -0,0 +1,297 @@ +describe("resolve", function () { + + var $r, tick; + + beforeEach(module('ui.router.util')); + beforeEach(inject(function($resolve, $q) { + $r = $resolve; + tick = $q.flush; + })); + + describe(".resolve()", function () { + it("calls injectable functions and returns a promise", function () { + var fun = jasmine.createSpy('fun').andReturn(42); + var r = $r.resolve({ fun: [ '$resolve', fun ] }); + expect(r).not.toBeResolved(); + tick(); + expect(resolvedValue(r)).toEqual({ fun: 42 }); + expect(fun).toHaveBeenCalled(); + expect(fun.callCount).toBe(1); + expect(fun.mostRecentCall.args.length).toBe(1); + expect(fun.mostRecentCall.args[0]).toBe($r); + }); + + it("resolves promises returned from the functions", inject(function ($q) { + var d = $q.defer(); + var fun = jasmine.createSpy('fun').andReturn(d.promise); + var r = $r.resolve({ fun: [ '$resolve', fun ] }); + tick(); + expect(r).not.toBeResolved(); + d.resolve('async'); + tick(); + expect(resolvedValue(r)).toEqual({ fun: 'async' }); + })); + + it("resolves dependencies between functions", function () { + var a = jasmine.createSpy('a'); + var b = jasmine.createSpy('b').andReturn('bb'); + var r = $r.resolve({ a: [ 'b', a ], b: [ b ] }); + tick(); + expect(a).toHaveBeenCalled(); + expect(a.mostRecentCall.args).toEqual([ 'bb' ]); + expect(b).toHaveBeenCalled(); + }); + + it("resolves dependencies between functions that return promises", inject(function ($q) { + var ad = $q.defer(), a = jasmine.createSpy('a').andReturn(ad.promise); + var bd = $q.defer(), b = jasmine.createSpy('b').andReturn(bd.promise); + var cd = $q.defer(), c = jasmine.createSpy('c').andReturn(cd.promise); + + var r = $r.resolve({ a: [ 'b', 'c', a ], b: [ 'c', b ], c: [ c ] }); + tick(); + expect(r).not.toBeResolved(); + expect(a).not.toHaveBeenCalled(); + expect(b).not.toHaveBeenCalled(); + expect(c).toHaveBeenCalled(); + cd.resolve('cc'); + tick(); + expect(r).not.toBeResolved(); + expect(a).not.toHaveBeenCalled(); + expect(b).toHaveBeenCalled(); + expect(b.mostRecentCall.args).toEqual([ 'cc' ]); + bd.resolve('bb'); + tick(); + expect(r).not.toBeResolved(); + expect(a).toHaveBeenCalled(); + expect(a.mostRecentCall.args).toEqual([ 'bb', 'cc' ]); + ad.resolve('aa'); + tick(); + expect(resolvedValue(r)).toEqual({ a: 'aa', b: 'bb', c: 'cc' }); + expect(a.callCount).toBe(1); + expect(b.callCount).toBe(1); + expect(c.callCount).toBe(1); + })); + + it("refuses cyclic dependencies", function () { + var a = jasmine.createSpy('a'); + var b = jasmine.createSpy('b'); + expect(caught(function () { + $r.resolve({ a: [ 'b', a ], b: [ 'a', b ] }); + })).toMatch(/cyclic/i); + expect(a).not.toHaveBeenCalled(); + expect(b).not.toHaveBeenCalled(); + }); + + it("allows a function to depend on an injector value of the same name", function () { + var r = $r.resolve({ $resolve: function($resolve) { return $resolve === $r; } }); + tick(); + expect(resolvedValue(r)).toEqual({ $resolve: true }); + }); + + it("allows locals to be passed that override the injector", function () { + var fun = jasmine.createSpy('fun'); + $r.resolve({ fun: [ '$resolve', fun ] }, { $resolve: 42 }); + tick(); + expect(fun).toHaveBeenCalled(); + expect(fun.mostRecentCall.args[0]).toBe(42); + }); + + it("does not call injectables overridden by a local", function () { + var fun = jasmine.createSpy('fun').andReturn("function"); + var r = $r.resolve({ fun: [ fun ] }, { fun: "local" }); + tick(); + expect(fun).not.toHaveBeenCalled(); + expect(resolvedValue(r)).toEqual({ fun: "local" }); + }); + + it("includes locals in the returned values", function () { + var locals = { foo: 'hi', bar: 'mom' }; + var r = $r.resolve({}, locals); + tick(); + expect(resolvedValue(r)).toEqual(locals); + }); + + it("allows inheritance from a parent resolve()", function () { + var r = $r.resolve({ fun: function () { return true; } }); + var s = $r.resolve({ games: function () { return true; } }, r); + tick(); + expect(r).toBeResolved(); + expect(resolvedValue(s)).toEqual({ fun: true, games: true }); + }); + + it("only accepts promises from $resolve as parent", inject(function ($q) { + expect(caught(function () { + $r.resolve({}, null, $q.defer().promise); + })).toMatch(/\$resolve\.resolve/); + })); + + it("resolves dependencies from a parent resolve()", function () { + var r = $r.resolve({ a: [ function() { return 'aa' } ] }); + var b = jasmine.createSpy('b'); + var s = $r.resolve({ b: [ 'a', b ] }, r); + tick(); + expect(b).toHaveBeenCalled(); + expect(b.mostRecentCall.args).toEqual([ 'aa' ]); + }); + + it("allows a function to override a parent value of the same name", function () { + var r = $r.resolve({ b: function() { return 'B' } }); + var s = $r.resolve({ + a: function (b) { return 'a:' + b }, + b: function (b) { return '(' + b + ')' }, + c: function (b) { return 'c:' + b } + }, r); + tick(); + expect(resolvedValue(s)).toEqual({ a: 'a:(B)', b:'(B)', c:'c:(B)' }); + }); + + it("allows a function to override a parent value of the same name with a promise", inject(function ($q) { + var r = $r.resolve({ b: function() { return 'B' } }); + var superb, bd = $q.defer(); + var s = $r.resolve({ + a: function (b) { return 'a:' + b }, + b: function (b) { superb = b; return bd.promise }, + c: function (b) { return 'c:' + b } + }, r); + tick(); + bd.resolve('(' + superb + ')'); + tick(); + expect(resolvedValue(s)).toEqual({ a: 'a:(B)', b:'(B)', c:'c:(B)' }); + })); + + it("it only resolves after the parent resolves", inject(function ($q) { + var bd = $q.defer(), b = jasmine.createSpy('b').andReturn(bd.promise); + var cd = $q.defer(), c = jasmine.createSpy('c').andReturn(cd.promise); + var r = $r.resolve({ c: [ c ] }); + var s = $r.resolve({ b: [ b ] }, r); + bd.resolve('bbb'); + tick(); + expect(r).not.toBeResolved(); + expect(s).not.toBeResolved(); + cd.resolve('ccc'); + tick(); + expect(resolvedValue(r)).toEqual({ c: 'ccc' }); + expect(resolvedValue(s)).toEqual({ b: 'bbb', c: 'ccc' }); + })); + + it("invokes functions as soon as possible", inject(function ($q) { + var ad = $q.defer(), a = jasmine.createSpy('a').andReturn(ad.promise); + var bd = $q.defer(), b = jasmine.createSpy('b').andReturn(bd.promise); + var cd = $q.defer(), c = jasmine.createSpy('c').andReturn(cd.promise); + + var r = $r.resolve({ c: [ c ] }); + var s = $r.resolve({ a: [ a ], b: [ 'c', b ] }, r); + expect(c).toHaveBeenCalled(); // synchronously + expect(a).toHaveBeenCalled(); // synchronously + expect(r).not.toBeResolved(); + expect(s).not.toBeResolved(); + cd.resolve('ccc'); + tick(); + expect(b).toHaveBeenCalled(); + expect(b.mostRecentCall.args).toEqual([ 'ccc' ]); + })); + + it("passes the specified 'self' argument as 'this'", function () { + var self = {}, passed; + $r.resolve({ fun: function () { passed = this; } }, null, null, self); + tick(); + expect(passed).toBe(self); + }); + + it("rejects missing dependencies but does not fail synchronously", function () { + var r = $r.resolve({ fun: function (invalid) {} }); + expect(r).not.toBeResolved(); + tick(); + expect(resolvedError(r)).toMatch(/unknown provider/i); + }); + + it("propagates exceptions thrown by the functions as a rejection", function () { + var r = $r.resolve({ fun: function () { throw "i want cake" } }); + expect(r).not.toBeResolved(); + tick(); + expect(resolvedError(r)).toBe("i want cake"); + }); + + it("propagates errors from a parent resolve", function () { + var error = [ "the cake is a lie" ]; + var r = $r.resolve({ foo: function () { throw error } }); + var s = $r.resolve({ bar: function () { 42 } }, r); + tick(); + expect(resolvedError(r)).toBe(error); + expect(resolvedError(s)).toBe(error); + }); + + it("does not invoke any functions if the parent resolve has already failed", function () { + var r = $r.resolve({ foo: function () { throw "oops" } }); + tick(); + expect(r).toBeResolved(); + var a = jasmine.createSpy('a'); + var s = $r.resolve({ a: [ a ] }, r); + tick(); + expect(resolvedError(s)).toBeDefined(); + expect(a).not.toHaveBeenCalled(); + }); + + it("does not invoke any more functions after a failure", inject(function ($q) { + var ad = $q.defer(), a = jasmine.createSpy('a').andReturn(ad.promise); + var cd = $q.defer(), c = jasmine.createSpy('c').andReturn(cd.promise); + var dd = $q.defer(), d = jasmine.createSpy('d').andReturn(dd.promise); + var r = $r.resolve({ a: [ 'c', a ], c: [ c ], d: [ d ] }); + dd.reject('dontlikeit'); + tick(); + expect(resolvedError(r)).toBeDefined(); + cd.resolve('ccc'); + tick(); + expect(a).not.toHaveBeenCalled(); + })); + + it("does not invoke any more functions after a parent failure", inject(function ($q) { + var ad = $q.defer(), a = jasmine.createSpy('a').andReturn(ad.promise); + var cd = $q.defer(), c = jasmine.createSpy('c').andReturn(cd.promise); + var dd = $q.defer(), d = jasmine.createSpy('d').andReturn(dd.promise); + var r = $r.resolve({ c: [ c ], d: [ d ] }); + var s = $r.resolve({ a: [ 'c', a ] }, r); + dd.reject('dontlikeit'); + tick(); + expect(resolvedError(r)).toBeDefined(); + expect(resolvedError(s)).toBeDefined(); + cd.resolve('ccc'); + tick(); + expect(a).not.toHaveBeenCalled(); + })); + }); + + describe(".study()", function () { + it("returns a resolver function", function () { + expect(typeof $r.study({})).toBe('function'); + }); + + it("refuses cyclic dependencies", function () { + var a = jasmine.createSpy('a'); + var b = jasmine.createSpy('b'); + expect(caught(function () { + $r.study({ a: [ 'b', a ], b: [ 'a', b ] }); + })).toMatch(/cyclic/i); + expect(a).not.toHaveBeenCalled(); + expect(b).not.toHaveBeenCalled(); + }); + + it("does not call the injectables", function () { + var a = jasmine.createSpy('a'); + var b = jasmine.createSpy('b'); + $r.study({ a: [ 'b', a ], b: [ b ] }); + expect(a).not.toHaveBeenCalled(); + expect(b).not.toHaveBeenCalled(); + }); + + it("returns a function that can be used multiple times", function () { + var trace = []; + var r = $r.study({ a: [ 'what', function (what) { trace.push("a: " + what) } ] }); + r({ what: '1' }); + expect(trace).toEqual([ 'a: 1' ]); + r({ what: 'hi' }); + expect(trace).toEqual([ 'a: 1', 'a: hi' ]); + }); + }); +}); + diff --git a/test/stateDirectivesSpec.js b/test/stateDirectivesSpec.js index c229c6383..5d92a4d8f 100644 --- a/test/stateDirectivesSpec.js +++ b/test/stateDirectivesSpec.js @@ -1,6 +1,6 @@ describe('uiStateRef', function() { - beforeEach(module('ui.state')); + beforeEach(module('ui.router.state')); beforeEach(module(function($stateProvider) { $stateProvider.state('index', { @@ -13,7 +13,41 @@ describe('uiStateRef', function() { })); describe('links', function() { - var el, scope; + var el, scope, document; + + beforeEach(inject(function($document) { + document = $document[0]; + })); + + function triggerClick(el, options) { + options = angular.extend({ + metaKey: false, + ctrlKey: false, + shiftKey: false, + altKey: false, + button: 0 + }, options || {}); + + var e = document.createEvent("MouseEvents"); + e.initMouseEvent( + "click", // typeArg of type DOMString, Specifies the event type. + true, // canBubbleArg of type boolean, Specifies whether or not the event can bubble. + true, // cancelableArg of type boolean, Specifies whether or not the event's default action can be prevented. + undefined, // viewArg of type views::AbstractView, Specifies the Event's AbstractView. + 0, // detailArg of type long, Specifies the Event's mouse click count. + 0, // screenXArg of type long, Specifies the Event's screen x coordinate + 0, // screenYArg of type long, Specifies the Event's screen y coordinate + 0, // clientXArg of type long, Specifies the Event's client x coordinate + 0, // clientYArg of type long, Specifies the Event's client y coordinate + options.ctrlKey, // ctrlKeyArg of type boolean, Specifies whether or not control key was depressed during the Event. + options.altKey, // altKeyArg of type boolean, Specifies whether or not alt key was depressed during the Event. + options.shiftKey, // shiftKeyArg of type boolean, Specifies whether or not shift key was depressed during the Event. + options.metaKey, // metaKeyArg of type boolean, Specifies whether or not meta key was depressed during the Event. + options.button, // buttonArg of type unsigned short, Specifies the Event's mouse button. + null // relatedTargetArg of type EventTarget + ); + el[0].dispatchEvent(e); + } beforeEach(inject(function($rootScope, $compile) { el = angular.element('Details'); @@ -27,27 +61,64 @@ describe('uiStateRef', function() { it('should generate the correct href', function() { - expect(el.attr('href')).toBe('/contacts/5'); + expect(el.attr('href')).toBe('#/contacts/5'); }); it('should update the href when parameters change', function() { - expect(el.attr('href')).toBe('/contacts/5'); + expect(el.attr('href')).toBe('#/contacts/5'); scope.contact.id = 6; scope.$apply(); - expect(el.attr('href')).toBe('/contacts/6'); + expect(el.attr('href')).toBe('#/contacts/6'); }); - it('should transition states when clicked', inject(function($state, $stateParams, $document, $q) { + it('should transition states when left-clicked', inject(function($state, $stateParams, $document, $q) { expect($state.$current.name).toEqual(''); - var e = $document[0].createEvent("MouseEvents"); - e.initMouseEvent("click"); - el[0].dispatchEvent(e); - + triggerClick(el); $q.flush(); + expect($state.current.name).toEqual('contacts.item.detail'); expect($stateParams).toEqual({ id: "5" }); })); + + it('should not transition states when ctrl-clicked', inject(function($state, $stateParams, $document, $q) { + expect($state.$current.name).toEqual(''); + triggerClick(el, { ctrlKey: true }); + + $q.flush(); + expect($state.current.name).toEqual(''); + expect($stateParams).toEqual({ id: "5" }); + })); + + it('should not transition states when meta-clicked', inject(function($state, $stateParams, $document, $q) { + expect($state.$current.name).toEqual(''); + + triggerClick(el, { metaKey: true }); + $q.flush(); + + expect($state.current.name).toEqual(''); + expect($stateParams).toEqual({ id: "5" }); + })); + + it('should not transition states when shift-clicked', inject(function($state, $stateParams, $document, $q) { + expect($state.$current.name).toEqual(''); + + triggerClick(el, { shiftKey: true }); + $q.flush(); + + expect($state.current.name).toEqual(''); + expect($stateParams).toEqual({ id: "5" }); + })); + + it('should not transition states when middle-clicked', inject(function($state, $stateParams, $document, $q) { + expect($state.$current.name).toEqual(''); + + triggerClick(el, { button: 1 }); + $q.flush(); + + expect($state.current.name).toEqual(''); + expect($stateParams).toEqual({ id: "5" }); + })); }); describe('forms', function() { @@ -64,7 +135,7 @@ describe('uiStateRef', function() { })); it('should generate the correct action', function() { - expect(el.attr('action')).toBe('/contacts/5'); + expect(el.attr('action')).toBe('#/contacts/5'); }); }); }); diff --git a/test/stateSpec.js b/test/stateSpec.js index 6c166ee8c..31a41032d 100644 --- a/test/stateSpec.js +++ b/test/stateSpec.js @@ -1,6 +1,11 @@ describe('state', function () { - - beforeEach(module('ui.state')); + + var locationProvider; + + beforeEach(module('ui.router.state', function($locationProvider) { + locationProvider = $locationProvider; + $locationProvider.html5Mode(false); + })); var log, logEvents, logEnterExit; function eventLogger(event, to, toParams, from, fromParams) { @@ -18,10 +23,13 @@ describe('state', function () { 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'} } AppInjectable = {}; beforeEach(module(function ($stateProvider, $provide) { - angular.forEach([ A, B, C, D, DD, E ], function (state) { + angular.forEach([ A, B, C, D, DD, E, H, HH, HHH ], function (state) { state.onEnter = callbackLogger('onEnter'); state.onExit = callbackLogger('onExit'); }); @@ -33,6 +41,9 @@ describe('state', function () { .state('D', D) .state('DD', DD) .state('E', E) + .state('H', H) + .state('HH', HH) + .state('HHH', HHH) .state('home', { url: "/" }) .state('home.item', { url: "front/:id" }) @@ -199,6 +210,54 @@ 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.go('^.^.sidebar'); $q.flush(); + expect($state.$current.name).toBe('about.sidebar'); + + // Transitions to absolute state + $state.go("home"); $q.flush(); + expect($state.$current.name).toBe('home'); + + + // Transition to a child state + $state.go(".item", { id: 5 }); $q.flush(); + expect($state.$current.name).toBe('home.item'); + + // Transition to grandparent's sibling through root + // (Equivalent to absolute transition, assuming the root is known). + $state.go("^.^.about"); $q.flush(); + expect($state.$current.name).toBe('about'); + + // Transition to grandchild + $state.go(".person.item", { person: "bob", id: 13 }); $q.flush(); + expect($state.$current.name).toBe('about.person.item'); + + // Transition to immediate parent + $state.go("^"); $q.flush(); + expect($state.$current.name).toBe('about.person'); + + // Transition to parent's sibling + $state.go("^.sidebar"); $q.flush(); + expect($state.$current.name).toBe('about.sidebar'); + })); + + it('keeps parameters from common ancestor states', inject(function ($state, $stateParams, $q) { + $state.transitionTo('about.person', { person: 'bob' }); + $q.flush(); + + $state.go('.item', { id: 5 }); + $q.flush(); + + expect($state.$current.name).toBe('about.person.item'); + expect($stateParams).toEqual({ person: 'bob', id: '5' }); + + $state.go('^.^.sidebar'); + $q.flush(); + expect($state.$current.name).toBe('about.sidebar'); + })); + }); describe('.current', function () { it('is always defined', inject(function ($state) { @@ -261,18 +320,142 @@ describe('state', function () { })); it('generates a parent state URL when lossy is true', inject(function ($state) { - expect($state.href("about.sidebar", null, { lossy: true })).toEqual("/about"); + expect($state.href("about.sidebar", null, { lossy: true })).toEqual("#/about"); })); it('generates a URL without parameters', inject(function ($state) { - expect($state.href("home")).toEqual("/"); - expect($state.href("about", {})).toEqual("/about"); - expect($state.href("about", { foo: "bar" })).toEqual("/about"); + expect($state.href("home")).toEqual("#/"); + expect($state.href("about", {})).toEqual("#/about"); + expect($state.href("about", { foo: "bar" })).toEqual("#/about"); })); it('generates a URL with parameters', inject(function ($state) { + expect($state.href("about.person", { person: "bob" })).toEqual("#/about/bob"); + expect($state.href("about.person.item", { person: "bob", id: null })).toEqual("#/about/bob/"); + })); + }); + + describe('.getConfig()', function () { + it("should return the state's config", inject(function ($state) { + expect($state.getConfig('home').url).toBe('/'); + expect($state.getConfig('home.item').url).toBe('front/:id'); + expect($state.getConfig('A')).toBe(A); + expect(function() { $state.getConfig('Z'); }).toThrow("No such state 'Z'"); + })); + }); + + describe(' "data" property inheritance/override', function () { + it('"data" property should stay immutable for if state doesn\'t have parent', inject(function ($state) { + initStateTo(H); + expect($state.current.name).toEqual('H'); + expect($state.current.data.propA).toEqual(H.data.propA); + expect($state.current.data.propB).toEqual(H.data.propB); + })); + + it('"data" property should be inherited from parent if state doesn\'t define it', inject(function ($state) { + initStateTo(HH); + expect($state.current.name).toEqual('HH'); + expect($state.current.data.propA).toEqual(H.data.propA); + expect($state.current.data.propB).toEqual(H.data.propB); + })); + + it('"data" property should be overridden/extended if state defines it', inject(function ($state) { + initStateTo(HHH); + expect($state.current.name).toEqual('HHH'); + expect($state.current.data.propA).toEqual(HHH.data.propA); + expect($state.current.data.propB).toEqual(H.data.propB); + expect($state.current.data.propB).toEqual(HH.data.propB); + expect($state.current.data.propC).toEqual(HHH.data.propC); + })); + }); + + describe('html5Mode compatibility', function() { + + it('should generate non-hashbang URLs in HTML5 mode', inject(function ($state) { + expect($state.href("about.person", { person: "bob" })).toEqual("#/about/bob"); + locationProvider.html5Mode(true); expect($state.href("about.person", { person: "bob" })).toEqual("/about/bob"); - expect($state.href("about.person.item", { person: "bob", id: null })).toEqual("/about/bob/"); + })); + }); + + describe('url handling', function () { + + it('should transition to the same state with different parameters', inject(function ($state, $rootScope, $location) { + $location.path("/about/bob"); + $rootScope.$broadcast("$locationChangeSuccess"); + $rootScope.$apply(); + expect($state.params).toEqual({ person: "bob" }); + + $location.path("/about/larry"); + $rootScope.$broadcast("$locationChangeSuccess"); + $rootScope.$apply(); + expect($state.params).toEqual({ person: "larry" }); + })); + }); + + describe('default properties', function() { + it('should always have a name', inject(function ($state, $q) { + $state.transitionTo(A); + $q.flush(); + expect($state.$current.name).toBe('A'); + expect($state.$current.toString()).toBe('A'); + })); + + it('should always have a resolve object', inject(function ($state) { + expect($state.$current.resolve).toEqual({}); + })); + }); + + describe(' "data" property inheritance/override', function () { + it('"data" property should stay immutable for if state doesn\'t have parent', inject(function ($state) { + initStateTo(H); + expect($state.current.name).toEqual('H'); + expect($state.current.data.propA).toEqual(H.data.propA); + expect($state.current.data.propB).toEqual(H.data.propB); + })); + + it('"data" property should be inherited from parent if state doesn\'t define it', inject(function ($state) { + initStateTo(HH); + expect($state.current.name).toEqual('HH'); + expect($state.current.data.propA).toEqual(H.data.propA); + expect($state.current.data.propB).toEqual(H.data.propB); + })); + + it('"data" property should be overridden/extended if state defines it', inject(function ($state) { + initStateTo(HHH); + expect($state.current.name).toEqual('HHH'); + expect($state.current.data.propA).toEqual(HHH.data.propA); + expect($state.current.data.propB).toEqual(H.data.propB); + expect($state.current.data.propB).toEqual(HH.data.propB); + expect($state.current.data.propC).toEqual(HHH.data.propC); + })); + }); + + describe('html5Mode compatibility', function() { + + it('should generate non-hashbang URLs in HTML5 mode', inject(function ($state) { + expect($state.href("about.person", { person: "bob" })).toEqual("#/about/bob"); + locationProvider.html5Mode(true); + expect($state.href("about.person", { person: "bob" })).toEqual("/about/bob"); + })); + }); + + describe('default properties', function () { + it('should always have a name', inject(function ($state, $q) { + $state.transitionTo(A); + $q.flush(); + expect($state.$current.name).toBe('A'); + expect($state.$current.toString()).toBe('A'); + })); + + it('should always have a resolve object', inject(function ($state) { + expect($state.$current.resolve).toEqual({}); + })); + + it('should include itself and parent states', inject(function ($state, $q) { + $state.transitionTo(DD); + $q.flush(); + expect($state.$current.includes).toEqual({ '': true, D: true, DD: true }); })); }); }); diff --git a/test/templateFactorySpec.js b/test/templateFactorySpec.js index 7a7d29d65..f330dadc2 100644 --- a/test/templateFactorySpec.js +++ b/test/templateFactorySpec.js @@ -1,6 +1,6 @@ describe('templateFactory', function () { - beforeEach(module('ui.util')); + beforeEach(module('ui.router.util')); it('exists', inject(function ($templateFactory) { expect($templateFactory).toBeDefined(); diff --git a/test/testUtils.js b/test/testUtils.js index c9944bc5a..c515651aa 100644 --- a/test/testUtils.js +++ b/test/testUtils.js @@ -45,12 +45,15 @@ angular.module('ngMock') return $delegate; }); }); - + +function testablePromise(promise) { + if (!promise || !promise.then) throw new Error('Expected a promise, but got ' + jasmine.pp(promise) + '.'); + if (!isDefined(promise.$$resolved)) throw new Error('Promise has not been augmented by ngMock'); + return promise; +} function resolvedPromise(promise) { - if (!promise.then) throw new Error('Expected a promise, but got ' + jasmine.pp(promise) + '.'); - var result = promise.$$resolved; - if (!isDefined(result)) throw new Error('Promise has not been augmented by ngMock'); + var result = testablePromise(promise).$$resolved; if (!result) throw new Error('Promise is not resolved yet'); return result; } @@ -67,8 +70,26 @@ function resolvedError(promise) { return result.error; } +beforeEach(function () { + this.addMatchers({ + toBeResolved: function() { + return !!testablePromise(this.actual).$$resolved; + } + }); +}); + +// Misc test utils +function caught(fn) { + try { + fn(); + return null; + } catch (e) { + return e; + } +} // Utils for test from core angular var noop = angular.noop, toJson = angular.toJson; -beforeEach(module('ui.compat')); +//beforeEach(module('ui.router.compat')); + diff --git a/test/urlMatcherFactorySpec.js b/test/urlMatcherFactorySpec.js index 3d0f44803..bf8202c6b 100644 --- a/test/urlMatcherFactorySpec.js +++ b/test/urlMatcherFactorySpec.js @@ -40,6 +40,10 @@ describe("UrlMatcher", function () { .toBeNull(); }); + 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('.exec() throws on unbalanced capture list', function () { var shouldThrow = { "/url/{matchedParam:([a-z]+)}/child/{childParam}": '/url/someword/child/childParam', @@ -69,6 +73,10 @@ describe("UrlMatcher", function () { .toEqual('/users/123/details/default/444?from=1970'); }); + it(".format() encodes 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(); @@ -91,7 +99,7 @@ describe("urlMatcherFactory", function () { var $umf; - beforeEach(module('ui.util')); + beforeEach(module('ui.router.util')); beforeEach(inject(function($urlMatcherFactory) { $umf = $urlMatcherFactory; })); diff --git a/test/urlRouterSpec.js b/test/urlRouterSpec.js index e69de29bb..0e5f8e754 100644 --- a/test/urlRouterSpec.js +++ b/test/urlRouterSpec.js @@ -0,0 +1,85 @@ +describe("UrlRouter", function () { + + var $urp, $ur, location, match, scope; + + beforeEach(function() { + angular.module('ui.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.test'); + + 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") + }); + + }); + + describe("service", function() { + it("should execute rewrite rules", function () { + location.path("/foo"); + scope.$emit("$locationChangeSuccess"); + expect(location.path()).toBe("/foo"); + + location.path("/baz"); + scope.$emit("$locationChangeSuccess"); + expect(location.path()).toBe("/b4z"); + }); + + it("should keep otherwise last", function () { + $urp.otherwise('/otherwise'); + + location.path("/lastrule"); + scope.$emit("$locationChangeSuccess"); + expect(location.path()).toBe("/otherwise"); + + $urp.when('/lastrule', function($match) { + match = ['/lastrule', $match]; + }); + + location.path("/lastrule"); + scope.$emit("$locationChangeSuccess"); + expect(location.path()).toBe("/lastrule"); + }); + + it("should allow custom URL matchers", function () { + var custom = { + url: { exec: function() {}, format: function() {}, concat: function() {} }, + handler: function() {} + }; + + spyOn(custom.url, "exec").andReturn({}); + spyOn(custom.url, "format").andReturn("/foo-bar"); + spyOn(custom, "handler").andReturn(true); + + $urp.when(custom.url, custom.handler); + scope.$broadcast("$locationChangeSuccess"); + scope.$apply(); + + expect(custom.url.exec).toHaveBeenCalled(); + expect(custom.url.format).not.toHaveBeenCalled(); + expect(custom.handler).toHaveBeenCalled(); + }); + }); + +}); \ No newline at end of file diff --git a/test/viewDirectiveSpec.js b/test/viewDirectiveSpec.js index 164cd7ca6..8fe1cf579 100644 --- a/test/viewDirectiveSpec.js +++ b/test/viewDirectiveSpec.js @@ -6,7 +6,7 @@ describe('uiView', function () { var scope, $compile, elem; - beforeEach(module('ui.state')); + beforeEach(module('ui.router.state')); var aState = { template: 'aState template'