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: "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 @@
*