I'm building a multistep form in the style of scotch.io. I used resolve to restrict access to next steps until the current step's object exists on the service model (side point – is there a DRYer way to do this?).
The States
Here's are my top-level routes:
angular
.module('donate')
.config(routerConfig);
/** @ngInject */
function routerConfig($stateProvider, $urlRouterProvider) {
$stateProvider
.state('root', {
url: '',
abstract: true,
views: {
'donate': {
controller: 'DonateController as donate',
templateUrl: 'app/components/donate/donate.html'
},
'stories': {
controller: 'StoriesController as stories',
templateUrl: 'app/components/stories/views/stories.html'
}
}
});
$urlRouterProvider.otherwise('/');
}
And these are the relevant child routes.
angular
.module('donate')
.config(function($stateProvider) {
$stateProvider
.state('root.amount', {
url: '/',
controller: 'AmountController as amount',
templateUrl: 'app/components/donate/views/amount.html'
})
.state('root.card', {
url: '/card',
resolve: {
amount: function($q,DonateService){
if(!DonateService.amount){
var errorObject = {code:'NO_AMOUNT'};
return $q.reject(errorObject);
}
}
},
controller: 'CardController as card',
templateUrl: 'app/components/donate/views/card.html'
})
.state('root.billing', {
url: '/billing-info',
resolve: {
card: function($q,DonateService){
if(!DonateService.card){
var errorObject = {code:'NO_CARD'};
return $q.reject(errorObject);
}
}
},
controller: 'BillingController as billing',
templateUrl: 'app/components/donate/views/billing.html'
})
.state('root.confirm', {
url:'/confirm',
resolve: {
billing: function($q,DonateService){
if(!DonateService.email){
var errorObject = {code:'NO_BILLING_INFO'};
return $q.reject(errorObject);
}
}
},
controller: 'ConfirmController as confirm',
templateUrl: 'app/components/donate/views/confirm.html'
})
.state('root.success', {
url:'/success',
controller: 'SuccessController as success',
templateUrl: 'app/components/donate/views/success.html'
})
.state('root.error', {
url:'/error',
controller: 'ErrorController as error',
templateUrl: 'app/components/donate/views/error.html'
});
});
A little bit of error reporting
This switch on the parent controller prevents users from jumping to the next step by clicking on the step nav when the the children's reject the promise set up above.
angular
.module('donate')
.controller('DonateController', DonateController);
DonateController.$inject = ['DonateService', '$rootScope', '$state', '$log'];
/* @ngInject */
function DonateController(DonateService, $rootScope, $state, $log) {
var vm = this;
vm.DonateService = DonateService;
$rootScope.$on('$stateChangeError', stateError);
function stateError (e, toState, toParams, fromState, fromParams, error) {
switch (error.code) {
case 'NO_AMOUNT':
$log.debug('State Error:', error.code);
$state.go('root.amount');
break;
case 'NO_CARD':
$log.debug('State Error:', error.code);
$state.go('root.card');
break;
case 'NO_BILLING_INFO':
$log.debug('State Error:', error.code);
$state.go('root.billing');
break;
default:
$log.debug('State Error:', error.code);
$state.get('error').error = error;
$state.go('root.amount');
}
}
}
The Question
This works swimmingly. When a user tries to click on an inaccessible step's nav pill, the console dutifully belches out an error message or three. But one problem remains: Users can browse (via the URL bar) to /card, /billing-info and /confirm and they'll be greeted with a blank view, with no console output to speak of.
How do I hook it so that users who browse to any route they aren't supposed to be on are bounced back to the beginning (or, even better, the last completed state)?
Moving
$rootScope.$on()
to therunBlock
solved the problem.