Advanced Angular Navigation with ui-router

Home / Advanced Angular Navigation with ui-router

angular_small
If you’ll recall my previous post on building a simple menu navigation system with Angular, I alluded to advanced scenarios such as determining when, or if, a user can navigate away from the current state.

There are many ways to prevent a user from changing state, but providing reusable mechanisms/services to achieve a unified solution should be our goal.

Using our previous menuService as our base code, we’re going to take advantage of the methods in place that utilize $stateChangeStart as our catalyst for intercepting the user’s interaction with our view. This is possible because of the way the ui-router states have been defined. However, if we weren’t using ui-router, we’d probably want to use the $location service and capture the $locationChangeStart/$locationChangeSuccess events. Additionally, if we had an application where the URL could change, without affecting the ui-router’s state, we’d probably want a more complex solution using $locationChangeStart/$locationChangeSuccess events as well.

Having mentioned that though, we’re going to utilize ui-router states since I think it’s easier to illustrate and is more succinct with the previous menuService. If we did use the $location provider, the basic premise is the same:

  • Detect when the user initiates a state change
  • Examine the current state’s validity and if the state can change
  • Call back to the current controller for more information
  • Allow or cancel the state change event based on our gathered information
  • Alert the user if necessary

The most salient point here is the 3rd bullet. What is meant by “call back to the current controller?” Well, we need a way for the menuService to communicate with the current controller to facilitate our state change validation. In turn, we need a vehicle for the current controller, that has had the menuService injected, to inform the menuService of how to communicate with it. This is really easy to accomplish since our menuService can simply expose some “setters” for any given controller to set specific callbacks. So, that’s what we’ll do.

For the purposes of this example, we’ll define (2) callback functions which a controller can set, a helper method that we can use to reset the callbacks, and expose the setters on the menuService’s return values.

These are added in our variable declarations:

            onNavigateFn,
            validationFn
...
       function resetCallbacks() {
            onNavigateFn = null;
            validationFn = function () { return true; };
        }

And our return object is modified like this:

...
        return {
            menuItems: menuItems,
            currentMenuItem: currentMenuItem,
            setOnNavigateCallback: function (onNavigateCb) {
                onNavigateFn = onNavigateCb;
            },
            setValidationCallback: function (validationCb) {
                validationFn = validationCb;
            }
        };

These addendums to our menuService let any controller that has a dependency on our menuService to set the onNavigate and validate callbacks. The initial approach to the menuService and the general workflow was to treat it as a sort-of wizard mechanism. So, we don’t want the user to leave a current state if it’s invalid. As such, if the controller has told the menuService how to determine the current state’s validity, we’ll use that, initially, to determine if the user is allowed to proceed. If we get past the validity check, then we can use the “onNavigate” callback to further determine if the state change should be intercepted since the user, for example, has unsaved changes. We could also use this, depending on what we want our controller to do, to perform any action, such as a post back, that’s appropriate to automate once the state changes.

Let’s take a look at our mechanism for checking validity. We’ll use our previous dialogService to simply tell the user they have some errors and to fix them in order to proceed. We need to modify our $stateChangeStart handler like so:

$rootScope.$on('$stateChangeStart', function (event, toState, toParams) {
    var valdatinFnResponse = validationFn();
    if ((valdatinFnResponse !== true || valdatinFnResponse.error === true)) {
        var errorTitle = 'Validation Error!';
        var errorBody = (valdatinFnResponse.errorMessage) ? valdatinFnResponse.errorMessage : 'Please correct the form errors listed!';
        dialogService.openDialog("modalErrorTemplate.html", ['$scope', '$modalInstance', function ($scope, $modalInstance) {
            $scope.modalHeader = $sce.trustAsHtml(errorTitle);
            $scope.modalBody = $sce.trustAsHtml($rootScope.stringFormat("<p><strong>{0}</strong></p>", errorBody));
            $scope.ok = function () {
                $modalInstance.close();
            };
            $scope.hasCancel = false;
        }]);
        event.preventDefault();
        return;
    };

    currentMenuItem = findMenuItem(toState.name, toParams);
});

The salient points here are that we’re calling the controller’s validation method. If the controller never set the validaiton method, then the default (from resetCallbacks) returns true. If the validate method returns false, indicating that the controller is in an invalid state, we display our simple error modal and cancel the $stateChangeStart by using the event’s

preventDefault()

method. We can test this in a moment with views that have form elements with validation.

All that’s needed to stop the state change is to cancel the event. I can’t stress this enough.

The next thing we want, which is a little more complex, is to allow the controller to execute a callback once navigation (state change) has been allowed to proceed after the validity check. We want this to be robust enough to allow AJAX requests or, really, any other action. With this in mind, we’ll expect the controller to return a promise.

Promises… I really like promises. Before promises, it was kind of hacky determining if something that is performed synchronously completed at an application level. I know I wound up rewriting code or bundling methods together within callback handlers to facilitate. It was messy, but I digress.

Using the Angular $q service, we’ll expect the promise to be returned to us and use the when/then mechanisms to call execute the callback. To determine success or failure of the promise, we’ll expect the calling controller to properly handle the reject or resolve scenarios. Adding the $q handler to our menuService makes our $stateChangeStart handler now look like this:

$rootScope.$on('$stateChangeStart', function (event, toState, toParams) {
    var valdatinFnResponse = validationFn();
    if ((valdatinFnResponse !== true || valdatinFnResponse.error === true)) {
        var errorTitle = 'Validation Error!';
        var errorBody = (valdatinFnResponse.errorMessage) ? valdatinFnResponse.errorMessage : 'Please correct the form errors listed!';
        dialogService.openDialog("modalErrorTemplate.html", ['$scope', '$modalInstance', function ($scope, $modalInstance) {
            $scope.modalHeader = $sce.trustAsHtml(errorTitle);
            $scope.modalBody = $sce.trustAsHtml($rootScope.stringFormat("<p><strong>{0}</strong></p>", errorBody));
            $scope.ok = function () {
                $modalInstance.close();
            };
            $scope.hasCancel = false;
        }]);
        event.preventDefault();
        return;
    };

    if (!onNavigateFn) {
        resetCallbacks();
        currentMenuItem = findMenuItem(toState.name, toParams);
        return;
    }

    event.preventDefault();

    $q.when(onNavigateFn()).then(
        function (result) {
            resetCallbacks();
            $state.go(toState, toParams);
        },
        function (error) {
            return; // we just don't change state
        }
    );
});

A few points to note about the code above – you’ll notice that we cancel the event prior to evaluating the ‘onNavigateFn.’ This is necessary because once we kick off our own deferred, the original method actually would complete which means the original Angular event would also be allowed to complete. But, we still know which state we were originally going to, and can utilize the $state provider to go to that state on resolution of the deferred promise.

To make this all hook together, and with the DRY concept in mind, I like to put the general “Your changes will be discarded – are you sure?” message in the dialogService that we previously created.

function discardChangesDialog(message) {
    var defer = $q.defer();
    var modalTitle = "Discard your changes?";
    var modalBody = message ? message : "Are you sure you want to discard your changes?";
    openDialog("modalGeneral.html", ['$scope', '$modalInstance', function ($scope, $modalInstance) {
        $scope.modalHeader = $sce.trustAsHtml(modalTitle);
        $scope.modalBody = $sce.trustAsHtml(stringFormat("<p><strong>{0}</strong></p>", modalBody));
        $scope.ok = function () {
            $modalInstance.close();
            defer.resolve();
        };
        $scope.hasCancel = true;
        $scope.cancel = function () {
            $modalInstance.close();
            defer.reject();
        };
    }]);
    return defer.promise;
};

This bit of code creates a deferred, and if the user clicks ‘Cancel’ we reject the deferred which in turn will stop the state change. If they click ‘Ok,’ the deferred is resolved, their changes are (more than likely) discarded, and the state change is allowed to proceed.

Finally, to make it all work, we need our controller to utilize it. Here’s a sample controller:

var state4Controller = function (menuService, dialogService) {
    var vm = this,
        isFormValid = function () {
            return vm.myForm.$valid === true;
        },
        isFormDirty = function () {
            return vm.myForm.$dirty === true;
        },
        navigateCallback = function () {
            if (isFormDirty()) {
                return dialogService.discardChangesDialog();
            } else {
                return true;
            };
        },
        init = function () {
            menuService.setOnNavigateCallback(navigateCallback);
            menuService.setValidationCallback(isFormValid);
        };

    init();
};

And there you have it. Below is a pen showing all of this in action. Please feel free to leave comments or feedback.

The full source can be seen on Github.
There’s a plunk if you prefer Plunker.
And, there’s a plunk if you prefer jsFiddle.

Leave a Reply