From 67b16f1ff863abe322a128143f60197dafe9ba24 Mon Sep 17 00:00:00 2001 From: Karsten Sperling Date: Sat, 10 Aug 2013 13:34:59 +1200 Subject: [PATCH 01/27] $resolve service (tested) and integration into $state (only partially tested) --- src/common.js | 9 -- src/resolve.js | 215 ++++++++++++++++++++++++++++++++ src/state.js | 73 ++++------- test/resolveSpec.js | 297 ++++++++++++++++++++++++++++++++++++++++++++ test/testUtils.js | 31 ++++- 5 files changed, 560 insertions(+), 65 deletions(-) create mode 100644 src/resolve.js create mode 100644 test/resolveSpec.js diff --git a/src/common.js b/src/common.js index 0c4b92d6b..cd4cbe672 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) { diff --git a/src/resolve.js b/src/resolve.js new file mode 100644 index 000000000..5982035dd --- /dev/null +++ b/src/resolve.js @@ -0,0 +1,215 @@ +/** + * Service (`ui-util`). Manages resolution of (acyclic) graphs of promises. + * @module $resolve + * @requires $q + * @requires $injector + */ +$Resolve.$inject = ['$q', '$injector']; +function $Resolve( $q, $injector) { + + var VISIT_IN_PROGRESS = 1, + VISIT_DONE = 2, + NOTHING = {}, + NO_DEPENDENCIES = [], + NO_LOCALS = NOTHING, + NO_PARENT = extend($q.when(NOTHING), { $$promises: NOTHING, $$values: NOTHING }); + + + /** + * Studies a set of invocables that are likely to be used multiple times. + * $resolve.study(invocables)(locals, parent, self) + * is equivalent to + * $resolve.resolve(invocables, locals, parent, self) + * but the former is more efficient (in fact `resolve` just calls `study` internally). + * See {@link module:$resolve/resolve} for details. + * @function + * @param {Object} invocables + * @return {Function} + */ + this.study = function (invocables) { + if (!isObject(invocables)) throw new Error("'invocables' must be an object"); + + // Perform a topological sort of invocables to build an ordered plan + var plan = [], cycle = [], visited = {}; + function visit(value, key) { + if (visited[key] === VISIT_DONE) return; + + cycle.push(key); + if (visited[key] === VISIT_IN_PROGRESS) { + cycle.splice(0, cycle.indexOf(key)); + throw new Error("Cyclic dependency: " + cycle.join(" -> ")); + } + visited[key] = VISIT_IN_PROGRESS; + + if (isString(value)) { + plan.push(key, [ function() { return $injector.get(key); }], NO_DEPENDENCIES); + } else { + var params = $injector.annotate(value); + forEach(params, function (param) { + if (param !== key && invocables.hasOwnProperty(param)) visit(invocables[param], param); + }); + plan.push(key, value, params); + } + + cycle.pop(); + visited[key] = VISIT_DONE; + } + forEach(invocables, visit); + invocables = cycle = visited = null; // plan is all that's required + + function isResolve(value) { + return isObject(value) && value.then && value.$$promises; + } + + return function (locals, parent, self) { + if (isResolve(locals) && self === undefined) { + self = parent; parent = locals; locals = null; + } + if (!locals) locals = NO_LOCALS; + else if (!isObject(locals)) { + throw new Error("'locals' must be an object"); + } + if (!parent) parent = NO_PARENT; + else if (!isResolve(parent)) { + throw new Error("'parent' must be a promise returned by $resolve.resolve()"); + } + + // To complete the overall resolution, we have to wait for the parent + // promise and for the promise for each invokable in our plan. + var resolution = $q.defer(), + result = resolution.promise, + promises = result.$$promises = {}, + values = extend({}, locals), + wait = 1 + plan.length/3, + merged = false; + + function done() { + // Merge parent values we haven't got yet and publish our own $$values + if (!--wait) { + if (!merged) merge(values, parent.$$values); + result.$$values = values; + result.$$promises = true; // keep for isResolve() + resolution.resolve(values); + } + } + + function fail(reason) { + result.$$failure = reason; + resolution.reject(reason); + } + + // Short-circuit if parent has already failed + if (isDefined(parent.$$failure)) { + fail(parent.$$failure); + return result; + } + + // Merge parent values if the parent has already resolved, or merge + // parent promises and wait if the parent resolve is still in progress. + if (parent.$$values) { + merged = merge(values, parent.$$values); + done(); + } else { + extend(promises, parent.$$promises); + parent.then(done, fail); + } + + // Process each invocable in the plan, but ignore any where a local of the same name exists. + for (var i=0, ii=plan.length; i} invocables functions to invoke or `$injector` services to fetch. + * @param {Object.} [locals] values to make available to the injectables + * @param {Promise.} [parent] a promise returned by another call to `$resolve`. + * @param {Object} [self] the `this` for the invoked methods + * @return {Promise.} Promise for an object that contains the resolved return value + * of all invocables, as well as any inherited and local values. + */ + this.resolve = function (invocables, locals, parent, self) { + return this.study(invocables)(locals, parent, self); + }; +} + +angular.module('ui.util').service('$resolve', $Resolve); + diff --git a/src/state.js b/src/state.js index 4d4c5ca72..c4bdc4a64 100644 --- a/src/state.js +++ b/src/state.js @@ -125,7 +125,6 @@ function $StateProvider( $urlRouterProvider, $urlMatcherFactory) { views: null, 'abstract': true }); - root.locals = { globals: { $stateParams: {} } }; root.navigable = null; @@ -142,12 +141,13 @@ function $StateProvider( $urlRouterProvider, $urlMatcherFactory) { // $urlRouter is injected just to ensure it gets instantiated this.$get = $get; - $get.$inject = ['$rootScope', '$q', '$templateFactory', '$injector', '$stateParams', '$location', '$urlRouter']; - function $get( $rootScope, $q, $templateFactory, $injector, $stateParams, $location, $urlRouter) { + $get.$inject = ['$rootScope', '$q', '$templateFactory', '$injector', '$resolve', '$stateParams', '$location', '$urlRouter']; + function $get( $rootScope, $q, $templateFactory, $injector, $resolve, $stateParams, $location, $urlRouter) { var TransitionSuperseded = $q.reject(new Error('transition superseded')); var TransitionPrevented = $q.reject(new Error('transition prevented')); + root.locals = { resolve: null, globals: { $stateParams: {} } }; $state = { params: {}, current: root.self, @@ -155,9 +155,6 @@ function $StateProvider( $urlRouterProvider, $urlMatcherFactory) { transition: null }; - // $state.go = function go(to, params) { - // }; - $state.transitionTo = function transitionTo(to, toParams, updateLocation) { if (!isDefined(updateLocation)) updateLocation = true; @@ -203,7 +200,7 @@ function $StateProvider( $urlRouterProvider, $urlMatcherFactory) { resolved = resolveState(state, toParams, state===to, resolved, locals); } - // Once everything is resolved, we are ready to perform the actual transition + // Once everything is resolved, wer are ready to perform the actual transition // and return a promise for the new state. We also keep track of what the // current promise is, so that we can detect overlapping transitions and // keep only the outcome of the last transition. @@ -274,10 +271,6 @@ function $StateProvider( $urlRouterProvider, $urlMatcherFactory) { }; function resolveState(state, params, paramsAreFiltered, inherited, dst) { - // We need to track all the promises generated during the resolution process. - // The first of these is for the fully resolved parent locals. - var promises = [inherited]; - // 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 @@ -292,56 +285,34 @@ function $StateProvider( $urlRouterProvider, $urlMatcherFactory) { } var locals = { $stateParams: $stateParams }; - // Resolves the values from an individual 'resolve' dependency spec - function resolve(deps, dst) { - forEach(deps, function (value, key) { - promises.push($q - .when(isString(value) ? - $injector.get(value) : - $injector.invoke(value, state.self, locals)) - .then(function (result) { - dst[key] = result; - })); - }); - } - // Resolve 'global' dependencies for the state, i.e. those not specific to a view. // We're also including $stateParams in this; that way the parameters are restricted // to the set that should be visible to the state, and are independent of when we update // the global $state and $stateParams values. - var globals = dst.globals = { $stateParams: $stateParams }; - resolve(state.resolve, globals); - globals.$$state = state; // Provide access to the state itself for internal use + dst.resolve = $resolve.resolve(state.resolve, locals, dst.resolve, state); + var promises = [ dst.resolve.then(function (globals) { + dst.globals = globals; + }) ]; + if (inherited) promises.push(inherited); // Resolve template and dependencies for all views. forEach(state.views, function (view, name) { - // References to the controller (only instantiated at link time) - var $view = dst[name] = { - $$controller: view.controller - }; - - // Template - promises.push($q - .when($templateFactory.fromConfig(view, $stateParams, locals) || '') - .then(function (result) { - $view.$template = result; - })); - - // View-local dependencies. If we've reused the state definition as the default - // view definition in .state(), we can end up with state.resolve === view.resolve. - // Avoid resolving everything twice in that case. - if (view.resolve !== state.resolve) resolve(view.resolve, $view); + var injectables = (view.resolve && view.resolve !== state.resolve ? view.resolve : {}); + injectables.$template = [ function () { + return $templateFactory.fromConfig(view, $stateParams, locals) || ''; + }]; + + promises.push($resolve.resolve(injectables, locals, dst.resolve, state).then(function (result) { + // References to the controller (only instantiated at link time) + result.$$controller = view.controller; + // Provide access to the state itself for internal use + result.$$state = state; + dst[name] = result; + })); }); - // Once we've resolved all the dependencies for this state, merge - // in any inherited dependencies, and merge common state dependencies - // into the dependency set for each view. Finally return a promise - // for the fully popuplated state dependencies. + // Wait for all the promises and then return the activation object return $q.all(promises).then(function (values) { - merge(dst.globals, values[0].globals); // promises[0] === inherited - forEach(state.views, function (view, name) { - merge(dst[name], dst.globals); - }); return dst; }); } diff --git a/test/resolveSpec.js b/test/resolveSpec.js new file mode 100644 index 000000000..d0a4de5b5 --- /dev/null +++ b/test/resolveSpec.js @@ -0,0 +1,297 @@ +describe("resolve", function () { + + var $r, tick; + + beforeEach(module('ui.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/testUtils.js b/test/testUtils.js index c9944bc5a..69362bc00 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.compat')); + From 07eb163f0eb50b302645658c517fa986f172858a Mon Sep 17 00:00:00 2001 From: Tim Kindberg Date: Sat, 13 Jul 2013 14:46:27 -0400 Subject: [PATCH 02/27] Update README.md --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 75e53f96c..f902a40b1 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ >* 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) +**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. From 93a61f739ebbfaf51cf5157148cea3573aa74b54 Mon Sep 17 00:00:00 2001 From: abaran Date: Sun, 14 Jul 2013 18:50:40 +0300 Subject: [PATCH 03/27] 'data' property inheritance/override in chain of states --- src/state.js | 5 +++++ test/stateSpec.js | 34 +++++++++++++++++++++++++++++++++- 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/src/state.js b/src/state.js index c4bdc4a64..079a64c3b 100644 --- a/src/state.js +++ b/src/state.js @@ -40,6 +40,11 @@ function $StateProvider( $urlRouterProvider, $urlMatcherFactory) { parent = findState(state.parent); } state.parent = parent; + // inherit 'data' from parent and override by own values (if any) + if (state.parent && state.parent.data) { + state.data = angular.extend({}, state.parent.data, state.data); + state.self.data = state.data; + } // state.children = []; // if (parent) parent.children.push(state); diff --git a/test/stateSpec.js b/test/stateSpec.js index 6c166ee8c..694273fe2 100644 --- a/test/stateSpec.js +++ b/test/stateSpec.js @@ -18,10 +18,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 +36,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" }) @@ -275,4 +281,30 @@ describe('state', function () { expect($state.href("about.person.item", { person: "bob", id: null })).toEqual("/about/bob/"); })); }); + + 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); + })); + }); + }); From 0849993f43a382780c45d86bfb916fef759a2ea6 Mon Sep 17 00:00:00 2001 From: Tim Kindberg Date: Fri, 19 Jul 2013 10:10:24 -0400 Subject: [PATCH 04/27] Reordered Features to de-emphasize multiple views and named views --- README.md | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index f902a40b1..e2135acac 100644 --- a/README.md +++ b/README.md @@ -18,20 +18,21 @@ To evolve the concept of an [angularjs](http://angularjs.org/) [***route***](htt 2. **More Powerful Views** >`ui-view` directive (used in place of `ng-view`) -3. **Named Views** +3. **Nested Views** +>load templates that contain nested `ui-view`s as deep as you'd like. + +4. **Routing** +>States can map to URLs (though it's not required) + +5. **Named Views** >`
` -4. **Multiple Parallel Views** +6. **Multiple Parallel Views** > ```
``` -5. **Nested Views** ->load templates that contain nested `ui-view`s as deep as you'd like. - -6. **Routing** ->States can map to URLs (though it's not required) *Basically, do whatever you want with states and routes.* From 585001da5d0dde2c0a2d1b4baa0dac9d0fcfd688 Mon Sep 17 00:00:00 2001 From: Edward Hutchins Date: Mon, 22 Jul 2013 13:51:23 -0700 Subject: [PATCH 05/27] Handle unmodified left left click only, allow open-in-new tab etc. to use the default handler. Added tests for shift/ctrl/meta/middle-clicked uiSrefs. --- src/stateDirectives.js | 8 ++- test/stateDirectivesSpec.js | 132 +++++++++++++++++++++++++++++++++++- 2 files changed, 135 insertions(+), 5 deletions(-) diff --git a/src/stateDirectives.js b/src/stateDirectives.js index a8dd4282c..2a2d4b693 100644 --- a/src/stateDirectives.js +++ b/src/stateDirectives.js @@ -38,9 +38,11 @@ function $StateRefDirective($state) { if (isForm) return; element.bind("click", function(e) { - $state.transitionTo(ref.state, params); - scope.$apply(); - e.preventDefault(); + if ((e.which == 1) && !e.ctrlKey && !e.metaKey && !e.shiftKey) { + $state.transitionTo(ref.state, params); + scope.$apply(); + e.preventDefault(); + } }); } }; diff --git a/test/stateDirectivesSpec.js b/test/stateDirectivesSpec.js index c229c6383..fcb296541 100644 --- a/test/stateDirectivesSpec.js +++ b/test/stateDirectivesSpec.js @@ -37,17 +37,145 @@ describe('uiStateRef', function() { 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"); + 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 + false, // ctrlKeyArg of type boolean, Specifies whether or not control key was depressed during the Event. + false, // altKeyArg of type boolean, Specifies whether or not alt key was depressed during the Event. + false, // shiftKeyArg of type boolean, Specifies whether or not shift key was depressed during the Event. + false, // metaKeyArg of type boolean, Specifies whether or not meta key was depressed during the Event. + 0, // buttonArg of type unsigned short, Specifies the Event's mouse button. + null // relatedTargetArg of type EventTarget + ); el[0].dispatchEvent(e); $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(''); + + var e = $document[0].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 + true, // ctrlKeyArg of type boolean, Specifies whether or not control key was depressed during the Event. + false, // altKeyArg of type boolean, Specifies whether or not alt key was depressed during the Event. + false, // shiftKeyArg of type boolean, Specifies whether or not shift key was depressed during the Event. + false, // metaKeyArg of type boolean, Specifies whether or not meta key was depressed during the Event. + 0, // buttonArg of type unsigned short, Specifies the Event's mouse button. + null // relatedTargetArg of type EventTarget + ); + el[0].dispatchEvent(e); + + $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(''); + + var e = $document[0].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 + false, // ctrlKeyArg of type boolean, Specifies whether or not control key was depressed during the Event. + false, // altKeyArg of type boolean, Specifies whether or not alt key was depressed during the Event. + false, // shiftKeyArg of type boolean, Specifies whether or not shift key was depressed during the Event. + true, // metaKeyArg of type boolean, Specifies whether or not meta key was depressed during the Event. + 0, // buttonArg of type unsigned short, Specifies the Event's mouse button. + null // relatedTargetArg of type EventTarget + ); + el[0].dispatchEvent(e); + + $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(''); + + var e = $document[0].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 + false, // ctrlKeyArg of type boolean, Specifies whether or not control key was depressed during the Event. + false, // altKeyArg of type boolean, Specifies whether or not alt key was depressed during the Event. + true, // shiftKeyArg of type boolean, Specifies whether or not shift key was depressed during the Event. + false, // metaKeyArg of type boolean, Specifies whether or not meta key was depressed during the Event. + 0, // buttonArg of type unsigned short, Specifies the Event's mouse button. + null // relatedTargetArg of type EventTarget + ); + el[0].dispatchEvent(e); + + $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(''); + + var e = $document[0].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 + false, // ctrlKeyArg of type boolean, Specifies whether or not control key was depressed during the Event. + false, // altKeyArg of type boolean, Specifies whether or not alt key was depressed during the Event. + false, // shiftKeyArg of type boolean, Specifies whether or not shift key was depressed during the Event. + false, // metaKeyArg of type boolean, Specifies whether or not meta key was depressed during the Event. + 1, // buttonArg of type unsigned short, Specifies the Event's mouse button. + null // relatedTargetArg of type EventTarget + ); + el[0].dispatchEvent(e); + + $q.flush(); + expect($state.current.name).toEqual(''); + expect($stateParams).toEqual({ id: "5" }); + })); }); describe('forms', function() { From 1a9623e7c19efbea80fccb3de2d5b5595a675cac Mon Sep 17 00:00:00 2001 From: Nate Abele Date: Tue, 23 Jul 2013 12:13:50 -0500 Subject: [PATCH 06/27] HTML5-mode compatibility + tests, fixes #276 - Refactoring directive test event handling --- src/state.js | 7 +- test/stateDirectivesSpec.js | 153 +++++++++++------------------------- test/stateSpec.js | 31 +++++--- 3 files changed, 74 insertions(+), 117 deletions(-) diff --git a/src/state.js b/src/state.js index 079a64c3b..1d43d988a 100644 --- a/src/state.js +++ b/src/state.js @@ -1,5 +1,5 @@ -$StateProvider.$inject = ['$urlRouterProvider', '$urlMatcherFactoryProvider']; -function $StateProvider( $urlRouterProvider, $urlMatcherFactory) { +$StateProvider.$inject = ['$urlRouterProvider', '$urlMatcherFactoryProvider', '$locationProvider']; +function $StateProvider( $urlRouterProvider, $urlMatcherFactory, $locationProvider) { var root, states = {}, $state; @@ -272,7 +272,8 @@ function $StateProvider( $urlRouterProvider, $urlMatcherFactory) { options = extend({ lossy: true }, options || {}); var state = findState(stateOrName); var nav = (state && options.lossy) ? state.navigable : state; - return (nav && nav.url) ? nav.url.format(normalize(state.params, params || {})) : null; + var url = (nav && nav.url) ? nav.url.format(normalize(state.params, params || {})) : null; + return !$locationProvider.html5Mode() && url ? "#" + url : url; }; function resolveState(state, params, paramsAreFiltered, inherited, dst) { diff --git a/test/stateDirectivesSpec.js b/test/stateDirectivesSpec.js index fcb296541..71b25e576 100644 --- a/test/stateDirectivesSpec.js +++ b/test/stateDirectivesSpec.js @@ -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,66 +61,29 @@ 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 left-clicked', inject(function($state, $stateParams, $document, $q) { expect($state.$current.name).toEqual(''); - var e = $document[0].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 - false, // ctrlKeyArg of type boolean, Specifies whether or not control key was depressed during the Event. - false, // altKeyArg of type boolean, Specifies whether or not alt key was depressed during the Event. - false, // shiftKeyArg of type boolean, Specifies whether or not shift key was depressed during the Event. - false, // metaKeyArg of type boolean, Specifies whether or not meta key was depressed during the Event. - 0, // buttonArg of type unsigned short, Specifies the Event's mouse button. - null // relatedTargetArg of type EventTarget - ); - 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(''); - - var e = $document[0].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 - true, // ctrlKeyArg of type boolean, Specifies whether or not control key was depressed during the Event. - false, // altKeyArg of type boolean, Specifies whether or not alt key was depressed during the Event. - false, // shiftKeyArg of type boolean, Specifies whether or not shift key was depressed during the Event. - false, // metaKeyArg of type boolean, Specifies whether or not meta key was depressed during the Event. - 0, // buttonArg of type unsigned short, Specifies the Event's mouse button. - null // relatedTargetArg of type EventTarget - ); - el[0].dispatchEvent(e); + triggerClick(el, { ctrlKey: true }); $q.flush(); expect($state.current.name).toEqual(''); @@ -96,27 +93,9 @@ describe('uiStateRef', function() { it('should not transition states when meta-clicked', inject(function($state, $stateParams, $document, $q) { expect($state.$current.name).toEqual(''); - var e = $document[0].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 - false, // ctrlKeyArg of type boolean, Specifies whether or not control key was depressed during the Event. - false, // altKeyArg of type boolean, Specifies whether or not alt key was depressed during the Event. - false, // shiftKeyArg of type boolean, Specifies whether or not shift key was depressed during the Event. - true, // metaKeyArg of type boolean, Specifies whether or not meta key was depressed during the Event. - 0, // buttonArg of type unsigned short, Specifies the Event's mouse button. - null // relatedTargetArg of type EventTarget - ); - el[0].dispatchEvent(e); - + triggerClick(el, { metaKey: true }); $q.flush(); + expect($state.current.name).toEqual(''); expect($stateParams).toEqual({ id: "5" }); })); @@ -124,27 +103,9 @@ describe('uiStateRef', function() { it('should not transition states when shift-clicked', inject(function($state, $stateParams, $document, $q) { expect($state.$current.name).toEqual(''); - var e = $document[0].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 - false, // ctrlKeyArg of type boolean, Specifies whether or not control key was depressed during the Event. - false, // altKeyArg of type boolean, Specifies whether or not alt key was depressed during the Event. - true, // shiftKeyArg of type boolean, Specifies whether or not shift key was depressed during the Event. - false, // metaKeyArg of type boolean, Specifies whether or not meta key was depressed during the Event. - 0, // buttonArg of type unsigned short, Specifies the Event's mouse button. - null // relatedTargetArg of type EventTarget - ); - el[0].dispatchEvent(e); - + triggerClick(el, { shiftKey: true }); $q.flush(); + expect($state.current.name).toEqual(''); expect($stateParams).toEqual({ id: "5" }); })); @@ -152,27 +113,9 @@ describe('uiStateRef', function() { it('should not transition states when middle-clicked', inject(function($state, $stateParams, $document, $q) { expect($state.$current.name).toEqual(''); - var e = $document[0].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 - false, // ctrlKeyArg of type boolean, Specifies whether or not control key was depressed during the Event. - false, // altKeyArg of type boolean, Specifies whether or not alt key was depressed during the Event. - false, // shiftKeyArg of type boolean, Specifies whether or not shift key was depressed during the Event. - false, // metaKeyArg of type boolean, Specifies whether or not meta key was depressed during the Event. - 1, // buttonArg of type unsigned short, Specifies the Event's mouse button. - null // relatedTargetArg of type EventTarget - ); - el[0].dispatchEvent(e); - + triggerClick(el, { button: 1 }); $q.flush(); + expect($state.current.name).toEqual(''); expect($stateParams).toEqual({ id: "5" }); })); @@ -192,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 694273fe2..eec7c7e2f 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.state', function($locationProvider) { + locationProvider = $locationProvider; + $locationProvider.html5Mode(false); + })); var log, logEvents, logEnterExit; function eventLogger(event, to, toParams, from, fromParams) { @@ -267,18 +272,18 @@ 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/"); + expect($state.href("about.person", { person: "bob" })).toEqual("#/about/bob"); + expect($state.href("about.person.item", { person: "bob", id: null })).toEqual("#/about/bob/"); })); }); @@ -307,4 +312,12 @@ describe('state', function () { })); }); -}); + 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"); + })); + }); +}); \ No newline at end of file From bc8ed511e7a8a553e672fe11a89f864bfaef0395 Mon Sep 17 00:00:00 2001 From: Tim Kindberg Date: Tue, 23 Jul 2013 16:41:55 -0400 Subject: [PATCH 07/27] Fixes #257 --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index e2135acac..ea5816313 100644 --- a/README.md +++ b/README.md @@ -3,8 +3,8 @@ # 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. From 16be1c7019c67040822195daad84e9c6d18e3456 Mon Sep 17 00:00:00 2001 From: Dan Kang Date: Wed, 24 Jul 2013 18:13:14 -0400 Subject: [PATCH 08/27] Fixed incorrect encoding/decoding in urlMatcherFactory for parameters. Also wrote unit tests. --- src/urlMatcherFactory.js | 4 ++-- test/urlMatcherFactorySpec.js | 8 ++++++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/urlMatcherFactory.js b/src/urlMatcherFactory.js index a4b4bd62b..0f0e603d5 100644 --- a/src/urlMatcherFactory.js +++ b/src/urlMatcherFactory.js @@ -163,7 +163,7 @@ UrlMatcher.prototype.exec = function (path, searchParams) { if (nPath !== m.length - 1) throw new Error("Unbalanced capture group in route '" + this.source + "'"); - for (i=0; i Date: Mon, 5 Aug 2013 19:44:32 +0200 Subject: [PATCH 09/27] test(state): getConfig --- test/stateSpec.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/test/stateSpec.js b/test/stateSpec.js index eec7c7e2f..2d60eec3c 100644 --- a/test/stateSpec.js +++ b/test/stateSpec.js @@ -287,6 +287,13 @@ describe('state', function () { })); }); + describe('.getConfig()', function () { + it("should return a copy of the state's config", inject(function ($state) { + expect($state.getConfig('home').url).toBe('/'); + expect($state.getConfig('home.item').url).toBe('front/:id'); + })); + }); + describe(' "data" property inheritance/override', function () { it('"data" property should stay immutable for if state doesn\'t have parent', inject(function ($state) { initStateTo(H); From 844c9f2edf9c84d7b582b4b5e2585515f93a4cf5 Mon Sep 17 00:00:00 2001 From: David Pfahler Date: Mon, 5 Aug 2013 19:44:43 +0200 Subject: [PATCH 10/27] feat(state): getConfig --- src/state.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/state.js b/src/state.js index 1d43d988a..eecc79f1c 100644 --- a/src/state.js +++ b/src/state.js @@ -219,7 +219,7 @@ function $StateProvider( $urlRouterProvider, $urlMatcherFactory, $ exiting = fromPath[l]; if (exiting.self.onExit) { $injector.invoke(exiting.self.onExit, exiting.self, exiting.locals.globals); - } + } exiting.locals = null; } @@ -276,6 +276,11 @@ function $StateProvider( $urlRouterProvider, $urlMatcherFactory, $ return !$locationProvider.html5Mode() && url ? "#" + url : url; }; + $state.getConfig = function (stateOrName) { + var state = findState(stateOrName); + return state.self ? angular.copy(state.self) : null; + }; + function resolveState(state, params, paramsAreFiltered, inherited, dst) { // Make a restricted $stateParams with only the parameters that apply to this state if // necessary. In addition to being available to the controller and onEnter/onExit callbacks, From 04449773b9108bcc174ff46fe84d33362bb409f2 Mon Sep 17 00:00:00 2001 From: Nate Abele Date: Tue, 6 Aug 2013 03:32:11 -0500 Subject: [PATCH 11/27] Initial test spec for $urlRouter. --- src/urlRouter.js | 8 +++---- test/urlRouterSpec.js | 50 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 4 deletions(-) diff --git a/src/urlRouter.js b/src/urlRouter.js index 08bf7c84e..4bc759aa3 100644 --- a/src/urlRouter.js +++ b/src/urlRouter.js @@ -54,7 +54,7 @@ function $UrlRouterProvider( $urlMatcherFactory) { handler = ['$match', function ($match) { return redirect.format($match); }]; } else if (!isFunction(handler) && !isArray(handler)) - throw new Error("invalid 'handler' in when()"); + throw new Error("invalid 'handler' in when()"); rule = function ($injector, $location) { return handleIfMatch($injector, handler, what.exec($location.path(), $location.search())); @@ -67,10 +67,10 @@ function $UrlRouterProvider( $urlMatcherFactory) { handler = ['$match', function ($match) { return interpolate(redirect, $match); }]; } else if (!isFunction(handler) && !isArray(handler)) - throw new Error("invalid 'handler' in when()"); + throw new Error("invalid 'handler' in when()"); if (what.global || what.sticky) - throw new Error("when() RegExp must not be global or sticky"); + throw new Error("when() RegExp must not be global or sticky"); rule = function ($injector, $location) { return handleIfMatch($injector, handler, what.exec($location.path())); @@ -78,7 +78,7 @@ function $UrlRouterProvider( $urlMatcherFactory) { rule.prefix = regExpPrefix(what); } else - throw new Error("invalid 'what' in when()"); + throw new Error("invalid 'what' in when()"); return this.rule(rule); }; diff --git a/test/urlRouterSpec.js b/test/urlRouterSpec.js index e69de29bb..e77a46abf 100644 --- a/test/urlRouterSpec.js +++ b/test/urlRouterSpec.js @@ -0,0 +1,50 @@ +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) { + return $location.path().replace('baz', 'b4z'); + }).when('/foo/:param', function($match) { + match = ['/foo/:param', $match]; + }).when('/bar', function($match) { + match = ['/bar', $match]; + }).when('/:param', function($match) { + match = ['/:param', $match]; + }); + }); + + module('ui.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"); + }); + }); + +}); \ No newline at end of file From 81f19dab2a7adf2c2a3e1bdf783f55bd0e286929 Mon Sep 17 00:00:00 2001 From: Edward Hutchins Date: Fri, 26 Jul 2013 17:01:52 -0700 Subject: [PATCH 12/27] Keep otherwise() handling last. Added a test. --- src/urlRouter.js | 16 ++++++++++------ test/urlRouterSpec.js | 24 ++++++++++++++++++++---- 2 files changed, 30 insertions(+), 10 deletions(-) diff --git a/src/urlRouter.js b/src/urlRouter.js index 4bc759aa3..5fc0510e8 100644 --- a/src/urlRouter.js +++ b/src/urlRouter.js @@ -86,18 +86,22 @@ function $UrlRouterProvider( $urlMatcherFactory) { this.$get = [ '$location', '$rootScope', '$injector', function ($location, $rootScope, $injector) { - if (otherwise) rules.push(otherwise); - // TODO: Optimize groups of rules with non-empty prefix into some sort of decision tree function update() { - var n=rules.length, i, handled; - for (i=0; i Date: Sat, 10 Aug 2013 00:48:13 -0400 Subject: [PATCH 13/27] $state.getConfig() now returns the actual state configuration object. --- src/state.js | 2 +- test/stateSpec.js | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/state.js b/src/state.js index eecc79f1c..3623ff90c 100644 --- a/src/state.js +++ b/src/state.js @@ -278,7 +278,7 @@ function $StateProvider( $urlRouterProvider, $urlMatcherFactory, $ $state.getConfig = function (stateOrName) { var state = findState(stateOrName); - return state.self ? angular.copy(state.self) : null; + return state.self || null; }; function resolveState(state, params, paramsAreFiltered, inherited, dst) { diff --git a/test/stateSpec.js b/test/stateSpec.js index 2d60eec3c..1efa52a1c 100644 --- a/test/stateSpec.js +++ b/test/stateSpec.js @@ -288,9 +288,11 @@ describe('state', function () { }); describe('.getConfig()', function () { - it("should return a copy of the state's config", inject(function ($state) { + 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'"); })); }); From a954e0bd690dc0e296ac0d617ac1c6efe20ed3a1 Mon Sep 17 00:00:00 2001 From: Nate Abele Date: Mon, 12 Aug 2013 01:27:32 -0400 Subject: [PATCH 14/27] URL transitions on param-only state change, fixes #289. --- src/state.js | 42 +++++++++++++++++++++++++----------------- test/stateSpec.js | 15 +++++++++++++++ 2 files changed, 40 insertions(+), 17 deletions(-) diff --git a/src/state.js b/src/state.js index 3623ff90c..6efc27140 100644 --- a/src/state.js +++ b/src/state.js @@ -115,8 +115,10 @@ function $StateProvider( $urlRouterProvider, $urlMatcherFactory, $ // Register the state in the global state list and with $urlRouter if necessary. if (!state['abstract'] && url) { - $urlRouterProvider.when(url, ['$match', function ($match) { - if ($state.$current.navigable != state) $state.transitionTo(state, $match, false); + $urlRouterProvider.when(url, ['$match', '$stateParams', function ($match, $stateParams) { + if ($state.$current.navigable != state || !equalForKeys($match, $stateParams)) { + $state.transitionTo(state, $match, false); + } }]); } states[name] = state; @@ -328,25 +330,31 @@ function $StateProvider( $urlRouterProvider, $urlMatcherFactory, $ }); } - function normalize(keys, values) { - var normalized = {}; + return $state; + } - forEach(keys, function (name) { - var value = values[name]; - normalized[name] = (value != null) ? String(value) : null; - }); - return normalized; - } + function normalize(keys, values) { + var normalized = {}; - function equalForKeys(a, b, keys) { - for (var i=0; i Date: Thu, 27 Jun 2013 17:46:42 -0400 Subject: [PATCH 15/27] Initial refactor. - Return home early - Extract repeated animator branching logic into internal service --- src/viewDirective.js | 89 ++++++++++++++++++++++++-------------------- 1 file changed, 49 insertions(+), 40 deletions(-) diff --git a/src/viewDirective.js b/src/viewDirective.js index 07e539554..f74096d7f 100644 --- a/src/viewDirective.js +++ b/src/viewDirective.js @@ -1,9 +1,11 @@ $ViewDirective.$inject = ['$state', '$compile', '$controller', '$injector', '$anchorScroll']; function $ViewDirective( $state, $compile, $controller, $injector, $anchorScroll) { - // Unfortunately there is no neat way to ask $injector if a service exists + // TODO: Change to $injector.has() when we version bump to Angular 1.1.5. + // See: https://github.com/angular/angular.js/blob/master/CHANGELOG.md#115-triangle-squarification-2013-05-22 var $animator; try { $animator = $injector.get('$animator'); } catch (e) { /* do nothing */ } + var directive = { restrict: 'ECA', terminal: true, @@ -15,6 +17,28 @@ function $ViewDirective( $state, $compile, $controller, $injector, $an onloadExp = attr.onload || '', animate = isDefined($animator) && $animator(scope, attr); + var renderer = function(doAnimate) { + return ({ + "true": { + remove: function(element) { animate.leave(element.contents(), element); }, + restore: function(compiled, element) { animate.enter(compiled, element); }, + populate: function(template, element) { + var contents = angular.element('
').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)); @@ -32,13 +56,10 @@ function $ViewDirective( $state, $compile, $controller, $injector, $an 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 +67,33 @@ 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 contents = render.populate(locals.$template, element); + 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'); + if (onloadExp) viewScope.$eval(onloadExp); + + // TODO: This seems strange, shouldn't $anchorScroll listen for $viewContentLoaded if necessary? + // $anchorScroll might listen on event... + $anchorScroll(); } }; } From b9045e757a1da4674b014bc422d3c355f588ed0a Mon Sep 17 00:00:00 2001 From: Nate Abele Date: Fri, 28 Jun 2013 18:05:38 -0400 Subject: [PATCH 16/27] Refactoring 'registerState()' into 'stateBuilder'. - Adding tests - Adding comments --- src/state.js | 185 ++++++++++++++++++++++++------------------- src/viewDirective.js | 2 + test/stateSpec.js | 13 +++ 3 files changed, 117 insertions(+), 83 deletions(-) diff --git a/src/state.js b/src/state.js index 6efc27140..494b065f3 100644 --- a/src/state.js +++ b/src/state.js @@ -3,6 +3,99 @@ function $StateProvider( $urlRouterProvider, $urlMatcherFactory, $ var root, states = {}, $state; + // Builds state properties from definition passed to registerState() + var stateBuilder = { + // Derive parent state from a hierarchical name only if 'parent' is not explicitly defined. + // state.children = []; + // if (parent) parent.children.push(state); + parent: function(state) { + if (isDefined(state.parent) && state.parent) return findState(state.parent); + // regex matches any valid composite state name + // would match "contact.list" but not "contacts" + var compositeName = /^(.+)\.[^.]+$/.exec(state.name); + return compositeName ? findState(compositeName[1]) : root; + }, + + // Build a URLMatcher if necessary, either via a relative or absolute URL + url: function(state) { + var url = state.url; + + if (isString(url)) { + if (url.charAt(0) == '^') { + return $urlMatcherFactory.compile(url.substring(1)); + } + return (state.parent.navigable || root).url.concat(url); + } + var isMatcher = ( + isObject(url) && isFunction(url.exec) && isFunction(url.format) && isFunction(url.concat) + ); + + if (isMatcher || url == null) { + return url; + } + throw new Error("Invalid url '" + url + "' in state '" + state + "'"); + }, + + // Keep track of the closest ancestor state that has a URL (i.e. is navigable) + navigable: function(state) { + return state.url ? state : (state.parent ? state.parent.navigable : null); + }, + + // Derive parameters for this state and ensure they're a super-set of parent's parameters + params: function(state) { + if (!state.params) { + return state.url ? state.url.parameters() : state.parent.params; + } + if (!isArray(state.params)) throw new Error("Invalid params in state '" + state + "'"); + if (state.url) throw new Error("Both params and url specicified in state '" + state + "'"); + return state.params; + }, + + // If there is no explicit multi-view configuration, make one up so we don't have + // to handle both cases in the view directive later. Note that having an explicit + // 'views' property will mean the default unnamed view properties are ignored. This + // is also a good time to resolve view names to absolute names, so everything is a + // straight lookup at link time. + views: function(state) { + var views = {}; + + forEach(isDefined(state.views) ? state.views : { '': state }, function (view, name) { + if (name.indexOf('@') < 0) name += '@' + state.parent.name; + views[name] = view; + }); + return views; + }, + + ownParams: function(state) { + if (!state.parent) { + return state.params; + } + var paramNames = {}; forEach(state.params, function (p) { paramNames[p] = true; }); + + forEach(state.parent.params, function (p) { + if (!paramNames[p]) { + throw new Error("Missing required parameter '" + p + "' in state '" + state.name + "'"); + } + paramNames[p] = false; + }); + var ownParams = []; + + forEach(paramNames, function (own, p) { + if (own) ownParams.push(p); + }); + return ownParams; + }, + + data: function(state) { + // inherit 'data' from parent and override by own values (if any) + if (state.parent && state.parent.data) { + state.data = angular.extend({}, state.parent.data, state.data); + state.self.data = state.data; + } + return state.data; + } + }; + function findState(stateOrName) { var state; if (isString(stateOrName)) { @@ -20,6 +113,7 @@ function $StateProvider( $urlRouterProvider, $urlMatcherFactory, $ // Wrap a new object around the state so we can store our private details easily. state = inherit(state, { self: state, + resolve: state.resolve || {}, toString: function () { return this.name; } }); @@ -27,95 +121,20 @@ function $StateProvider( $urlRouterProvider, $urlMatcherFactory, $ if (!isString(name) || name.indexOf('@') >= 0) throw new Error("State must have a valid name"); if (states[name]) throw new Error("State '" + name + "'' is already defined"); - // Derive parent state from a hierarchical name only if 'parent' is not explicitly defined. - var parent = root; - if (!isDefined(state.parent)) { - // regex matches any valid composite state name - // would match "contact.list" but not "contacts" - var compositeName = /^(.+)\.[^.]+$/.exec(name); - if (compositeName != null) { - parent = findState(compositeName[1]); - } - } else if (state.parent != null) { - parent = findState(state.parent); + for (var key in stateBuilder) { + state[key] = stateBuilder[key](state); } - state.parent = parent; - // inherit 'data' from parent and override by own values (if any) - if (state.parent && state.parent.data) { - state.data = angular.extend({}, state.parent.data, state.data); - state.self.data = state.data; - } - // state.children = []; - // if (parent) parent.children.push(state); - - // Build a URLMatcher if necessary, either via a relative or absolute URL - var url = state.url; - if (isString(url)) { - if (url.charAt(0) == '^') { - url = state.url = $urlMatcherFactory.compile(url.substring(1)); - } else { - url = state.url = (parent.navigable || root).url.concat(url); - } - } else if (isObject(url) && - isFunction(url.exec) && isFunction(url.format) && isFunction(url.concat)) { - /* use UrlMatcher (or compatible object) as is */ - } else if (url != null) { - throw new Error("Invalid url '" + url + "' in state '" + state + "'"); - } - - // Keep track of the closest ancestor state that has a URL (i.e. is navigable) - state.navigable = url ? state : parent ? parent.navigable : null; - - // Derive parameters for this state and ensure they're a super-set of parent's parameters - var params = state.params; - if (params) { - if (!isArray(params)) throw new Error("Invalid params in state '" + state + "'"); - if (url) throw new Error("Both params and url specicified in state '" + state + "'"); - } else { - params = state.params = url ? url.parameters() : state.parent.params; - } - - var paramNames = {}; forEach(params, function (p) { paramNames[p] = true; }); - if (parent) { - forEach(parent.params, function (p) { - if (!paramNames[p]) { - throw new Error("Missing required parameter '" + p + "' in state '" + name + "'"); - } - paramNames[p] = false; - }); - - var ownParams = state.ownParams = []; - forEach(paramNames, function (own, p) { - if (own) ownParams.push(p); - }); - } else { - state.ownParams = params; - } - - // If there is no explicit multi-view configuration, make one up so we don't have - // to handle both cases in the view directive later. Note that having an explicit - // 'views' property will mean the default unnamed view properties are ignored. This - // is also a good time to resolve view names to absolute names, so everything is a - // straight lookup at link time. - var views = {}; - forEach(isDefined(state.views) ? state.views : { '': state }, function (view, name) { - if (name.indexOf('@') < 0) name = name + '@' + state.parent.name; - views[name] = view; - }); - state.views = views; // Keep a full path from the root down to this state as this is needed for state activation. - state.path = parent ? parent.path.concat(state) : []; // exclude root from path + state.path = state.parent ? state.parent.path.concat(state) : []; // exclude root from path // Speed up $state.contains() as it's used a lot - var includes = state.includes = parent ? extend({}, parent.includes) : {}; + var includes = state.includes = state.parent ? extend({}, state.parent.includes) : {}; includes[name] = true; - if (!state.resolve) state.resolve = {}; // prevent null checks later - // Register the state in the global state list and with $urlRouter if necessary. - if (!state['abstract'] && url) { - $urlRouterProvider.when(url, ['$match', '$stateParams', function ($match, $stateParams) { + if (!state['abstract'] && state.url) { + $urlRouterProvider.when(state.url, ['$match', '$stateParams', function ($match, $stateParams) { if ($state.$current.navigable != state || !equalForKeys($match, $stateParams)) { $state.transitionTo(state, $match, false); } @@ -191,8 +210,8 @@ function $StateProvider( $urlRouterProvider, $urlMatcherFactory, $ toParams = normalize(to.params, toParams || {}); // Broadcast start event and cancel the transition if requested - if ($rootScope.$broadcast('$stateChangeStart', to.self, toParams, from.self, fromParams) - .defaultPrevented) return TransitionPrevented; + var evt = $rootScope.$broadcast('$stateChangeStart', to.self, toParams, from.self, fromParams); + if (evt.defaultPrevented) return TransitionPrevented; // Resolve locals for the remaining states, but don't update any global state just // yet -- if anything fails to resolve the current state needs to remain untouched. diff --git a/src/viewDirective.js b/src/viewDirective.js index f74096d7f..5f9139636 100644 --- a/src/viewDirective.js +++ b/src/viewDirective.js @@ -17,6 +17,8 @@ function $ViewDirective( $state, $compile, $controller, $injector, $an onloadExp = attr.onload || '', animate = isDefined($animator) && $animator(scope, attr); + // Returns a set of DOM manipulation functions based on whether animation + // should be performed var renderer = function(doAnimate) { return ({ "true": { diff --git a/test/stateSpec.js b/test/stateSpec.js index 6a1d18bd0..e8c340a36 100644 --- a/test/stateSpec.js +++ b/test/stateSpec.js @@ -344,4 +344,17 @@ describe('state', function () { 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({}); + })); + }); }); \ No newline at end of file From ad91f00b51c1d2763704e39c82cbdb258c4c12b1 Mon Sep 17 00:00:00 2001 From: abaran Date: Sun, 14 Jul 2013 18:50:40 +0300 Subject: [PATCH 17/27] 'data' property inheritance/override in chain of states --- src/state.js | 17 ++++++++--------- test/stateSpec.js | 26 ++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 9 deletions(-) diff --git a/src/state.js b/src/state.js index 494b065f3..fd7b773f7 100644 --- a/src/state.js +++ b/src/state.js @@ -16,6 +16,14 @@ function $StateProvider( $urlRouterProvider, $urlMatcherFactory, $ return compositeName ? findState(compositeName[1]) : root; }, + // inherit 'data' from parent and override by own values (if any) + data: function(state) { + if (state.parent && state.parent.data) { + state.data = state.self.data = angular.extend({}, state.parent.data, state.data); + } + return state.data; + }, + // Build a URLMatcher if necessary, either via a relative or absolute URL url: function(state) { var url = state.url; @@ -84,15 +92,6 @@ function $StateProvider( $urlRouterProvider, $urlMatcherFactory, $ if (own) ownParams.push(p); }); return ownParams; - }, - - data: function(state) { - // inherit 'data' from parent and override by own values (if any) - if (state.parent && state.parent.data) { - state.data = angular.extend({}, state.parent.data, state.data); - state.self.data = state.data; - } - return state.data; } }; diff --git a/test/stateSpec.js b/test/stateSpec.js index e8c340a36..736a3d3cf 100644 --- a/test/stateSpec.js +++ b/test/stateSpec.js @@ -357,4 +357,30 @@ describe('state', function () { 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); + })); + }); + }); \ No newline at end of file From 6a66b7d47da98614019d5f71376149ab842ca5e0 Mon Sep 17 00:00:00 2001 From: Nate Abele Date: Fri, 28 Jun 2013 18:05:38 -0400 Subject: [PATCH 18/27] Refactoring 'registerState()' into 'stateBuilder'. - Adding tests - Adding comments --- test/stateSpec.js | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/test/stateSpec.js b/test/stateSpec.js index 736a3d3cf..c55d1cd85 100644 --- a/test/stateSpec.js +++ b/test/stateSpec.js @@ -383,4 +383,25 @@ describe('state', function () { })); }); -}); \ No newline at end of file + 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({}); + })); + }); +}); From 5261cabc295d17e780f8fa83fafbb3798450a2ca Mon Sep 17 00:00:00 2001 From: Nate Abele Date: Sat, 27 Jul 2013 13:38:05 +0900 Subject: [PATCH 19/27] Additional state builder refactoring. --- src/state.js | 57 ++++++++++++++++++++++++++++++++++------------------ 1 file changed, 38 insertions(+), 19 deletions(-) diff --git a/src/state.js b/src/state.js index fd7b773f7..e023589e0 100644 --- a/src/state.js +++ b/src/state.js @@ -5,6 +5,7 @@ function $StateProvider( $urlRouterProvider, $urlMatcherFactory, $ // Builds state properties from definition passed to registerState() var stateBuilder = { + // Derive parent state from a hierarchical name only if 'parent' is not explicitly defined. // state.children = []; // if (parent) parent.children.push(state); @@ -92,28 +93,52 @@ function $StateProvider( $urlRouterProvider, $urlMatcherFactory, $ if (own) ownParams.push(p); }); return ownParams; + }, + + // Keep a full path from the root down to this state as this is needed for state activation. + path: function(state) { + return state.parent ? state.parent.path.concat(state) : []; // exclude root from path + }, + + // Speed up $state.contains() as it's used a lot + includes: function(state) { + var includes = state.parent ? extend({}, state.parent.includes) : {}; + includes[state.name] = true; + return includes; } }; - function findState(stateOrName) { - var state; - if (isString(stateOrName)) { - state = states[stateOrName]; - if (!state) throw new Error("No such state '" + stateOrName + "'"); - } else { - state = states[stateOrName.name]; - if (!state || state !== stateOrName && state.self !== stateOrName) - throw new Error("Invalid or unregistered state"); + + function findState(stateOrName, base) { + var isStr = isString(stateOrName), + name = isStr ? stateOrName : stateOrName.name, + path = isStr ? name.match(/^((?:(?:\^)(?:\.)?){1,})(.+)/) : null; + + if (path && path.length) { + if (!base) throw new Error("No reference point given for path '" + stateOrName + "'"); + var rel = path[1].split("."), i = 0, pathLength = rel.length - 1, current = base; + + for (; i < pathLength; i++) { + if (rel[i] === "^") current = current.parent; + if (!current) throw new Error("Path '" + name + "' not valid for state '" + base.name + "'"); + } + name = current.name + "." + path[2]; + } + var state = states[name]; + + if (state && (isStr || (!isStr && (state === stateOrName || state.self === stateOrName)))) { + return state; } - return state; + throw new Error(isStr ? "No such state '" + name + "'" : "Invalid or unregistered state"); } + function registerState(state) { // Wrap a new object around the state so we can store our private details easily. state = inherit(state, { self: state, resolve: state.resolve || {}, - toString: function () { return this.name; } + toString: function() { return this.name; } }); var name = state.name; @@ -123,13 +148,7 @@ function $StateProvider( $urlRouterProvider, $urlMatcherFactory, $ for (var key in stateBuilder) { state[key] = stateBuilder[key](state); } - - // Keep a full path from the root down to this state as this is needed for state activation. - state.path = state.parent ? state.parent.path.concat(state) : []; // exclude root from path - - // Speed up $state.contains() as it's used a lot - var includes = state.includes = state.parent ? extend({}, state.parent.includes) : {}; - includes[name] = true; + states[name] = state; // Register the state in the global state list and with $urlRouter if necessary. if (!state['abstract'] && state.url) { @@ -139,10 +158,10 @@ function $StateProvider( $urlRouterProvider, $urlMatcherFactory, $ } }]); } - states[name] = state; return state; } + // Implicit root state that is always active root = registerState({ name: '', From 63e11e61c4d6ca85856b6f474e3be3cfbfa0485e Mon Sep 17 00:00:00 2001 From: Nate Abele Date: Sat, 27 Jul 2013 13:50:30 +0900 Subject: [PATCH 20/27] First pass to extract $view into separate service. --- Gruntfile.js | 1 + config/karma.js | 1 + src/state.js | 26 ++++++++++++++------------ src/view.js | 28 ++++++++++++++++++++++++++++ src/viewDirective.js | 19 +++++++++++++++---- 5 files changed, 59 insertions(+), 16 deletions(-) create mode 100644 src/view.js 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/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/src/state.js b/src/state.js index e023589e0..d998c2b4f 100644 --- a/src/state.js +++ b/src/state.js @@ -185,8 +185,8 @@ function $StateProvider( $urlRouterProvider, $urlMatcherFactory, $ // $urlRouter is injected just to ensure it gets instantiated this.$get = $get; - $get.$inject = ['$rootScope', '$q', '$templateFactory', '$injector', '$resolve', '$stateParams', '$location', '$urlRouter']; - function $get( $rootScope, $q, $templateFactory, $injector, $resolve, $stateParams, $location, $urlRouter) { + $get.$inject = ['$rootScope', '$q', '$view', '$injector', '$resolve', '$stateParams', '$location', '$urlRouter']; + function $get( $rootScope, $q, $view, $injector, $resolve, $stateParams, $location, $urlRouter) { var TransitionSuperseded = $q.reject(new Error('transition superseded')); var TransitionPrevented = $q.reject(new Error('transition prevented')); @@ -325,14 +325,7 @@ function $StateProvider( $urlRouterProvider, $urlMatcherFactory, $ // necessary. In addition to being available to the controller and onEnter/onExit callbacks, // we also need $stateParams to be available for any $injector calls we make during the // dependency resolution process. - var $stateParams; - if (paramsAreFiltered) $stateParams = params; - else { - $stateParams = {}; - forEach(state.params, function (name) { - $stateParams[name] = params[name]; - }); - } + var $stateParams = (paramsAreFiltered) ? params : filterByKeys(state.params, params); var locals = { $stateParams: $stateParams }; // Resolve 'global' dependencies for the state, i.e. those not specific to a view. @@ -349,9 +342,9 @@ function $StateProvider( $urlRouterProvider, $urlMatcherFactory, $ forEach(state.views, function (view, name) { var injectables = (view.resolve && view.resolve !== state.resolve ? view.resolve : {}); injectables.$template = [ function () { - return $templateFactory.fromConfig(view, $stateParams, locals) || ''; + return $view.load(name, { view: view, locals: locals, notify: false }) || ''; }]; - + promises.push($resolve.resolve(injectables, locals, dst.resolve, state).then(function (result) { // References to the controller (only instantiated at link time) result.$$controller = view.controller; @@ -393,6 +386,15 @@ function $StateProvider( $urlRouterProvider, $urlMatcherFactory, $ } return true; } + + function filterByKeys(keys, values) { + var filtered = {}; + + forEach(keys, function (name) { + filtered[name] = values[name]; + }); + return filtered; + } } angular.module('ui.state') diff --git a/src/view.js b/src/view.js new file mode 100644 index 000000000..36d361427 --- /dev/null +++ b/src/view.js @@ -0,0 +1,28 @@ + +$ViewProvider.$inject = []; +function $ViewProvider() { + + this.$get = $get; + $get.$inject = ['$rootScope', '$templateFactory', '$stateParams']; + function $get( $rootScope, $templateFactory, $stateParams) { + return { + // $view.load('full.viewName', { template: ..., controller: ..., resolve: ..., async: false }) + load: function load(name, options) { + var result; + options = extend({ + template: null, controller: null, view: null, locals: null, notify: true, async: true + }, options); + + if (options.view) { + result = $templateFactory.fromConfig(options.view, $stateParams, options.locals); + } + if (result && options.notify) { + $rootScope.$broadcast('$viewContentLoading', options); + } + return result; + } + }; + } +} + +angular.module('ui.state').provider('$view', $ViewProvider); diff --git a/src/viewDirective.js b/src/viewDirective.js index 5f9139636..f0235a94a 100644 --- a/src/viewDirective.js +++ b/src/viewDirective.js @@ -4,7 +4,7 @@ function $ViewDirective( $state, $compile, $controller, $injector, $an // TODO: Change to $injector.has() when we version bump to Angular 1.1.5. // See: https://github.com/angular/angular.js/blob/master/CHANGELOG.md#115-triangle-squarification-2013-05-22 var $animator; try { $animator = $injector.get('$animator'); } catch (e) { /* do nothing */ } - + var viewIsUpdating = false; var directive = { restrict: 'ECA', @@ -52,7 +52,19 @@ 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) { @@ -80,8 +92,7 @@ function $ViewDirective( $state, $compile, $controller, $injector, $an viewLocals = locals; view.state = locals.$$state; - var contents = render.populate(locals.$template, element); - var link = $compile(contents); + var link = $compile(render.populate(locals.$template, element)); viewScope = scope.$new(); if (locals.$$controller) { From d1284831bd7247872d914db331185141bbbc44db Mon Sep 17 00:00:00 2001 From: Nate Abele Date: Mon, 29 Jul 2013 18:06:30 +0900 Subject: [PATCH 21/27] Methods for $state now have proper names. --- src/state.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/state.js b/src/state.js index d998c2b4f..54b29c7ed 100644 --- a/src/state.js +++ b/src/state.js @@ -299,15 +299,15 @@ function $StateProvider( $urlRouterProvider, $urlMatcherFactory, $ return transition; }; - $state.is = function (stateOrName) { + $state.is = function is(stateOrName) { return $state.$current === findState(stateOrName); }; - $state.includes = function (stateOrName) { + $state.includes = function includes(stateOrName) { return $state.$current.includes[findState(stateOrName).name]; }; - $state.href = function (stateOrName, params, options) { + $state.href = function href(stateOrName, params, options) { options = extend({ lossy: true }, options || {}); var state = findState(stateOrName); var nav = (state && options.lossy) ? state.navigable : state; From 45b7285adad4eb633d900c23c644b1bd14172e8a Mon Sep 17 00:00:00 2001 From: Nate Abele Date: Mon, 29 Jul 2013 18:09:34 +0900 Subject: [PATCH 22/27] Implementing $state.go(), for relative transitions & param inheritance. --- src/common.js | 43 +++++++++++++++++++++++++++++++++++++++++++ src/state.js | 6 ++++++ test/stateSpec.js | 32 +++++++++++++++++++++++++++++++- 3 files changed, 80 insertions(+), 1 deletion(-) diff --git a/src/common.js b/src/common.js index cd4cbe672..e155cf157 100644 --- a/src/common.js +++ b/src/common.js @@ -26,6 +26,49 @@ function merge(dst) { return dst; } +/** + * 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.util', ['ng']); angular.module('ui.router', ['ui.util']); angular.module('ui.state', ['ui.router', 'ui.util']); diff --git a/src/state.js b/src/state.js index 54b29c7ed..29558ee46 100644 --- a/src/state.js +++ b/src/state.js @@ -199,6 +199,12 @@ function $StateProvider( $urlRouterProvider, $urlMatcherFactory, $ transition: null }; + $state.go = function go(to, params, options) { + var toState = findState(to, $state.$current); + params = inheritParams($stateParams, params || {}, $state.$current, toState); + return this.transitionTo(toState, params, options); + }; + $state.transitionTo = function transitionTo(to, toParams, updateLocation) { if (!isDefined(updateLocation)) updateLocation = true; diff --git a/test/stateSpec.js b/test/stateSpec.js index c55d1cd85..671c9edb6 100644 --- a/test/stateSpec.js +++ b/test/stateSpec.js @@ -210,6 +210,30 @@ 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'); + })); + + 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) { @@ -345,7 +369,7 @@ describe('state', function () { })); }); - describe('default properties', function () { + describe('default properties', function() { it('should always have a name', inject(function ($state, $q) { $state.transitionTo(A); $q.flush(); @@ -403,5 +427,11 @@ describe('state', function () { 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 }); + })); }); }); From 088ea441e7be9b97d166e91d2b272d5b215efc51 Mon Sep 17 00:00:00 2001 From: Nate Abele Date: Mon, 29 Jul 2013 18:10:19 +0900 Subject: [PATCH 23/27] Refactoring 3rd param in $state.transitionTo() to options hash. --- src/state.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/state.js b/src/state.js index 29558ee46..76a99bdea 100644 --- a/src/state.js +++ b/src/state.js @@ -205,11 +205,13 @@ function $StateProvider( $urlRouterProvider, $urlMatcherFactory, $ return this.transitionTo(toState, params, options); }; - $state.transitionTo = function transitionTo(to, toParams, updateLocation) { - if (!isDefined(updateLocation)) updateLocation = true; + $state.transitionTo = function transitionTo(to, toParams, options) { + if (!isDefined(options)) options = (options === true || options === false) ? { location: options } : {}; + options = extend({ location: true, inherit: false }); to = findState(to); if (to['abstract']) throw new Error("Cannot transition to abstract state '" + to + "'"); + var toPath = to.path, from = $state.$current, fromParams = $state.params, fromPath = from.path; @@ -286,7 +288,7 @@ function $StateProvider( $urlRouterProvider, $urlMatcherFactory, $ // Update $location var toNav = to.navigable; - if (updateLocation && toNav) { + if (options.location && toNav) { $location.url(toNav.url.format(toNav.locals.globals.$stateParams)); } From 1e00aca9b45caa3d47b39d9df8c1532fad343538 Mon Sep 17 00:00:00 2001 From: Nate Abele Date: Mon, 5 Aug 2013 21:17:56 -0500 Subject: [PATCH 24/27] Refactoring $state.go() functionality into transitionTo() as options. --- src/state.js | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/state.js b/src/state.js index 76a99bdea..7696c500a 100644 --- a/src/state.js +++ b/src/state.js @@ -200,17 +200,16 @@ function $StateProvider( $urlRouterProvider, $urlMatcherFactory, $ }; $state.go = function go(to, params, options) { - var toState = findState(to, $state.$current); - params = inheritParams($stateParams, params || {}, $state.$current, toState); - return this.transitionTo(toState, params, options); + return this.transitionTo(to, params, extend({ inherit: true, relative: $state.$current }, options)); }; $state.transitionTo = function transitionTo(to, toParams, options) { if (!isDefined(options)) options = (options === true || options === false) ? { location: options } : {}; - options = extend({ location: true, inherit: false }); + options = extend({ location: true, inherit: false, relative: null }, options); - to = findState(to); + to = findState(to, options.relative); if (to['abstract']) throw new Error("Cannot transition to abstract state '" + to + "'"); + if (options.inherit) toParams = inheritParams($stateParams, toParams || {}, $state.$current, to); var toPath = to.path, from = $state.$current, fromParams = $state.params, fromPath = from.path; From ee705d061246361505f8141b63ca6e82ffd94415 Mon Sep 17 00:00:00 2001 From: Nate Abele Date: Sat, 10 Aug 2013 17:32:44 -0400 Subject: [PATCH 25/27] Using refactored URL matcher detection in $state, fixes #154 - Refactoring $urlRouterProvider.when() for improved composability - Adding tests for $urlRouterProvider --- src/state.js | 5 +-- src/urlMatcherFactory.js | 4 +-- src/urlRouter.js | 67 +++++++++++++++++++++------------------- test/urlRouterSpec.js | 21 ++++++++++++- 4 files changed, 59 insertions(+), 38 deletions(-) diff --git a/src/state.js b/src/state.js index 7696c500a..0f9868d93 100644 --- a/src/state.js +++ b/src/state.js @@ -35,11 +35,8 @@ function $StateProvider( $urlRouterProvider, $urlMatcherFactory, $ } return (state.parent.navigable || root).url.concat(url); } - var isMatcher = ( - isObject(url) && isFunction(url.exec) && isFunction(url.format) && isFunction(url.concat) - ); - if (isMatcher || url == null) { + if ($urlMatcherFactory.isMatcher(url) || url == null) { return url; } throw new Error("Invalid url '" + url + "' in state '" + state + "'"); diff --git a/src/urlMatcherFactory.js b/src/urlMatcherFactory.js index 0f0e603d5..4ca50bd32 100644 --- a/src/urlMatcherFactory.js +++ b/src/urlMatcherFactory.js @@ -58,7 +58,7 @@ function UrlMatcher(pattern) { // \{(?:[^{}\\]+|\\.)*\} - a matched set of curly braces containing other atoms var placeholder = /([:*])(\w+)|\{(\w+)(?:\:((?:[^{}\\]+|\\.|\{(?:[^{}\\]+|\\.)*\})+))?\}/g, names = {}, compiled = '^', last = 0, m, - segments = this.segments = [], + segments = this.segments = [], params = this.params = []; function addParameter(id) { @@ -244,7 +244,7 @@ function $UrlMatcherFactory() { * @return {boolean} */ this.isMatcher = function (o) { - return o instanceof UrlMatcher; + return isObject(o) && isFunction(o.exec) && isFunction(o.format) && isFunction(o.concat); }; this.$get = function () { diff --git a/src/urlRouter.js b/src/urlRouter.js index 5fc0510e8..d4bc8e02f 100644 --- a/src/urlRouter.js +++ b/src/urlRouter.js @@ -44,43 +44,48 @@ function $UrlRouterProvider( $urlMatcherFactory) { this.when = function (what, handler) { - var rule, redirect; - if (isString(what)) - what = $urlMatcherFactory.compile(what); - - if ($urlMatcherFactory.isMatcher(what)) { - if (isString(handler)) { - redirect = $urlMatcherFactory.compile(handler); - handler = ['$match', function ($match) { return redirect.format($match); }]; - } - else if (!isFunction(handler) && !isArray(handler)) - throw new Error("invalid 'handler' in when()"); + var redirect, handlerIsString = isString(handler); + if (isString(what)) what = $urlMatcherFactory.compile(what); - rule = function ($injector, $location) { - return handleIfMatch($injector, handler, what.exec($location.path(), $location.search())); - }; - rule.prefix = isString(what.prefix) ? what.prefix : ''; - } - else if (what instanceof RegExp) { - if (isString(handler)) { - redirect = handler; - handler = ['$match', function ($match) { return interpolate(redirect, $match); }]; + if (!handlerIsString && !isFunction(handler) && !isArray(handler)) + throw new Error("invalid 'handler' in when()"); + + var strategies = { + matcher: function (what, handler) { + if (handlerIsString) { + redirect = $urlMatcherFactory.compile(handler); + handler = ['$match', function ($match) { return redirect.format($match); }]; + } + return extend(function ($injector, $location) { + return handleIfMatch($injector, handler, what.exec($location.path(), $location.search())); + }, { + prefix: isString(what.prefix) ? what.prefix : '' + }); + }, + regex: function (what, handler) { + if (what.global || what.sticky) throw new Error("when() RegExp must not be global or sticky"); + + if (handlerIsString) { + redirect = handler; + handler = ['$match', function ($match) { return interpolate(redirect, $match); }]; + } + return extend(function ($injector, $location) { + return handleIfMatch($injector, handler, what.exec($location.path())); + }, { + prefix: regExpPrefix(what) + }); } - else if (!isFunction(handler) && !isArray(handler)) - throw new Error("invalid 'handler' in when()"); + }; - if (what.global || what.sticky) - throw new Error("when() RegExp must not be global or sticky"); + var check = { matcher: $urlMatcherFactory.isMatcher(what), regex: what instanceof RegExp }; - rule = function ($injector, $location) { - return handleIfMatch($injector, handler, what.exec($location.path())); - }; - rule.prefix = regExpPrefix(what); + for (var n in check) { + if (check[n]) { + return this.rule(strategies[n](what, handler)); + } } - else - throw new Error("invalid 'what' in when()"); - return this.rule(rule); + throw new Error("invalid 'what' in when()"); }; this.$get = diff --git a/test/urlRouterSpec.js b/test/urlRouterSpec.js index 957da374a..17d86ddd2 100644 --- a/test/urlRouterSpec.js +++ b/test/urlRouterSpec.js @@ -61,6 +61,25 @@ describe("UrlRouter", function () { 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 From 7122aad6e3bda084d42d14c93de3e935c7b26904 Mon Sep 17 00:00:00 2001 From: Nate Abele Date: Mon, 12 Aug 2013 08:07:41 -0400 Subject: [PATCH 26/27] Final implementation of relative state targeting. --- src/state.js | 25 +++++++++++++++++-------- test/stateSpec.js | 34 +++++++++++++++++++++++++++++----- 2 files changed, 46 insertions(+), 13 deletions(-) diff --git a/src/state.js b/src/state.js index 0f9868d93..540e0928c 100644 --- a/src/state.js +++ b/src/state.js @@ -108,18 +108,27 @@ function $StateProvider( $urlRouterProvider, $urlMatcherFactory, $ function findState(stateOrName, base) { var isStr = isString(stateOrName), - name = isStr ? stateOrName : stateOrName.name, - path = isStr ? name.match(/^((?:(?:\^)(?:\.)?){1,})(.+)/) : null; + name = isStr ? stateOrName : stateOrName.name, + path = name.indexOf(".") === 0 || name.indexOf("^") === 0; - if (path && path.length) { - if (!base) throw new Error("No reference point given for path '" + stateOrName + "'"); - var rel = path[1].split("."), i = 0, pathLength = rel.length - 1, current = base; + if (path) { + if (!base) throw new Error("No reference point given for path '" + name + "'"); + var rel = name.split("."), i = 0, pathLength = rel.length, current = base; for (; i < pathLength; i++) { - if (rel[i] === "^") current = current.parent; - if (!current) throw new Error("Path '" + name + "' not valid for state '" + base.name + "'"); + if (rel[i] === "" && i === 0) { + current = base; + continue; + } + if (rel[i] === "^") { + if (!current.parent) throw new Error("Path '" + name + "' not valid for state '" + base.name + "'"); + current = current.parent; + continue; + } + break; } - name = current.name + "." + path[2]; + rel = rel.slice(i).join("."); + name = current.name + (current.name && rel ? "." : "") + rel; } var state = states[name]; diff --git a/test/stateSpec.js b/test/stateSpec.js index 671c9edb6..1354df481 100644 --- a/test/stateSpec.js +++ b/test/stateSpec.js @@ -212,10 +212,34 @@ 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(); + $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'); })); @@ -223,7 +247,7 @@ describe('state', function () { $state.transitionTo('about.person', { person: 'bob' }); $q.flush(); - $state.go('^item', { id: 5 }); + $state.go('.item', { id: 5 }); $q.flush(); expect($state.$current.name).toBe('about.person.item'); From a828bfd04095c9cd216dc4e4bac049684a0493ea Mon Sep 17 00:00:00 2001 From: Dean Sofer Date: Tue, 27 Aug 2013 19:45:40 -0700 Subject: [PATCH 27/27] Convert module names to new ui.router nested convention --- README.md | 4 ++-- sample/index.html | 2 +- src/common.js | 9 +++++---- src/compat.js | 2 +- src/resolve.js | 2 +- src/state.js | 2 +- src/stateDirectives.js | 2 +- src/templateFactory.js | 2 +- src/urlMatcherFactory.js | 2 +- src/urlRouter.js | 2 +- src/view.js | 2 +- src/viewDirective.js | 2 +- test/resolveSpec.js | 2 +- test/stateDirectivesSpec.js | 2 +- test/stateSpec.js | 2 +- test/templateFactorySpec.js | 2 +- test/testUtils.js | 2 +- test/urlMatcherFactorySpec.js | 2 +- test/urlRouterSpec.js | 2 +- test/viewDirectiveSpec.js | 2 +- 20 files changed, 25 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index ea5816313..1be40156e 100644 --- a/README.md +++ b/README.md @@ -65,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/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 e155cf157..acf539ddc 100644 --- a/src/common.js +++ b/src/common.js @@ -69,7 +69,8 @@ function inheritParams(currentParams, newParams, $current, $to) { return extend({}, inherited, newParams); } -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']); +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 index 5982035dd..ea151b722 100644 --- a/src/resolve.js +++ b/src/resolve.js @@ -211,5 +211,5 @@ function $Resolve( $q, $injector) { }; } -angular.module('ui.util').service('$resolve', $Resolve); +angular.module('ui.router.util').service('$resolve', $Resolve); diff --git a/src/state.js b/src/state.js index 540e0928c..3df7c0adf 100644 --- a/src/state.js +++ b/src/state.js @@ -410,6 +410,6 @@ function $StateProvider( $urlRouterProvider, $urlMatcherFactory, $ } } -angular.module('ui.state') +angular.module('ui.router.state') .value('$stateParams', {}) .provider('$state', $StateProvider); diff --git a/src/stateDirectives.js b/src/stateDirectives.js index 2a2d4b693..c429224de 100644 --- a/src/stateDirectives.js +++ b/src/stateDirectives.js @@ -48,4 +48,4 @@ function $StateRefDirective($state) { }; } -angular.module('ui.state').directive('uiSref', $StateRefDirective); +angular.module('ui.router.state').directive('uiSref', $StateRefDirective); diff --git a/src/templateFactory.js b/src/templateFactory.js index 721888b50..1ca1c6a91 100644 --- a/src/templateFactory.js +++ b/src/templateFactory.js @@ -84,4 +84,4 @@ function $TemplateFactory( $http, $templateCache, $injector) { }; } -angular.module('ui.util').service('$templateFactory', $TemplateFactory); +angular.module('ui.router.util').service('$templateFactory', $TemplateFactory); diff --git a/src/urlMatcherFactory.js b/src/urlMatcherFactory.js index 4ca50bd32..8ecaa4891 100644 --- a/src/urlMatcherFactory.js +++ b/src/urlMatcherFactory.js @@ -253,4 +253,4 @@ function $UrlMatcherFactory() { } // Register as a provider so it's available to other providers -angular.module('ui.util').provider('$urlMatcherFactory', $UrlMatcherFactory); +angular.module('ui.router.util').provider('$urlMatcherFactory', $UrlMatcherFactory); diff --git a/src/urlRouter.js b/src/urlRouter.js index d4bc8e02f..46dd1deaf 100644 --- a/src/urlRouter.js +++ b/src/urlRouter.js @@ -114,4 +114,4 @@ function $UrlRouterProvider( $urlMatcherFactory) { }]; } -angular.module('ui.router').provider('$urlRouter', $UrlRouterProvider); +angular.module('ui.router.router').provider('$urlRouter', $UrlRouterProvider); diff --git a/src/view.js b/src/view.js index 36d361427..c8d98a495 100644 --- a/src/view.js +++ b/src/view.js @@ -25,4 +25,4 @@ function $ViewProvider() { } } -angular.module('ui.state').provider('$view', $ViewProvider); +angular.module('ui.router.state').provider('$view', $ViewProvider); diff --git a/src/viewDirective.js b/src/viewDirective.js index f0235a94a..3df630d97 100644 --- a/src/viewDirective.js +++ b/src/viewDirective.js @@ -114,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 index d0a4de5b5..d5c7d745d 100644 --- a/test/resolveSpec.js +++ b/test/resolveSpec.js @@ -2,7 +2,7 @@ describe("resolve", function () { var $r, tick; - beforeEach(module('ui.util')); + beforeEach(module('ui.router.util')); beforeEach(inject(function($resolve, $q) { $r = $resolve; tick = $q.flush; diff --git a/test/stateDirectivesSpec.js b/test/stateDirectivesSpec.js index 71b25e576..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', { diff --git a/test/stateSpec.js b/test/stateSpec.js index 1354df481..31a41032d 100644 --- a/test/stateSpec.js +++ b/test/stateSpec.js @@ -2,7 +2,7 @@ describe('state', function () { var locationProvider; - beforeEach(module('ui.state', function($locationProvider) { + beforeEach(module('ui.router.state', function($locationProvider) { locationProvider = $locationProvider; $locationProvider.html5Mode(false); })); 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 69362bc00..c515651aa 100644 --- a/test/testUtils.js +++ b/test/testUtils.js @@ -91,5 +91,5 @@ function caught(fn) { // 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 a88b713c8..bf8202c6b 100644 --- a/test/urlMatcherFactorySpec.js +++ b/test/urlMatcherFactorySpec.js @@ -99,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 17d86ddd2..0e5f8e754 100644 --- a/test/urlRouterSpec.js +++ b/test/urlRouterSpec.js @@ -17,7 +17,7 @@ describe("UrlRouter", function () { }); }); - module('ui.router', 'ui.router.test'); + module('ui.router.router', 'ui.router.test'); inject(function($rootScope, $location, $injector) { scope = $rootScope.$new(); 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'