diff --git a/src/state.js b/src/state.js index 330a6c470..beb9f8f05 100644 --- a/src/state.js +++ b/src/state.js @@ -169,6 +169,7 @@ function $StateProvider( $urlRouterProvider, $urlMatcherFactory, $ } }]); } + return state; } @@ -220,6 +221,8 @@ function $StateProvider( $urlRouterProvider, $urlMatcherFactory, $ var TransitionSuperseded = $q.reject(new Error('transition superseded')); var TransitionPrevented = $q.reject(new Error('transition prevented')); + var TransitionAborted = $q.reject(new Error('transition aborted')); + var TransitionFailed = $q.reject(new Error('transition failed')); root.locals = { resolve: null, globals: { $stateParams: {} } }; $state = { @@ -236,20 +239,51 @@ function $StateProvider( $urlRouterProvider, $urlMatcherFactory, $ $state.transitionTo = function transitionTo(to, toParams, options) { if (!isDefined(options)) options = (options === true || options === false) ? { location: options } : {}; toParams = toParams || {}; - options = extend({ location: true, inherit: false, relative: null }, options); + options = extend({ location: true, inherit: false, relative: null, $retry: false }, options); + + var from = $state.$current, fromParams = $state.params, fromPath = from.path; var toState = findState(to, options.relative); + var evt; + if (!isDefined(toState)) { - if (options.relative) throw new Error("Could not resolve '" + to + "' from state '" + options.relative + "'"); - throw new Error("No such state '" + to + "'"); + // Broadcast not found event and abort the transition if prevented + var redirect = { to: to, toParams: toParams, options: options }; + evt = $rootScope.$broadcast('$stateNotFound', redirect, from.self, fromParams); + if (evt.defaultPrevented) return TransitionAborted; + + // Allow the handler to return a promise to defer state lookup retry + if (evt.retry) { + if (options.$retry) return TransitionFailed; + var retryTransition = $state.transition = $q.when(evt.retry); + retryTransition.then(function() { + if (retryTransition !== $state.transition) return TransitionSuperseded; + redirect.options.$retry = true; + return $state.transitionTo(redirect.to, redirect.toParams, redirect.options); + }, + function() { + return TransitionAborted; + }); + return retryTransition; + } + + // Always retry once if the $stateNotFound was not prevented + // (handles either redirect changed or state lazy-definition) + to = redirect.to; + toParams = redirect.toParams; + options = redirect.options; + toState = findState(to, options.relative); + if (!isDefined(toState)) { + if (options.relative) throw new Error("Could not resolve '" + to + "' from state '" + options.relative + "'"); + throw new Error("No such state '" + to + "'"); + } } if (toState['abstract']) throw new Error("Cannot transition to abstract state '" + to + "'"); if (options.inherit) toParams = inheritParams($stateParams, toParams || {}, $state.$current, toState); to = toState; - var toPath = to.path, - from = $state.$current, fromParams = $state.params, fromPath = from.path; + var toPath = to.path; // Starting from the root of the path, keep all levels that haven't changed var keep, state, locals = root.locals, toLocals = []; @@ -272,7 +306,7 @@ function $StateProvider( $urlRouterProvider, $urlMatcherFactory, $ toParams = normalize(to.params, toParams || {}); // Broadcast start event and cancel the transition if requested - var evt = $rootScope.$broadcast('$stateChangeStart', to.self, toParams, from.self, fromParams); + 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 @@ -288,7 +322,7 @@ function $StateProvider( $urlRouterProvider, $urlMatcherFactory, $ resolved = resolveState(state, toParams, state===to, resolved, locals); } - // Once everything is resolved, wer are ready to perform the actual transition + // Once everything is resolved, we are ready to perform the actual transition // and return a promise for the new state. We also keep track of what the // current promise is, so that we can detect overlapping transitions and // keep only the outcome of the last transition. diff --git a/test/stateSpec.js b/test/stateSpec.js index 2fdf19f6f..605a260f9 100644 --- a/test/stateSpec.js +++ b/test/stateSpec.js @@ -72,6 +72,7 @@ describe('state', function () { $rootScope.$on('$stateChangeStart', eventLogger); $rootScope.$on('$stateChangeSuccess', eventLogger); $rootScope.$on('$stateChangeError', eventLogger); + $rootScope.$on('$stateNotFound', eventLogger); })); @@ -139,6 +140,109 @@ describe('state', function () { expect(resolvedError(promise)).toBeTruthy(); })); + it('triggers $stateNotFound', inject(function ($state, $q, $rootScope) { + initStateTo(E, { i: 'iii' }); + var called; + $rootScope.$on('$stateNotFound', function (ev, redirect, from, fromParams) { + expect(from).toBe(E); + expect(fromParams).toEqual({ i: 'iii' }); + expect(redirect.to).toEqual('never_defined'); + expect(redirect.toParams).toEqual({ x: '1', y: '2' }); + + expect($state.current).toBe(from); // $state not updated yet + expect($state.params).toEqual(fromParams); + called = true; + }); + var message; + try { + $state.transitionTo('never_defined', { x: '1', y: '2' }); + } catch(err) { + message = err.message; + } + $q.flush(); + expect(message).toEqual('No such state \'never_defined\''); + expect(called).toBeTruthy(); + expect($state.current).toBe(E); + })); + + it('can be cancelled by preventDefault() in $stateNotFound', inject(function ($state, $q, $rootScope) { + initStateTo(A); + var called; + $rootScope.$on('$stateNotFound', function (ev) { + ev.preventDefault(); + called = true; + }); + var promise = $state.transitionTo('never_defined', {}); + $q.flush(); + expect(called).toBeTruthy(); + expect($state.current).toBe(A); + expect(resolvedError(promise)).toBeTruthy(); + })); + + it('can be redirected in $stateNotFound', inject(function ($state, $q, $rootScope) { + initStateTo(A); + var called; + $rootScope.$on('$stateNotFound', function (ev, redirect) { + redirect.to = D; + redirect.toParams = { x: '1', y: '2' }; + called = true; + }); + var promise = $state.transitionTo('never_defined', { z: 3 }); + $q.flush(); + expect(called).toBeTruthy(); + expect($state.current).toBe(D); + expect($state.params).toEqual({ x: '1', y: '2' }); + })); + + it('can lazy-define a state in $stateNotFound', inject(function ($state, $q, $rootScope) { + initStateTo(DD, { x: 1, y: 2, z: 3 }); + var called; + $rootScope.$on('$stateNotFound', function (ev, redirect) { + stateProvider.state(redirect.to, { parent: DD, params: [ 'x', 'y', 'z', 'w' ]}); + called = true; + }); + var promise = $state.go('DDD', { w: 4 }); + $q.flush(); + expect(called).toBeTruthy(); + expect($state.current.name).toEqual('DDD'); + expect($state.params).toEqual({ x: '1', y: '2', z: '3', w: '4' }); + })); + + it('can defer a state transition in $stateNotFound', inject(function ($state, $q, $rootScope) { + initStateTo(A); + var called; + var deferred = $q.defer(); + $rootScope.$on('$stateNotFound', function (ev, redirect) { + ev.retry = deferred.promise; + called = true; + }); + var promise = $state.go('AA', { a: 1 }); + stateProvider.state('AA', { parent: A, params: [ 'a' ]}); + deferred.resolve(); + $q.flush(); + expect(called).toBeTruthy(); + expect($state.current.name).toEqual('AA'); + expect($state.params).toEqual({ a: '1' }); + })); + + it('can defer and supersede a state transition in $stateNotFound', inject(function ($state, $q, $rootScope) { + initStateTo(A); + var called; + var deferred = $q.defer(); + $rootScope.$on('$stateNotFound', function (ev, redirect) { + ev.retry = deferred.promise; + called = true; + }); + var promise = $state.go('AA', { a: 1 }); + $state.go(B); + stateProvider.state('AA', { parent: A, params: [ 'a' ]}); + deferred.resolve(); + $q.flush(); + expect(called).toBeTruthy(); + expect($state.current).toEqual(B); + expect($state.params).toEqual({}); + })); + it('triggers $stateChangeSuccess', inject(function ($state, $q, $rootScope) { initStateTo(E, { i: 'iii' }); var called;