Managing Conditional Links And Route Changes In AngularJS
Sometimes, in an AngularJS application, you have a context in which a particular link might not be clickable based on the current view-model state. In the past, I've dealt with this by either injecting the $event into the Controller (which is a code-smell and an anti-pattern); or, by hiding the location-change inside the Controller, which makes it impossible to copy-paste or right-click the given link. Neither of these approaches feel good; so, I wanted to look at another approach that leaves the route in the markup, the logic in the controller, and the glue in the directive link function.
Run this demo in my JavaScript Demos project on GitHub.
Before we look at the directive approach, let's think about why the other two approaches are sub-optimal:
Passing the $event object in to the Controller. Sometimes, you'll see people pass the $event object into the Controller so that the $event.preventDefault() method can be called if the navigation needs to be cancelled. The problem here is that $event object is related to the DOM (Document Object Model) and the Controller should know nothing about the DOM.
Hiding the location-change inside the Controller. Rather than passing the $event object around, sometimes you'll see the entirety of the logic hidden behind an ngClick directive that conditionally manages the route change based on the view-model inside the Controller. This decouples the Controller from the DOM; but, at the cost of the link losing its natural behavior. Meaning, it can no longer be right-clicked for the purposes of opening up in a new Tab or copying the target location. While this might not matter to most users, it presents a great deal of emotional friction for any user that expects it to work.
NOTE: Being able to see the URL in the HREF, and during mouse-over, also makes the application easier to debug for your fellow developer. Yay for being good to your fellow engineers!
To keep the appropriate responsibilities in the appropriate places, let's look at how a component directive approach might be the best of all worlds. With a component directive, you have a Controller, a link function, and a view all working together in harmony. This allows the link function to manage the DOM while leaning on the Controller for the business logic and the view-model state.
In the following exploration, I have a list of friends that all link to a detail page. One of the friends cannot be viewed. However, the HTML represents the "happy path" of user interaction and defines the route in each ngHref directive. It is then up to the directive link function - the so-called "glue" - to override the click-event with help from the controller.
NOTE: I don't actually have the detail pages wired up; but, the routes are configured to allow location changes to be monitored.
<!doctype html>
<html ng-app="Demo">
<head>
<meta charset="utf-8" />
<title>
Managing Conditional Route Changes In AngularJS
</title>
</head>
<body>
<h1>
Managing Conditional Route Changes In AngularJS
</h1>
<!-- This directive uses a Controller, a link function, and this view here. -->
<div bn-route-demo>
<p>
<strong>Current Route</strong>: {{ vm.currentRoute }}
</p>
<ul>
<li ng-repeat="friend in vm.friends track by friend.id">
<!--
Although some of the friends cannot viewed, the HREF will always
define the route and the "happy path" of execution. Overrides of
this, based on JavaScript interactions, will be managed by the
directive link bindings, leaning heavily on the Controller.
-->
<a ng-href="#/friends/{{ friend.id }}" class="friend">
{{ friend.id}} - {{ friend.name }}
</a>
</li>
</ul>
<p>
<a href="#/friends">Back to list</a>
</p>
</div>
<!--
Load scripts.
--
NOTE: I am using jQuery here to facilitate easier event-delegation in the
component directive.
-->
<script type="text/javascript" src="../../vendor/jquery/jquery-2.1.0.min.js"></script>
<script type="text/javascript" src="../../vendor/angularjs/angular-1.4.2.min.js"></script>
<script type="text/javascript" src="../../vendor/angularjs/angular-route-1.4.2.min.js"></script>
<script type="text/javascript">
// Create an application module for our demo.
var app = angular.module( "Demo", [ "ngRoute" ] );
// --------------------------------------------------------------------------- //
// --------------------------------------------------------------------------- //
// Let's configure some simple routing for this demo. We're not actually going
// to use a different view - we're just going to use the value of the current
// $location.path() to show the route activity.
angular.module( "Demo" ).config(
function configureRoutes( $routeProvider ) {
$routeProvider
.when( "/friends", {} )
.when( "/friends/:id/detail", {} )
.when(
"/friends/:id",
{
redirectTo: "/friends/:id/detail"
}
)
.otherwise({
redirectTo: "/friends"
})
;
}
);
// --------------------------------------------------------------------------- //
// --------------------------------------------------------------------------- //
// I am a component directive that manages the demo.
angular.module( "Demo" ).directive(
"bnRouteDemo",
function rotueDemoDirective( $exceptionHandler ) {
// Return the directive configuration.
return({
controller: RouteDemoController,
controllerAs: "vm",
link: link,
restrict: "A"
});
// I control the view-model for the component.
function RouteDemoController( $scope, $route, $location, $window ) {
var vm = this;
// I hold the list of friends to demo. Each of these will link
// to a detail page (that may or may not be accessible).
vm.friends = [
{
id: 1,
name: "Sarah"
},
{
id: 2,
name: "Joanna"
},
{
id: 3,
name: "Kim"
}
];
// I hold the currently-active application route.
vm.currentRoute = $location.path();
// I keep track of the current route whenever it changes.
$scope.$on(
"$locationChangeSuccess",
function handleLocationChange() {
vm.currentRoute = $location.path();
}
);
// Expose the public API.
vm.canViewFriendDetail = canViewFriendDetail;
vm.showFriendBlock = showFriendBlock;
// ---
// PUBLIC METHODS.
// ---
// I determine if the given friend's detail can be viewed.
function canViewFriendDetail( friend ) {
return( friend.name !== "Kim" );
}
// Assuming the given friend cannot be viewed, I update the
// "view-model" to show an appropriate message as to why the given
// friend cannot be viewed.
function showFriendBlock( friend ) {
// NOTE: Using alert() to keep things super simple.
$window.alert( "Sorry, you can't view " + friend.name );
}
}
// I bind the JavaScript events to the view-model.
function link( scope, element, attributes, controller ) {
// We want to manage any click event on a Friend item.
element.on( "click", "a.friend", handleFriendClick );
// I handle the friend-click event.
function handleFriendClick( event ) {
try {
// Since we are using event-delegation, we have to extract
// the target friend from the DOM.
var target = angular.element( event.target );
var targetScope = target.scope();
var friend = targetScope.friend;
// Check with the controller to see if this friend is
// available for viewing.
if ( ! controller.canViewFriendDetail( friend ) ) {
// Apparently, this friend cannot be viewed, so cancel
// the click event (will prevent the Route change).
event.preventDefault();
// Ask the controller to indicate to the user that their
// navigation has been blocked for some reason.
scope.$apply(
function changeViewModel() {
controller.showFriendBlock( friend );
}
);
}
} catch ( error ) {
$exceptionHandler( error );
}
}
}
}
);
</script>
</body>
</html>
As you can see, the link URL is always present in the markup. This means that the link can be shift-clicked or right-clicked and copy-pasted. In other words, the link "works as expected." Furthermore, the Controller doesn't have to deal with any DOM-events - that's left up to the directive's link function, which manages the conditional canceling of the DOM-event.
Now, you might be thinking that this approach still lets the user get through to a view even if they are not supposed to (via copy-paste). This is true. But, this is actually a good thing. Since URLs can be manually entered, no matter what, this approach forces the developer to think about putting a similar security check in the target view as well, which is already necessary.
To me, this approach feels like the most appropriate since it delegates responsibilities to the most appropriate parts of the architecture. And, it also gets you to think about using "component" directives, which seem to be the wave of the future.
Want to use code from this post? Check out the license.
Reader Comments