diff --git a/Gruntfile.js b/Gruntfile.js index d3318528c..0066bccb5 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -97,6 +97,12 @@ module.exports = function (grunt) { background: { background: true, browsers: [ grunt.option('browser') || 'PhantomJS' ] + }, + watch: { + configFile: 'config/karma.js', + singleRun: false, + autoWatch: true, + autoWatchInterval: 1 } }, changelog: { diff --git a/files.js b/files.js index 2e6463399..4d9439a0d 100644 --- a/files.js +++ b/files.js @@ -4,6 +4,7 @@ routerFiles = { 'src/resolve.js', 'src/templateFactory.js', 'src/urlMatcherFactory.js', + 'src/transition.js', 'src/urlRouter.js', 'src/state.js', 'src/view.js', @@ -16,6 +17,10 @@ routerFiles = { 'test/testUtils.js' ], test: [ + // 'test/stateSpec.js', + // 'test/resolveSpec.js', + // 'test/urlMatcherFactorySpec.js', + // 'test/urlRouterSpec.js', 'test/*Spec.js', 'test/compat/matchers.js' ], diff --git a/sample/app/app.js b/sample/app/app.js index 10a82ecde..eceec621c 100644 --- a/sample/app/app.js +++ b/sample/app/app.js @@ -22,8 +22,20 @@ angular.module('uiRouterSample', [ ) .config( - [ '$stateProvider', '$urlRouterProvider', - function ($stateProvider, $urlRouterProvider) { + [ '$stateProvider', '$urlRouterProvider', '$urlMatcherFactoryProvider', + function ($stateProvider, $urlRouterProvider, $urlMatcherFactoryProvider) { + + $urlMatcherFactoryProvider.type('contact', ['contacts', function(contacts) { + return { + encode: function(contact) { + return contact.id; + }, + decode: function(id) { + return contacts.get(id); + }, + pattern: /[0-9]{1,4}/ + }; + }]); ///////////////////////////// // Redirects and Otherwise // @@ -34,8 +46,8 @@ angular.module('uiRouterSample', [ // The `when` method says if the url is ever the 1st param, then redirect to the 2nd param // Here we are just setting up some convenience urls. - .when('/c?id', '/contacts/:id') - .when('/user/:id', '/contacts/:id') + // .when('/c?id', '/contacts/:id') + // .when('/user/:id', '/contacts/:id') // If the url is ever invalid, e.g. '/asdf', then redirect to '/' aka the home state .otherwise('/'); diff --git a/sample/app/contacts/contacts.js b/sample/app/contacts/contacts.js index 6414f578a..7b30d92db 100644 --- a/sample/app/contacts/contacts.js +++ b/sample/app/contacts/contacts.js @@ -5,6 +5,7 @@ angular.module('uiRouterSample.contacts', [ .config( [ '$stateProvider', '$urlRouterProvider', function ($stateProvider, $urlRouterProvider) { + $stateProvider ////////////// // Contacts // @@ -95,7 +96,7 @@ angular.module('uiRouterSample.contacts', [ // So its url will end up being '/contacts/{contactId:[0-9]{1,8}}'. When the // url becomes something like '/contacts/42' then this state becomes active // and the $stateParams object becomes { contactId: 42 }. - url: '/{contactId:[0-9]{1,4}}', + url: '/{contact:contact}', // If there is more than a single ui-view in the parent template, or you would // like to target a ui-view from even higher up the state tree, you can use the @@ -113,7 +114,7 @@ angular.module('uiRouterSample.contacts', [ templateUrl: 'app/contacts/contacts.detail.html', controller: ['$scope', '$stateParams', 'utils', function ( $scope, $stateParams, utils) { - $scope.contact = utils.findById($scope.contacts, $stateParams.contactId); + $scope.contact = $stateParams.contact }] }, diff --git a/src/common.js b/src/common.js index 1dc0172ee..110961cac 100644 --- a/src/common.js +++ b/src/common.js @@ -142,10 +142,74 @@ function filterByKeys(keys, values) { var filtered = {}; forEach(keys, function (name) { - filtered[name] = values[name]; + if (isDefined(values[name])) filtered[name] = values[name]; }); return filtered; } + +// like _.indexBy +// when you know that your index values will be unique, or you want last-one-in to win +function indexBy(array, propName) { + var result = {}; + forEach(array, function(item) { + result[item[propName]] = item; + }); + return result; +} + +// extracted from underscore.js +// Return a copy of the object only containing the whitelisted properties. +function pick(obj) { + var copy = {}; + var keys = Array.prototype.concat.apply(Array.prototype, Array.prototype.slice.call(arguments, 1)); + forEach(keys, function(key) { + if (key in obj) copy[key] = obj[key]; + }); + return copy; +} + +// extracted from underscore.js +// Return a copy of the object omitting the blacklisted properties. +function omit(obj) { + var copy = {}; + var keys = Array.prototype.concat.apply(Array.prototype, Array.prototype.slice.call(arguments, 1)); + for (var key in obj) { + if (keys.indexOf(key) == -1) copy[key] = obj[key]; + } + return copy; +} + +function pluck(collection, key) { + var result = isArray(collection) ? [] : {}; + + forEach(collection, function(val, i) { + result[i] = isFunction(key) ? key(val) : val[key]; + }); + return result; +} + +function map(collection, callback) { + var result = isArray(collection) ? [] : {}; + + forEach(collection, function(val, i) { + result[i] = callback(val, i); + }); + return result; +} + +function flattenPrototypeChain(obj) { + var objs = []; + do { + objs.push(obj); + } while ((obj = Object.getPrototypeOf(obj))); + objs.reverse(); + + var result = {}; + forEach(objs, function(obj) { + extend(result, obj); + }); + return result; +} /** * @ngdoc overview * @name ui.router.util diff --git a/src/resolve.js b/src/resolve.js index ea30680cf..062b13733 100644 --- a/src/resolve.js +++ b/src/resolve.js @@ -1,3 +1,5 @@ +var Resolvable, Path, PathElement, ResolveContext; + /** * @ngdoc object * @name ui.router.util.$resolve @@ -10,7 +12,289 @@ */ $Resolve.$inject = ['$q', '$injector']; function $Resolve( $q, $injector) { - + + /* + ------- Resolvable, PathElement, Path, ResolveContext ------------------ + I think these should be private API for now because we may need to iterate it for a while. + /* + + /* Resolvable + + The basic building block for the new resolve system. + Resolvables encapsulate a state's resolve's resolveFn, the resolveFn's declared dependencies, and the wrapped (.promise) + and unwrapped-when-complete (.data) result of the resolveFn. + + Resolvable.get() either retrieves the Resolvable's existing promise, or else invokes resolve() (which invokes the + resolveFn) and returns the resulting promise. + + Resolvable.get() and Resolvable.resolve() both execute within a ResolveContext, which is passed as the first + parameter to those fns. + */ + + + Resolvable = function Resolvable(name, resolveFn, state) { + var self = this; + + // Resolvable: resolveResolvable() This function is aliased to Resolvable.resolve() + + // synchronous part: + // - sets up the Resolvable's promise + // - retrieves dependencies' promises + // - returns promise for async part + + // asynchronous part: + // - wait for dependencies promises to resolve + // - invoke the resolveFn + // - wait for resolveFn promise to resolve + // - store unwrapped data + // - resolve the Resolvable's promise + function resolveResolvable(resolveContext) { + // First, set up an overall deferred/promise for this Resolvable + var deferred = $q.defer(); + self.promise = deferred.promise; + + // Load an assoc-array of all resolvables for this state from the resolveContext + // omit the current Resolvable from the PathElement in the ResolveContext so we don't try to inject self into self + var options = { omitPropsFromPrototype: [ self.name ], flatten: true }; + var ancestorsByName = resolveContext.getResolvableLocals(self.state.name, options); + + // Limit the ancestors Resolvables map to only those that the current Resolvable fn's annotations depends on + var depResolvables = pick(ancestorsByName, self.deps); + + // Get promises (or synchronously invoke resolveFn) for deps + var depPromises = map(depResolvables, function(resolvable) { + return resolvable.get(resolveContext); + }); + + // Return a promise chain that waits for all the deps to resolve, then invokes the resolveFn passing in the + // dependencies as locals, then unwraps the resulting promise's data. + return $q.all(depPromises).then(function invokeResolve(locals) { + try { + var result = $injector.invoke(self.resolveFn, state, locals); + deferred.resolve(result); + } catch (error) { + deferred.reject(error); + } + return self.promise; + }).then(function(data) { + self.data = data; + return self.promise; + }); + } + + // Public API + extend(this, { + name: name, + resolveFn: resolveFn, + state: state, + deps: $injector.annotate(resolveFn), + resolve: resolveResolvable, // aliased function name for stacktraces + promise: undefined, + data: undefined, + get: function(resolveContext) { + return self.promise || resolveResolvable(resolveContext); + } + }); + }; + + // An element in the path which represents a state and that state's Resolvables and their resolve statuses. + // When the resolved data is ready, it is stored in each Resolvable object within the PathElement + + // Should be passed a state object. I think maybe state could even be the public state, so users can add resolves + // on the fly. + PathElement = function PathElement(state) { + var self = this; + // Convert state's resolvable assoc-array into an assoc-array of empty Resolvable(s) + var resolvables = map(state.resolve || {}, function(resolveFn, resolveName) { + return new Resolvable(resolveName, resolveFn, state); + }); + + // private function + // returns a promise for all resolvables on this PathElement + function resolvePathElement(resolveContext) { + return $q.all(map(resolvables, function(resolvable) { return resolvable.get(resolveContext); })); + } + + // Injects a function at this PathElement level with available Resolvables + // First it resolves all resolvables. When they are done resolving, invokes the function. + // Returns a promise for the return value of the function. + // public function + // fn is the function to inject (onEnter, onExit, controller) + // locals are the regular-style locals to inject + // resolveContext is a ResolveContext which is for injecting state Resolvable(s) + function invokeLater(fn, locals, resolveContext) { + var deps = $injector.annotate(fn); + var resolvables = pick(resolveContext.getResolvableLocals(self.$$state.name), deps); + var promises = map(resolvables, function(resolvable) { return resolvable.get(resolveContext); }); + return $q.all(promises).then(function() { + try { + return self.invokeNow(fn, locals, resolveContext); + } catch (error) { + return $q.reject(error); + } + }); + } + + // private function? Maybe needs to be public-to-$transition to allow onEnter/onExit to be invoked synchronously + // and in the correct order, but only after we've manually ensured all the deps are resolved. + + // Injects a function at this PathElement level with available Resolvables + // Does not wait until all Resolvables have been resolved; you must call PathElement.resolve() (or manually resolve each dep) first + function invokeNow(fn, locals, resolveContext) { + var resolvables = resolveContext.getResolvableLocals(self.$$state.name); + var moreLocals = map(resolvables, function(resolvable) { return resolvable.data; }); + var combinedLocals = extend({}, locals, moreLocals); + return $injector.invoke(fn, self.$$state, combinedLocals); + } + + // public API so far + extend(this, { + state: function() { return state; }, + $$state: state, + resolvables: function() { return resolvables; }, + $$resolvables: resolvables, + resolve: resolvePathElement, // aliased function for stacktraces + invokeNow: invokeNow, // this might be private later + invokeLater: invokeLater + }); + }; + + // A Path Object holds an ordered list of PathElements. + // This object is used by ResolveContext to store resolve status for an entire path of states. + // It has concat and slice helper methods to return new Paths, based on the current Path. + + // statesOrPathElements must be an array of either state(s) or PathElement(s) + // states could be "public" state objects for this? + Path = function Path(statesOrPathElements) { + var self = this; + if (!isArray(statesOrPathElements)) throw new Error("states must be an array of state(s) or PathElement(s)", statesOrPathElements); + var isPathElementArray = (statesOrPathElements.length && (statesOrPathElements[0] instanceof PathElement)); + + var elements = statesOrPathElements; + if (!isPathElementArray) { // they passed in states; convert them to PathElements + elements = map(elements, function (state) { return new PathElement(state); }); + } + + // resolveContext holds stateful Resolvables (containing possibly resolved data), mapped per state-name. + function resolvePath(resolveContext) { + return $q.all(map(elements, function(element) { return element.resolve(resolveContext); })); + } + + // Not used + function invoke(hook, self, locals) { + if (!hook) return; + return $injector.invoke(hook, self, locals); + } + + // Public API + extend(this, { + resolve: resolvePath, + $$elements: elements, // for development at least + concat: function(path) { + return new Path(elements.concat(path.elements())); + }, + slice: function(start, end) { + return new Path(elements.slice(start, end)); + }, + elements: function() { + return elements; + }, + // I haven't looked at how $$enter and $$exit are going be used. + $$enter: function(/* locals */) { + // TODO: Replace with PathElement.invoke(Now|Later) + // TODO: If invokeNow (synchronous) then we have to .get() all Resolvables for all functions first. + for (var i = 0; i < states.length; i++) { + // entering.locals = toLocals[i]; + if (invoke(states[i].self.onEnter, states[i].self, locals(states[i])) === false) return false; + } + return true; + }, + $$exit: function(/* locals */) { + // TODO: Replace with PathElement.invoke(Now|Later) + for (var i = states.length - 1; i >= 0; i--) { + if (invoke(states[i].self.onExit, states[i].self, locals(states[i])) === false) return false; + // states[i].locals = null; + } + return true; + } + }); + }; + + // ResolveContext is passed into each resolve() function, and is used to statefully manage Resolve status. + // ResolveContext is essentially the replacement data structure for $state.$current.locals and we'll have to + // figure out where to store/manage this data structure. + // It manages a set of Resolvables that are available at each level of the Path. + // It follows the list of PathElements and inherit()s the PathElement's Resolvables on top of the + // previous PathElement's Resolvables. i.e., it builds a prototypal chain for the PathElements' Resolvables. + // Before moving on to the next PathElement, it makes a note of what Resolvables are available for the current + // PathElement, and maps it by state name. + + // ResolveContext constructor takes a path which is assumed to be partially resolved, or + // not resolved at all, which we're in process of resolving + ResolveContext = function ResolveContext(path) { + if (path === undefined) path = new Path([]); + var resolvablesByState = {}, previousIteration = {}; + + forEach(path.elements(), function (pathElem) { + var resolvablesForPE = pathElem.resolvables(); + var resolvesbyName = indexBy(resolvablesForPE, 'name'); + var resolvables = inherit(previousIteration, resolvesbyName); // note prototypal inheritance + previousIteration = resolvablesByState[pathElem.state().name] = resolvables; + }); + + // Gets resolvables available for a particular state. + // TODO: This should probably be "for a particular PathElement" instead of state, but PathElements encapsulate a state. + // This returns the Resolvable map by state name. + + // options.omitPropsFromPrototype + // Remove the props specified in options.omitPropsFromPrototype from the prototype of the object. + + // This hides a top-level resolvable by name, potentially exposing a parent resolvable of the same name + // further down the prototype chain. + + // This is used to provide a Resolvable access to all other Resolvables in its same PathElement, yet disallow + // that Resolvable access to its own injectable Resolvable reference. + + // This is also used to allow a state to override a parent state's resolve while also injecting + // that parent state's resolve: + + // state({ name: 'G', resolve: { _G: function() { return "G"; } } }); + // state({ name: 'G.G2', resolve: { _G: function(_G) { return _G + "G2"; } } }); + // where injecting _G into a controller will yield "GG2" + + // options.flatten + // $$resolvablesByState has resolvables organized in a prototypal inheritance chain. options.flatten will + // flatten the object from prototypal inheritance to a simple object with all its prototype chain properties + // exposed with child properties taking precedence over parent properties. + function getResolvableLocals(stateName, options) { + var resolvables = (resolvablesByState[stateName] || {}); + options = extend({ flatten: true, omitPropsFromPrototype: [] }, options); + + // Create a shallow clone referencing the original prototype chain. This is so we can alter the clone's + // prototype without affecting the actual object (for options.omitPropsFromPrototype) + var shallowClone = Object.create(Object.getPrototypeOf(resolvables)); + for (var property in resolvables) { + if (resolvables.hasOwnProperty(property)) { shallowClone[property] = resolvables[property]; } + } + + // Omit any specified top-level prototype properties + forEach(options.omitPropsFromPrototype, function(prop) { + delete(shallowClone[prop]); // possibly exposes the same prop from prototype chain + }); + + if (options.flatten) // Flatten from prototypal chain to simple object + shallowClone = flattenPrototypeChain(shallowClone); + + return shallowClone; + } + + extend(this, { + getResolvableLocals: getResolvableLocals, + $$resolvablesByState: resolvablesByState + }); + }; + + // ----------------- 0.2.xx Legacy API here ------------------------ var VISIT_IN_PROGRESS = 1, VISIT_DONE = 2, NOTHING = {}, @@ -33,7 +317,7 @@ function $Resolve( $q, $injector) { *
    * $resolve.resolve(invocables, locals, parent, self)
    * 
- * but the former is more efficient (in fact `resolve` just calls `study` + * but the former is more efficient (in fact `resolve` just calls `study` * internally). * * @param {object} invocables Invocable objects @@ -41,19 +325,22 @@ function $Resolve( $q, $injector) { */ 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(value); }], NO_DEPENDENCIES); } else { @@ -63,61 +350,59 @@ function $Resolve( $q, $injector) { }); 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"); - } + 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()"); - } - + 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, + 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); + if (!merged) merge(values, parent.$$values); result.$$values = values; result.$$promises = true; // keep for isResolve() delete result.$$inheritedValues; 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; } - + if (parent.$$inheritedValues) { merge(values, parent.$$inheritedValues); } @@ -135,13 +420,13 @@ function $Resolve( $q, $injector) { 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 -1; + }, + + // Factories a glob matcher from a string + fromString: function(text) { + if (!this.is(text)) return null; + return new Glob(text); + } + }; +})(); + + +function StateQueueManager(states, builder, $urlRouterProvider) { + var queue = [], abstractKey = 'abstract'; + + extend(this, { + register: function(config, pre) { + // Wrap a new object around the state so we can store our private details easily. + state = inherit(config, { + name: builder.name(config), + self: config, + resolve: config.resolve || {}, + toString: function() { return this.name; } + }); - var root, states = {}, $state, queue = {}, abstractKey = 'abstract'; + if (!isString(state.name)) throw new Error("State must have a valid name"); + // if (registered.hasOwnProperty(name)) throw new Error("State '" + name + "'' is already defined"); + if (pre) + queue.unshift(state); + else + queue.push(state); + return state; + }, + + flush: function() { + var result, state, orphans = []; - // Builds state properties from definition passed to registerState() - var stateBuilder = { + while (queue.length > 0) { + state = queue.shift(); + result = builder.build(state); + + if (result) { + states[state.name] = state; + this.attachRoute(state); + continue; + } + if (orphans.indexOf(state) >= 0) { + throw new Error("Cannot register orphaned state '" + state.name + "'"); + } + orphans.push(state); + queue.push(state); + } + return states; + }, + + attachRoute: function(state) { + if (state[abstractKey] || !state.url) return; + + $urlRouterProvider.when(state.url, ['$match', '$stateParams', function ($match, $stateParams) { + if ($state.$current.navigable != state || !equalForKeys($match, $stateParams)) { + $state.transitionTo(state, $match, { location: false }); + } + }]); + } + }); +} + +// Builds state properties from definition passed to StateQueueManager.register() +function StateBuilder(root, matcher, $urlMatcherFactoryProvider) { + + var self = this, delegates = {}, builders = { - // Derive parent state from a hierarchical name only if 'parent' is not explicitly defined. - // 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(state.name); - return compositeName ? findState(compositeName[1]) : root; + return matcher.find(self.parentName(state)); }, - // 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 = extend({}, state.parent.data, state.data); @@ -49,13 +118,13 @@ function $StateProvider( $urlRouterProvider, $urlMatcherFactory) { // Build a URLMatcher if necessary, either via a relative or absolute URL url: function(state) { var url = state.url, config = { params: state.params || {} }; + var parent = state.parent; if (isString(url)) { - if (url.charAt(0) == '^') return $urlMatcherFactory.compile(url.substring(1), config); - return (state.parent.navigable || root).url.concat(url, config); + if (url.charAt(0) == '^') return $urlMatcherFactoryProvider.compile(url.substring(1), config); + return ((parent && parent.navigable) || root()).url.concat(url, config); } - - if (!url || $urlMatcherFactory.isMatcher(url)) return url; + if (!url || $urlMatcherFactoryProvider.isMatcher(url)) return url; throw new Error("Invalid url '" + url + "' in state '" + state + "'"); }, @@ -66,10 +135,10 @@ 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.params : state.parent.params; - } - return state.params; + if (state.params) return state.params; + if (state.url) return state.url.params; + if (state.parent) return state.parent.params; + return null; }, // If there is no explicit multi-view configuration, make one up so we don't have @@ -78,35 +147,21 @@ function $StateProvider( $urlRouterProvider, $urlMatcherFactory) { // 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 = {}; + var views = {}, + tplKeys = ['templateProvider', 'templateUrl', 'template', 'notify', 'async'], + ctrlKeys = ['controller', 'controllerProvider', 'controllerAs']; + var allKeys = tplKeys.concat(ctrlKeys); - forEach(isDefined(state.views) ? state.views : { '': state }, function (view, name) { - if (name.indexOf('@') < 0) name += '@' + state.parent.name; - views[name] = view; - }); - return views; - }, + forEach(state.views || { "$default": filterByKeys(allKeys, state) }, function (config, name) { - ownParams: function(state) { - state.params = state.params || {}; - - if (!state.parent) { - return objectKeys(state.params); - } - var paramNames = {}; forEach(state.params, function (v, k) { paramNames[k] = true; }); + // Allow controller settings to be defined at the state level for all views + forEach(ctrlKeys, function(key) { + if (state[key] && !config[key]) config[key] = state[key]; + }); - forEach(state.parent.params, function (v, k) { - if (!paramNames[k]) { - throw new Error("Missing required parameter '" + k + "' in state '" + state.name + "'"); - } - paramNames[k] = false; - }); - var ownParams = []; - - forEach(paramNames, function (own, p) { - if (own) ownParams.push(p); + if (objectKeys(config).length > 0) views[name] = config; }); - return ownParams; + return views; }, // Keep a full path from the root down to this state as this is needed for state activation. @@ -114,28 +169,78 @@ function $StateProvider( $urlRouterProvider, $urlMatcherFactory) { return state.parent ? state.parent.path.concat(state) : []; // exclude root from path }, - // Speed up $state.contains() as it's used a lot + // Speed up $state.includes() as it's used a lot includes: function(state) { var includes = state.parent ? extend({}, state.parent.includes) : {}; includes[state.name] = true; return includes; + } + }; + + extend(this, { + builder: function(name, func) { + if (isString(name) && !isDefined(func)) return builders[name]; + if (!isFunction(func) || !isString(name)) return; + + if (builders[name]) { + delegates[name] = delegates[name] || []; + delegates[name].push(builders[name]); + } + builders[name] = func; }, - $delegates: {} - }; + build: function(state) { + var parent = this.parentName(state); + if (parent && !matcher.find(parent)) return null; - function isRelative(stateName) { - return stateName.indexOf(".") === 0 || stateName.indexOf("^") === 0; - } + for (var key in builders) { + // @todo Implement currying for multiple delegates + if (delegates[key] && delegates[key].length) { + state[key] = delegates[key][0](state, builders[key]); + } else { + state[key] = builders[key](state); + } + } + return state; + }, + + parentName: function(state) { + var name = state.name; + if (name.indexOf('.') !== -1) return name.substring(0, name.lastIndexOf('.')); + if (!state.parent) return ""; + return isString(state.parent) ? state.parent : state.parent.name; + }, - function findState(stateOrName, base) { - if (!stateOrName) return undefined; + name: function(state) { + var name = state.name; + if (name.indexOf('.') !== -1 || !state.parent) return name; + + var parentName = isString(state.parent) ? state.parent : state.parent.name; + return parentName ? parentName + "." + name : name; + } + }); +} + +function StateMatcher(states) { + extend(this, { + isRelative: function(stateName) { + return stateName.indexOf(".") === 0 || stateName.indexOf("^") === 0; + }, - var isStr = isString(stateOrName), - name = isStr ? stateOrName : stateOrName.name, - path = isRelative(name); + find: function(stateOrName, base) { + if (!stateOrName && stateOrName !== "") return undefined; + var isStr = isString(stateOrName), name = isStr ? stateOrName : stateOrName.name; - if (path) { + if (this.isRelative(name)) name = this.resolvePath(name, base); + var state = states[name]; + + if (state && (isStr || (!isStr && (state === stateOrName || state.self === stateOrName)))) { + return state; + } + return undefined; + }, + + resolvePath: function(name, base) { if (!base) throw new Error("No reference point given for path '" + name + "'"); var rel = name.split("."), i = 0, pathLength = rel.length, current = base; @@ -152,114 +257,42 @@ function $StateProvider( $urlRouterProvider, $urlMatcherFactory) { 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; - } - return undefined; - } - - function queueState(parentName, state) { - if (!queue[parentName]) { - queue[parentName] = []; - } - queue[parentName].push(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.hasOwnProperty(name)) throw new Error("State '" + name + "'' is already defined"); - - // Get parent name - var parentName = (name.indexOf('.') !== -1) ? name.substring(0, name.lastIndexOf('.')) - : (isString(state.parent)) ? state.parent - : ''; - - // If parent is not registered yet, add state to queue and register later - if (parentName && !states[parentName]) { - return queueState(parentName, state.self); - } - - for (var key in stateBuilder) { - if (isFunction(stateBuilder[key])) state[key] = stateBuilder[key](state, stateBuilder.$delegates[key]); - } - states[name] = state; - - // Register the state in the global state list and with $urlRouter if necessary. - if (!state[abstractKey] && state.url) { - $urlRouterProvider.when(state.url, ['$match', '$stateParams', function ($match, $stateParams) { - if ($state.$current.navigable != state || !equalForKeys($match, $stateParams)) { - $state.transitionTo(state, $match, { location: false }); - } - }]); - } - - // Register any queued children - if (queue[name]) { - for (var i = 0; i < queue[name].length; i++) { - registerState(queue[name][i]); - } - } - - return state; - } - - // Checks text to see if it looks like a glob. - function isGlob (text) { - return text.indexOf('*') > -1; - } - - // Returns true if glob matches current $state name. - function doesStateMatchGlob (glob) { - var globSegments = glob.split('.'), - segments = $state.$current.name.split('.'); - - //match greedy starts - if (globSegments[0] === '**') { - segments = segments.slice(segments.indexOf(globSegments[1])); - segments.unshift('**'); - } - //match greedy ends - if (globSegments[globSegments.length - 1] === '**') { - segments.splice(segments.indexOf(globSegments[globSegments.length - 2]) + 1, Number.MAX_VALUE); - segments.push('**'); - } - - if (globSegments.length != segments.length) { - return false; - } - - //match single stars - for (var i = 0, l = globSegments.length; i < l; i++) { - if (globSegments[i] === '*') { - segments[i] = '*'; - } + return current.name + (current.name && rel ? "." : "") + rel; } + }); +} - return segments.join('') === globSegments.join(''); - } +/** + * @ngdoc object + * @name ui.router.state.$stateProvider + * + * @requires ui.router.router.$urlRouterProvider + * @requires ui.router.util.$urlMatcherFactoryProvider + * + * @description + * The new `$stateProvider` works similar to Angular's v1 router, but it focuses purely + * on state. + * + * A state corresponds to a "place" in the application in terms of the overall UI and + * navigation. A state describes (via the controller / template / view properties) what + * the UI looks like and does at that place. + * + * States often have things in common, and the primary way of factoring out these + * commonalities in this model is via the state hierarchy, i.e. parent/child states aka + * nested states. + * + * The `$stateProvider` provides interfaces to declare these states for your app. + */ +$StateProvider.$inject = ['$urlRouterProvider', '$urlMatcherFactoryProvider']; +function $StateProvider( $urlRouterProvider, $urlMatcherFactoryProvider) { + var root, states = {}, abstractKey = 'abstract'; - // Implicit root state that is always active - root = registerState({ - name: '', - url: '^', - views: null, - 'abstract': true - }); - root.navigable = null; + var matcher = new StateMatcher(states); + var builder = new StateBuilder(function() { return root; }, matcher, $urlMatcherFactoryProvider); + var queue = new StateQueueManager(states, builder, $urlRouterProvider); + function $state() {} /** * @ngdoc function @@ -267,9 +300,9 @@ function $StateProvider( $urlRouterProvider, $urlMatcherFactory) { * @methodOf ui.router.state.$stateProvider * * @description - * Allows you to extend (carefully) or override (at your own peril) the - * `stateBuilder` object used internally by `$stateProvider`. This can be used - * to add custom functionality to ui-router, for example inferring templateUrl + * Allows you to extend (carefully) or override (at your own peril) the + * `stateBuilder` object used internally by `$stateProvider`. This can be used + * to add custom functionality to ui-router, for example inferring templateUrl * based on the state name. * * When passing only a name, it returns the current (original or decorated) builder @@ -278,14 +311,14 @@ function $StateProvider( $urlRouterProvider, $urlMatcherFactory) { * The builder functions that can be decorated are listed below. Though not all * necessarily have a good use case for decoration, that is up to you to decide. * - * In addition, users can attach custom decorators, which will generate new - * properties within the state's internal definition. There is currently no clear - * use-case for this beyond accessing internal states (i.e. $state.$current), - * however, expect this to become increasingly relevant as we introduce additional + * In addition, users can attach custom decorators, which will generate new + * properties within the state's internal definition. There is currently no clear + * use-case for this beyond accessing internal states (i.e. $state.$current), + * however, expect this to become increasingly relevant as we introduce additional * meta-programming features. * - * **Warning**: Decorators should not be interdependent because the order of - * execution of the builder functions in non-deterministic. Builder functions + * **Warning**: Decorators should not be interdependent because the order of + * execution of the builder functions in non-deterministic. Builder functions * should only be dependent on the state definition object and super function. * * @@ -296,21 +329,21 @@ function $StateProvider( $urlRouterProvider, $urlMatcherFactory) { * overridden by own values (if any). * - **url** `{object}` - returns a {@link ui.router.util.type:UrlMatcher UrlMatcher} * or `null`. - * - **navigable** `{object}` - returns closest ancestor state that has a URL (aka is + * - **navigable** `{object}` - returns closest ancestor state that has a URL (aka is * navigable). - * - **params** `{object}` - returns an array of state params that are ensured to + * - **params** `{object}` - returns an array of state params that are ensured to * be a super-set of parent's params. - * - **views** `{object}` - returns a views object where each key is an absolute view - * name (i.e. "viewName@stateName") and each value is the config object - * (template, controller) for the view. Even when you don't use the views object + * - **views** `{object}` - returns a views object where each key is an absolute view + * name (i.e. "viewName@stateName") and each value is the config object + * (template, controller) for the view. Even when you don't use the views object * explicitly on a state config, one is still created for you internally. - * So by decorating this builder function you have access to decorating template + * So by decorating this builder function you have access to decorating template * and controller properties. - * - **ownParams** `{object}` - returns an array of params that belong to the state, + * - **ownParams** `{object}` - returns an array of params that belong to the state, * not including any params defined by ancestor states. - * - **path** `{string}` - returns the full path from the root down to this state. + * - **path** `{string}` - returns the full path from the root down to this state. * Needed for state activation. - * - **includes** `{object}` - returns an object that includes every state that + * - **includes** `{object}` - returns an object that includes every state that * would pass a `$state.includes()` test. * * @example @@ -343,8 +376,8 @@ function $StateProvider( $urlRouterProvider, $urlMatcherFactory) { * // and /partials/home/contact/item.html, respectively. * * - * @param {string} name The name of the builder function to decorate. - * @param {object} func A function that is responsible for decorating the original + * @param {string} name The name of the builder function to decorate. + * @param {object} func A function that is responsible for decorating the original * builder function. The function receives two parameters: * * - `{object}` - state - The state config object. @@ -355,17 +388,7 @@ function $StateProvider( $urlRouterProvider, $urlMatcherFactory) { this.decorator = decorator; function decorator(name, func) { /*jshint validthis: true */ - if (isString(name) && !isDefined(func)) { - return stateBuilder[name]; - } - if (!isFunction(func) || !isString(name)) { - return this; - } - if (stateBuilder[name] && !stateBuilder.$delegates[name]) { - stateBuilder.$delegates[name] = stateBuilder[name]; - } - stateBuilder[name] = func; - return this; + return builder.builder(name, func) || this; } /** @@ -380,9 +403,9 @@ function $StateProvider( $urlRouterProvider, $urlMatcherFactory) { * * * - **`template`** - {string|function=} - html template as a string or a function that returns - * an html template as a string which should be used by the uiView directives. This property + * an html template as a string which should be used by the uiView directives. This property * takes precedence over templateUrl. - * + * * If `template` is a function, it will be called with the following parameters: * * - {array.<object>} - state parameters extracted from the current $location.path() by @@ -390,12 +413,12 @@ function $StateProvider( $urlRouterProvider, $urlMatcherFactory) { * * * - * - **`templateUrl`** - {string|function=} - path or function that returns a path to an html + * - **`templateUrl`** - {string|function=} - path or function that returns a path to an html * template that should be used by uiView. - * + * * If `templateUrl` is a function, it will be called with the following parameters: * - * - {array.<object>} - state parameters extracted from the current $location.path() by + * - {array.<object>} - state parameters extracted from the current $location.path() by * applying the current state * * @@ -405,7 +428,7 @@ function $StateProvider( $urlRouterProvider, $urlMatcherFactory) { * * * - * - **`controller`** - {string|function=} - Controller fn that should be associated with newly + * - **`controller`** - {string|function=} - Controller fn that should be associated with newly * related scope or the name of a registered controller if passed as a string. * * @@ -414,35 +437,35 @@ function $StateProvider( $urlRouterProvider, $urlMatcherFactory) { * the actual controller or string. * * - * - * - **`controllerAs`** – {string=} – A controller alias name. If present the controller will be + * + * - **`controllerAs`** – {string=} – A controller alias name. If present the controller will be * published to scope under the controllerAs name. * * * - * - **`resolve`** - {object.<string, function>=} - An optional map of dependencies which - * should be injected into the controller. If any of these dependencies are promises, - * the router will wait for them all to be resolved or one to be rejected before the - * controller is instantiated. If all the promises are resolved successfully, the values - * of the resolved promises are injected and $stateChangeSuccess event is fired. If any + * - **`resolve`** - {object.<string, function>=} - An optional map of dependencies which + * should be injected into the controller. If any of these dependencies are promises, + * the router will wait for them all to be resolved or one to be rejected before the + * controller is instantiated. If all the promises are resolved successfully, the values + * of the resolved promises are injected and $stateChangeSuccess event is fired. If any * of the promises are rejected the $stateChangeError event is fired. The map object is: - * + * * - key - {string}: name of dependency to be injected into controller - * - factory - {string|function}: If string then it is alias for service. Otherwise if function, - * it is injected and return value it treated as dependency. If result is a promise, it is + * - factory - {string|function}: If string then it is alias for service. Otherwise if function, + * it is injected and return value it treated as dependency. If result is a promise, it is * resolved before its value is injected into controller. * * * * - **`url`** - {string=} - A url with optional parameters. When a state is navigated or - * transitioned to, the `$stateParams` service will be populated with any + * transitioned to, the `$stateParams` service will be populated with any * parameters that were passed. * * * - * - **`params`** - {object=} - An array of parameter names or regular expressions. Only + * - **`params`** - {object=} - An array of parameter names or regular expressions. Only * use this within a state if you are not using url. Otherwise you can specify your - * parameters within the url. When a state is navigated or transitioned to, the + * parameters within the url. When a state is navigated or transitioned to, the * $stateParams service will be populated with any parameters that were passed. * * @@ -452,7 +475,7 @@ function $StateProvider( $urlRouterProvider, $urlMatcherFactory) { * * * - * - **`abstract`** - {boolean=} - An abstract state will never be directly activated, + * - **`abstract`** - {boolean=} - An abstract state will never be directly activated, * but can provide inherited properties to its common children states. * * @@ -469,8 +492,8 @@ function $StateProvider( $urlRouterProvider, $urlMatcherFactory) { * * * - * - **`reloadOnSearch = true`** - {boolean=} - If `false`, will not retrigger the same state - * just because a search/query parameter has changed (via $location.search() or $location.hash()). + * - **`reloadOnSearch = true`** - {boolean=} - If `false`, will not retrigger the same state + * just because a search/query parameter has changed (via $location.search() or $location.hash()). * Useful for when you'd like to modify $location.search() without triggering a reload. * * @@ -484,7 +507,7 @@ function $StateProvider( $urlRouterProvider, $urlMatcherFactory) { * // stateName can be a single top-level name (must be unique). * $stateProvider.state("home", {}); * - * // Or it can be a nested state name. This state is a child of the + * // Or it can be a nested state name. This state is a child of the * // above "home" state. * $stateProvider.state("home.newest", {}); * @@ -498,7 +521,7 @@ function $StateProvider( $urlRouterProvider, $urlMatcherFactory) { * .state("contacts", {}); * * - * @param {string} name A unique state name, e.g. "home", "about", "contacts". + * @param {string} name A unique state name, e.g. "home", "about", "contacts". * To create a parent/child state use a dot, e.g. "about.sales", "home.newest". * @param {object} definition State configuration object. */ @@ -507,7 +530,7 @@ function $StateProvider( $urlRouterProvider, $urlMatcherFactory) { /*jshint validthis: true */ if (isObject(name)) definition = name; else definition.name = name; - registerState(definition); + queue.register(definition); return this; } @@ -517,17 +540,18 @@ function $StateProvider( $urlRouterProvider, $urlMatcherFactory) { * * @requires $rootScope * @requires $q - * @requires ui.router.state.$view * @requires $injector - * @requires ui.router.util.$resolve + * @requires ui.router.state.$view * @requires ui.router.state.$stateParams * @requires ui.router.router.$urlRouter + * @requires ui.router.state.$transition + * @requires ui.router.util.$urlMatcherFactory * - * @property {object} params A param object, e.g. {sectionId: section.id)}, that + * @property {object} params A param object, e.g. {sectionId: section.id)}, that * you'd like to test against the current active state. - * @property {object} current A reference to the state's config object. However + * @property {object} current A reference to the state's config object. However * you passed it in. Useful for accessing custom data. - * @property {object} transition Currently pending transition. A promise that'll + * @property {object} transition Currently pending transition. A promise that'll * resolve or reject. * * @description @@ -536,87 +560,42 @@ function $StateProvider( $urlRouterProvider, $urlMatcherFactory) { * you're coming from. */ this.$get = $get; - $get.$inject = ['$rootScope', '$q', '$view', '$injector', '$resolve', '$stateParams', '$urlRouter']; - function $get( $rootScope, $q, $view, $injector, $resolve, $stateParams, $urlRouter) { + $get.$inject = ['$rootScope', '$q', '$injector', '$view', '$stateParams', '$urlRouter', '$transition', '$urlMatcherFactory']; + function $get( $rootScope, $q, $injector, $view, $stateParams, $urlRouter, $transition, $urlMatcherFactory) { var TransitionSuperseded = $q.reject(new Error('transition superseded')); var TransitionPrevented = $q.reject(new Error('transition prevented')); var TransitionAborted = $q.reject(new Error('transition aborted')); var TransitionFailed = $q.reject(new Error('transition failed')); - // Handles the case where a state which is the target of a transition is not found, and the user - // can optionally retry or defer the transition - function handleRedirect(redirect, state, params, options) { - /** - * @ngdoc event - * @name ui.router.state.$state#$stateNotFound - * @eventOf ui.router.state.$state - * @eventType broadcast on root scope - * @description - * Fired when a requested state **cannot be found** using the provided state name during transition. - * The event is broadcast allowing any handlers a single chance to deal with the error (usually by - * lazy-loading the unfound state). A special `unfoundState` object is passed to the listener handler, - * you can see its three properties in the example. You can use `event.preventDefault()` to abort the - * transition and the promise returned from `go` will be rejected with a `'transition aborted'` value. - * - * @param {Object} event Event object. - * @param {Object} unfoundState Unfound State information. Contains: `to, toParams, options` properties. - * @param {State} fromState Current state object. - * @param {Object} fromParams Current state params. - * - * @example - * - *
-       * // somewhere, assume lazy.state has not been defined
-       * $state.go("lazy.state", {a:1, b:2}, {inherit:false});
-       *
-       * // somewhere else
-       * $scope.$on('$stateNotFound',
-       * function(event, unfoundState, fromState, fromParams){
-       *     console.log(unfoundState.to); // "lazy.state"
-       *     console.log(unfoundState.toParams); // {a:1, b:2}
-       *     console.log(unfoundState.options); // {inherit:false} + default options
-       * })
-       * 
- */ - var evt = $rootScope.$broadcast('$stateNotFound', redirect, state, params); - - if (evt.defaultPrevented) { - $urlRouter.update(); - return TransitionAborted; - } - - if (!evt.retry) { - return null; - } - - // Allow the handler to return a promise to defer state lookup retry - if (options.$retry) { - $urlRouter.update(); - return TransitionFailed; - } - var retryTransition = $state.transition = $q.when(evt.retry); - - retryTransition.then(function() { - if (retryTransition !== $state.transition) return TransitionSuperseded; - redirect.options.$retry = true; - return $state.transitionTo(redirect.to, redirect.toParams, redirect.options); - }, function() { - return TransitionAborted; - }); - $urlRouter.update(); - - return retryTransition; - } + var REJECT = { + superseded: function() { return TransitionSuperseded; }, + prevented: function() { return TransitionPrevented; }, + aborted: function() { return TransitionAborted; }, + failed: function() { return TransitionFailed; } + }; - root.locals = { resolve: null, globals: { $stateParams: {} } }; + // Implicit root state that is always active + root = queue.register({ + name: '', + url: '^', + views: null, + 'abstract': true + }, true); + root.navigable = null; - $state = { + extend($state, { params: {}, current: root.self, $current: root, transition: null - }; + }); + + queue.flush(); + + $transition.init(root, $state.params, function(ref, options) { + return matcher.find(ref, options.relative); + }); /** * @ngdoc function @@ -624,7 +603,7 @@ function $StateProvider( $urlRouterProvider, $urlMatcherFactory) { * @methodOf ui.router.state.$state * * @description - * A method that force reloads the current state. All resolves are re-resolved, events are not re-fired, + * A method that force reloads the current state. All resolves are re-resolved, events are not re-fired, * and controllers reinstantiated (bug with controllers reinstantiating right now, fixing soon). * * @example @@ -640,8 +619,8 @@ function $StateProvider( $urlRouterProvider, $urlMatcherFactory) { * * `reload()` is just an alias for: *
-     * $state.transitionTo($state.current, $stateParams, { 
-     *   reload: true, inherit: false, notify: false 
+     * $state.transitionTo($state.current, $stateParams, {
+     *   reload: true, inherit: false, notify: false
      * });
      * 
* @@ -658,11 +637,11 @@ function $StateProvider( $urlRouterProvider, $urlMatcherFactory) { * @methodOf ui.router.state.$state * * @description - * Convenience method for transitioning to a new state. `$state.go` calls - * `$state.transitionTo` internally but automatically sets options to - * `{ location: true, inherit: true, relative: $state.$current, notify: true }`. - * This allows you to easily use an absolute or relative to path and specify - * only the parameters you'd like to update (while letting unspecified parameters + * Convenience method for transitioning to a new state. `$state.go` calls + * `$state.transitionTo` internally but automatically sets options to + * `{ location: true, inherit: true, relative: $state.$current, notify: true }`. + * This allows you to easily use an absolute or relative to path and specify + * only the parameters you'd like to update (while letting unspecified parameters * inherit from the currently active ancestor states). * * @example @@ -684,8 +663,8 @@ function $StateProvider( $urlRouterProvider, $urlMatcherFactory) { * - `$state.go('^.sibling')` - will go to a sibling state * - `$state.go('.child.grandchild')` - will go to grandchild state * - * @param {object=} params A map of the parameters that will be sent to the state, - * will populate $stateParams. Any parameters that are not specified will be inherited from currently + * @param {object=} params A map of the parameters that will be sent to the state, + * will populate $stateParams. Any parameters that are not specified will be inherited from currently * defined parameters. This allows, for example, going to a sibling state that shares parameters * specified in a parent state. Parameter inheritance only works between common ancestor states, I.e. * transitioning to a sibling will get you the parameters for all parents, transitioning to a child @@ -695,10 +674,10 @@ function $StateProvider( $urlRouterProvider, $urlMatcherFactory) { * - **`location`** - {boolean=true|string=} - If `true` will update the url in the location bar, if `false` * will not. If string, must be `"replace"`, which will update url and also replace last history record. * - **`inherit`** - {boolean=true}, If `true` will inherit url parameters from current url. - * - **`relative`** - {object=$state.$current}, When transitioning with relative path (e.g '^'), + * - **`relative`** - {object=$state.$current}, When transitioning with relative path (e.g '^'), * defines which state to be relative from. * - **`notify`** - {boolean=true}, If `true` will broadcast $stateChangeStart and $stateChangeSuccess events. - * - **`reload`** (v0.2.5) - {boolean=false}, If `true` will force transition even if the state or params + * - **`reload`** (v0.2.5) - {boolean=false}, If `true` will force transition even if the state or params * have not changed, aka a reload of the same state. It differs from reloadOnSearch because you'd * use this when you want to force a reload when *everything* is the same, including search params. * @@ -750,10 +729,10 @@ function $StateProvider( $urlRouterProvider, $urlMatcherFactory) { * - **`location`** - {boolean=true|string=} - If `true` will update the url in the location bar, if `false` * will not. If string, must be `"replace"`, which will update url and also replace last history record. * - **`inherit`** - {boolean=false}, If `true` will inherit url parameters from current url. - * - **`relative`** - {object=}, When transitioning with relative path (e.g '^'), + * - **`relative`** - {object=}, When transitioning with relative path (e.g '^'), * defines which state to be relative from. * - **`notify`** - {boolean=true}, If `true` will broadcast $stateChangeStart and $stateChangeSuccess events. - * - **`reload`** (v0.2.5) - {boolean=false}, If `true` will force transition even if the state or params + * - **`reload`** (v0.2.5) - {boolean=false}, If `true` will force transition even if the state or params * have not changed, aka a reload of the same state. It differs from reloadOnSearch because you'd * use this when you want to force a reload when *everything* is the same, including search params. * @@ -761,181 +740,164 @@ function $StateProvider( $urlRouterProvider, $urlMatcherFactory) { * {@link ui.router.state.$state#methods_go $state.go}. */ $state.transitionTo = function transitionTo(to, toParams, options) { - toParams = toParams || {}; - options = extend({ - location: true, inherit: false, relative: null, notify: true, reload: false, $retry: false - }, options || {}); - - var from = $state.$current, fromParams = $state.params, fromPath = from.path; - var evt, toState = findState(to, options.relative); - - if (!isDefined(toState)) { - var redirect = { to: to, toParams: toParams, options: options }; - var redirectResult = handleRedirect(redirect, from.self, fromParams, options); - - if (redirectResult) { - return redirectResult; - } + var transition = $transition.start(to, toParams || {}, extend({ + location: true, + relative: null, + inherit: false, + notify: true, + reload: false + }, options || {})); + + var stateHandler = { + retryIfNotFound: function(transition) { + /** + * @ngdoc event + * @name ui.router.state.$state#$stateNotFound + * @eventOf ui.router.state.$state + * @eventType broadcast on root scope + * @description + * Fired when a requested state **cannot be found** using the provided state name during transition. + * The event is broadcast allowing any handlers a single chance to deal with the error (usually by + * lazy-loading the unfound state). A `Transition` object is passed to the listener handler, + * you can see its three properties in the example. You can use `event.preventDefault()` to abort the + * transition and the promise returned from `transitionTo()` will be rejected with a + * `'transition aborted'` error. + * + * @param {Object} event Event object. + * @param {Object} transition The `Transition` object representing the current state transition. + * + * @example + * + *
+           * // somewhere, assume lazy.state has not been defined
+           * $state.go("lazy.state", { a: 1, b: 2 }, { inherit: false });
+           *
+           * // somewhere else
+           * $scope.$on('$stateNotFound', function(event, transition) {
+           *   console.log(transition.to()); // "lazy.state"
+           *   console.log(transition.params().to); // { a: 1, b: 2 }
+           *   console.log(transition.options()); // { inherit: false } + default options
+           * });
+           * 
+ */ + var e = $rootScope.$broadcast('$stateNotFound', transition); + if (e.defaultPrevented || e.retry) $urlRouter.update(); + if (e.defaultPrevented) return TransitionAborted; + if (!e.retry) return transition.rejection() || TransitionSuperseded; + return e.retry; + }, + + checkIgnoredOrPrevented: function(transition) { + if (transition.ignored()) { + var isDynamic = $stateParams.$set(toParams, toState.url); + + if (isDynamic && toState.url) { + $urlRouter.push(toState.url, $stateParams, { replace: true }); + $urlRouter.update(true); + } + + if (isDynamic || to.locals === from.locals) { + if (!isDynamic) $urlRouter.update(); + if (options.notify) $rootScope.$broadcast('$stateChangeIgnored', transition); + $state.transition = null; + return $state.current; + } + } - // Always retry once if the $stateNotFound was not prevented - // (handles either redirect changed or state lazy-definition) - to = redirect.to; - toParams = redirect.toParams; - options = redirect.options; - toState = findState(to, options.relative); + /** + * @ngdoc event + * @name ui.router.state.$state#$stateChangeStart + * @eventOf ui.router.state.$state + * @eventType broadcast on root scope + * @description + * Fired when the state transition **begins**. You can use `event.preventDefault()` + * to prevent the transition from happening and then the transition promise will be + * rejected with a `'transition prevented'` value. + * + * @param {Object} event Event object. + * @param {Transition} Transition An object containing all contextual information about + * the current transition, including to and from states and parameters. + * + * @example + * + *
+           * $rootScope.$on('$stateChangeStart', function(event, transition) {
+           *   event.preventDefault();
+           *   // transitionTo() promise will be rejected with
+           *   // a 'transition prevented' error
+           * })
+           * 
+ */ + if (options.notify && $rootScope.$broadcast('$stateChangeStart', transition).defaultPrevented) { + $urlRouter.update(); + return TransitionPrevented; + } - if (!isDefined(toState)) { - if (!options.relative) throw new Error("No such state '" + to + "'"); - throw new Error("Could not resolve '" + to + "' from state '" + options.relative + "'"); + return transition; } - } - if (toState[abstractKey]) throw new Error("Cannot transition to abstract state '" + to + "'"); - if (options.inherit) toParams = inheritParams($stateParams, toParams || {}, $state.$current, toState); - to = toState; + }; - var toPath = to.path; + transition.ensureValid(stateHandler.retryIfNotFound) + .then(stateHandler.checkIgnoredOrPrevented, REJECT.aborted) + .then(function() { + console.log("WIN", arguments); + }, function() { + console.log("FALE", arguments); + }); - // Starting from the root of the path, keep all levels that haven't changed - var keep = 0, state = toPath[keep], locals = root.locals, toLocals = []; - - if (!options.reload) { - while (state && state === fromPath[keep] && equalForKeys(toParams, fromParams, state.ownParams)) { - locals = toLocals[keep] = state.locals; - keep++; - state = toPath[keep]; - } - } - - // If we're going to the same state and all locals are kept, we've got nothing to do. - // But clear 'transition', as we still want to cancel any other pending transitions. - // 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 (to.self.reloadOnSearch !== false) $urlRouter.update(); - $state.transition = null; - return $q.when($state.current); - } - - // 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) { - /** - * @ngdoc event - * @name ui.router.state.$state#$stateChangeStart - * @eventOf ui.router.state.$state - * @eventType broadcast on root scope - * @description - * Fired when the state transition **begins**. You can use `event.preventDefault()` - * to prevent the transition from happening and then the transition promise will be - * rejected with a `'transition prevented'` value. - * - * @param {Object} event Event object. - * @param {State} toState The state being transitioned to. - * @param {Object} toParams The params supplied to the `toState`. - * @param {State} fromState The current state, pre-transition. - * @param {Object} fromParams The params supplied to the `fromState`. - * - * @example - * - *
-         * $rootScope.$on('$stateChangeStart',
-         * function(event, toState, toParams, fromState, fromParams){
-         *     event.preventDefault();
-         *     // transitionTo() promise will be rejected with
-         *     // a 'transition prevented' error
-         * })
-         * 
- */ - if ($rootScope.$broadcast('$stateChangeStart', to.self, toParams, from.self, fromParams).defaultPrevented) { - $urlRouter.update(); - 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. - // We also set up an inheritance chain for the locals here. This allows the view directive - // to quickly look up the correct definition for each view in the current state. Even - // though we create the locals object itself outside resolveState(), it is initially - // empty and gets filled asynchronously. We need to keep track of the promise for the - // (fully resolved) current locals, and pass this down the chain. - var resolved = $q.when(locals); - - for (var l = keep; l < toPath.length; l++, state = toPath[l]) { - locals = toLocals[l] = inherit(locals); - resolved = resolveState(state, toParams, state === to, resolved, locals); - } // Once everything is resolved, we 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. - var transition = $state.transition = resolved.then(function () { - var l, entering, exiting; + var current = resolved.then(function() { + var result = transition.begin(function() { return $state.transition === current; }, function() { + return transition.run(); + }); - if ($state.transition !== transition) return TransitionSuperseded; - - // Exit 'from' states not kept - for (l = fromPath.length - 1; l >= keep; l--) { - exiting = fromPath[l]; - if (exiting.self.onExit) { - $injector.invoke(exiting.self.onExit, exiting.self, exiting.locals.globals); - } - exiting.locals = null; - } - - // Enter 'to' states not kept - for (l = keep; l < toPath.length; l++) { - entering = toPath[l]; - entering.locals = toLocals[l]; - if (entering.self.onEnter) { - $injector.invoke(entering.self.onEnter, entering.self, entering.locals.globals); - } - } - - // Run it again, to catch any transitions in callbacks - if ($state.transition !== transition) return TransitionSuperseded; + if (result === transition.SUPERSEDED) return TransitionSuperseded; + if (result === transition.ABORTED) return TransitionAborted; + transition.end(); // Update globals in $state $state.$current = to; $state.current = to.self; + + // Filter parameters before we pass them to event handlers etc. + // ??? toParams = filterByKeys(objectKeys(to.params), /* transition.params().$to */ toParams || {}); ??? // + $state.params = toParams; copy($state.params, $stateParams); $state.transition = null; + $stateParams.$sync(); + $stateParams.$off(); if (options.location && to.navigable) { - $urlRouter.push(to.navigable.url, to.navigable.locals.globals.$stateParams, { - replace: options.location === 'replace' - }); + $urlRouter.push(to.navigable.url, $stateParams, { replace: options.location === 'replace' }); } if (options.notify) { - /** - * @ngdoc event - * @name ui.router.state.$state#$stateChangeSuccess - * @eventOf ui.router.state.$state - * @eventType broadcast on root scope - * @description - * Fired once the state transition is **complete**. - * - * @param {Object} event Event object. - * @param {State} toState The state being transitioned to. - * @param {Object} toParams The params supplied to the `toState`. - * @param {State} fromState The current state, pre-transition. - * @param {Object} fromParams The params supplied to the `fromState`. - */ - $rootScope.$broadcast('$stateChangeSuccess', to.self, toParams, from.self, fromParams); + /** + * @ngdoc event + * @name ui.router.state.$state#$stateChangeSuccess + * @eventOf ui.router.state.$state + * @eventType broadcast on root scope + * @description + * Fired once the state transition is **complete**. + * + * @param {Object} event Event object. + * @param {Transition} transition The object encapsulating the transition info. + */ + $rootScope.$broadcast('$stateChangeSuccess', transition); } $urlRouter.update(true); return $state.current; + }, function (error) { + if ($state.transition !== transition) return TransitionSuperseded; - $state.transition = null; /** * @ngdoc event * @name ui.router.state.$state#$stateChangeError @@ -947,19 +909,13 @@ function $StateProvider( $urlRouterProvider, $urlMatcherFactory) { * they will not throw traditionally. You must listen for this $stateChangeError event to * catch **ALL** errors. * - * @param {Object} event Event object. - * @param {State} toState The state being transitioned to. - * @param {Object} toParams The params supplied to the `toState`. - * @param {State} fromState The current state, pre-transition. - * @param {Object} fromParams The params supplied to the `fromState`. - * @param {Error} error The resolve error object. + * @param {Object} event Event object. + * @param {Transition} transition The `Transition` object. + * @param {Error} error The resolve error object. */ - evt = $rootScope.$broadcast('$stateChangeError', to.self, toParams, from.self, fromParams, error); - - if (!evt.defaultPrevented) { - $urlRouter.update(); + if (!$rootScope.$broadcast('$stateChangeError', transition, error).defaultPrevented) { + $urlRouter.update(); } - return $q.reject(error); }); @@ -973,8 +929,8 @@ function $StateProvider( $urlRouterProvider, $urlMatcherFactory) { * * @description * Similar to {@link ui.router.state.$state#methods_includes $state.includes}, - * but only checks for the full state name. If params is supplied then it will be - * tested for strict equality against the current active params object, so all params + * but only checks for the full state name. If params is supplied then it will be + * tested for strict equality against the current active params object, so all params * must match with none missing and no extras. * * @example @@ -991,21 +947,14 @@ function $StateProvider( $urlRouterProvider, $urlMatcherFactory) { * * * @param {string|object} stateName The state name (absolute or relative) or state object you'd like to check. - * @param {object=} params A param object, e.g. `{sectionId: section.id}`, that you'd like + * @param {object=} params A param object, e.g. `{sectionId: section.id}`, that you'd like * to test against the current active state. * @returns {boolean} Returns true if it is the state. */ $state.is = function is(stateOrName, params) { - var state = findState(stateOrName); - - if (!isDefined(state)) { - return undefined; - } - - if ($state.$current !== state) { - return false; - } - + var state = matcher.find(stateOrName); + if (!isDefined(state)) return undefined; + if ($state.$current !== state) return false; return isDefined(params) && params !== null ? angular.equals($stateParams, params) : true; }; @@ -1056,20 +1005,16 @@ function $StateProvider( $urlRouterProvider, $urlMatcherFactory) { * @returns {boolean} Returns true if it does include the state */ $state.includes = function includes(stateOrName, params) { - if (isString(stateOrName) && isGlob(stateOrName)) { - if (!doesStateMatchGlob(stateOrName)) { - return false; - } + var glob = isString(stateOrName) && GlobBuilder.fromString(stateOrName); + + if (glob) { + if (!glob.matches($state.$current)) return false; stateOrName = $state.$current.name; } - var state = findState(stateOrName); + var state = matcher.find(stateOrName), include = $state.$current.includes; - if (!isDefined(state)) { - return undefined; - } - if (!isDefined($state.$current.includes[state.name])) { - return false; - } + if (!isDefined(state)) return undefined; + if (!isDefined(include[state.name])) return false; return equalForKeys(params, $stateParams); }; @@ -1098,7 +1043,7 @@ function $StateProvider( $urlRouterProvider, $urlMatcherFactory) { * - **`relative`** - {object=$state.$current}, When transitioning with relative path (e.g '^'), * defines which state to be relative from. * - **`absolute`** - {boolean=false}, If true will generate an absolute url, e.g. "http://www.example.com/fullurl". - * + * * @returns {string} compiled state url */ $state.href = function href(stateOrName, params, options) { @@ -1109,11 +1054,11 @@ function $StateProvider( $urlRouterProvider, $urlMatcherFactory) { relative: $state.$current }, options || {}); - var state = findState(stateOrName, options.relative); + var state = matcher.find(stateOrName, options.relative); if (!isDefined(state)) return null; if (options.inherit) params = inheritParams($stateParams, params || {}, $state.$current, state); - + var nav = (state && options.lossy) ? state.navigable : state; if (!nav || !nav.url) { @@ -1132,72 +1077,116 @@ function $StateProvider( $urlRouterProvider, $urlMatcherFactory) { * @description * Returns the state configuration object for any specific state or all states. * - * @param {string|Sbject=} stateOrName (absolute or relative) If provided, will only get the config for + * @param {string|Object=} stateOrName (absolute or relative) If provided, will only get the config for * the requested state. If not provided, returns an array of ALL state configs. * @returns {Object|Array} State configuration object or array of all objects. */ $state.get = function (stateOrName, context) { if (arguments.length === 0) return objectKeys(states).map(function(name) { return states[name].self; }); - var state = findState(stateOrName, context); - return (state && state.self) ? state.self : null; + return (matcher.find(stateOrName, context) || {}).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 = (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. - // 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. - 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) { - var injectables = (view.resolve && view.resolve !== state.resolve ? view.resolve : {}); - injectables.$template = [ function () { - return $view.load(name, { view: view, locals: locals, params: $stateParams }) || ''; - }]; - - promises.push($resolve.resolve(injectables, locals, dst.resolve, state).then(function (result) { - // References to the controller (only instantiated at link time) - if (isFunction(view.controllerProvider) || isArray(view.controllerProvider)) { - var injectLocals = angular.extend({}, injectables, locals); - result.$$controller = $injector.invoke(view.controllerProvider, null, injectLocals); - } else { - result.$$controller = view.controller; - } - // Provide access to the state itself for internal use - result.$$state = state; - result.$$controllerAs = view.controllerAs; - dst[name] = result; - })); - }); + return $state; + } +} + +$StateParamsProvider.$inject = []; +function $StateParamsProvider() { - // Wait for all the promises and then return the activation object - return $q.all(promises).then(function (values) { - return dst; + function stateParamsFactory() { + var observers = {}, current = {}; + + function unhook(key, func) { + return function() { + forEach(key.split(" "), function(k) { + observers[k].splice(observers[k].indexOf(func), 1); + }); + }; + } + + function observeChange(key, val) { + if (!observers[key] || !observers[key].length) return; + + forEach(observers[key], function(func) { + func(val); }); } - return $state; + function StateParams() { + } + + StateParams.prototype.$digest = function() { + forEach(this, function(val, key) { + if (val == current[key] || !this.hasOwnProperty(key)) return; + current[key] = val; + observeChange(key, val); + }, this); + }; + + StateParams.prototype.$set = function(params, url) { + var hasChanged = false, abort = false; + + if (url) { + forEach(params, function(val, key) { + if ((url.parameters(key) || {}).dynamic !== true) abort = true; + }); + } + if (abort) return false; + + forEach(params, function(val, key) { + if (val != this[key]) { + this[key] = val; + observeChange(key); + hasChanged = true; + } + }, this); + + this.$sync(); + return hasChanged; + }; + + StateParams.prototype.$sync = function() { + copy(this, current); + }; + + StateParams.prototype.$off = function() { + observers = {}; + }; + + StateParams.prototype.$localize = function(state, params) { + var localized = new StateParams(); + params = params || this; + + forEach(state.params, function(val, key) { + localized[key] = params[key]; + }); + return localized; + }; + + StateParams.prototype.$observe = function(key, func) { + forEach(key.split(" "), function(k) { + (observers[k] || (observers[k] = [])).push(func); + }); + return unhook(key, func); + }; + + return new StateParams(); } - function shouldTriggerReload(to, from, locals, options) { - if (to === from && ((locals === from.locals && !options.reload) || (to.self.reloadOnSearch === false))) { - return true; - } + var global = stateParamsFactory(); + + this.$get = $get; + $get.$inject = ['$rootScope']; + function $get( $rootScope) { + + $rootScope.$watch(function() { + global.$digest(); + }); + + return global; } } angular.module('ui.router.state') - .value('$stateParams', {}) + .provider('$stateParams', $StateParamsProvider) .provider('$state', $StateProvider); diff --git a/src/stateDirectives.js b/src/stateDirectives.js index 96ca0ca2b..92776e323 100644 --- a/src/stateDirectives.js +++ b/src/stateDirectives.js @@ -9,8 +9,8 @@ function parseStateRef(ref, current) { function stateContext(el) { var stateData = el.parent().inheritedData('$uiView'); - if (stateData && stateData.state && stateData.state.name) { - return stateData.state; + if (stateData && stateData.context && stateData.context.name) { + return stateData.context; } } @@ -230,8 +230,8 @@ function $StateRefActiveDirective($state, $stateParams, $interpolate) { activeClass = $interpolate($attrs.uiSrefActiveEq || $attrs.uiSrefActive || '', false)($scope); // Allow uiSref to communicate with uiSrefActive[Equals] - this.$$setStateInfo = function (newState, newParams) { - state = $state.get(newState, stateContext($element)); + this.$$setStateInfo = function(newState, newParams) { + state = $state.get(newState, stateContext($element) || $state.$current); params = newParams; update(); }; diff --git a/src/transition.js b/src/transition.js new file mode 100644 index 000000000..0c64cd050 --- /dev/null +++ b/src/transition.js @@ -0,0 +1,314 @@ + + +/** + * @ngdoc object + * @name ui.router.state.$transitionProvider + */ +$TransitionProvider.$inject = []; +function $TransitionProvider() { + + var $transition = {}, events, stateMatcher = angular.noop, abstractKey = 'abstract'; + + // $transitionProvider.on({ from: "home", to: "somewhere.else" }, function($transition$, $http) { + // // ... + // }); + this.on = function(states, callback) { + }; + + // $transitionProvider.onEnter({ from: "home", to: "somewhere.else" }, function($transition$, $http) { + // // ... + // }); + this.onEnter = function(states, callback) { + }; + + // $transitionProvider.onExit({ from: "home", to: "somewhere.else" }, function($transition$, $http) { + // // ... + // }); + this.onExit = function(states, callback) { + }; + + // $transitionProvider.onSuccess({ from: "home", to: "somewhere.else" }, function($transition$, $http) { + // // ... + // }); + this.onSuccess = function(states, callback) { + }; + + // $transitionProvider.onError({ from: "home", to: "somewhere.else" }, function($transition$, $http) { + // // ... + // }); + this.onError = function(states, callback) { + }; + + + /** + * @ngdoc service + * @name ui.router.state.$transition + * + * @requires $q + * @requires $injector + * @requires ui.router.util.$resolve + * + * @description + * The `$transition` service manages changes in states and parameters. + */ + this.$get = $get; + $get.$inject = ['$q', '$injector', '$resolve', '$stateParams']; + function $get( $q, $injector, $resolve, $stateParams) { + + var from = { state: null, params: null }, + to = { state: null, params: null }; + + /** + * @ngdoc object + * @name ui.router.state.type:Transition + * + * @description + * Represents a transition between two states, and contains all contextual information about the + * to/from states and parameters, as well as the list of states being entered and exited as a + * result of this transition. + * + * @param {Object} fromState The origin {@link ui.router.state.$stateProvider#state state} from which the transition is leaving. + * @param {Object} fromParams An object hash of the current parameters of the `from` state. + * @param {Object} toState The target {@link ui.router.state.$stateProvider#state state} being transitioned to. + * @param {Object} toParams An object hash of the target parameters for the `to` state. + * @param {Object} options An object hash of the options for this transition. + * + * @returns {Object} New `Transition` object + */ + function Transition(fromState, fromParams, toState, toParams, options) { + var keep = 0, state, retained = [], entering = [], exiting = []; + var hasRun = false, hasCalculated = false; + + var states = { + to: stateMatcher(toState, options), + from: stateMatcher(fromState, options) + }; + + function isTargetStateValid() { + var state = stateMatcher(toState, options); + + if (!isDefined(state)) { + if (!options || !options.relative) return "No such state " + angular.toJson(toState); + return "Could not resolve " + angular.toJson(toState) + " from state " + angular.toJson(options.relative); + } + if (state[abstractKey]) return "Cannot transition to abstract state " + angular.toJson(toState); + return null; + } + + function hasBeenSuperseded() { + return !(fromState === from.state && fromParams === from.params); + } + + function calculateTreeChanges() { + if (hasCalculated) return; + + state = toState.path[keep]; + + while (state && state === fromState.path[keep] && equalForKeys(toParams, fromParams, state.ownParams)) { + retained.push(state); + keep++; + state = toState.path[keep]; + } + + for (var i = fromState.path.length - 1; i >= keep; i--) { + exiting.push(fromState.path[i]); + } + + for (i = keep; i < toState.path.length; i++) { + entering.push(toState.path[i]); + } + hasCalculated = true; + } + + + extend(this, { + /** + * @ngdoc function + * @name ui.router.state.type:Transition#from + * @methodOf ui.router.state.type:Transition + * + * @description + * Returns the origin state of the current transition, as passed to the `Transition` constructor. + * + * @returns {Object} The origin {@link ui.router.state.$stateProvider#state state} of the transition. + */ + from: extend(function() { return fromState; }, { + + /** + * @ngdoc function + * @name ui.router.state.type:Transition.from#state + * @methodOf ui.router.state.type:Transition + * + * @description + * Returns the object definition of the origin state of the current transition. + * + * @returns {Object} The origin {@link ui.router.state.$stateProvider#state state} of the transition. + */ + state: function() { + return states.from && states.from.self; + }, + + $state: function() { + return states.from; + } + }), + + /** + * @ngdoc function + * @name ui.router.state.type:Transition#to + * @methodOf ui.router.state.type:Transition + * + * @description + * Returns the target state of the current transition, as passed to the `Transition` constructor. + * + * @returns {Object} The target {@link ui.router.state.$stateProvider#state state} of the transition. + */ + to: extend(function() { return toState; }, { + + /** + * @ngdoc function + * @name ui.router.state.type:Transition.to#state + * @methodOf ui.router.state.type:Transition + * + * @description + * Returns the object definition of the target state of the current transition. + * + * @returns {Object} The target {@link ui.router.state.$stateProvider#state state} of the transition. + */ + state: function() { + return states.to && states.to.self; + }, + + $state: function() { + return states.to; + } + }), + + isValid: function() { + return isTargetStateValid() === null && !hasBeenSuperseded(); + }, + + rejection: function() { + var reason = isTargetStateValid(); + return reason ? $q.reject(new Error(reason)) : null; + }, + + /** + * @ngdoc function + * @name ui.router.state.type:Transition#params + * @methodOf ui.router.state.type:Transition + * + * @description + * Gets the origin and target parameters for the transition. + * + * @returns {Object} An object with `to` and `from` keys, each of which contains an object hash of + * state parameters. + */ + params: function() { + // toParams = (options.inherit) ? inheritParams(fromParams, toParams, from, toState); + return { from: fromParams, to: toParams }; + }, + + options: function() { + return options; + }, + + entering: function() { + calculateTreeChanges(); + return extend(pluck(entering, 'self'), new Path(entering)); + }, + + exiting: function() { + calculateTreeChanges(); + return extend(pluck(entering, 'self'), new Path(exiting)); + }, + + retained: function() { + calculateTreeChanges(); + return pluck(retained, 'self'); + }, + + views: function() { + return map(entering, function(state) { + return [state.self, state.views]; + }); + }, + + redirect: function(to, params, options) { + if (to === toState && params === toParams) return false; + return new Transition(fromState, fromParams, to, params, options || this.options()); + }, + + ensureValid: function(failHandler) { + if (this.isValid()) return $q.when(this); + return $q.when(failHandler(this)); + }, + + /** + * @ngdoc function + * @name ui.router.state.type:Transition#ignored + * @methodOf ui.router.state.type:Transition + * + * @description + * Indicates whether the transition should be ignored, based on whether the to and from states are the + * same, and whether the `reload` option is set. + * + * @returns {boolean} Whether the transition should be ignored. + */ + ignored: function() { + return (toState === fromState && !options.reload); + }, + + run: function() { + var exiting = this.exiting().$$exit(); + if (exiting !== true) return exiting; + + var entering = this.entering().$$enter(); + if (entering !== true) return entering; + + return true; + }, + + begin: function(compare, exec) { + if (!compare()) return this.SUPERSEDED; + if (!exec()) return this.ABORTED; + if (!compare()) return this.SUPERSEDED; + return true; + }, + + end: function() { + from = { state: toState, params: toParams }; + to = { state: null, params: null }; + } + }); + } + + Transition.prototype.SUPERSEDED = 2; + Transition.prototype.ABORTED = 3; + Transition.prototype.INVALID = 4; + + + $transition.init = function init(state, params, matcher) { + from = { state: state, params: params }; + to = { state: null, params: null }; + stateMatcher = matcher; + }; + + $transition.start = function start(state, params, options) { + to = { state: state, params: params }; + return new Transition(from.state, from.params, state, params, options || {}); + }; + + $transition.isActive = function isActive() { + return !!to.state && !!from.state; + }; + + $transition.isTransition = function isTransition(transition) { + return transition instanceof Transition; + }; + + return $transition; + } +} + +angular.module('ui.router.state').provider('$transition', $TransitionProvider); \ No newline at end of file diff --git a/src/urlMatcherFactory.js b/src/urlMatcherFactory.js index 93c5f4232..aae5cf4ca 100644 --- a/src/urlMatcherFactory.js +++ b/src/urlMatcherFactory.js @@ -8,7 +8,7 @@ * of search parameters. Multiple search parameter names are separated by '&'. Search parameters * do not influence whether or not a URL is matched, but their values are passed through into * the matched parameters returned by {@link ui.router.util.type:UrlMatcher#methods_exec exec}. - * + * * Path parameter placeholders can be specified using simple colon/catch-all syntax or curly brace * syntax, which optionally allows a regular expression for the parameter to be specified: * @@ -19,13 +19,13 @@ * curly braces, they must be in matched pairs or escaped with a backslash. * * Parameter names may contain only word characters (latin letters, digits, and underscore) and - * must be unique within the pattern (across both path and search parameters). For colon + * must be unique within the pattern (across both path and search parameters). For colon * placeholders or curly placeholders without an explicit regexp, a path parameter matches any * number of characters other than '/'. For catch-all placeholders the path parameter matches * any number of characters. - * + * * Examples: - * + * * * `'/hello/'` - Matches only if the path is exactly '/hello/'. There is no special treatment for * trailing slashes, and patterns have to match the entire path, not just a prefix. * * `'/user/:id'` - Matches '/user/bob' or '/user/1234!!!' or even '/user/' but not '/user' or @@ -54,104 +54,13 @@ * * @property {string} sourceSearch The search portion of the source property * - * @property {string} regex The constructed regex that will be used to match against the url when + * @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 */ function UrlMatcher(pattern, config) { - config = angular.isObject(config) ? config : {}; - - // Find all placeholders and create a compiled pattern, using either classic or curly syntax: - // '*' name - // ':' name - // '{' name '}' - // '{' name ':' regexp '}' - // The regular expression is somewhat complicated due to the need to allow curly braces - // inside the regular expression. The placeholder regexp breaks down as follows: - // ([:*])(\w+) classic placeholder ($1 / $2) - // \{(\w+)(?:\:( ... ))?\} curly brace placeholder ($3) with optional regexp ... ($4) - // (?: ... | ... | ... )+ the regexp consists of any number of atoms, an atom being either - // [^{}\\]+ - anything other than curly braces or backslash - // \\. - a backslash escape - // \{(?:[^{}\\]+|\\.)*\} - a matched set of curly braces containing other atoms - var placeholder = /([:*])(\w+)|\{(\w+)(?:\:((?:[^{}\\]+|\\.|\{(?:[^{}\\]+|\\.)*\})+))?\}/g, - compiled = '^', last = 0, m, - segments = this.segments = [], - params = this.params = {}; - - /** - * [Internal] Gets the decoded representation of a value if the value is defined, otherwise, returns the - * default value, which may be the result of an injectable function. - */ - function $value(value) { - /*jshint validthis: true */ - return isDefined(value) ? this.type.decode(value) : $UrlMatcherFactory.$$getDefaultValue(this); - } - - function addParameter(id, type, config) { - if (!/^\w+(-+\w+)*$/.test(id)) throw new Error("Invalid parameter name '" + id + "' in pattern '" + pattern + "'"); - if (params[id]) throw new Error("Duplicate parameter name '" + id + "' in pattern '" + pattern + "'"); - params[id] = extend({ type: type || new Type(), $value: $value }, config); - } - - function quoteRegExp(string, pattern, isOptional) { - var result = string.replace(/[\\\[\]\^$*+?.()|{}]/g, "\\$&"); - if (!pattern) return result; - var flag = isOptional ? '?' : ''; - return result + flag + '(' + pattern + ')' + flag; - } - - function paramConfig(param) { - if (!config.params || !config.params[param]) return {}; - var cfg = config.params[param]; - return isObject(cfg) ? cfg : { value: cfg }; - } - - this.source = pattern; - - // Split into static segments separated by path parameter placeholders. - // The number of segments is always 1 more than the number of parameters. - 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] == '*' ? '.*' : '[^/]*'); - 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, type.$subPattern(), isDefined(cfg.value)); - addParameter(id, type, cfg); - segments.push(segment); - last = placeholder.lastIndex; - } - segment = pattern.substring(last); - - // 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); - - // Allow parameters to be separated by '?' as well as '&' to make concat() easier - forEach(search.substring(1).split(/[&?]/), function(key) { - addParameter(key, null, paramConfig(key)); - }); - } else { - this.sourcePath = pattern; - this.sourceSearch = ''; - } - - compiled += quoteRegExp(segment) + (config.strict === false ? '\/?' : '') + '$'; - segments.push(segment); - - this.regexp = new RegExp(compiled, config.caseInsensitive ? 'i' : undefined); - this.prefix = segments[0]; + $UrlMatcherFactory.$$instance.call(this, pattern, config); } /** @@ -180,11 +89,17 @@ 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, config); + $UrlMatcherFactory.$$init(); + var UrlMatcherChild = function() {}; + UrlMatcherChild.prototype = this; + var child = new UrlMatcherChild(); + child.parent = this; + $UrlMatcherFactory.$$instance.call(child, pattern, config); + return child; }; UrlMatcher.prototype.toString = function () { - return this.source; + return this.source.toString(); }; /** @@ -211,29 +126,43 @@ UrlMatcher.prototype.toString = function () { * @param {Object} searchParams URL search parameters, e.g. `$location.search()`. * @returns {Object} The captured parameter values. */ -UrlMatcher.prototype.exec = function (path, searchParams) { - var m = this.regexp.exec(path); - if (!m) return null; +UrlMatcher.prototype.exec = function (path, searchParams, options) { + options = extend({ isolate: false }, options); + + var match = this.regexp.exec(path); + if (!match) return null; + searchParams = searchParams || {}; - var params = this.parameters(), nTotal = params.length, - nPath = this.segments.length - 1, - values = {}, i, cfg, param; + var result = [], i, cfg, param, current = this; - if (nPath !== m.length - 1) throw new Error("Unbalanced capture group in route '" + this.source + "'"); + while (current) { + var local = {}, params = current.parameters(true), searchVal; - for (i = 0; i < nPath; i++) { - param = params[i]; - cfg = this.params[param]; - values[param] = cfg.$value(m[i + 1]); - } - for (/**/; i < nTotal; i++) { - param = params[i]; - cfg = this.params[param]; - values[param] = cfg.$value(searchParams[param]); + for (i = params.length - 1; i >= 0; i--) { + param = params[i]; + cfg = current.params[param]; + + if (searchParams && cfg.search) { + searchVal = cfg.$value(searchParams[param]); + if (isDefined(searchVal)) local[param] = searchVal; + } + if (cfg.search) continue; + local[param] = cfg.$value(match.pop()); + } + result.unshift(local); + current = current.parent; } - return values; + if (match.length !== 1) throw new Error("Unbalanced capture group in route '" + this.source + "'"); + if (options.isolate) return result; + + var collapsed = {}; + + for (i = 0; i < result.length; i++) { + extend(collapsed, result[i]); + } + return collapsed; }; /** @@ -243,13 +172,14 @@ UrlMatcher.prototype.exec = function (path, searchParams) { * * @description * Returns the names of all path and search parameters of this pattern in an unspecified order. - * + * * @returns {Array.} An array of parameter names. Must be treated as read-only. If the * pattern has no parameters, an empty array is returned. */ -UrlMatcher.prototype.parameters = function (param) { - if (!isDefined(param)) return objectKeys(this.params); - return this.params[param] || null; +UrlMatcher.prototype.parameters = function (paramOrIsolate) { + if (paramOrIsolate === true) return objectKeys(this.params); + if (isString(paramOrIsolate)) return this.params[param] || null; + return objectKeys(this.params).concat(this.parent ? this.parent.parameters() : []); }; /** @@ -288,7 +218,7 @@ UrlMatcher.prototype.validates = function (params) { * * @example *
- * new UrlMatcher('/user/{id}?q').format({ id:'bob', q:'yes' });
+ * new UrlMatcher('/user/{id}?q').format({ id: 'bob', q: 'yes' });
  * // returns '/user/bob?q=yes'
  * 
* @@ -296,38 +226,47 @@ UrlMatcher.prototype.validates = function (params) { * @returns {string} the formatted URL (path and optionally search part). */ UrlMatcher.prototype.format = function (values) { - var segments = this.segments, params = this.parameters(); + var format = "", map = {}, ids = {}, current = this, params = this.parameters(); - if (!values) return segments.join('').replace('//', '/'); + while (current) { + ids = extend(ids, current.idMap); + map = extend(map, current.formatMap); + format = current.formatString + format; + current = current.parent; + } + + function clean(string) { + return string.replace(/__ \d+ __/g, '').replace(/\/{2,}/g, '/'); + } - var nPath = segments.length - 1, nTotal = params.length, - result = segments[0], i, search, value, param, cfg, array; + if (!values || isObject(values) && objectKeys(values).length === 0) return clean(format); + + var self = this, mapped = {}; if (!this.validates(values)) return null; - for (i = 0; i < nPath; i++) { - param = params[i]; - value = values[param]; - cfg = this.params[param]; + var result = clean(format.replace(/__ (\d+) __/g, function(_, id) { + var value = values[ids[id].name]; + if (value === null || !isDefined(value)) return ''; + var key = ids[id].name, param = self.params[key]; + return (!param || !values[key]) ? '' : encodeURIComponent(param.type.encode(value)); + })); - if (!isDefined(value) && (segments[i] === '/' || segments[i + 1] === '/')) continue; - if (value != null) result += encodeURIComponent(cfg.type.encode(value)); - result += segments[i + 1]; - } + var query = []; - for (/**/; i < nTotal; i++) { - param = params[i]; - value = values[param]; - if (value == null) continue; - array = isArray(value); + forEach(this.search, function(key) { + var value = values[key], param = self.params[key]; - if (array) { - value = value.map(encodeURIComponent).join('&' + param + '='); + if (!isDefined(value)) return; + if (!isArray(value)) { + query.push(key + '=' + encodeURIComponent(param ? param.type.encode(value) : value)); + return; } - result += (search ? '&' : '?') + param + '=' + (array ? value : encodeURIComponent(value)); - search = true; - } - return result; + if (param) value = value.map(param.type.encode); + query.push(key + '[]=' + value.map(encodeURIComponent).join('&' + key + '[]=')); + }); + + return query.length > 0 ? result + "?" + (query.join('&').replace(/&{2,}/g, '&')) : result; }; UrlMatcher.prototype.$types = {}; @@ -392,7 +331,7 @@ Type.prototype.is = function(val, key) { * @returns {string} Returns a string representation of `val` that can be encoded in a URL. */ Type.prototype.encode = function(val, key) { - return val; + return val || ""; }; /** @@ -428,9 +367,14 @@ Type.prototype.equals = function(a, b) { return a == b; }; -Type.prototype.$subPattern = function() { - var sub = this.pattern.toString(); - return sub.substr(1, sub.length - 2); +Type.prototype.$subPattern = function(options) { + options = extend({ optional: false, wrap: false }, options); + + var sub = this.pattern.toString(), result = sub.substr(1, sub.length - 2); + var flag = options.optional ? '?' : ''; + + result = options.wrap ? '(' + result + ')' : result; + return flag + result + flag; }; Type.prototype.pattern = /.*/; @@ -445,7 +389,7 @@ Type.prototype.pattern = /.*/; */ function $UrlMatcherFactory() { - var isCaseInsensitive = false, isStrictMode = true; + var isCaseInsensitive = false, isStrictMode = true, basePrefixUrl = null; var enqueue = true, typeQueue = [], injector, defaultTypes = { int: { @@ -511,6 +455,118 @@ function $UrlMatcherFactory() { return injector.invoke(config.value); }; + /** + * [Internal] As soon as a UrlMatcher is constructed, flush the queue of definitions. + */ + $UrlMatcherFactory.$$init = function() { + if (!enqueue) return; + enqueue = false; + UrlMatcher.prototype.$types = {}; + flushTypeQueue(); + }; + + /** + * [Internal] Used to configure new UrlMatcher instances by UrlMatcher() and UrlMatcher#concat(). + */ + $UrlMatcherFactory.$$instance = function(pattern, config) { + config = angular.isObject(config) ? config : {}; + + // Find all placeholders and create a compiled pattern, using either classic or curly syntax: + // '*' name + // ':' name + // '{' name '}' + // '{' name ':' regexp '}' + // The regular expression is somewhat complicated due to the need to allow curly braces + // inside the regular expression. The placeholder regexp breaks down as follows: + // ([:*])(\w+) classic placeholder ($1 / $2) + // \{(\w+)(?:\:( ... ))?\} curly brace placeholder ($3) with optional regexp ... ($4) + // (?: ... | ... | ... )+ the regexp consists of any number of atoms, an atom being either + // [^{}\\]+ - anything other than curly braces or backslash + // \\. - a backslash escape + // \{(?:[^{}\\]+|\\.)*\} - a matched set of curly braces containing other atoms + var placeholder = /([:*])(\w+)|\{(\w+)(?:\:((?:[^{}\\]+|\\.|\{(?:[^{}\\]+|\\.)*\})+))?\}/g, + params = this.params = {}, + self = this; + + /** + * [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(name, type, config) { + if (!/^\w+(-+\w+)*$/.test(name)) throw new Error("Invalid parameter name '" + name + "' in pattern '" + pattern + "'"); + if (params[name]) throw new Error("Duplicate parameter name '" + name + "' in pattern '" + pattern + "'"); + params[name] = extend({ type: type || new Type(), $value: $value }, config); + } + + 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 }; + } + + var idMap = {}, formatMap = {}, search = []; + + if (pattern.indexOf('?') >= 0) { + var split = pattern.split('?'); + pattern = split.shift(); + + forEach(split.join("?").split("&"), function(key) { + search.push(key); + addParameter(key, null, extend({ search: true }, paramConfig(key))); + }); + } + + var formatString = pattern.replace(placeholder, function(_, wild, name1, name2, typeName) { + var id = Math.round(Math.random() * 100000) + "", + name = name1 || name2, + cfg = paramConfig(name), + type = (typeName && self.$types[typeName]) || new Type({ + pattern: new RegExp(type || (wild === "*" ? '.*' : '[^/]*')) + }); + + addParameter(name, type, cfg); + formatMap[name] = id; + idMap[id] = { name: name, type: type, config: cfg }; + return "__ " + id + " __"; + }); + var prefix = this.parent ? this.parent.source.pattern : '^'; + + var compiled = prefix + quoteRegExp(formatString).replace(/__ (\d+) __/g, function(_, id) { + var mapped = idMap[id]; + return mapped.type.$subPattern({ optional: isDefined(mapped.config.value), wrap: true }); + }); + + var fullPattern = this.parent ? this.parent.source.append(pattern) : pattern; + + this.source = { + toString: function() { return fullPattern; }, + append: function(pattern) { + return (this.path || "") + (pattern || "") + (this.search || ""); + } + }; + + this.formatString = formatString; + this.formatMap = formatMap; + this.idMap = idMap; + this.source.pattern = compiled; + this.search = search; + + this.regexp = new RegExp( + compiled + (config.strict === false ? '\/?' : '') + '$', + config.caseInsensitive ? 'i' : undefined + ); + // this.prefix = segments[0]; + }; + /** * @ngdoc function * @name ui.router.util.$urlMatcherFactory#caseInsensitive @@ -539,6 +595,25 @@ function $UrlMatcherFactory() { isStrictMode = value; }; + /** + * @ngdoc function + * @name ui.router.util.$urlMatcherFactory#prefix + * @methodOf ui.router.util.$urlMatcherFactory + * + * @description + * Allows a base URL to be created that will be prefixed to all generated URLs. + * + * @param {string} pattern The URL pattern to be prefixed, or `null` to disable prefixing. + * @param {Object} config The configuration options for the URL. + */ + this.prefix = function(pattern, config) { + if (pattern === null) { + basePrefixUrl = null; + return; + } + basePrefixUrl = new UrlMatcher(pattern, extend(getDefaultConfig(), config || {})); + }; + /** * @ngdoc function * @name ui.router.util.$urlMatcherFactory#compile @@ -546,13 +621,15 @@ function $UrlMatcherFactory() { * * @description * Creates a {@link ui.router.util.type:UrlMatcher `UrlMatcher`} for the specified pattern. - * + * * @param {string} pattern The URL pattern. * @param {Object} config The config object hash. * @returns {UrlMatcher} The UrlMatcher. */ this.compile = function (pattern, config) { - return new UrlMatcher(pattern, extend(getDefaultConfig(), config)); + $UrlMatcherFactory.$$init(); + var localConfig = extend(getDefaultConfig(), config || {}); + return basePrefixUrl ? basePrefixUrl.concat(pattern, localConfig) : new UrlMatcher(pattern, localConfig); }; /** @@ -693,9 +770,7 @@ function $UrlMatcherFactory() { /* No need to document $get, since it returns this */ this.$get = ['$injector', function ($injector) { injector = $injector; - enqueue = false; - UrlMatcher.prototype.$types = {}; - flushTypeQueue(); + $UrlMatcherFactory.$$init(); forEach(defaultTypes, function(type, name) { if (!UrlMatcher.prototype.$types[name]) UrlMatcher.prototype.$types[name] = new Type(type); @@ -711,6 +786,7 @@ function $UrlMatcherFactory() { if (UrlMatcher.prototype.$types[type.name]) { throw new Error("A type named '" + type.name + "' has already been defined."); } + if (!injector) throw new Error("No injector!"); var def = new Type(isInjectable(type.def) ? injector.invoke(type.def) : type.def); UrlMatcher.prototype.$types[type.name] = def; }); diff --git a/src/urlRouter.js b/src/urlRouter.js index 5bdb58ff8..c1a2260bf 100644 --- a/src/urlRouter.js +++ b/src/urlRouter.js @@ -155,6 +155,8 @@ function $UrlRouterProvider( $locationProvider, $urlMatcherFactory) { */ this.when = function (what, handler) { var redirect, handlerIsString = isString(handler); + + // @todo Queue this if (isString(what)) what = $urlMatcherFactory.compile(what); if (!handlerIsString && !isFunction(handler) && !isArray(handler)) diff --git a/src/view.js b/src/view.js index f19a3c569..45e287f5e 100644 --- a/src/view.js +++ b/src/view.js @@ -1,71 +1,310 @@ +/** + * @ngdoc object + * @name ui.router.state.$view + * + * @requires ui.router.util.$templateFactory + * @requires $rootScope + * + * @description + * + */ +$View.$inject = ['$rootScope', '$templateFactory', '$q', '$injector']; +function $View( $rootScope, $templateFactory, $q, $injector) { -$ViewProvider.$inject = []; -function $ViewProvider() { + var views = {}, queued = {}, waiting = []; - this.$get = $get; /** - * @ngdoc object - * @name ui.router.state.$view + * Pushes a view configuration to be assigned to a named `uiView` element that either already + * exists, or is waiting to be created. If the view identified by `name` exists, the + * configuration will be assigned immediately. If it does not, and `async` is `true`, the + * configuration will be queued for assignment until the view exists. * - * @requires ui.router.util.$templateFactory - * @requires $rootScope + * @param {String} name The fully-qualified view name the configuration should be assigned to. + * @param {Boolean} async Determines whether the configuration can be queued if the view does + * not currently exist on the page. If the view does not exist and + * `async` is `false`, will return a rejected promise. + * @param {Object} config The view configuration to be assigned to the named `uiView`. Should + * include a `$template` key containing the HTML string to render, and + * can optionally include a `$controller`, `$locals`, and a `$context` + * object, which represents the object responsibile for the view (i.e. + * a UI state object), that can be used to look up the view later by a + * relative/non-fully-qualified name. + */ + function push(name, async, config) { + if (config && config.$context && waiting.length) { + tick(name, config.$context); + } + if (views[name]) { + views[name](config); + views[name].$config = config; + return config; + } + if (!async) { + return $q.reject(new Error("Attempted to synchronously load template into non-existent view " + name)); + } + queued[name] = config; + return config; + } + + + /** + * Pops a queued view configuration for a `uiView` that has come into existence. + * + * @param {String} name The fully-qualified dot-separated name of the view. + * @param {Function} callback The initialization function passed by `uiView` to + * `$view.register()`. + */ + function pop(name, callback) { + if (queued[name]) { + callback(queued[name]); + views[name].$config = queued[name]; + delete queued[name]; + } + } + + + /** + * Invoked when views have been queued for which fully-qualified names cannot be resolved + * (i.e. the parent view exists but has not been loaded/configured yet). Checks the list to + * see if the context of the most-recently-resolved view matches the parent context being + * waited for. + * + * @param {String} name The name of the loaded view. + * @param {Object} context The context object responsible for the view. + */ + function tick(name, context) { + for (var i = waiting.length - 1; i >= 0; i--) { + if (waiting[i].context === context) { + waiting.splice(i, 1)[0].defer.resolve(name); + } + } + } + + /** + * Returns a controller from a hash of options, either by executing an injectable + * `controllerProvider` function, or by returning the value of the `controller` key. + * + * @param {Object} options An object hash with either a `controllerProvider` key or a + * `controller` key. + * @return {*} Returns a controller. + */ + function resolveController(options) { + if (isFunction(options.controllerProvider) || isArray(options.controllerProvider)) { + return $injector.invoke(options.controllerProvider, null, options.locals); + } + return options.controller; + } + + /** + * Checks a view configuration to ensure that it specifies a template. + * + * @param {Object} options An object hash with either a `template` key, a `templateUrl` key or a + * `templateProvider` key. + * @return {boolean} Returns `true` if the configuration is valid, otherwise `false`. + */ + function hasValidTemplate(options) { + return (options.template || options.templateUrl || options.templateProvider); + } + + /** + * @ngdoc function + * @name ui.router.state.$view#load + * @methodOf ui.router.state.$view * * @description + * Uses `$templateFactory` to load a template from a configuration object into a named view. * + * @param {string} name The fully-qualified name of the view to load the template into + * @param {Object} options The options used to load the template: + * @param {boolean} options.notify Indicates whether a `$viewContentLoading` event should be + * this call. + * @params {*} options.* Accepts the full list of parameters and options accepted by + * `$templateFactory.fromConfig()`, including `params` and `locals`. + * @return {Promise.} Returns a promise that resolves to the value of the template loaded. */ - $get.$inject = ['$rootScope', '$templateFactory']; - function $get( $rootScope, $templateFactory) { - return { - // $view.load('full.viewName', { template: ..., controller: ..., resolve: ..., async: false, params: ... }) + this.load = function load (name, options) { + var $template, $parent, defaults = { + template: undefined, + templateUrl: undefined, + templateProvider: undefined, + controller: null, + controllerAs: null, + controllerProvider: null, + context: null, + parent: null, + locals: null, + notify: true, + async: true, + params: {} + }; + options = extend(defaults, options); + + if (!hasValidTemplate(options)) return $q.reject(new Error('No template configuration specified for ' + name)); + $template = $templateFactory.fromConfig(options, options.params, options.locals); + + if ($template && options.notify) { /** - * @ngdoc function - * @name ui.router.state.$view#load - * @methodOf ui.router.state.$view - * + * @ngdoc event + * @name ui.router.state.$state#$viewContentLoading + * @eventOf ui.router.state.$view + * @eventType broadcast on root scope * @description * - * @param {string} name name - * @param {object} options option object. + * Fired once the view **begins loading**, *before* the DOM is rendered. + * + * @param {Object} event Event object. + * @param {Object} viewConfig The view config properties (template, controller, etc). + * + * @example + * + *
+       * $scope.$on('$viewContentLoading',
+       * function(event, viewConfig){
+       *     // Access to all the view config properties.
+       *     // and one special property 'targetView'
+       *     // viewConfig.targetView
+       * });
+       * 
*/ - load: function load(name, options) { - var result, defaults = { - template: null, controller: null, view: null, locals: null, notify: true, async: true, params: {} - }; - options = extend(defaults, options); - - if (options.view) { - result = $templateFactory.fromConfig(options.view, options.params, options.locals); - } - if (result && options.notify) { - /** - * @ngdoc event - * @name ui.router.state.$state#$viewContentLoading - * @eventOf ui.router.state.$view - * @eventType broadcast on root scope - * @description - * - * Fired once the view **begins loading**, *before* the DOM is rendered. - * - * @param {Object} event Event object. - * @param {Object} viewConfig The view config properties (template, controller, etc). - * - * @example - * - *
-         * $scope.$on('$viewContentLoading',
-         * function(event, viewConfig){
-         *     // Access to all the view config properties.
-         *     // and one special property 'targetView'
-         *     // viewConfig.targetView
-         * });
-         * 
- */ - $rootScope.$broadcast('$viewContentLoading', options); - } - return result; - } + options.targetView = name; + $rootScope.$broadcast('$viewContentLoading', options); + } + var promises = [$q.when($template)], fqn = (options.parent) ? this.find(name, options.parent) : name; + + if (!fqn) { + var self = this; + $parent = $q.defer(); + + promises.push($parent.promise.then(function(parent) { + fqn = parent + "." + name; + })); + + waiting.push({ context: options.parent, defer: $parent }); + } + + return $q.all(promises).then(function(results) { + return push(fqn, options.async, { + template: results[0], + controller: resolveController(options), + controllerAs: options.controllerAs, + locals: options.locals, + context: options.context + }); + }); + }; + + /** + * Resets a view to its initial state. + * + * @param {String} name The fully-qualified name of the view to reset. + * @return {Boolean} Returns `true` if the view exists, otherwise `false`. + */ + this.reset = function reset (name) { + if (!views[name]) { + return false; + } + return push(name, false, null) === null; + }; + + /** + * Syncs a set of view configurations + * + * @param {String} name The fully-qualified name of the view to reset. + * @return {Boolean} Returns `true` if the view exists, otherwise `false`. + */ + this.sync = function sync (views, locals, options) { + var defaults = { + eager: false }; - } + options = extend(defaults, options); + }; + + /** + * Allows a `ui-view` element to register its canonical name with a callback that allows it to + * be updated with a template, controller, and local variables. + * + * @param {String} name The fully-qualified name of the `ui-view` object being registered. + * @param {Function} callback A callback that receives updates to the content & configuration + * of the view. + * @return {Function} Returns a de-registration function used when the view is destroyed. + */ + this.register = function register (name, callback) { + views[name] = callback; + views[name].$config = null; + pop(name, callback); + + return function() { + delete views[name]; + }; + }; + + /** + * Determines whether a particular view exists on the page, by querying the fully-qualified name. + * + * @param {String} name The fully-qualified dot-separated name of the view, if `context` is not + specified. If `context` is specified, `name` should be relative to the parent `context`. + * @param {Object} context Optional parent context in which to look for the named view. + * @return {Boolean} Returns `true` if the view exists on the page, otherwise `false`. + */ + this.exists = function exists (name, context) { + return isDefined(views[context ? this.find(name, context) : name]); + }; + + /** + * Resolves a view's relative name to a fully-qualified name by looking up the parent of the view, + * by the parent view's context object. + * + * @param {String} name A relative view name. + * @param {Object} context The context object of the parent view in which to look up the view to + * return. + * @return {String} Returns the fully-qualified view name, or `null`, if `context` cannot be found. + */ + this.find = function find (name, context) { + var result; + + if (angular.isArray(name)) { + result = []; + + angular.forEach(name, function(name) { + result.push(this.find(name, context)); + }, this); + + return result; + } + + angular.forEach(views, function(def, absName) { + if (!def || !def.$config || context !== def.$config.$context) { + return; + } + result = absName + "." + name; + }); + return result; + }; + + /** + * Returns the list of views currently available on the page, by fully-qualified name. + * + * @return {Array} Returns an array of fully-qualified view names. + */ + this.available = function available () { + return keys(views); + }; + + /** + * Returns the list of views on the page containing loaded content. + * + * @return {Array} Returns an array of fully-qualified view names. + */ + this.active = function active () { + var result = []; + + angular.forEach(views, function(config, key) { + if (config && config.$config) { + result.push(key); + } + }); + return result; + }; } -angular.module('ui.router.state').provider('$view', $ViewProvider); +angular.module('ui.router.state').service('$view', $View); diff --git a/src/viewDirective.js b/src/viewDirective.js index 7f9632d80..e1c81c2df 100644 --- a/src/viewDirective.js +++ b/src/viewDirective.js @@ -26,26 +26,26 @@ * functionality, call `$uiViewScrollProvider.useAnchorScroll()`.* * * @param {string=} onload Expression to evaluate whenever the view updates. - * + * * @example - * A view can be unnamed or named. + * A view can be unnamed or named. *
  * 
- * 
- * + *
+ * * *
*
* - * You can only have one unnamed view within any template (or root html). If you are only using a + * You can only have one unnamed view within any template (or root html). If you are only using a * single view and it is unnamed then you can populate it like so: *
- * 
+ *
* $stateProvider.state("home", { * template: "

HELLO!

" * }) *
- * + * * The above is a convenient shortcut equivalent to specifying your view explicitly with the {@link ui.router.state.$stateProvider#views `views`} * config property, by name, in this case an empty name: *
@@ -54,33 +54,33 @@
  *     "": {
  *       template: "

HELLO!

" * } - * } + * } * }) *
- * - * But typically you'll only use the views property if you name your view or have more than one view - * in the same template. There's not really a compelling reason to name a view if its the only one, + * + * But typically you'll only use the views property if you name your view or have more than one view + * in the same template. There's not really a compelling reason to name a view if its the only one, * but you could if you wanted, like so: *
  * 
- *
+ * *
  * $stateProvider.state("home", {
  *   views: {
  *     "main": {
  *       template: "

HELLO!

" * } - * } + * } * }) *
- * + * * Really though, you'll use views to set up multiple views: *
  * 
- *
- *
+ *
+ *
*
- * + * *
  * $stateProvider.state("home", {
  *   views: {
@@ -93,7 +93,7 @@
  *     "data": {
  *       template: ""
  *     }
- *   }    
+ *   }
  * })
  * 
* @@ -111,8 +111,10 @@ * * */ -$ViewDirective.$inject = ['$state', '$injector', '$uiViewScroll']; -function $ViewDirective( $state, $injector, $uiViewScroll) { +$ViewDirective.$inject = ['$state', '$view', '$injector', '$uiViewScroll']; +function $ViewDirective( $state, $view, $injector, $uiViewScroll) { + + var views = {}; function getService() { return ($injector.has) ? function(service) { @@ -166,19 +168,41 @@ function $ViewDirective( $state, $injector, $uiViewScroll) { transclude: 'element', compile: function (tElement, tAttrs, $transclude) { return function (scope, $element, attrs) { - var previousEl, currentEl, currentScope, latestLocals, + var previousEl, currentEl, currentScope, latestLocals, unregister, onloadExp = attrs.onload || '', autoScrollExp = attrs.autoscroll, - renderer = getRenderer(attrs, scope); + renderer = getRenderer(attrs, scope), + viewConfig = {}, + inherited = $element.inheritedData('$uiView'); + + updateView(true); + + + + var viewData = { name: inherited ? inherited.name + "." + name : name }; + $element.data('$uiView', viewData); - scope.$on('$stateChangeSuccess', function() { - updateView(false); + unregister = $view.register(viewData.name, function(config) { + var nothingToDo = (config === viewConfig) || (config && viewConfig && ( + config.controller === viewConfig.controller && + config.template === viewConfig.template && + config.locals === viewConfig.locals + )); + if (nothingToDo) return; + + updateView(false, config); }); - scope.$on('$viewContentLoading', function() { - updateView(false); + + + scope.$on("$destroy", function() { + unregister(); }); - updateView(true); + if (!viewConfig) updateView(false); + + + + function cleanupLastView() { if (previousEl) { @@ -201,10 +225,10 @@ function $ViewDirective( $state, $injector, $uiViewScroll) { } } - function updateView(firstTime) { + function updateView(firstTime, config) { var newScope, name = getUiViewName(attrs, $element.inheritedData('$uiView')), - previousLocals = name && $state.$current && $state.$current.locals[name]; + previousLocals = viewConfig && viewConfig.locals; if (!firstTime && previousLocals === latestLocals) return; // nothing to do newScope = scope.$new(); @@ -223,6 +247,8 @@ function $ViewDirective( $state, $injector, $uiViewScroll) { cleanupLastView(); }); + latestLocals = viewConfig.locals; + currentEl = clone; currentScope = newScope; /** @@ -235,7 +261,7 @@ function $ViewDirective( $state, $injector, $uiViewScroll) { * * @param {Object} event Event object. */ - currentScope.$emit('$viewContentLoaded'); + currentScope.$emit('$viewContentLoaded', viewConfig); currentScope.$eval(onloadExp); } }; @@ -252,26 +278,20 @@ function $ViewDirectiveFill ($compile, $controller, $state) { priority: -400, compile: function (tElement) { var initial = tElement.html(); - return function (scope, $element, attrs) { - var current = $state.$current, - name = getUiViewName(attrs, $element.inheritedData('$uiView')), - locals = current && current.locals[name]; - if (! locals) { - return; - } + return function (scope, $element) { + var locals = $element.data('$uiView').locals; - $element.data('$uiView', { name: name, state: locals.$$state }); - $element.html(locals.$template ? locals.$template : initial); + if (!locals) return; + + $element.html(locals.$template || initial); var link = $compile($element.contents()); if (locals.$$controller) { - locals.$scope = scope; - var controller = $controller(locals.$$controller, locals); - if (locals.$$controllerAs) { - scope[locals.$$controllerAs] = controller; - } + var controller = $controller(locals.$$controller, extend(locals, { $scope: scope })); + if (locals.$$controllerAs) scope[locals.$$controllerAs] = controller; + $element.data('$ngControllerController', controller); $element.children().data('$ngControllerController', controller); } diff --git a/test/resolveSpec.js b/test/resolveSpec.js index 67117b9ed..b0f57680c 100644 --- a/test/resolveSpec.js +++ b/test/resolveSpec.js @@ -1,5 +1,448 @@ -describe("resolve", function () { - +describe('Resolvables system:', function () { + var statesTree, statesMap = {}; + var emptyPath; + var counts; + var asyncCount; + + beforeEach(inject(function ($transition) { + emptyPath = new Path([]); + asyncCount = 0; + })); + + beforeEach(function () { + counts = { _J: 0, _J2: 0, _K: 0, _L: 0, _M: 0}; + states = { + A: { resolve: { _A: function () { return "A"; }, _A2: function() { return "A2"; }}, + B: { resolve: { _B: function () { return "B"; }, _B2: function() { return "B2"; }}, + C: { resolve: { _C: function (_A, _B) { return _A + _B + "C"; }, _C2: function() { return "C2"; }}, + D: { resolve: { _D: function (_D2) { return "D1" + _D2; }, _D2: function () { return "D2"; }} } + } + }, + E: { resolve: { _E: function() { return "E"; } }, + F: { resolve: { _E: function() { return "_E"; }, _F: function(_E) { return _E + "F"; }} } + }, + G: { resolve: { _G: function() { return "G"; } }, + H: { resolve: { _G: function(_G) { return _G + "_G"; }, _H: function(_G) { return _G + "H"; } } } + }, + I: { resolve: { _I: function(_I) { return "I"; } } } + }, + J: { resolve: { _J: function() { counts['_J']++; return "J"; }, _J2: function(_J) { counts['_J2']++; return _J + "J2"; } }, + K: { resolve: { _K: function(_J2) { counts['_K']++; return _J2 + "K"; }}, + L: { resolve: { _L: function(_K) { counts['_L']++; return _K + "L"; }}, + M: { resolve: { _M: function(_L) { counts['_M']++; return _L + "M"; }} } + } + } + }, + N: { resolve: { _N: function(_N2) { return _N2 + "N"; }, _N2: function(_N) { return _N + "N2"; } }} + }; + + var stateProps = ["resolve"]; + statesTree = loadStates({}, states, ''); + + function loadStates(parent, state, name) { + var thisState = pick.apply(null, [state].concat(stateProps)); + var substates = omit.apply(null, [state].concat(stateProps)); + + thisState.name = name; + thisState.parent = parent.name; + thisState.data = { children: [] }; + + angular.forEach(substates, function (value, key) { + thisState.data.children.push(loadStates(thisState, value, key)); + }); + statesMap[name] = thisState; + return thisState; + } +// console.log(map(makePath([ "A", "B", "C" ]), function(s) { return s.name; })); + }); + + function makePath(names) { + return new Path(map(names, function(name) { return statesMap[name]; })); + } + + describe('PathElement.resolve()', function () { + it('should resolve all resolves in a PathElement', inject(function ($q) { + var path = makePath([ "A" ]); + var promise = path.elements()[0].resolve(new ResolveContext(path)); // A + promise.then(function () { + expect(path.$$elements[0].$$resolvables['_A']).toBeDefined(); + expect(path.$$elements[0].$$resolvables['_A'].data).toBe("A"); + expect(path.$$elements[0].$$resolvables['_A2'].data).toBe("A2"); + asyncCount++; + }); + + $q.flush(); + expect(asyncCount).toBe(1); + })); + }); + + describe('PathElement.resolve()', function () { + it('should not resolve non-dep parent PathElements', inject(function ($q) { + var path = makePath([ "A", "B" ]); + var promise = path.elements()[1].resolve(new ResolveContext(path)); // B + promise.then(function () { + expect(path.$$elements[0].$$resolvables['_A']).toBeDefined(); + expect(path.$$elements[0].$$resolvables['_A'].data).toBeUndefined(); + expect(path.$$elements[0].$$resolvables['_A2'].data).toBeUndefined(); + expect(path.$$elements[1].$$resolvables['_B'].data).toBe("B"); + expect(path.$$elements[1].$$resolvables['_B2'].data).toBe("B2"); + asyncCount++; + }); + + $q.flush(); + expect(asyncCount).toBe(1); + })); + }); + + describe('ResolveContext.getResolvableLocals', function () { + it('should return Resolvables from itself and all parents', inject(function ($q) { + var path = makePath([ "A", "B", "C" ]); + var resolveContext = new ResolveContext(path); + var resolvableLocals = resolveContext.getResolvableLocals("C", { flatten: true } ); + var keys = Object.keys(resolvableLocals).sort(); + expect(keys).toEqual( ["_A", "_A2", "_B", "_B2", "_C", "_C2" ] ); + })); + }); + + describe('Path.resolve()', function () { + it('should resolve all resolves in a Path', inject(function ($q) { + var path = makePath([ "A", "B" ]); + var promise = path.resolve(new ResolveContext(path)); + promise.then(function () { + expect(path.$$elements[0].$$resolvables['_A'].data).toBe("A"); + expect(path.$$elements[0].$$resolvables['_A2'].data).toBe("A2"); + expect(path.$$elements[1].$$resolvables['_B'].data).toBe("B"); + expect(path.$$elements[1].$$resolvables['_B2'].data).toBe("B2"); + asyncCount++; + }); + + $q.flush(); + expect(asyncCount).toBe(1); + })); + }); + + describe('Resolvable.resolve()', function () { + it('should resolve one Resolvable, and its deps', inject(function ($q) { + var path = makePath([ "A", "B", "C" ]); + var promise = path.$$elements[2].$$resolvables['_C'].resolve(new ResolveContext(path)); + promise.then(function () { + expect(path.$$elements[0].$$resolvables['_A'].data).toBe("A"); + expect(path.$$elements[0].$$resolvables['_A2'].data).toBeUndefined(); + expect(path.$$elements[1].$$resolvables['_B'].data).toBe("B"); + expect(path.$$elements[1].$$resolvables['_B2'].data).toBeUndefined(); + expect(path.$$elements[2].$$resolvables['_C'].data).toBe("ABC"); + asyncCount++; + }); + + $q.flush(); + expect(asyncCount).toBe(1); + + })); + }); + + describe('PathElement.invokeLater()', function () { + it('should resolve only the required deps, then inject the fn', inject(function ($q) { + var path = makePath([ "A", "B", "C", "D" ]); + var cPathElement = path.elements()[2]; + var context = new ResolveContext(path); + + var result; + + var onEnter1 = function (_C2) { result = _C2; }; + var promise = cPathElement.invokeLater(onEnter1, {}, context); + promise.then(function (data) { + expect(result).toBe("C2"); + expect(path.$$elements[0].$$resolvables['_A'].data).toBeUndefined(); + expect(path.$$elements[1].$$resolvables['_B'].data).toBeUndefined(); + expect(path.$$elements[2].$$resolvables['_C'].data).toBeUndefined(); + expect(path.$$elements[2].$$resolvables['_C2'].data).toBe("C2"); + expect(path.$$elements[3].$$resolvables['_D'].data).toBeUndefined(); + asyncCount++; + }); + $q.flush(); + expect(asyncCount).toBe(1); + })); + }); + + describe('PathElement.invokeLater()', function () { + it('should resolve the required deps on demand', inject(function ($q) { + var path = makePath([ "A", "B", "C", "D" ]); + var cPathElement = path.elements()[2]; + var context = new ResolveContext(path); + + var result; + + var cOnEnter1 = function (_C2) { result = _C2; }; + var promise = cPathElement.invokeLater(cOnEnter1, {}, context); + promise.then(function (data) { + expect(result).toBe("C2"); + expect(path.$$elements[0].$$resolvables['_A'].data).toBeUndefined(); + expect(path.$$elements[1].$$resolvables['_B'].data).toBeUndefined(); + expect(path.$$elements[2].$$resolvables['_C'].data).toBeUndefined(); + expect(path.$$elements[2].$$resolvables['_C2'].data).toBe("C2"); + expect(path.$$elements[3].$$resolvables['_D'].data).toBeUndefined(); + asyncCount++; + }); + $q.flush(); + expect(asyncCount).toBe(1); + + var cOnEnter2 = function (_C) { result = _C; }; + promise = cPathElement.invokeLater(cOnEnter2, {}, context); + promise.then(function (data) { + expect(result).toBe("ABC"); + expect(path.$$elements[0].$$resolvables['_A'].data).toBe("A"); + expect(path.$$elements[1].$$resolvables['_B'].data).toBe("B"); + expect(path.$$elements[2].$$resolvables['_C'].data).toBe("ABC"); + expect(path.$$elements[2].$$resolvables['_C2'].data).toBe("C2"); + expect(path.$$elements[3].$$resolvables['_D'].data).toBeUndefined(); + asyncCount++; + }); + $q.flush(); + expect(asyncCount).toBe(2); + })); + }); + + describe('invokeLater', function () { + it('should Error if the onEnter dependency cannot be injected', inject(function ($q) { + var path = makePath([ "A", "B", "C", "D" ]); + var cPathElement = path.elements()[2]; + var context = new ResolveContext(path); + + var cOnEnter = function (_D) { }; + var caught; + var promise = cPathElement.invokeLater(cOnEnter, {}, context); + promise.catch(function (err) { + caught = err; + asyncCount++; + }); + + $q.flush(); + expect(asyncCount).toBe(1); + expect(caught.message).toContain("Unknown provider: _DProvider"); + })); + }); + + + describe('Resolvables', function () { + it('should inject deps from the same PathElement', inject(function ($q) { + var path = makePath([ "A", "B", "C", "D" ]); + var dPathElement = path.elements()[3]; + var context = new ResolveContext(path); + + var result; + var dOnEnter = function (_D) { + result = _D; + }; + + var promise = dPathElement.invokeLater(dOnEnter, {}, context); + promise.then(function () { + expect(result).toBe("D1D2"); + expect(path.$$elements[0].$$resolvables['_A'].data).toBeUndefined(); + expect(path.$$elements[3].$$resolvables['_D'].data).toBe("D1D2"); + expect(path.$$elements[3].$$resolvables['_D2'].data).toBe("D2"); + asyncCount++; + }); + + $q.flush(); + expect(asyncCount).toBe(1); + })); + }); + + describe('Resolvables', function () { + it('should allow PathElement to override parent deps Resolvables of the same name', inject(function ($q) { + var path = makePath([ "A", "E", "F" ]); + var fPathElement = path.elements()[2]; + var context = new ResolveContext(path); + + var result; + var fOnEnter = function (_F) { + result = _F; + }; + + var promise = fPathElement.invokeLater(fOnEnter, {}, context); + promise.then(function () { + expect(result).toBe("_EF"); + asyncCount++; + }); + + $q.flush(); + expect(asyncCount).toBe(1); + })); + }); + + // State H has a resolve named _G which takes _G as an injected parameter. injected _G should come from state "G" + // It also has a resolve named _H which takes _G as an injected parameter. injected _G should come from state "H" + describe('Resolvables', function () { + it('of a particular name should be injected from the parent PathElements for their own name', inject(function ($q) { + var path = makePath([ "A", "G", "H" ]); + var context = new ResolveContext(path); + + var resolvable_G = path.elements()[2].$$resolvables._G; + var promise = resolvable_G.get(context); + promise.then(function (data) { + expect(data).toBe("G_G"); + asyncCount++; + }); + $q.flush(); + expect(asyncCount).toBe(1); + + var result; + var hOnEnter = function (_H) { + result = _H; + }; + + var hPathElement = path.elements()[2]; + promise = hPathElement.invokeLater(hOnEnter, {}, context); + promise.then(function (data) { + expect(result).toBe("G_GH"); + asyncCount++; + }); + + $q.flush(); + expect(asyncCount).toBe(2); + })); + }); + + describe('Resolvables', function () { + it('should fail to inject same-name deps to self if no parent PathElement contains the name.', inject(function ($q) { + var path = makePath([ "A", "I" ]); + var context = new ResolveContext(path); + + var iPathElement = path.elements()[1]; + var iOnEnter = function (_I) { }; + var caught; + var promise = iPathElement.invokeLater(iOnEnter, {}, context); + promise.catch(function (err) { + caught = err; + asyncCount++; + }); + + $q.flush(); + expect(asyncCount).toBe(1); + expect(caught.message).toContain("[$injector:unpr] Unknown provider: _IProvider "); + })); + }); + + xdescribe('Resolvables', function () { + it('should fail to inject circular dependency', inject(function ($q) { + var path = makePath([ "N" ]); + var context = new ResolveContext(path); + + var iPathElement = path.elements()[0]; + var iOnEnter = function (_N) { }; + var caught; + var promise = iPathElement.invokeLater(iOnEnter, {}, context); + promise.catch(function (err) { + caught = err; + }); + + $q.flush(); + expect(asyncCount).toBe(1); + expect(caught.message).toContain("[$injector:unpr] Unknown provider: _IProvider "); + })); + }); + + describe('Resolvables', function () { + it('should not re-resolve', inject(function ($q) { + var path = makePath([ "J", "K" ]); + var context = new ResolveContext(path); + + var kPathElement = path.elements()[1]; + var result; + function checkCounts() { + expect(result).toBe("JJ2K"); + expect(counts['_J']).toBe(1); + expect(counts['_J2']).toBe(1); + expect(counts['_K']).toBe(1); + } + + var onEnterCount = 0; + var kOnEnter = function (_K) { + result = _K; + onEnterCount++; + }; + var promise = kPathElement.invokeLater(kOnEnter, {}, context); + promise.then(checkCounts); + $q.flush(); + expect(onEnterCount).toBe(1); + + // invoke again + promise = kPathElement.invokeLater(kOnEnter, {}, context); + promise.then(checkCounts); + $q.flush(); + expect(onEnterCount).toBe(2); + })); + }); + + describe('Pre-Resolved Path', function () { + it('from previous resolve operation should be re-useable when passed to a new ResolveContext', inject(function ($q) { + var path = makePath([ "J", "K" ]); + var async = 0; + + expect(counts["_J"]).toBe(0); + expect(counts["_J2"]).toBe(0); + path.resolve(new ResolveContext(path)).then(function () { + expect(counts["_J"]).toBe(1); + expect(counts["_J2"]).toBe(1); + expect(counts["_K"]).toBe(1); + asyncCount++; + }); + $q.flush(); + expect(asyncCount).toBe(1); + + var path2 = path.concat(makePath([ "L", "M" ])); + path2.resolve(new ResolveContext(path2)).then(function () { + expect(counts["_J"]).toBe(1); + expect(counts["_J2"]).toBe(1); + expect(counts["_K"]).toBe(1); + expect(counts["_L"]).toBe(1); + expect(counts["_M"]).toBe(1); + asyncCount++; + }); + $q.flush(); + expect(asyncCount).toBe(2); + })); + }); + + describe('Path.slice()', function () { + it('should create a partial path from an original path', inject(function ($q) { + var path = makePath([ "J", "K", "L" ]); + path.resolve(new ResolveContext(path)).then(function () { + expect(counts["_J"]).toBe(1); + expect(counts["_J2"]).toBe(1); + expect(counts["_K"]).toBe(1); + expect(counts["_L"]).toBe(1); + asyncCount++; + }); + $q.flush(); + expect(asyncCount).toBe(1); + + var slicedPath = path.slice(0, 2); + expect(slicedPath.elements().length).toBe(2); + expect(slicedPath.elements()[1]).toBe(path.elements()[1]); + var path2 = path.concat(makePath([ "L", "M" ])); + path2.resolve(new ResolveContext(path2)).then(function () { + expect(counts["_J"]).toBe(1); + expect(counts["_J2"]).toBe(1); + expect(counts["_K"]).toBe(1); + expect(counts["_L"]).toBe(2); + expect(counts["_M"]).toBe(1); + asyncCount++; + }); + $q.flush(); + expect(asyncCount).toBe(2); + })); + }); + + // TODO: test injection of annotated functions + // TODO: test injection of services + // TODO: test injection of other locals + // TODO: Implement and test injection to onEnter/Exit + // TODO: Implement and test injection into controllers +}); + +describe("legacy resolve", function () { + var $r, tick; beforeEach(module('ui.router.util')); @@ -13,7 +456,7 @@ describe("resolve", function () { $r = $resolve; tick = $q.flush; })); - + describe(".resolve()", function () { it("calls injectable functions and returns a promise", function () { var fun = jasmine.createSpy('fun').andReturn(42); @@ -26,7 +469,7 @@ describe("resolve", function () { 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); @@ -37,7 +480,7 @@ describe("resolve", function () { 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'); @@ -47,12 +490,12 @@ describe("resolve", function () { 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(); @@ -77,7 +520,7 @@ describe("resolve", function () { 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'); @@ -87,13 +530,13 @@ describe("resolve", function () { 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 }); @@ -101,7 +544,7 @@ describe("resolve", function () { 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" }); @@ -109,14 +552,14 @@ describe("resolve", function () { 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); @@ -124,13 +567,13 @@ describe("resolve", function () { 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'); @@ -139,7 +582,7 @@ describe("resolve", function () { expect(b).toHaveBeenCalled(); expect(b.mostRecentCall.args).toEqual([ 'aa' ]); }); - + it("allow access to ancestor resolves in descendent resolve blocks", inject(function ($q) { var gPromise = $q.defer(), gInjectable = jasmine.createSpy('gInjectable').andReturn(gPromise.promise), @@ -172,7 +615,7 @@ describe("resolve", function () { 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(); @@ -206,7 +649,7 @@ describe("resolve", function () { 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 @@ -218,28 +661,28 @@ describe("resolve", function () { 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 } }); @@ -248,7 +691,7 @@ describe("resolve", function () { 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(); @@ -259,7 +702,7 @@ describe("resolve", function () { 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); @@ -272,7 +715,7 @@ describe("resolve", function () { 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); @@ -288,12 +731,12 @@ describe("resolve", function () { 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'); @@ -303,7 +746,7 @@ describe("resolve", function () { 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'); @@ -331,4 +774,3 @@ describe("resolve", function () { }); }); }); - diff --git a/test/stateDirectivesSpec.js b/test/stateDirectivesSpec.js index 962a5471a..192642738 100644 --- a/test/stateDirectivesSpec.js +++ b/test/stateDirectivesSpec.js @@ -128,7 +128,7 @@ describe('uiStateRef', function() { $q.flush(); expect($state.current.name).toEqual('contacts.item.detail'); - expect($stateParams).toEqual({ id: 5 }); + expect($stateParams).toEqualData({ id: 5 }); })); it('should transition when given a click that contains no data (fake-click)', inject(function($state, $stateParams, $q) { @@ -139,19 +139,20 @@ describe('uiStateRef', function() { ctrlKey: undefined, shiftKey: undefined, altKey: undefined, - button: undefined + button: undefined }); timeoutFlush(); $q.flush(); expect($state.current.name).toEqual('contacts.item.detail'); - expect($stateParams).toEqual({ id: 5 }); + expect($stateParams).toEqualData({ id: 5 }); })); it('should not transition states when ctrl-clicked', inject(function($state, $stateParams, $q) { expect($state.$current.name).toEqual('top'); - triggerClick(el, { ctrlKey: true }); + expect($stateParams).toEqualData({}); + triggerClick(el, { ctrlKey: true }); timeoutFlush(); $q.flush(); @@ -161,6 +162,7 @@ describe('uiStateRef', function() { it('should not transition states when meta-clicked', inject(function($state, $stateParams, $q) { expect($state.$current.name).toEqual('top'); + expect($stateParams).toEqualData({}); triggerClick(el, { metaKey: true }); timeoutFlush(); @@ -172,6 +174,7 @@ describe('uiStateRef', function() { it('should not transition states when shift-clicked', inject(function($state, $stateParams, $q) { expect($state.$current.name).toEqual('top'); + expect($stateParams).toEqualData({}); triggerClick(el, { shiftKey: true }); timeoutFlush(); @@ -183,6 +186,7 @@ describe('uiStateRef', function() { it('should not transition states when middle-clicked', inject(function($state, $stateParams, $q) { expect($state.$current.name).toEqual('top'); + expect($stateParams).toEqualData({}); triggerClick(el, { button: 1 }); timeoutFlush(); @@ -206,6 +210,8 @@ describe('uiStateRef', function() { it('should not transition states if preventDefault() is called in click handler', inject(function($state, $stateParams, $q) { expect($state.$current.name).toEqual('top'); + expect($stateParams).toEqualData({}); + el.bind('click', function(e) { e.preventDefault(); }); @@ -217,10 +223,10 @@ describe('uiStateRef', function() { expect($state.current.name).toEqual('top'); expect($stateParams).toEqualData({}); })); - + it('should allow passing params to current state', inject(function($compile, $rootScope, $state) { $state.current.name = 'contacts.item.detail'; - + el = angular.element("Details"); $rootScope.$index = 3; $rootScope.$apply(); @@ -229,10 +235,10 @@ describe('uiStateRef', function() { $rootScope.$digest(); expect(el.attr('href')).toBe('#/contacts/3'); })); - + it('should allow multi-line attribute values when passing params to current state', inject(function($compile, $rootScope, $state) { $state.current.name = 'contacts.item.detail'; - + el = angular.element("Details"); $rootScope.$index = 3; $rootScope.$apply(); diff --git a/test/stateSpec.js b/test/stateSpec.js index 65a8c04b3..d7833cd94 100644 --- a/test/stateSpec.js +++ b/test/stateSpec.js @@ -1,4 +1,207 @@ -describe('state', function () { +describe('state helpers', function() { + + var states; + + beforeEach(function() { + states = {}; + states[''] = { name: '', parent: null }; + states['home'] = { name: 'home', parent: states[''] }; + states['home.about'] = { name: 'home.about', parent: states['home'] }; + states['home.about.people'] = { name: 'home.about.people', parent: states['home.about'] }; + states['home.about.people.person'] = { name: 'home.about.people.person', parent: states['home.about.people'] }; + states['home.about.company'] = { name: 'home.about.company', parent: states['home.about'] }; + states['other'] = { name: 'other', parent: states[''] }; + states['other.foo'] = { name: 'other.foo', parent: states['other'] }; + states['other.foo.bar'] = { name: 'other.foo.bar' }; + + states['home.withData'] = { + name: 'home.withData', + data: { val1: "foo", val2: "bar" }, + parent: states['home'] + }; + states['home.withData.child'] = { + name: 'home.withData.child', + data: { val2: "baz" }, + parent: states['home.withData'] + }; + }); + + + describe('GlobBuilder', function() { + it('should match glob strings', function() { + expect(GlobBuilder.is('*')).toBe(true); + expect(GlobBuilder.is('**')).toBe(true); + expect(GlobBuilder.is('*.*')).toBe(true); + + expect(GlobBuilder.is('')).toBe(false); + expect(GlobBuilder.is('.')).toBe(false); + }); + + it('should construct glob matchers', function() { + expect(GlobBuilder.fromString('')).toBeNull(); + + var state = { name: 'about.person.item' }; + + expect(GlobBuilder.fromString('*.person.*').matches(state)).toBe(true); + expect(GlobBuilder.fromString('*.person.**').matches(state)).toBe(true); + + expect(GlobBuilder.fromString('**.item.*').matches(state)).toBe(false); + expect(GlobBuilder.fromString('**.item').matches(state)).toBe(true); + expect(GlobBuilder.fromString('**.stuff.*').matches(state)).toBe(false); + expect(GlobBuilder.fromString('*.*.*').matches(state)).toBe(true); + + expect(GlobBuilder.fromString('about.*.*').matches(state)).toBe(true); + expect(GlobBuilder.fromString('about.**').matches(state)).toBe(true); + expect(GlobBuilder.fromString('*.about.*').matches(state)).toBe(false); + expect(GlobBuilder.fromString('about.*.*').matches(state)).toBe(true); + }); + }); + + describe('StateMatcher', function() { + it('should find states by name', function() { + var states = {}, matcher = new StateMatcher(states), home = { name: 'home' }; + expect(matcher.find('home')).toBeUndefined(); + + states['home'] = home; + expect(matcher.find('home')).toBe(home); + expect(matcher.find(home)).toBe(home); + + expect(matcher.find('home.about')).toBeUndefined(); + + states['home.about'] = { name: 'home.about' }; + expect(matcher.find('home.about')).toEqual({ name: 'home.about' }); + + expect(matcher.find()).toBeUndefined(); + expect(matcher.find('')).toBeUndefined(); + expect(matcher.find(null)).toBeUndefined(); + }); + + it('should determine whether a path is relative', function() { + var matcher = new StateMatcher(); + expect(matcher.isRelative('.')).toBe(true); + expect(matcher.isRelative('.foo')).toBe(true); + expect(matcher.isRelative('^')).toBe(true); + expect(matcher.isRelative('^foo')).toBe(true); + expect(matcher.isRelative('^.foo')).toBe(true); + expect(matcher.isRelative('foo')).toBe(false); + }); + + it('should resolve relative paths', function() { + var matcher = new StateMatcher(states); + + expect(matcher.find('^', states['home.about'])).toBe(states.home); + expect(matcher.find('^.company', states['home.about.people'])).toBe(states['home.about.company']); + expect(matcher.find('^.^.company', states['home.about.people.person'])).toBe(states['home.about.company']); + expect(matcher.find('^.foo', states.home)).toBeUndefined(); + expect(matcher.find('^.other.foo', states.home)).toBe(states['other.foo']); + expect(function() { matcher.find('^.^', states.home); }).toThrow("Path '^.^' not valid for state 'home'"); + }); + }); + + describe('StateBuilder', function() { + var builder, root, matcher, urlMatcherFactoryProvider = { + compile: function() {}, + isMatcher: function() {} + }; + + beforeEach(function() { + matcher = new StateMatcher(states); + builder = new StateBuilder(function() { return root; }, matcher, urlMatcherFactoryProvider); + }); + + describe('interface', function() { + describe('name()', function() { + it('should return dot-separated paths', function() { + expect(builder.name(states['home.about.people'])).toBe('home.about.people'); + expect(builder.name(states['home.about'])).toBe('home.about'); + expect(builder.name(states['home'])).toBe('home'); + }); + + it('should concatenate parent names', function() { + expect(builder.name({ name: "bar", parent: "foo" })).toBe("foo.bar"); + expect(builder.name({ name: "bar", parent: { name: "foo" } })).toBe("foo.bar"); + }); + }); + + describe('parentName()', function() { + it('should parse dot-separated paths', function() { + expect(builder.parentName(states['other.foo.bar'])).toBe('other.foo'); + }); + it('should always return parent name as string', function() { + expect(builder.parentName(states['other.foo'])).toBe('other'); + }); + it('should return empty string if state has no parent', function() { + expect(builder.parentName(states[''])).toBe(""); + }); + }); + }); + + describe('state building', function() { + it('should build parent property', function() { + expect(builder.builder('parent')({ name: 'home.about' })).toBe(states['home']); + }); + + it('should inherit parent data', function() { + var state = angular.extend(states['home.withData.child'], { self: {} }); + expect(builder.builder('data')(state)).toEqual({ val1: "foo", val2: "baz" }); + + var state = angular.extend(states['home.withData'], { self: {} }); + expect(builder.builder('data')(state)).toEqual({ val1: "foo", val2: "bar" }); + }); + + it('should compile a UrlMatcher for ^ URLs', function() { + var url = {}; + spyOn(urlMatcherFactoryProvider, 'compile').andReturn(url); + + expect(builder.builder('url')({ url: "^/foo" })).toBe(url); + expect(urlMatcherFactoryProvider.compile).toHaveBeenCalledWith("/foo", { params: {} }); + }); + + it('should concatenate URLs from root', function() { + root = { url: { concat: function() {} } }, url = {}; + spyOn(root.url, 'concat').andReturn(url); + + expect(builder.builder('url')({ url: "/foo" })).toBe(url); + expect(root.url.concat).toHaveBeenCalledWith("/foo", { params: {} }); + }); + + it('should pass through empty URLs', function() { + expect(builder.builder('url')({ url: null })).toBeNull(); + }); + + it('should pass through custom UrlMatchers', function() { + var url = ["!"]; + spyOn(urlMatcherFactoryProvider, 'isMatcher').andReturn(true); + expect(builder.builder('url')({ url: url })).toBe(url); + expect(urlMatcherFactoryProvider.isMatcher).toHaveBeenCalledWith(url); + }); + + it('should throw on invalid UrlMatchers', function() { + spyOn(urlMatcherFactoryProvider, 'isMatcher').andReturn(false); + + expect(function() { + builder.builder('url')({ toString: function() { return "foo"; }, url: { foo: "bar" } }); + }).toThrow("Invalid url '[object Object]' in state 'foo'"); + + expect(urlMatcherFactoryProvider.isMatcher).toHaveBeenCalledWith({ foo: "bar" }); + }); + + it('should return filtered keys if view config is provided', function() { + var config = { url: "/foo", templateUrl: "/foo.html", controller: "FooController" }; + expect(builder.builder('views')(config)).toEqual({ + $default: { templateUrl: "/foo.html", controller: "FooController" } + }); + }); + + it("should return unmodified view configs if defined", function() { + var config = { a: { foo: "bar", controller: "FooController" } }; + expect(builder.builder('views')({ views: config })).toEqual(config); + }); + }); + }); +}); + +xdescribe('state', function () { var stateProvider, locationProvider, templateParams, ctrlName; @@ -8,7 +211,7 @@ describe('state', function () { })); var log, logEvents, logEnterExit; - function eventLogger(event, to, toParams, from, fromParams) { + function eventLogger(event, transition) { if (logEvents) log += event.name + '(' + to.name + ',' + from.name + ');'; } function callbackLogger(what) { @@ -26,8 +229,7 @@ describe('state', function () { H = { data: {propA: 'propA', propB: 'propB'} }, HH = { parent: H }, HHH = {parent: HH, data: {propA: 'overriddenA', propC: 'propC'} }, - RS = { url: '^/search?term', reloadOnSearch: false }, - AppInjectable = {}; + RS = { url: '^/search?term', params: { term: { dynamic: true } } }; beforeEach(module(function ($stateProvider, $provide) { angular.forEach([ A, B, C, D, DD, E, H, HH, HHH ], function (state) { @@ -101,10 +303,8 @@ describe('state', function () { // State param inheritance tests. param1 is inherited by sub1 & sub2; // param2 should not be transferred (unless explicitly set). .state('root', { url: '^/root?param1' }) - .state('root.sub1', {url: '/1?param2' }) - .state('root.sub2', {url: '/2?param2' }); - - $provide.value('AppInjectable', AppInjectable); + .state('root.sub1', { url: '/1?param2' }) + .state('root.sub2', { url: '/2?param2' }); })); beforeEach(inject(function ($rootScope) { @@ -121,9 +321,9 @@ describe('state', function () { return jasmine.getEnv().currentSpec.$injector.get(what); } - function initStateTo(state, optionalParams) { + function initStateTo(state, params) { var $state = $get('$state'), $q = $get('$q'); - $state.transitionTo(state, optionalParams || {}); + $state.transitionTo(state, params || {}); $q.flush(); expect($state.current).toBe(state); } @@ -146,22 +346,36 @@ describe('state', function () { expect(resolvedValue(trans)).toBe(A); })); + // @todo this should fail: + // $state.transitionTo('about.person.item', { id: 5 }); $q.flush(); + it('allows transitions by name', inject(function ($state, $q) { $state.transitionTo('A', {}); $q.flush(); expect($state.current).toBe(A); })); - it('doesn\'t trigger state change if reloadOnSearch is false', inject(function ($state, $q, $location, $rootScope){ - initStateTo(RS); - $location.search({term: 'hello'}); - var called; - $rootScope.$on('$stateChangeStart', function (ev, to, toParams, from, fromParams) { - called = true + it('does not trigger state change if params are dynamic', inject(function ($state, $q, $location, $rootScope, $stateParams) { + var called = { change: false, observe: false }; + initStateTo(RS, { term: 'goodbye' }); + + $location.search({ term: 'hello' }); + expect($stateParams.term).toBe("goodbye"); + + $rootScope.$on('$stateChangeStart', function (ev, transition) { + called.change = true; }); + + $stateParams.$observe('term', function(val) { + called.observe = true; + }); + $q.flush(); - expect($location.search()).toEqual({term: 'hello'}); - expect(called).toBeFalsy(); + expect($location.search()).toEqual({ term: 'hello' }); + expect($stateParams.term).toBe('hello'); + + expect(called.change).toBe(false); + expect(called.observe).toBe(true); })); it('ignores non-applicable state parameters', inject(function ($state, $q) { @@ -173,14 +387,14 @@ describe('state', function () { it('triggers $stateChangeStart', inject(function ($state, $q, $rootScope) { initStateTo(E, { i: 'iii' }); var called; - $rootScope.$on('$stateChangeStart', function (ev, to, toParams, from, fromParams) { + $rootScope.$on('$stateChangeStart', function (ev, transition) { expect(from).toBe(E); - expect(fromParams).toEqual({ i: 'iii' }); + expect(transition.params().from).toEqual({ i: 'iii' }); expect(to).toBe(D); expect(toParams).toEqual({ x: '1', y: '2' }); expect($state.current).toBe(from); // $state not updated yet - expect($state.params).toEqual(fromParams); + expect($state.params).toEqual(transition.params().from); called = true; }); $state.transitionTo(D, { x: '1', y: '2' }); @@ -206,7 +420,7 @@ describe('state', function () { it('triggers $stateNotFound', inject(function ($state, $q, $rootScope) { initStateTo(E, { i: 'iii' }); var called; - $rootScope.$on('$stateNotFound', function (ev, redirect, from, fromParams) { + $rootScope.$on('$stateNotFound', function (ev, transition) { expect(from).toBe(E); expect(fromParams).toEqual({ i: 'iii' }); expect(redirect.to).toEqual('never_defined'); @@ -344,7 +558,7 @@ describe('state', function () { initStateTo(E, { x: 'iii' }); var called; - $rootScope.$on('$stateChangeSuccess', function (ev, to, toParams, from, fromParams) { + $rootScope.$on('$stateChangeSuccess', function (ev, transition) { called = true; }); $state.transitionTo(E, { i: '1', y: '2' }, { notify: false }); @@ -478,7 +692,7 @@ describe('state', function () { $q.flush(); expect($state.$current.name).toBe('about.person.item'); - expect($stateParams).toEqual({ person: 'bob', id: 5 }); + expect($stateParams).toEqualData({ person: 'bob', id: 5 }); $state.go('^.^.sidebar'); $q.flush(); @@ -663,7 +877,7 @@ describe('state', function () { expect($state.href("root", {}, {inherit:false})).toEqual("#/root"); expect($state.href("root", {}, {inherit:true})).toEqual("#/root?param1=1"); })); - + it('generates absolute url when absolute is true', inject(function ($state) { expect($state.href("about.sidebar", null, { absolute: true })).toEqual("http://server/#/about"); locationProvider.html5Mode(true); @@ -908,7 +1122,7 @@ describe('state', function () { $state.go('root.sub1', { param2: 2 }); $q.flush(); expect($state.current.name).toEqual('root.sub1'); - expect($stateParams).toEqual({ param1: 1, param2: 2 }); + expect($stateParams).toEqualData({ param1: 1, param2: 2 }); })); it('should not inherit siblings\' states', inject(function ($state, $stateParams, $q) { @@ -921,7 +1135,7 @@ describe('state', function () { $q.flush(); expect($state.current.name).toEqual('root.sub2'); - expect($stateParams).toEqual({ param1: 1, param2: undefined }); + expect($stateParams).toEqualData({ param1: 1, param2: undefined }); })); }); @@ -1016,7 +1230,7 @@ describe('state', function () { }); }); -describe('state queue', function(){ +describe('state queue', function() { angular.module('ui.router.queue.test', ['ui.router.queue.test.dependency']) .config(function($stateProvider) { $stateProvider @@ -1024,6 +1238,7 @@ describe('state queue', function(){ .state('queue-test-b-child', { parent: 'queue-test-b' }) .state('queue-test-b', {}); }); + angular.module('ui.router.queue.test.dependency', []) .config(function($stateProvider) { $stateProvider @@ -1050,3 +1265,86 @@ describe('state queue', function(){ }); }); }); + +describe("state params", function() { + + describe("observation", function() { + it("should broadcast updates when values change", inject(function($stateParams, $rootScope) { + var called = false; + + $stateParams.$observe("a", function(newVal) { + called = (newVal === "Hello"); + }); + + $stateParams.a = "Hello"; + $rootScope.$digest(); + expect(called).toBe(true); + })); + + it("should broadcast once on change", inject(function($stateParams, $rootScope) { + var called = 0; + + $stateParams.$observe("a", function(newVal) { + called++; + }); + + $stateParams.a = "Hello"; + $rootScope.$digest(); + expect(called).toBe(1); + + $rootScope.$digest(); + expect(called).toBe(1); + + $stateParams.a = "Goodbye"; + $rootScope.$digest(); + expect(called).toBe(2); + })); + + it("should be attachable to multiple fields", inject(function($stateParams, $rootScope) { + var called = 0; + + $stateParams.$observe("a b", function(newVal) { + called += (newVal === "Hello") ? 1 : 0; + }); + + $stateParams.a = "Hello"; + $rootScope.$digest(); + + expect(called).toBe(1); + + $stateParams.b = "Hello"; + $rootScope.$digest(); + + expect(called).toBe(2); + })); + + it("should be detachable", inject(function($stateParams, $rootScope) { + var called = 0, off = $stateParams.$observe("a", function(newVal) { + called++; + }); + + $stateParams.a = "Hello"; + $rootScope.$digest(); + off(); + + $stateParams.a = "Goodbye"; + $rootScope.$digest(); + + expect(called).toBe(1); + + $stateParams.$observe("a", function(newVal) { + called++; + }); + + $stateParams.a = "Hello"; + $rootScope.$digest(); + expect(called).toBe(2); + + $stateParams.$off(); + + $stateParams.a = "Hello"; + $rootScope.$digest(); + expect(called).toBe(2); + })); + }); +}); diff --git a/test/urlMatcherFactorySpec.js b/test/urlMatcherFactorySpec.js index 0b253a123..2f6588f39 100644 --- a/test/urlMatcherFactorySpec.js +++ b/test/urlMatcherFactorySpec.js @@ -16,6 +16,12 @@ describe("UrlMatcher", function () { }); }); + it("should allow prefix URLs", function() { + provider.prefix('/{lang:[a-z]{2}}', { params: { lang: "en" } }); + expect(provider.compile('/foo').exec('/foo')).toEqual({ lang: "en" }); + expect(provider.compile('/foo').exec('/de/foo')).toEqual({ lang: "de" }); + }); + it("should factory matchers with correct configuration", function () { provider.caseInsensitive(false); expect(provider.compile('/hello').exec('/HELLO')).toBeNull(); @@ -151,13 +157,7 @@ describe("UrlMatcher", function () { 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'); + expect(matcher.parameters().sort()).toEqual(['id', 'type', 'repeat', 'from', 'to'].sort()); }); it("should return a new matcher", function () { @@ -169,7 +169,7 @@ describe("UrlMatcher", function () { }); describe("urlMatcherFactory", function () { - + var $umf; beforeEach(module('ui.router.util')); @@ -380,6 +380,14 @@ describe("urlMatcherFactory", function () { $stateParams.user = user; expect(m.exec('/users').user).toBe(user); })); + + it("should match when used as prefix", function() { + var m = new UrlMatcher('/{lang:[a-z]{2}}/foo', { + params: { lang: "de" } + }); + expect(m.exec('/de/foo')).toEqual({ lang: "de" }); + expect(m.exec('/foo')).toEqual({ lang: "de" }); + }); }); }); @@ -405,4 +413,28 @@ describe("urlMatcherFactory", function () { expect(m.exec('/users/bob//')).toBeNull(); }); }); + + describe("parameter isolation", function() { + it("should allow parameters of the same name in different segments", function() { + var m = new UrlMatcher('/users/:id').concat('/photos/:id'); + expect(m.exec('/users/11/photos/38', {}, { isolate: true })).toEqual([{ id: '11' }, { id: '38' }]); + }); + + it("should prioritize the last child when non-isolated", function() { + var m = new UrlMatcher('/users/:id').concat('/photos/:id'); + expect(m.exec('/users/11/photos/38')).toEqual({ id: '38' }); + }); + + it("should copy search parameter values to all matching segments", function() { + var m = new UrlMatcher('/users/:id?from').concat('/photos/:id?from'); + var result = m.exec('/users/11/photos/38', { from: "bob" }, { isolate: true }); + expect(result).toEqual([{ from: "bob", id: "11" }, { from: "bob", id: "38" }]); + }); + + it("should pair empty objects with static segments", function() { + var m = new UrlMatcher('/users/:id').concat('/foo').concat('/photos/:id'); + var result = m.exec('/users/11/foo/photos/38', {}, { isolate: true }); + expect(result).toEqual([{ id: '11' }, {}, { id: '38' }]); + }); + }); });