UI-Router doesn't get previous state on $stateChangeSuccess - angularjs

I need to prevent users from going to certain states (dashboard and account) on the app if they have not completed all required enrollment steps. If a user tries to access those states by direct URL input, I will redirect them back to where they are. I am doing this simple check on my run block to redirect the user conditionally:
$rootScope.$on("$stateChangeSuccess", (event, toState, toParams, fromState, fromParams) => {
let shouldPreventNavigationToAccountPages = fromState.name.includes('enroll') && toState.parent === 'layout';
if (shouldPreventNavigationToAccountPages) {
redirectUser();
}
}
So if a user comes from a "enroll" state (say "enroll.step-one") and tries to access a "layout" state they are not allowed to, they should be redirected. However, when I input the URL directly (no click on link), "fromState"returns the following object:
{name: "", url: "^", views: null, abstract: true}
I don't have access to the previous state and cannot perform the check on fromState.name.includes('enroll'). Is this a default behavior in UI-Router? Is there a way to get the previous state if user tries to access a page by inputing the URL directly on browser?

You are very close. All you were missing is:
$rootScope.$on("$stateChangeSuccess", (event, toState, toParams, fromState, fromParams) => {
let shouldPreventNavigationToAccountPages = fromState.name.includes('enroll') && toState.parent === 'layout';
if (shouldPreventNavigationToAccountPages) {
//This
event.preventDefault();
redirectUser();
}
}

I need to prevent users from going to certain states (dashboard and account) on the app if they have not completed all required enrollment steps.
Prevent users from going to a state by rejecting a resolver.
myApp.config(function($stateProvider, $urlRouterProvider) {
$stateProvider
.state('dashboard', {
url: "/dashboard",
resolve: {
enrolled: function(enrollmentStatus) {
if (enrollmentStatus.enrolled) {
return "enrolled";
} else {
//throw to reject resolve
throw "not enrolled";
};
}
},
templateUrl: "partials/dashboard.html"
})
By tracking enrollment status with a service, state changes can be rejected without regard to the previous state.
UPDATE
The $stateChangeError event can be used to handle the rejection.
$rootScope.$on("$stateChangeError", function(event, toState, toParams, fromState, fromParams, error) {
if (error && fromState.name=='') {
$state.go("enroll");
}
});
For more information, see UI Router $state API Reference -- $stateChangeError

Related

Using $state.go inside $stateChangeStart causes Infinite loop

I am using AngularJS ui-router. I am trying to implement protecting routes for unauthenticated user. I am checking if user is logged in on $stateChangeStart. If the user is not logged in then redirect to login state.
But when i am using $state.go("login") in stateChangeStart handler, the handler code goes in infinite loop and getting console error "RangeError: Maximum call stack size exceeded"
Below is my code:
$rootScope.$on('$stateChangeStart',
function(event, toState, toParams, fromState, fromParams) {
var allowedStates = ["signup","confirmaccount","resetPassword"];
if(!$window.localStorage.getItem('userInfo') && !(allowedStates.includes($state.current.name)))
{
$state.go("login");
}
}
);
And below is the screenshot of console error.
Prevent the default behavior and check for allowed state without using $state.current.name since toState is already a parameter to $stateChangeStart
Update
I think you need here a No State Change logic rather than redirecting to login always.
$rootScope.$on('$stateChangeStart',
function(event, toState, toParams, fromState, fromParams) {
var noChangeStates = ["login", "signup", "confirmaccount", "resetPassword"];
var noStateChange = noChangeStates.indexOf(toState.name) > -1;
if (noStateChange) {
return;
}
//Check for Allowed or Not Allowed logic here then redirect to Login
if (!$window.localStorage.getItem('userInfo')) {
event.preventDefault();
$state.go("login")
}
}
);
Please note, you should also add "login" to No state change
But when i am using $state.go("login") in stateChangeStart handler, the handler code goes in infinite loop and getting console error "RangeError: Maximum call stack size exceeded"
Looks like you always call $state.go("login");
You can check toState and fromState to avoid calling additional time $state.go("login");
Something like:
if(!$window.localStorage.getItem('userInfo')
&& !(allowedStates.includes($state.current.name))
&& fromState.name !== 'login'
){
event.preventDefault();
$state.go("login");
}
Using a stateChange event is not the best way to handle that. Actually, what it does:
You change state
Then you check for the authentication.
It would be better to check before changing the state. For this, you can use ui-router's resolve:
$stateProvider
.state('login', { // Login page: do not need an authentication
url: '/login',
templateUrl: 'login.html',
controller: 'loginCtrl',
})
.state('home', { // Authenticated users only (see resolve)
url: '/home',
templateUrl: 'home.html',
controller: 'homeCtrl',
resolve: { authenticate: authenticate }
});
function authenticate($q, user, $state, $timeout) {
if (user.isAuthenticated()) {
// Resolve the promise successfully
return $q.when()
} else {
// The next bit of code is asynchronously tricky.
$timeout(function() {
// This code runs after the authentication promise has been rejected.
// Go to the log-in page
$state.go('logInPage')
})
// Reject the authentication promise to prevent the state from loading
return $q.reject()
}
}
See also this answer.

Change the URL breaks the routing prevent

I have a requirement to prevent routing if it is first time login user, they have to stay in the setting page to reset password before do something else (or go to other pages), the code is 99% working now, I can change the url/ refresh page, it works fine but only one issue. Here is code:
.state('accountsetting.security', {
url: '/security/{isFirstTimeUser}',
templateUrl: 'settings/security/security.html',
params: {'isFirstTimeUser': null}
}) // this is where I define the route
// in the run block
.run(['$state','$rootScope',function($state,$rootScope) {
// var isFirstTimeUser = false;
// userinforservice.getUserInformation().then(function(data){
// isFirstTimeUser = data.isFirstTimeUser;
// });
$rootScope.$on('$stateChangeStart', function(event, toState, toParams, fromState, fromParams) {
if(fromState.name!=="undefined" && toState.name=='firsttimeuser'){
$rootScope.isFirstTimeUser=true;
$state.go('accountsetting.security',{isFirstTimeUser:true});
event.preventDefault();
}else if((toParams.isFirstTimeUser || fromParams.isFirstTimeUser) && toState.name !='accountsetting.security'){
$state.go('accountsetting.security',{isFirstTimeUser:true});
event.preventDefault();
}
else{
return;
}
});
}]);
The url is like: https://localhost/app/#/account/security/true
As I mentioned, I can refresh the page or change the url like:https://localhost/app/#/account or https://localhost/app/#
they all work fine, but when I change the url like this:
https://localhost/app/ it will take me to the home page. I check console, in the statechangestart, I lost the isFirstTimeUser, it is undefind. any idea about it?
Thanks in advance.
You lose angular state when you go to the url of the root rather than the state url (i.e) #(hash) urls. The root url reloads the page wherein you lose memory of all javascript variables as they are all client side. Hence the variable is undefined.
State changes happen in a single instance of page load, the url changes give you a illusion as if a page load is happening
The issue causing this behaviour is described by Shintus answer.
A possible solution would be to make sure the event order is correctly resolved. I assume $stateChangeStart is fired before userinforservice.getUserInformation() is resolved. Instead of calling them in parallel you could query the returned promise inside your $stateChangeStart instead of the variable assigned at any undefined time.
.run(['$state','$rootScope',function($state,$rootScope) {
var storedUserPromise;
storedUserPromise = userinfoservice.getUserInformation();
$rootScope.$on('$stateChangeStart', function(event, toState, toParams, fromState, fromParams) {
storedUserPromise.then(function(data) {
if(data.isFirstTimeUser) {
//do redirection logic here
}
})
});
}]);
Storing the user promise allows you to only have the overhead of calling userinfoservice.getUserInformation() once. Afterwards any .then on the stored promise resolves instantly.
PS: you probably have a typo in userinfo>r<service ;)
You can intercept any route loading in your route definition with $routeProvider.when.resolve, check their status in the resolve block and redirect them or anything else you want to do.
Tutorial Example showing the below code snippet:
$routeProvider
.when("/news", {
templateUrl: "newsView.html",
controller: "newsController",
resolve: {
message: function(messageService){
return messageService.getMessage();
}
}
})

How would I have ui-router go to an external link, such as google.com?

For example:
$stateProvider
.state('external', {
url: 'http://www.google.com',
})
url assumes that this is an internal state. I want it to be like href or something to that effect.
I have a navigation structure that will build from the ui-routes and I have a need for a link to go to an external link. Not necessarily just google, that's only an example.
Not looking for it in a link or as $state.href('http://www.google.com'). Need it declaratively in the routes config.
Angular-ui-router doesn't support external URL, you need redirect the user using either $location.url() or $window.open()
I would suggest you to use $window.open('http://www.google.com', '_self') which will open URL on the same page.
Update
You can also customize ui-router by adding parameter external, it can be true/false.
$stateProvider
.state('external', {
url: 'http://www.google.com',
external: true
})
Then configure $stateChangeStart in your state & handle redirection part there.
Run Block
myapp.run(function($rootScope, $window) {
$rootScope.$on('$stateChangeStart',
function(event, toState, toParams, fromState, fromParams) {
if (toState.external) {
event.preventDefault();
$window.open(toState.url, '_self');
}
});
})
Sample Plunkr
Note: Open Plunkr in a new window in order to make it working, because google doesn't get open in iFrame due to some security reason.
You could use the onEnter callback:
$stateProvider
.state('external', {
onEnter: function($window) {
$window.open('http://www.google.com', '_self');
}
});
Edit
Building on pankajparkar's answer, as I said I think you should avoid overriding an existing param name. ui-router put a great deal of effort to distinguish between states and url, so using both url and externalUrl could make sense...
So, I would implement an externalUrl param like so:
myApp.run(function($rootScope, $window) {
$rootScope.$on(
'$stateChangeStart',
function(event, toState, toParams, fromState, fromParams) {
if (toState.externalUrl) {
event.preventDefault();
$window.open(toState.externalUrl, '_self');
}
}
);
});
And use it like so, with or without internal url:
$stateProvider.state('external', {
// option url for sref
// url: '/to-google',
externalUrl: 'http://www.google.com'
});
As mentioned in angular.js link behaviour - disable deep linking for specific URLs
you need just to use
link to external
this will disable angularJS routing on a specific desired link.
I transformed the accepted answer into one that assumes the latest version of AngularJS (currently 1.6), ui-router 1.x, Webpack 2 with Babel transpilation and the ng-annotate plugin for Babel.
.run(($transitions, $window) => {
'ngInject'
$transitions.onStart({
to: (state) => state.external === true && state.url
}, ($transition) => {
const to = $transition.to()
$window.open(to.url, to.target || '_self')
return false
})
})
And here's how the state may be configured:
.config(($stateProvider) => {
'ngInject'
$stateProvider
.state({
name: 'there',
url:'https://google.com',
external: true,
target: '_blank'
})
})
Usage:
<a ui-sref="there">To Google</a>
The original answer is deprecated and disabled by default in UI Router, you may wish to explore implementing it using transition hooks
.state("mystate", {
externalUrl: 'https://google.com'
})
then:
myApp.run(['$transitions', '$window', ($transitions, $window) => {
$transitions.onEnter({}, function (transition) {
const toState = transition.to();
if (toState.externalUrl) {
$window.open(toState.externalUrl, '_self');
return false;
}
return true;
});
}]
);

UI- Router -- run function on every route change -- where does the state name live?

Using Angularjs and UI-Router, trying to run a function every time state changes
$rootScope.$on('$stateChangeStart',
function(toState){
if(toState !== 'login')
UsersService.redirect();
})
I put this in .run() and i can successfully log out toState every time route changes. However, i can't seem to find the property which has the name of the state that we are going to. If someone can tell me where to find that, i think i should be in good shape.
Ended up with this and it does what i want.
$rootScope.$on('$stateChangeStart',
function(event, toState, toParams, fromState, fromParams){
if(toState.name !== 'login' && !UsersService.getCurrentUser()) {
event.preventDefault();
$state.go('login');
}
});

Should the resolve map redirect to the 'otherwise' location during the first ui-router request?

I have found the resolve map incredibly useful. If I request a state change within a page, if any resolves fail the state will not conclude/succeed as expected.
I do not seem to be performing anything within my app that would affect this behaviour, but I noticed that if I attempt to load a state directly in my app (through URL, eg. app.com/my/state) and the resolves fail, there is no state to fallback to, seeings this is the first route requested.
I would have assumed it would fallback to my 'otherwise' location, which is defined as '/'. Is this expected functionality? If not, is there an efficient way to handle this?
My current solution involves catching state errors and setting the location if there isn't a fromState (meaning the error occurred on the first state)
EbApp.run([
'$rootScope', '$location', '$timeout',
function($rootScope, $location, $timeout)
{
var first_state_error = $rootScope.$on('$stateChangeError', function(event, toState, toParams, fromState)
{
// Redirect to the app root on a state error during the first state request
if ( ! fromState.name) {
$timeout(function()
{
$rootScope.$apply(function()
{
$location.path('/').search({});
});
});
}
// Remove watching state change errors once there is a fromState available
else {
first_state_error();
}
});
}
]);
If your state resolve function fails you don't go to "otherwise" route because the stateManager has found the route to go.
What you have to test is the toState name (not fromState), if you want to have different routes for errors, something like this:
$rootScope.$on('$stateChangeError', function (event, toState, toParams, fromState, fromParams, error) {
if (toState.name === 'yourRouteName') {
return $state.go('whereverRoute');
}
return error;
});
or simply
$rootScope.$on('$stateChangeError', function (event, toState, toParams, fromState, fromParams, error) {
$location.path('/')
});
if you want always redirect to your root

Resources