Asking The User To Confirm Location Or Route Changes In AngularJS
The other day, Ward Bell and I were discussing both the future and existing routing features in AngularJS. In that conversation, we talked about what was and wasn't an appropriate responsibility for a routing module. In my opinion, the router itself shouldn't know when it's safe to navigate away from the current location; to me, that logic belongs in the Controller(s) that both understand and manage the current view-model. The controllers can hook into the $location (or $route) events and then examine the view-model in order to determine if the user should be prompted for location-change approval.
Run this demo in my JavaScript Demos project on GitHub.
In this blog post, I'm using the $location service. But, the same functionality could be achieved using the $route service as well. It's important to understand that the $route service is nothing more than a thin layer built on top of the core $location service. As such, it provides no extra value in this particular context.
The key to prompting a user for a location-change confirmation is in understanding the $location lifecycle. There are two events triggered by the $location service:
- $locationChangeStart - fires before the location view-model and browser URL are synchronized.
- $locationChangeSuccess - fires after the location view-model and the browser URL are synchronized.
The $locationChangeStart event gives our application a hook into pre-synchronization validation, which is exactly what we want. In the $locationChangeStart event handler, we can call event.preventDefault(), which will cancel the location change entirely. Our controller can then prompt the user for change-confirmation; and, if the user agrees, we can re-initiate the location-change event.
In the following demo, I have a few links that will change the URL. Each time the user goes to change the URL, we're going to ask them if they really want to allow the navigation.
Because I am doing this in the root Controller, there's an edge-case that we have to work-around. Since the AppController and the $location service are being initialized in the same cycle, we run into an edge-case in which our AppController will pick up the $locationChangeStart event triggered during the $location service initialization. To get around this, we are putting our event-binding inside of a $timeout(). While this is a valid edge-case, it's less likely to be one that you encounter in your real-world application (since most Controllers are instantiated as a result of the location change, not before the location change).
<!doctype html>
<html ng-app="Demo">
<head>
<meta charset="utf-8" />
<title>
Asking The User To Confirm Location Or Route Changes In AngularJS
</title>
</head>
<body ng-controller="AppController">
<h1>
Asking The User To Confirm Location Or Route Changes In AngularJS
</h1>
<ul>
<li>
<a href="#/foo/bar">#/foo/bar</a>
</li>
<li>
<a href="#/hello/world">#/hello/world</a>
</li>
<li>
<a href="#/meep/moop">#/meep/moop</a>
</li><li>
<a href="#/thats/so?kablamo">#/thats/so?kablamo</a>
</li>
</ul>
<p>
<strong>Current Location</strong>: {{ currentLocation }}
</p>
<!-- Load scripts. -->
<script type="text/javascript" src="../../vendor/angularjs/angular-1.3.15.min.js"></script>
<script type="text/javascript">
// Create an application module for our demo.
var app = angular.module( "Demo", [] );
// -------------------------------------------------- //
// -------------------------------------------------- //
// I control the root of the application.
app.controller(
"AppController",
function( $scope, $location, $timeout, confirminator ) {
// I hold the current location, once it has changed successfully.
$scope.currentLocation = $location.url();
// When the location is changed, update the view-model to make the demo
// UI a bit more intuitive.
$scope.$on(
"$locationChangeSuccess",
function handleLocationChangeSuccessEvent( event ) {
$scope.currentLocation = $location.url();
}
);
// In order to confirm navigation, we have to start watching for location
// changes. We need to defer the start of the watching, however, so that
// it doesn't pick up the initial location change event trigger on
// application start.
// --
// NOTE: In this demo, I'm using $locationChangeStart. However, this
// could also be done with $routeChangeStart. Routing is just a very thin
// layer built on top of the location service. There's very little
// difference between the two events.
// --
// CAUTION: We need to use $timeout() here instead of $applyAsync() since
// the controller body runs in the $apply phase, which means that the
// $digest phase hasn't started. As such, $applyAsync() would execute too
// early (before all the $watch-bindings and the $location initialization).
// This is an edge-case due to the fact that this Controller is part of the
// core HTML rendering.
var startWatchingTimer = $timeout( startWatchingForLocationChanges, 0, false );
// I hold the deregistration method for the location-watcher. We need to
// store the deregistrtion method so that we can explicitly stop watching
// for changes if the user does want to leave the current location.
var stopWatchingLocation = null;
// ---
// PRIVATE METHODS.
// ---
// I handle the $locationChangeStart event and ask the user if they
// really want to leave the current location. In a real-world application,
// this would often mean that the user is about to navigate away from the
// current user-interface (UI) (and possibly away from unsaved form-data).
function handleLocationChangeStartEvent( event ) {
// Prevent the location from actually changing.
event.preventDefault();
// Keep track of which location the user was about to move to.
var targetPath = $location.path();
var targetSearch = $location.search();
var targetHash = $location.hash();
// Trigger a confirmation modal to see if the user really wanted to
// leave the current page; this returns a promise.
confirminator
.open( "Are you sure you want to go to:\n\n" + targetPath )
.then(
function handleResolve() {
// Since the user has confirmed that they want to leave
// the current location, let's reconfigure the location
// to mirror the location they wanted to go to.
$location
.path( targetPath )
.search( targetSearch )
.hash( targetHash )
;
// The above location-change will eventually trigger
// another "$locationChangeStart" event. As such, we want
// to stop watching the location so that the user may
// leave the current view without being prompted again.
stopWatchingLocation();
// NOTE: This is [mostly] for the demo; but, there are
// some real-world situations in which you would want to
// restart the watch. We have to use $applyAsync() so
// that we don't rebind the event-handler before the
// subsequent location change is triggered.
$scope.$applyAsync( startWatchingForLocationChanges );
}
)
;
}
// In a real-world situation, the reason you'd want to confirm the
// location change is likely related (though not always) to the
// destruction of the current scope. As such, deregistering the watcher
// is likely where this controller's responsibility ends; however, in
// this demo, we never actually change controller. As such, I have to
// restart the location-watch so that subsequent location-change events
// will be managed.
function startWatchingForLocationChanges() {
stopWatchingLocation = $scope.$on( "$locationChangeStart", handleLocationChangeStartEvent );
}
}
);
// -------------------------------------------------- //
// -------------------------------------------------- //
// I provide an asynchronous promise-based workflow for the core confirm()
// provided by the global object. The confirm() modal is guaranteed to be
// opened in the next tick.
app.service(
"confirminator",
function( $q, $timeout, $window ) {
var currentModal = null;
// Return the public API.
return({
open: open
});
// ---
// PULBIC METHODS.s
// ---
// I open the the confirm() modal with the given message and return a
// promise. The promise will be resolved or rejected based on the result
// of the confirmation.
function open( message ) {
// If there is already a pending confirmation modal, reject it.
if ( currentModal ) {
currentModal.reject();
}
currentModal = $q.defer();
// Open confirmation modal in next tick.
$timeout(
function openConfirm() {
$window.confirm( message )
? currentModal.resolve()
: currentModal.reject()
;
currentModal = null;
},
0,
// No need to trigger a digest - $q will do that.
false
);
return( currentModal.promise );
}
}
);
</script>
</body>
</html>
If you look past the comments, there's really only a few lines of code here. When the Controller picks up the $locationChangeStart event, it cancels the location change and stores the target location data. Then, if the user confirms the location change, the Controller re-initiates the target location change.
Since most real-world scenarios would result in the destruction of the given controller (otherwise, why bother prompting the user), it would generally be sufficient to simply deregister the event bindings. But, in our case, since the Controller is never actually being destroyed, we have to deregister and then reregister the $locationChangeStart event binding so that the demo may continue to work properly.
Beyond the controller-based logic, the real secret sauce to this workflow is being able to prompt the user for data and then return that data in the form of a promise. In this case, I'm wrapping the core window.confirm() method in a promise; but, you could also create a simple modal window system in AngularJS that runs on promises.
Want to use code from this post? Check out the license.
Reader Comments
Nice dive, as always! There's a module I found awhile back that does something very similar, but it is centered squarely around form submission and integrates well with formController. Been using it in production for awhile now. Here's the repo for anyone interested:
https://github.com/facultymatt/angular-unsavedChanges
@Brent,
Cool directive. The whole Form ecosystem is really something that I need to learn more about. I've only scratched the surface, looking at the ngModelController. It seems like there's so much more to forms and the form lifecycle that I know nothing about. So much to learn, so few hours :D
Very good article, help me so much. Thank you.
Does this work for when the user writes the destination url in the searchbar? What about front and back button clicks? I could not get it to work for this.
Thanks!