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
- * // 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.
- * 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.
+ * $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: "- * + * * 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: *HELLO!
" * }) *
@@ -54,33 +54,33 @@ * "": { * template: "- * - * 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: *HELLO!
" * } - * } + * } * }) *
* - *+ * *
* $stateProvider.state("home", { * views: { * "main": { * template: "- * + * * Really though, you'll use views to set up multiple views: *HELLO!
" * } - * } + * } * }) *
* - * - * + * + * *- * + * *
* $stateProvider.state("home", { * views: { @@ -93,7 +93,7 @@ * "data": { * template: "* @@ -111,8 +111,10 @@ *" * } - * } + * } * }) *