diff --git a/Gruntfile.js b/Gruntfile.js
index 6b53d341f..589b3d242 100644
--- a/Gruntfile.js
+++ b/Gruntfile.js
@@ -36,6 +36,7 @@ module.exports = function (grunt) {
'src/urlMatcherFactory.js',
'src/urlRouter.js',
'src/state.js',
+ 'src/view.js',
'src/viewDirective.js',
'src/stateDirectives.js',
'src/compat.js'
diff --git a/README.md b/README.md
index 75e53f96c..1be40156e 100644
--- a/README.md
+++ b/README.md
@@ -3,9 +3,10 @@
# UI-Router
####Finally a de-facto solution to nested views and routing.
->* Latest release 0.0.1: [Compressed](http://angular-ui.github.io/ui-router/release/angular-ui-router.min.js) / [Uncompressed](http://angular-ui.github.io/ui-router/release/angular-ui-router.js)
->* Latest snapshot: [Compressed](http://angular-ui.github.io/ui-router/build/angular-ui-router.min.js) / [Uncompressed](http://angular-ui.github.io/ui-router/build/angular-ui-router.js)
+* Latest release 0.0.1: [Compressed](http://angular-ui.github.io/ui-router/release/angular-ui-router.min.js) / [Uncompressed](http://angular-ui.github.io/ui-router/release/angular-ui-router.js)
+* Latest snapshot: [Build the Project Locally](https://github.com/angular-ui/ui-router#developing)
+**Warning:** UI-Router is in active development. The API is highly subject to change. It is not recommended to use this library on projects that require guaranteed stability.
## Main Goal
To evolve the concept of an [angularjs](http://angularjs.org/) [***route***](http://docs.angularjs.org/api/ng.$routeProvider) into a more general concept of a ***state*** for managing complex application UI states.
@@ -17,20 +18,21 @@ To evolve the concept of an [angularjs](http://angularjs.org/) [***route***](htt
2. **More Powerful Views**
>`ui-view` directive (used in place of `ng-view`)
-3. **Named Views**
+3. **Nested Views**
+>load templates that contain nested `ui-view`s as deep as you'd like.
+
+4. **Routing**
+>States can map to URLs (though it's not required)
+
+5. **Named Views**
>`
```
-5. **Nested Views**
->load templates that contain nested `ui-view`s as deep as you'd like.
-
-6. **Routing**
->States can map to URLs (though it's not required)
*Basically, do whatever you want with states and routes.*
@@ -63,10 +65,10 @@ To evolve the concept of an [angularjs](http://angularjs.org/) [***route***](htt
```
-2. Set `ui.state` as a dependency in your module
+2. Set `ui.router` as a dependency in your module
>
```javascript
-var myapp = angular.module('myapp', ['ui.state'])
+var myapp = angular.module('myapp', ['ui.router'])
```
### Nested States & Views
diff --git a/config/karma.js b/config/karma.js
index d9a2945c6..6effb1fb5 100644
--- a/config/karma.js
+++ b/config/karma.js
@@ -16,6 +16,7 @@ files = [
'src/templateFactory.js',
'src/urlMatcherFactory.js',
'src/urlRouter.js',
+ 'src/view.js',
'src/state.js',
'src/viewDirective.js',
'src/stateDirectives.js',
diff --git a/sample/index.html b/sample/index.html
index 4d8a55588..b694a83cc 100644
--- a/sample/index.html
+++ b/sample/index.html
@@ -45,7 +45,7 @@
}
}
-angular.module('sample', ['ui.compat'])
+angular.module('sample', ['ui.router.compat'])
.config(
[ '$stateProvider', '$routeProvider', '$urlRouterProvider',
function ($stateProvider, $routeProvider, $urlRouterProvider) {
diff --git a/src/common.js b/src/common.js
index 0c4b92d6b..acf539ddc 100644
--- a/src/common.js
+++ b/src/common.js
@@ -15,15 +15,6 @@ function inherit(parent, extra) {
return extend(new (extend(function() {}, { prototype: parent }))(), extra);
}
-/**
- * Extends the destination object `dst` by copying all of the properties from the `src` object(s)
- * to `dst` if the `dst` object has no own property of the same name. You can specify multiple
- * `src` objects.
- *
- * @param {Object} dst Destination object.
- * @param {...Object} src Source object(s).
- * @see angular.extend
- */
function merge(dst) {
forEach(arguments, function(obj) {
if (obj !== dst) {
@@ -35,7 +26,51 @@ function merge(dst) {
return dst;
}
-angular.module('ui.util', ['ng']);
-angular.module('ui.router', ['ui.util']);
-angular.module('ui.state', ['ui.router', 'ui.util']);
-angular.module('ui.compat', ['ui.state']);
+/**
+ * Finds the common ancestor path between two states.
+ *
+ * @param {Object} first The first state.
+ * @param {Object} second The second state.
+ * @return {Array} Returns an array of state names in descending order, not including the root.
+ */
+function ancestors(first, second) {
+ var path = [];
+
+ for (var n in first.path) {
+ if (first.path[n] === "") continue;
+ if (!second.path[n]) break;
+ path.push(first.path[n]);
+ }
+ return path;
+}
+
+/**
+ * Merges a set of parameters with all parameters inherited between the common parents of the
+ * current state and a given destination state.
+ *
+ * @param {Object} currentParams The value of the current state parameters ($stateParams).
+ * @param {Object} newParams The set of parameters which will be composited with inherited params.
+ * @param {Object} $current Internal definition of object representing the current state.
+ * @param {Object} $to Internal definition of object representing state to transition to.
+ */
+function inheritParams(currentParams, newParams, $current, $to) {
+ var parents = ancestors($current, $to), parentParams, inherited = {}, inheritList = [];
+
+ for (var i in parents) {
+ if (!parents[i].params || !parents[i].params.length) continue;
+ parentParams = parents[i].params;
+
+ for (var j in parentParams) {
+ if (inheritList.indexOf(parentParams[j]) >= 0) continue;
+ inheritList.push(parentParams[j]);
+ inherited[parentParams[j]] = currentParams[parentParams[j]];
+ }
+ }
+ return extend({}, inherited, newParams);
+}
+
+angular.module('ui.router.util', ['ng']);
+angular.module('ui.router.router', ['ui.router.util']);
+angular.module('ui.router.state', ['ui.router.router', 'ui.router.util']);
+angular.module('ui.router', ['ui.router.state']);
+angular.module('ui.router.compat', ['ui.router']);
diff --git a/src/compat.js b/src/compat.js
index 4adfd5fbb..63d092d93 100644
--- a/src/compat.js
+++ b/src/compat.js
@@ -79,6 +79,6 @@ function $RouteProvider( $stateProvider, $urlRouterProvider) {
}
}
-angular.module('ui.compat')
+angular.module('ui.router.compat')
.provider('$route', $RouteProvider)
.directive('ngView', $ViewDirective);
diff --git a/src/resolve.js b/src/resolve.js
new file mode 100644
index 000000000..ea151b722
--- /dev/null
+++ b/src/resolve.js
@@ -0,0 +1,215 @@
+/**
+ * Service (`ui-util`). Manages resolution of (acyclic) graphs of promises.
+ * @module $resolve
+ * @requires $q
+ * @requires $injector
+ */
+$Resolve.$inject = ['$q', '$injector'];
+function $Resolve( $q, $injector) {
+
+ var VISIT_IN_PROGRESS = 1,
+ VISIT_DONE = 2,
+ NOTHING = {},
+ NO_DEPENDENCIES = [],
+ NO_LOCALS = NOTHING,
+ NO_PARENT = extend($q.when(NOTHING), { $$promises: NOTHING, $$values: NOTHING });
+
+
+ /**
+ * Studies a set of invocables that are likely to be used multiple times.
+ * $resolve.study(invocables)(locals, parent, self)
+ * is equivalent to
+ * $resolve.resolve(invocables, locals, parent, self)
+ * but the former is more efficient (in fact `resolve` just calls `study` internally).
+ * See {@link module:$resolve/resolve} for details.
+ * @function
+ * @param {Object} invocables
+ * @return {Function}
+ */
+ this.study = function (invocables) {
+ if (!isObject(invocables)) throw new Error("'invocables' must be an object");
+
+ // Perform a topological sort of invocables to build an ordered plan
+ var plan = [], cycle = [], visited = {};
+ function visit(value, key) {
+ if (visited[key] === VISIT_DONE) return;
+
+ cycle.push(key);
+ if (visited[key] === VISIT_IN_PROGRESS) {
+ cycle.splice(0, cycle.indexOf(key));
+ throw new Error("Cyclic dependency: " + cycle.join(" -> "));
+ }
+ visited[key] = VISIT_IN_PROGRESS;
+
+ if (isString(value)) {
+ plan.push(key, [ function() { return $injector.get(key); }], NO_DEPENDENCIES);
+ } else {
+ var params = $injector.annotate(value);
+ forEach(params, function (param) {
+ if (param !== key && invocables.hasOwnProperty(param)) visit(invocables[param], param);
+ });
+ plan.push(key, value, params);
+ }
+
+ cycle.pop();
+ visited[key] = VISIT_DONE;
+ }
+ forEach(invocables, visit);
+ invocables = cycle = visited = null; // plan is all that's required
+
+ function isResolve(value) {
+ return isObject(value) && value.then && value.$$promises;
+ }
+
+ return function (locals, parent, self) {
+ if (isResolve(locals) && self === undefined) {
+ self = parent; parent = locals; locals = null;
+ }
+ if (!locals) locals = NO_LOCALS;
+ else if (!isObject(locals)) {
+ throw new Error("'locals' must be an object");
+ }
+ if (!parent) parent = NO_PARENT;
+ else if (!isResolve(parent)) {
+ throw new Error("'parent' must be a promise returned by $resolve.resolve()");
+ }
+
+ // To complete the overall resolution, we have to wait for the parent
+ // promise and for the promise for each invokable in our plan.
+ var resolution = $q.defer(),
+ result = resolution.promise,
+ promises = result.$$promises = {},
+ values = extend({}, locals),
+ wait = 1 + plan.length/3,
+ merged = false;
+
+ function done() {
+ // Merge parent values we haven't got yet and publish our own $$values
+ if (!--wait) {
+ if (!merged) merge(values, parent.$$values);
+ result.$$values = values;
+ result.$$promises = true; // keep for isResolve()
+ resolution.resolve(values);
+ }
+ }
+
+ function fail(reason) {
+ result.$$failure = reason;
+ resolution.reject(reason);
+ }
+
+ // Short-circuit if parent has already failed
+ if (isDefined(parent.$$failure)) {
+ fail(parent.$$failure);
+ return result;
+ }
+
+ // Merge parent values if the parent has already resolved, or merge
+ // parent promises and wait if the parent resolve is still in progress.
+ if (parent.$$values) {
+ merged = merge(values, parent.$$values);
+ done();
+ } else {
+ extend(promises, parent.$$promises);
+ parent.then(done, fail);
+ }
+
+ // Process each invocable in the plan, but ignore any where a local of the same name exists.
+ for (var i=0, ii=plan.length; i} invocables functions to invoke or `$injector` services to fetch.
+ * @param {Object.} [locals] values to make available to the injectables
+ * @param {Promise.
').html(template).contents();
+ animate.enter(contents, element);
+ return contents;
+ }
+ },
+ "false": {
+ remove: function(element) { element.html(''); },
+ restore: function(compiled, element) { element.append(compiled); },
+ populate: function(template, element) {
+ element.html(template);
+ return element.contents();
+ }
+ }
+ })[doAnimate.toString()];
+ };
+
// Put back the compiled initial view
element.append(transclude(scope));
@@ -26,19 +52,28 @@ function $ViewDirective( $state, $compile, $controller, $injector, $an
var view = { name: name, state: null };
element.data('$uiView', view);
- scope.$on('$stateChangeSuccess', function() { updateView(true); });
+ var eventHook = function() {
+ if (viewIsUpdating) return;
+ viewIsUpdating = true;
+
+ try { updateView(true); } catch (e) {
+ viewIsUpdating = false;
+ throw e;
+ }
+ viewIsUpdating = false;
+ };
+
+ scope.$on('$stateChangeSuccess', eventHook);
+ scope.$on('$viewContentLoading', eventHook);
updateView(false);
function updateView(doAnimate) {
var locals = $state.$current && $state.$current.locals[name];
if (locals === viewLocals) return; // nothing to do
+ var render = renderer(animate && doAnimate);
// Remove existing content
- if (animate && doAnimate) {
- animate.leave(element.contents(), element);
- } else {
- element.html('');
- }
+ render.remove(element);
// Destroy previous view scope
if (viewScope) {
@@ -46,45 +81,32 @@ function $ViewDirective( $state, $compile, $controller, $injector, $an
viewScope = null;
}
- if (locals) {
- viewLocals = locals;
- view.state = locals.$$state;
-
- var contents;
- if (animate && doAnimate) {
- contents = angular.element('').html(locals.$template).contents();
- animate.enter(contents, element);
- } else {
- element.html(locals.$template);
- contents = element.contents();
- }
-
- var link = $compile(contents);
- viewScope = scope.$new();
- if (locals.$$controller) {
- locals.$scope = viewScope;
- var controller = $controller(locals.$$controller, locals);
- element.children().data('$ngControllerController', controller);
- }
- link(viewScope);
- viewScope.$emit('$viewContentLoaded');
- viewScope.$eval(onloadExp);
-
- // TODO: This seems strange, shouldn't $anchorScroll listen for $viewContentLoaded if necessary?
- // $anchorScroll might listen on event...
- $anchorScroll();
- } else {
+ if (!locals) {
viewLocals = null;
view.state = null;
// Restore the initial view
- var compiledElem = transclude(scope);
- if (animate && doAnimate) {
- animate.enter(compiledElem, element);
- } else {
- element.append(compiledElem);
- }
+ return render.restore(transclude(scope), element);
}
+
+ viewLocals = locals;
+ view.state = locals.$$state;
+
+ var link = $compile(render.populate(locals.$template, element));
+ viewScope = scope.$new();
+
+ if (locals.$$controller) {
+ locals.$scope = viewScope;
+ var controller = $controller(locals.$$controller, locals);
+ element.children().data('$ngControllerController', controller);
+ }
+ link(viewScope);
+ viewScope.$emit('$viewContentLoaded');
+ if (onloadExp) viewScope.$eval(onloadExp);
+
+ // TODO: This seems strange, shouldn't $anchorScroll listen for $viewContentLoaded if necessary?
+ // $anchorScroll might listen on event...
+ $anchorScroll();
}
};
}
@@ -92,4 +114,4 @@ function $ViewDirective( $state, $compile, $controller, $injector, $an
return directive;
}
-angular.module('ui.state').directive('uiView', $ViewDirective);
+angular.module('ui.router.state').directive('uiView', $ViewDirective);
diff --git a/test/resolveSpec.js b/test/resolveSpec.js
new file mode 100644
index 000000000..d5c7d745d
--- /dev/null
+++ b/test/resolveSpec.js
@@ -0,0 +1,297 @@
+describe("resolve", function () {
+
+ var $r, tick;
+
+ beforeEach(module('ui.router.util'));
+ beforeEach(inject(function($resolve, $q) {
+ $r = $resolve;
+ tick = $q.flush;
+ }));
+
+ describe(".resolve()", function () {
+ it("calls injectable functions and returns a promise", function () {
+ var fun = jasmine.createSpy('fun').andReturn(42);
+ var r = $r.resolve({ fun: [ '$resolve', fun ] });
+ expect(r).not.toBeResolved();
+ tick();
+ expect(resolvedValue(r)).toEqual({ fun: 42 });
+ expect(fun).toHaveBeenCalled();
+ expect(fun.callCount).toBe(1);
+ expect(fun.mostRecentCall.args.length).toBe(1);
+ expect(fun.mostRecentCall.args[0]).toBe($r);
+ });
+
+ it("resolves promises returned from the functions", inject(function ($q) {
+ var d = $q.defer();
+ var fun = jasmine.createSpy('fun').andReturn(d.promise);
+ var r = $r.resolve({ fun: [ '$resolve', fun ] });
+ tick();
+ expect(r).not.toBeResolved();
+ d.resolve('async');
+ tick();
+ expect(resolvedValue(r)).toEqual({ fun: 'async' });
+ }));
+
+ it("resolves dependencies between functions", function () {
+ var a = jasmine.createSpy('a');
+ var b = jasmine.createSpy('b').andReturn('bb');
+ var r = $r.resolve({ a: [ 'b', a ], b: [ b ] });
+ tick();
+ expect(a).toHaveBeenCalled();
+ expect(a.mostRecentCall.args).toEqual([ 'bb' ]);
+ expect(b).toHaveBeenCalled();
+ });
+
+ it("resolves dependencies between functions that return promises", inject(function ($q) {
+ var ad = $q.defer(), a = jasmine.createSpy('a').andReturn(ad.promise);
+ var bd = $q.defer(), b = jasmine.createSpy('b').andReturn(bd.promise);
+ var cd = $q.defer(), c = jasmine.createSpy('c').andReturn(cd.promise);
+
+ var r = $r.resolve({ a: [ 'b', 'c', a ], b: [ 'c', b ], c: [ c ] });
+ tick();
+ expect(r).not.toBeResolved();
+ expect(a).not.toHaveBeenCalled();
+ expect(b).not.toHaveBeenCalled();
+ expect(c).toHaveBeenCalled();
+ cd.resolve('cc');
+ tick();
+ expect(r).not.toBeResolved();
+ expect(a).not.toHaveBeenCalled();
+ expect(b).toHaveBeenCalled();
+ expect(b.mostRecentCall.args).toEqual([ 'cc' ]);
+ bd.resolve('bb');
+ tick();
+ expect(r).not.toBeResolved();
+ expect(a).toHaveBeenCalled();
+ expect(a.mostRecentCall.args).toEqual([ 'bb', 'cc' ]);
+ ad.resolve('aa');
+ tick();
+ expect(resolvedValue(r)).toEqual({ a: 'aa', b: 'bb', c: 'cc' });
+ expect(a.callCount).toBe(1);
+ expect(b.callCount).toBe(1);
+ expect(c.callCount).toBe(1);
+ }));
+
+ it("refuses cyclic dependencies", function () {
+ var a = jasmine.createSpy('a');
+ var b = jasmine.createSpy('b');
+ expect(caught(function () {
+ $r.resolve({ a: [ 'b', a ], b: [ 'a', b ] });
+ })).toMatch(/cyclic/i);
+ expect(a).not.toHaveBeenCalled();
+ expect(b).not.toHaveBeenCalled();
+ });
+
+ it("allows a function to depend on an injector value of the same name", function () {
+ var r = $r.resolve({ $resolve: function($resolve) { return $resolve === $r; } });
+ tick();
+ expect(resolvedValue(r)).toEqual({ $resolve: true });
+ });
+
+ it("allows locals to be passed that override the injector", function () {
+ var fun = jasmine.createSpy('fun');
+ $r.resolve({ fun: [ '$resolve', fun ] }, { $resolve: 42 });
+ tick();
+ expect(fun).toHaveBeenCalled();
+ expect(fun.mostRecentCall.args[0]).toBe(42);
+ });
+
+ it("does not call injectables overridden by a local", function () {
+ var fun = jasmine.createSpy('fun').andReturn("function");
+ var r = $r.resolve({ fun: [ fun ] }, { fun: "local" });
+ tick();
+ expect(fun).not.toHaveBeenCalled();
+ expect(resolvedValue(r)).toEqual({ fun: "local" });
+ });
+
+ it("includes locals in the returned values", function () {
+ var locals = { foo: 'hi', bar: 'mom' };
+ var r = $r.resolve({}, locals);
+ tick();
+ expect(resolvedValue(r)).toEqual(locals);
+ });
+
+ it("allows inheritance from a parent resolve()", function () {
+ var r = $r.resolve({ fun: function () { return true; } });
+ var s = $r.resolve({ games: function () { return true; } }, r);
+ tick();
+ expect(r).toBeResolved();
+ expect(resolvedValue(s)).toEqual({ fun: true, games: true });
+ });
+
+ it("only accepts promises from $resolve as parent", inject(function ($q) {
+ expect(caught(function () {
+ $r.resolve({}, null, $q.defer().promise);
+ })).toMatch(/\$resolve\.resolve/);
+ }));
+
+ it("resolves dependencies from a parent resolve()", function () {
+ var r = $r.resolve({ a: [ function() { return 'aa' } ] });
+ var b = jasmine.createSpy('b');
+ var s = $r.resolve({ b: [ 'a', b ] }, r);
+ tick();
+ expect(b).toHaveBeenCalled();
+ expect(b.mostRecentCall.args).toEqual([ 'aa' ]);
+ });
+
+ it("allows a function to override a parent value of the same name", function () {
+ var r = $r.resolve({ b: function() { return 'B' } });
+ var s = $r.resolve({
+ a: function (b) { return 'a:' + b },
+ b: function (b) { return '(' + b + ')' },
+ c: function (b) { return 'c:' + b }
+ }, r);
+ tick();
+ expect(resolvedValue(s)).toEqual({ a: 'a:(B)', b:'(B)', c:'c:(B)' });
+ });
+
+ it("allows a function to override a parent value of the same name with a promise", inject(function ($q) {
+ var r = $r.resolve({ b: function() { return 'B' } });
+ var superb, bd = $q.defer();
+ var s = $r.resolve({
+ a: function (b) { return 'a:' + b },
+ b: function (b) { superb = b; return bd.promise },
+ c: function (b) { return 'c:' + b }
+ }, r);
+ tick();
+ bd.resolve('(' + superb + ')');
+ tick();
+ expect(resolvedValue(s)).toEqual({ a: 'a:(B)', b:'(B)', c:'c:(B)' });
+ }));
+
+ it("it only resolves after the parent resolves", inject(function ($q) {
+ var bd = $q.defer(), b = jasmine.createSpy('b').andReturn(bd.promise);
+ var cd = $q.defer(), c = jasmine.createSpy('c').andReturn(cd.promise);
+ var r = $r.resolve({ c: [ c ] });
+ var s = $r.resolve({ b: [ b ] }, r);
+ bd.resolve('bbb');
+ tick();
+ expect(r).not.toBeResolved();
+ expect(s).not.toBeResolved();
+ cd.resolve('ccc');
+ tick();
+ expect(resolvedValue(r)).toEqual({ c: 'ccc' });
+ expect(resolvedValue(s)).toEqual({ b: 'bbb', c: 'ccc' });
+ }));
+
+ it("invokes functions as soon as possible", inject(function ($q) {
+ var ad = $q.defer(), a = jasmine.createSpy('a').andReturn(ad.promise);
+ var bd = $q.defer(), b = jasmine.createSpy('b').andReturn(bd.promise);
+ var cd = $q.defer(), c = jasmine.createSpy('c').andReturn(cd.promise);
+
+ var r = $r.resolve({ c: [ c ] });
+ var s = $r.resolve({ a: [ a ], b: [ 'c', b ] }, r);
+ expect(c).toHaveBeenCalled(); // synchronously
+ expect(a).toHaveBeenCalled(); // synchronously
+ expect(r).not.toBeResolved();
+ expect(s).not.toBeResolved();
+ cd.resolve('ccc');
+ tick();
+ expect(b).toHaveBeenCalled();
+ expect(b.mostRecentCall.args).toEqual([ 'ccc' ]);
+ }));
+
+ it("passes the specified 'self' argument as 'this'", function () {
+ var self = {}, passed;
+ $r.resolve({ fun: function () { passed = this; } }, null, null, self);
+ tick();
+ expect(passed).toBe(self);
+ });
+
+ it("rejects missing dependencies but does not fail synchronously", function () {
+ var r = $r.resolve({ fun: function (invalid) {} });
+ expect(r).not.toBeResolved();
+ tick();
+ expect(resolvedError(r)).toMatch(/unknown provider/i);
+ });
+
+ it("propagates exceptions thrown by the functions as a rejection", function () {
+ var r = $r.resolve({ fun: function () { throw "i want cake" } });
+ expect(r).not.toBeResolved();
+ tick();
+ expect(resolvedError(r)).toBe("i want cake");
+ });
+
+ it("propagates errors from a parent resolve", function () {
+ var error = [ "the cake is a lie" ];
+ var r = $r.resolve({ foo: function () { throw error } });
+ var s = $r.resolve({ bar: function () { 42 } }, r);
+ tick();
+ expect(resolvedError(r)).toBe(error);
+ expect(resolvedError(s)).toBe(error);
+ });
+
+ it("does not invoke any functions if the parent resolve has already failed", function () {
+ var r = $r.resolve({ foo: function () { throw "oops" } });
+ tick();
+ expect(r).toBeResolved();
+ var a = jasmine.createSpy('a');
+ var s = $r.resolve({ a: [ a ] }, r);
+ tick();
+ expect(resolvedError(s)).toBeDefined();
+ expect(a).not.toHaveBeenCalled();
+ });
+
+ it("does not invoke any more functions after a failure", inject(function ($q) {
+ var ad = $q.defer(), a = jasmine.createSpy('a').andReturn(ad.promise);
+ var cd = $q.defer(), c = jasmine.createSpy('c').andReturn(cd.promise);
+ var dd = $q.defer(), d = jasmine.createSpy('d').andReturn(dd.promise);
+ var r = $r.resolve({ a: [ 'c', a ], c: [ c ], d: [ d ] });
+ dd.reject('dontlikeit');
+ tick();
+ expect(resolvedError(r)).toBeDefined();
+ cd.resolve('ccc');
+ tick();
+ expect(a).not.toHaveBeenCalled();
+ }));
+
+ it("does not invoke any more functions after a parent failure", inject(function ($q) {
+ var ad = $q.defer(), a = jasmine.createSpy('a').andReturn(ad.promise);
+ var cd = $q.defer(), c = jasmine.createSpy('c').andReturn(cd.promise);
+ var dd = $q.defer(), d = jasmine.createSpy('d').andReturn(dd.promise);
+ var r = $r.resolve({ c: [ c ], d: [ d ] });
+ var s = $r.resolve({ a: [ 'c', a ] }, r);
+ dd.reject('dontlikeit');
+ tick();
+ expect(resolvedError(r)).toBeDefined();
+ expect(resolvedError(s)).toBeDefined();
+ cd.resolve('ccc');
+ tick();
+ expect(a).not.toHaveBeenCalled();
+ }));
+ });
+
+ describe(".study()", function () {
+ it("returns a resolver function", function () {
+ expect(typeof $r.study({})).toBe('function');
+ });
+
+ it("refuses cyclic dependencies", function () {
+ var a = jasmine.createSpy('a');
+ var b = jasmine.createSpy('b');
+ expect(caught(function () {
+ $r.study({ a: [ 'b', a ], b: [ 'a', b ] });
+ })).toMatch(/cyclic/i);
+ expect(a).not.toHaveBeenCalled();
+ expect(b).not.toHaveBeenCalled();
+ });
+
+ it("does not call the injectables", function () {
+ var a = jasmine.createSpy('a');
+ var b = jasmine.createSpy('b');
+ $r.study({ a: [ 'b', a ], b: [ b ] });
+ expect(a).not.toHaveBeenCalled();
+ expect(b).not.toHaveBeenCalled();
+ });
+
+ it("returns a function that can be used multiple times", function () {
+ var trace = [];
+ var r = $r.study({ a: [ 'what', function (what) { trace.push("a: " + what) } ] });
+ r({ what: '1' });
+ expect(trace).toEqual([ 'a: 1' ]);
+ r({ what: 'hi' });
+ expect(trace).toEqual([ 'a: 1', 'a: hi' ]);
+ });
+ });
+});
+
diff --git a/test/stateDirectivesSpec.js b/test/stateDirectivesSpec.js
index c229c6383..5d92a4d8f 100644
--- a/test/stateDirectivesSpec.js
+++ b/test/stateDirectivesSpec.js
@@ -1,6 +1,6 @@
describe('uiStateRef', function() {
- beforeEach(module('ui.state'));
+ beforeEach(module('ui.router.state'));
beforeEach(module(function($stateProvider) {
$stateProvider.state('index', {
@@ -13,7 +13,41 @@ describe('uiStateRef', function() {
}));
describe('links', function() {
- var el, scope;
+ var el, scope, document;
+
+ beforeEach(inject(function($document) {
+ document = $document[0];
+ }));
+
+ function triggerClick(el, options) {
+ options = angular.extend({
+ metaKey: false,
+ ctrlKey: false,
+ shiftKey: false,
+ altKey: false,
+ button: 0
+ }, options || {});
+
+ var e = document.createEvent("MouseEvents");
+ e.initMouseEvent(
+ "click", // typeArg of type DOMString, Specifies the event type.
+ true, // canBubbleArg of type boolean, Specifies whether or not the event can bubble.
+ true, // cancelableArg of type boolean, Specifies whether or not the event's default action can be prevented.
+ undefined, // viewArg of type views::AbstractView, Specifies the Event's AbstractView.
+ 0, // detailArg of type long, Specifies the Event's mouse click count.
+ 0, // screenXArg of type long, Specifies the Event's screen x coordinate
+ 0, // screenYArg of type long, Specifies the Event's screen y coordinate
+ 0, // clientXArg of type long, Specifies the Event's client x coordinate
+ 0, // clientYArg of type long, Specifies the Event's client y coordinate
+ options.ctrlKey, // ctrlKeyArg of type boolean, Specifies whether or not control key was depressed during the Event.
+ options.altKey, // altKeyArg of type boolean, Specifies whether or not alt key was depressed during the Event.
+ options.shiftKey, // shiftKeyArg of type boolean, Specifies whether or not shift key was depressed during the Event.
+ options.metaKey, // metaKeyArg of type boolean, Specifies whether or not meta key was depressed during the Event.
+ options.button, // buttonArg of type unsigned short, Specifies the Event's mouse button.
+ null // relatedTargetArg of type EventTarget
+ );
+ el[0].dispatchEvent(e);
+ }
beforeEach(inject(function($rootScope, $compile) {
el = angular.element('Details');
@@ -27,27 +61,64 @@ describe('uiStateRef', function() {
it('should generate the correct href', function() {
- expect(el.attr('href')).toBe('/contacts/5');
+ expect(el.attr('href')).toBe('#/contacts/5');
});
it('should update the href when parameters change', function() {
- expect(el.attr('href')).toBe('/contacts/5');
+ expect(el.attr('href')).toBe('#/contacts/5');
scope.contact.id = 6;
scope.$apply();
- expect(el.attr('href')).toBe('/contacts/6');
+ expect(el.attr('href')).toBe('#/contacts/6');
});
- it('should transition states when clicked', inject(function($state, $stateParams, $document, $q) {
+ it('should transition states when left-clicked', inject(function($state, $stateParams, $document, $q) {
expect($state.$current.name).toEqual('');
- var e = $document[0].createEvent("MouseEvents");
- e.initMouseEvent("click");
- el[0].dispatchEvent(e);
-
+ triggerClick(el);
$q.flush();
+
expect($state.current.name).toEqual('contacts.item.detail');
expect($stateParams).toEqual({ id: "5" });
}));
+
+ it('should not transition states when ctrl-clicked', inject(function($state, $stateParams, $document, $q) {
+ expect($state.$current.name).toEqual('');
+ triggerClick(el, { ctrlKey: true });
+
+ $q.flush();
+ expect($state.current.name).toEqual('');
+ expect($stateParams).toEqual({ id: "5" });
+ }));
+
+ it('should not transition states when meta-clicked', inject(function($state, $stateParams, $document, $q) {
+ expect($state.$current.name).toEqual('');
+
+ triggerClick(el, { metaKey: true });
+ $q.flush();
+
+ expect($state.current.name).toEqual('');
+ expect($stateParams).toEqual({ id: "5" });
+ }));
+
+ it('should not transition states when shift-clicked', inject(function($state, $stateParams, $document, $q) {
+ expect($state.$current.name).toEqual('');
+
+ triggerClick(el, { shiftKey: true });
+ $q.flush();
+
+ expect($state.current.name).toEqual('');
+ expect($stateParams).toEqual({ id: "5" });
+ }));
+
+ it('should not transition states when middle-clicked', inject(function($state, $stateParams, $document, $q) {
+ expect($state.$current.name).toEqual('');
+
+ triggerClick(el, { button: 1 });
+ $q.flush();
+
+ expect($state.current.name).toEqual('');
+ expect($stateParams).toEqual({ id: "5" });
+ }));
});
describe('forms', function() {
@@ -64,7 +135,7 @@ describe('uiStateRef', function() {
}));
it('should generate the correct action', function() {
- expect(el.attr('action')).toBe('/contacts/5');
+ expect(el.attr('action')).toBe('#/contacts/5');
});
});
});
diff --git a/test/stateSpec.js b/test/stateSpec.js
index 6c166ee8c..31a41032d 100644
--- a/test/stateSpec.js
+++ b/test/stateSpec.js
@@ -1,6 +1,11 @@
describe('state', function () {
-
- beforeEach(module('ui.state'));
+
+ var locationProvider;
+
+ beforeEach(module('ui.router.state', function($locationProvider) {
+ locationProvider = $locationProvider;
+ $locationProvider.html5Mode(false);
+ }));
var log, logEvents, logEnterExit;
function eventLogger(event, to, toParams, from, fromParams) {
@@ -18,10 +23,13 @@ describe('state', function () {
D = { params: [ 'x', 'y' ] },
DD = { parent: D, params: [ 'x', 'y', 'z' ] },
E = { params: [ 'i' ] },
+ H = { data: {propA: 'propA', propB: 'propB'} },
+ HH = { parent: H },
+ HHH = {parent: HH, data: {propA: 'overriddenA', propC: 'propC'} }
AppInjectable = {};
beforeEach(module(function ($stateProvider, $provide) {
- angular.forEach([ A, B, C, D, DD, E ], function (state) {
+ angular.forEach([ A, B, C, D, DD, E, H, HH, HHH ], function (state) {
state.onEnter = callbackLogger('onEnter');
state.onExit = callbackLogger('onExit');
});
@@ -33,6 +41,9 @@ describe('state', function () {
.state('D', D)
.state('DD', DD)
.state('E', E)
+ .state('H', H)
+ .state('HH', HH)
+ .state('HHH', HHH)
.state('home', { url: "/" })
.state('home.item', { url: "front/:id" })
@@ -199,6 +210,54 @@ describe('state', function () {
}));
});
+ describe('.go()', function() {
+ it('transitions to a relative state', inject(function ($state, $q) {
+ $state.transitionTo('about.person.item', { id: 5 }); $q.flush();
+ $state.go('^.^.sidebar'); $q.flush();
+ expect($state.$current.name).toBe('about.sidebar');
+
+ // Transitions to absolute state
+ $state.go("home"); $q.flush();
+ expect($state.$current.name).toBe('home');
+
+
+ // Transition to a child state
+ $state.go(".item", { id: 5 }); $q.flush();
+ expect($state.$current.name).toBe('home.item');
+
+ // Transition to grandparent's sibling through root
+ // (Equivalent to absolute transition, assuming the root is known).
+ $state.go("^.^.about"); $q.flush();
+ expect($state.$current.name).toBe('about');
+
+ // Transition to grandchild
+ $state.go(".person.item", { person: "bob", id: 13 }); $q.flush();
+ expect($state.$current.name).toBe('about.person.item');
+
+ // Transition to immediate parent
+ $state.go("^"); $q.flush();
+ expect($state.$current.name).toBe('about.person');
+
+ // Transition to parent's sibling
+ $state.go("^.sidebar"); $q.flush();
+ expect($state.$current.name).toBe('about.sidebar');
+ }));
+
+ it('keeps parameters from common ancestor states', inject(function ($state, $stateParams, $q) {
+ $state.transitionTo('about.person', { person: 'bob' });
+ $q.flush();
+
+ $state.go('.item', { id: 5 });
+ $q.flush();
+
+ expect($state.$current.name).toBe('about.person.item');
+ expect($stateParams).toEqual({ person: 'bob', id: '5' });
+
+ $state.go('^.^.sidebar');
+ $q.flush();
+ expect($state.$current.name).toBe('about.sidebar');
+ }));
+ });
describe('.current', function () {
it('is always defined', inject(function ($state) {
@@ -261,18 +320,142 @@ describe('state', function () {
}));
it('generates a parent state URL when lossy is true', inject(function ($state) {
- expect($state.href("about.sidebar", null, { lossy: true })).toEqual("/about");
+ expect($state.href("about.sidebar", null, { lossy: true })).toEqual("#/about");
}));
it('generates a URL without parameters', inject(function ($state) {
- expect($state.href("home")).toEqual("/");
- expect($state.href("about", {})).toEqual("/about");
- expect($state.href("about", { foo: "bar" })).toEqual("/about");
+ expect($state.href("home")).toEqual("#/");
+ expect($state.href("about", {})).toEqual("#/about");
+ expect($state.href("about", { foo: "bar" })).toEqual("#/about");
}));
it('generates a URL with parameters', inject(function ($state) {
+ expect($state.href("about.person", { person: "bob" })).toEqual("#/about/bob");
+ expect($state.href("about.person.item", { person: "bob", id: null })).toEqual("#/about/bob/");
+ }));
+ });
+
+ describe('.getConfig()', function () {
+ it("should return the state's config", inject(function ($state) {
+ expect($state.getConfig('home').url).toBe('/');
+ expect($state.getConfig('home.item').url).toBe('front/:id');
+ expect($state.getConfig('A')).toBe(A);
+ expect(function() { $state.getConfig('Z'); }).toThrow("No such state 'Z'");
+ }));
+ });
+
+ describe(' "data" property inheritance/override', function () {
+ it('"data" property should stay immutable for if state doesn\'t have parent', inject(function ($state) {
+ initStateTo(H);
+ expect($state.current.name).toEqual('H');
+ expect($state.current.data.propA).toEqual(H.data.propA);
+ expect($state.current.data.propB).toEqual(H.data.propB);
+ }));
+
+ it('"data" property should be inherited from parent if state doesn\'t define it', inject(function ($state) {
+ initStateTo(HH);
+ expect($state.current.name).toEqual('HH');
+ expect($state.current.data.propA).toEqual(H.data.propA);
+ expect($state.current.data.propB).toEqual(H.data.propB);
+ }));
+
+ it('"data" property should be overridden/extended if state defines it', inject(function ($state) {
+ initStateTo(HHH);
+ expect($state.current.name).toEqual('HHH');
+ expect($state.current.data.propA).toEqual(HHH.data.propA);
+ expect($state.current.data.propB).toEqual(H.data.propB);
+ expect($state.current.data.propB).toEqual(HH.data.propB);
+ expect($state.current.data.propC).toEqual(HHH.data.propC);
+ }));
+ });
+
+ describe('html5Mode compatibility', function() {
+
+ it('should generate non-hashbang URLs in HTML5 mode', inject(function ($state) {
+ expect($state.href("about.person", { person: "bob" })).toEqual("#/about/bob");
+ locationProvider.html5Mode(true);
expect($state.href("about.person", { person: "bob" })).toEqual("/about/bob");
- expect($state.href("about.person.item", { person: "bob", id: null })).toEqual("/about/bob/");
+ }));
+ });
+
+ describe('url handling', function () {
+
+ it('should transition to the same state with different parameters', inject(function ($state, $rootScope, $location) {
+ $location.path("/about/bob");
+ $rootScope.$broadcast("$locationChangeSuccess");
+ $rootScope.$apply();
+ expect($state.params).toEqual({ person: "bob" });
+
+ $location.path("/about/larry");
+ $rootScope.$broadcast("$locationChangeSuccess");
+ $rootScope.$apply();
+ expect($state.params).toEqual({ person: "larry" });
+ }));
+ });
+
+ describe('default properties', function() {
+ it('should always have a name', inject(function ($state, $q) {
+ $state.transitionTo(A);
+ $q.flush();
+ expect($state.$current.name).toBe('A');
+ expect($state.$current.toString()).toBe('A');
+ }));
+
+ it('should always have a resolve object', inject(function ($state) {
+ expect($state.$current.resolve).toEqual({});
+ }));
+ });
+
+ describe(' "data" property inheritance/override', function () {
+ it('"data" property should stay immutable for if state doesn\'t have parent', inject(function ($state) {
+ initStateTo(H);
+ expect($state.current.name).toEqual('H');
+ expect($state.current.data.propA).toEqual(H.data.propA);
+ expect($state.current.data.propB).toEqual(H.data.propB);
+ }));
+
+ it('"data" property should be inherited from parent if state doesn\'t define it', inject(function ($state) {
+ initStateTo(HH);
+ expect($state.current.name).toEqual('HH');
+ expect($state.current.data.propA).toEqual(H.data.propA);
+ expect($state.current.data.propB).toEqual(H.data.propB);
+ }));
+
+ it('"data" property should be overridden/extended if state defines it', inject(function ($state) {
+ initStateTo(HHH);
+ expect($state.current.name).toEqual('HHH');
+ expect($state.current.data.propA).toEqual(HHH.data.propA);
+ expect($state.current.data.propB).toEqual(H.data.propB);
+ expect($state.current.data.propB).toEqual(HH.data.propB);
+ expect($state.current.data.propC).toEqual(HHH.data.propC);
+ }));
+ });
+
+ describe('html5Mode compatibility', function() {
+
+ it('should generate non-hashbang URLs in HTML5 mode', inject(function ($state) {
+ expect($state.href("about.person", { person: "bob" })).toEqual("#/about/bob");
+ locationProvider.html5Mode(true);
+ expect($state.href("about.person", { person: "bob" })).toEqual("/about/bob");
+ }));
+ });
+
+ describe('default properties', function () {
+ it('should always have a name', inject(function ($state, $q) {
+ $state.transitionTo(A);
+ $q.flush();
+ expect($state.$current.name).toBe('A');
+ expect($state.$current.toString()).toBe('A');
+ }));
+
+ it('should always have a resolve object', inject(function ($state) {
+ expect($state.$current.resolve).toEqual({});
+ }));
+
+ it('should include itself and parent states', inject(function ($state, $q) {
+ $state.transitionTo(DD);
+ $q.flush();
+ expect($state.$current.includes).toEqual({ '': true, D: true, DD: true });
}));
});
});
diff --git a/test/templateFactorySpec.js b/test/templateFactorySpec.js
index 7a7d29d65..f330dadc2 100644
--- a/test/templateFactorySpec.js
+++ b/test/templateFactorySpec.js
@@ -1,6 +1,6 @@
describe('templateFactory', function () {
- beforeEach(module('ui.util'));
+ beforeEach(module('ui.router.util'));
it('exists', inject(function ($templateFactory) {
expect($templateFactory).toBeDefined();
diff --git a/test/testUtils.js b/test/testUtils.js
index c9944bc5a..c515651aa 100644
--- a/test/testUtils.js
+++ b/test/testUtils.js
@@ -45,12 +45,15 @@ angular.module('ngMock')
return $delegate;
});
});
-
+
+function testablePromise(promise) {
+ if (!promise || !promise.then) throw new Error('Expected a promise, but got ' + jasmine.pp(promise) + '.');
+ if (!isDefined(promise.$$resolved)) throw new Error('Promise has not been augmented by ngMock');
+ return promise;
+}
function resolvedPromise(promise) {
- if (!promise.then) throw new Error('Expected a promise, but got ' + jasmine.pp(promise) + '.');
- var result = promise.$$resolved;
- if (!isDefined(result)) throw new Error('Promise has not been augmented by ngMock');
+ var result = testablePromise(promise).$$resolved;
if (!result) throw new Error('Promise is not resolved yet');
return result;
}
@@ -67,8 +70,26 @@ function resolvedError(promise) {
return result.error;
}
+beforeEach(function () {
+ this.addMatchers({
+ toBeResolved: function() {
+ return !!testablePromise(this.actual).$$resolved;
+ }
+ });
+});
+
+// Misc test utils
+function caught(fn) {
+ try {
+ fn();
+ return null;
+ } catch (e) {
+ return e;
+ }
+}
// Utils for test from core angular
var noop = angular.noop,
toJson = angular.toJson;
-beforeEach(module('ui.compat'));
+//beforeEach(module('ui.router.compat'));
+
diff --git a/test/urlMatcherFactorySpec.js b/test/urlMatcherFactorySpec.js
index 3d0f44803..bf8202c6b 100644
--- a/test/urlMatcherFactorySpec.js
+++ b/test/urlMatcherFactorySpec.js
@@ -40,6 +40,10 @@ describe("UrlMatcher", function () {
.toBeNull();
});
+ it(".exec() treats the URL as already decoded and does not decode it further", function () {
+ expect(new UrlMatcher('/users/:id').exec('/users/100%25', {})).toEqual({ id: '100%25'});
+ });
+
it('.exec() throws on unbalanced capture list', function () {
var shouldThrow = {
"/url/{matchedParam:([a-z]+)}/child/{childParam}": '/url/someword/child/childParam',
@@ -69,6 +73,10 @@ describe("UrlMatcher", function () {
.toEqual('/users/123/details/default/444?from=1970');
});
+ it(".format() encodes URL parameters", function () {
+ expect(new UrlMatcher('/users/:id').format({ id:'100%'})).toEqual('/users/100%25');
+ });
+
it(".concat() concatenates matchers", function () {
var matcher = new UrlMatcher('/users/:id/details/{type}?from').concat('/{repeat:[0-9]+}?to');
var params = matcher.parameters();
@@ -91,7 +99,7 @@ describe("urlMatcherFactory", function () {
var $umf;
- beforeEach(module('ui.util'));
+ beforeEach(module('ui.router.util'));
beforeEach(inject(function($urlMatcherFactory) {
$umf = $urlMatcherFactory;
}));
diff --git a/test/urlRouterSpec.js b/test/urlRouterSpec.js
index e69de29bb..0e5f8e754 100644
--- a/test/urlRouterSpec.js
+++ b/test/urlRouterSpec.js
@@ -0,0 +1,85 @@
+describe("UrlRouter", function () {
+
+ var $urp, $ur, location, match, scope;
+
+ beforeEach(function() {
+ angular.module('ui.router.test', function() {}).config(function ($urlRouterProvider) {
+ $urp = $urlRouterProvider;
+
+ $urp.rule(function ($injector, $location) {
+ var path = $location.path();
+ if (!/baz/.test(path)) return false;
+ return path.replace('baz', 'b4z');
+ }).when('/foo/:param', function($match) {
+ match = ['/foo/:param', $match];
+ }).when('/bar', function($match) {
+ match = ['/bar', $match];
+ });
+ });
+
+ module('ui.router.router', 'ui.router.test');
+
+ inject(function($rootScope, $location, $injector) {
+ scope = $rootScope.$new();
+ location = $location;
+ $ur = $injector.invoke($urp.$get);
+ });
+ });
+
+ describe("provider", function () {
+
+ it("should throw on non-function rules", function () {
+ expect(function() { $urp.rule(null); }).toThrow("'rule' must be a function")
+ expect(function() { $urp.otherwise(null); }).toThrow("'rule' must be a function")
+ });
+
+ });
+
+ describe("service", function() {
+ it("should execute rewrite rules", function () {
+ location.path("/foo");
+ scope.$emit("$locationChangeSuccess");
+ expect(location.path()).toBe("/foo");
+
+ location.path("/baz");
+ scope.$emit("$locationChangeSuccess");
+ expect(location.path()).toBe("/b4z");
+ });
+
+ it("should keep otherwise last", function () {
+ $urp.otherwise('/otherwise');
+
+ location.path("/lastrule");
+ scope.$emit("$locationChangeSuccess");
+ expect(location.path()).toBe("/otherwise");
+
+ $urp.when('/lastrule', function($match) {
+ match = ['/lastrule', $match];
+ });
+
+ location.path("/lastrule");
+ scope.$emit("$locationChangeSuccess");
+ expect(location.path()).toBe("/lastrule");
+ });
+
+ it("should allow custom URL matchers", function () {
+ var custom = {
+ url: { exec: function() {}, format: function() {}, concat: function() {} },
+ handler: function() {}
+ };
+
+ spyOn(custom.url, "exec").andReturn({});
+ spyOn(custom.url, "format").andReturn("/foo-bar");
+ spyOn(custom, "handler").andReturn(true);
+
+ $urp.when(custom.url, custom.handler);
+ scope.$broadcast("$locationChangeSuccess");
+ scope.$apply();
+
+ expect(custom.url.exec).toHaveBeenCalled();
+ expect(custom.url.format).not.toHaveBeenCalled();
+ expect(custom.handler).toHaveBeenCalled();
+ });
+ });
+
+});
\ No newline at end of file
diff --git a/test/viewDirectiveSpec.js b/test/viewDirectiveSpec.js
index 164cd7ca6..8fe1cf579 100644
--- a/test/viewDirectiveSpec.js
+++ b/test/viewDirectiveSpec.js
@@ -6,7 +6,7 @@ describe('uiView', function () {
var scope, $compile, elem;
- beforeEach(module('ui.state'));
+ beforeEach(module('ui.router.state'));
var aState = {
template: 'aState template'