1

I think that my objective won't work with AngularUI Router - but I'm going to put it out here in case someone can prove me wrong or has an alternative solution or a workaround that solves the same problem.

The Objective

I want to display modal windows which change the url but can be opened from anywhere in my application - regardless as to which parent state is currently active. Specifically, I want the url changed so that, when the browser/device back button is pushed, the modal is closed (i.e. the app will return to whichever parent state they were using). Such a modal could be opened by the user at any time while using the app (an example would be a help window accessible from the app's main menu bar).

What I really don't want to do is copy and paste the modal state as a child of every possible parent state (i.e. register the help state as a child for each of user profile/search results/home/etc...). If there were just one such modal in the app then doing so may be an acceptable approach - but when you start introducing several globally accessible modal child states into an app then multiple child state registration starts to become a real problem.

To illustrate more clearly, here's a user story:

  1. The user is viewing some search results (they've infinitely scrolled through several pages worth of results).
  2. There is an action they want to perform but they're not sure how to achieve it so they click the help icon in the app's header.
  3. A modal dialog opens which is layered above the search results they were viewing.
  4. They search through the help and figure out what they need to do.
  5. They press the device's back button.
  6. The modal dialog closes, revealing the previous state they had been viewing without any loss of context.
  7. The user performs their task and is extremely happy with themselves - and not pissed off at the app developers due to a stupid user experience design.

In the above story, the only way I can think to cause the back event to close the modal is to tie the modal to AngularUI Router's state transitions. The user would go from the search results state (url: /search-results) to the help state (url: /search-results?help) - however, in another case they may go from a user profile state (url: /profile/123) to the help state (url: /profile/123?help). The key here being, that help wasn't registered directly as a child of both the search results and profile states, but somehow independently as a type of orphaned state which could be potentially applied to any parent.

Alternative Objective

This is not my preferred solution. If it's possible to cause the browser/device back button to close a modal without changing the url then I can make these modals work independently of AngularUI Router - but I don't like this as an approach, it means having an inconsistent development approach for different types of views (and who knows, maybe in the future we'll decide that one of these modal windows should be a first-class state in its own right and this would require a change from one approach to the other - which is undesirable). I also think this is an unreliable approach as handling the back event is no trivial matter, in my experience.

This actually would be useful for many situations (for example, a user could click back to close a sub-menu or context-menu), I just don't think it's a technically viable solution - but feel free to prove me wrong. ;-)

Notes

  1. I am aware that it is possible to open modal child states - in-fact, I've implemented this where child states are explicitly tied to a specific parent state.
  2. This is for an app which specifically targets mobile as its main use-case. This means the back button is a fundamentally important consideration - it's normal behaviour for a mobile user to use the back button to close or cancel a dialog and I categorically do not want to have to train my app's users to click close when they're already used to using the back button.
  3. Sorry, I have no code attempts to present - I have no idea how to get this to work or even where to start - and none of my research has shed any light on the problem (maybe I'm searching with the wrong terms?).

Thanks in advance for any assistance provided!

Edit 1. Updated the user story explanation to include concrete url/state examples for greater clarity.

Zac Seth
  • 2,742
  • 5
  • 37
  • 56

2 Answers2

1

Well, for anyone who has a similar need, I found a simple solution which basically goes outside of the whole routing mechanism of UI Router.

Firstly, I believe it should be possible to use the deferIntercept feature in the upcoming 0.3 release, as detailed in this SO answer. However, my solution takes a different approach. Instead of using a query parameter to identify these orphaned views (i.e. ?help), I'm using url fragment identifiers (i.e. #help). This works because the routing mechanism seems to ignore anything after the hash symbol.

I did come across a couple of gotchas before I managed to get this fully working - specifically, when dealing with non-html5 mode in the $location service. As I understand it, it's technically illegal to include a hash symbol in a fragment identifier (i.e. a url cannot contain two # symbols), so it comes with some risk, but from my testing it seems that browsers don't complain too much).

My solution involves having a hashRouter service which manages the jobs of serialising and deserialising your query data to and from the fragment identifier, and monitoring $locationChangeSuccess events to hand external changes in the url (i.e. when the browser or device's back and forward buttons are pressed).

Here's a simplified version of my service:

hashRouter.$inject = [
    '$rootScope',
    '$location'
];

function hashRouter($rootScope, $location) {
    var service = this,
        hashString = $location.hash(),
        hash = fromHashString(hashString);

    $rootScope.$on('$locationChangeSuccess', function (e, newUrl) {
        var newHashString = getHashSection(newUrl);
        if (newHashString != hashString) {
            var newHash = fromHashString(newHashString);
            service.hash(newHash.name, newHash.params);
        }
    });

    service.hash = function (name, params) {
        var oldHash = hash,
            oldHashString = hashString;

        hash = { name: name || '', params: params || {} };
        hashString = toHashString(hash);

        if (hashString !== oldHashString) {
            var oldHashExists = oldHashString.length > 0;
            if (oldHashExists) {
                $rootScope.$broadcast('hashRouteRemoved', oldHash);
            }

            if (hashString.length > 0) {
                $rootScope.$broadcast('hashRouteAdded', hash);
            }

            $location.hash(hashString);

            if (oldHashExists) {
                $location.replace();
            }
        }
    };

    return service;

    function toHashString(data) {
        var newHashString = '';

        var name = data.name;
        if (!!name) {
            newHashString += encodeURIComponent(name);
        }

        var params = data.params;
        if (!!params) {
            var paramList = [];

            for (var prop in params) {
                var key = encodeURIComponent(prop),
                    value = params.hasOwnProperty(prop) ? encodeURIComponent(params[prop].toString()) : '';
                paramList.push(key + '=' + value);
            }

            if (paramList.length > 0) {
                newHashString += ':' + paramList.join('&');
            }
        }

        return newHashString;
    }

    function fromHashString(urlHash) {
        var parsedHash = {
            name: '',
            params: {}
        };

        if (!!urlHash && urlHash.length > 0) {
            if (urlHash.indexOf(':') !== -1) {
                var hashSegments = urlHash.split(':');
                parsedHash.name = decodeURIComponent(hashSegments[0]);

                var querySegments = hashSegments[1].split('&');
                for (var i = 0; i < querySegments.length; i++) {
                    var pair = querySegments[i].split('=');
                    parsedHash.params[decodeURIComponent(pair[0])] = decodeURIComponent(pair[1]) || null;
                }

            } else {
                parsedHash.name = decodeURIComponent(urlHash);
            }
        }

        return parsedHash;
    }

    function getHashSection(url) {
        if (url.indexOf('#') === -1 || (url.indexOf('#!') !== -1 && url.indexOf('#') === url.lastIndexOf('#'))) {
            return '';
        }

        var urlSegments = url.split('#');

        return urlSegments[urlSegments.length - 1];
    }
}

angular.module('myApp').service('hashRouter', hashRouter);

There are a couple of things to note about the service:

  1. I rolled-my-own serialisation/deserialisation functions and they're anything but complete, so use at your own risk - or replace with something more suitable.
  2. This depends upon using the bang part of the hash-bang (#! as opposed to #) when not in html5 mode.
  3. If you do mess around with the serialisation/deserialisation functionality, be very careful: I found my self in a few infinite-loop scenarios which basically crashed my browser. So make sure you test thoroughly!
  4. You still need to invoke the service whenever you open/close a dialog/menu/etc which uses the service and listen to the hashRouteAdded and hashRouteRemoved events as appropriate.
  5. I've built this system to support only one view at a time - if you need multiple views then you'll need to customise the code somewhat (although I guess it could support nested views easily enough).

Hopefully, if anyone else needs to do the same as I've done here this can save them some time :-)

Community
  • 1
  • 1
Zac Seth
  • 2,742
  • 5
  • 37
  • 56
0

Could you use a single state as the parent for everything in the app? I do the same thing in my angular app.

$stateProvider
            //root route
            .state('app', {
                url: '/',
                templateUrl: '/scripts/app/app/views/app.html',
                controller: 'appController',
                resolve: {
                    //resolve any app wide data here
                }
            });

Then you can do your modal as a child of this state. That way you can always transition back to this route to get back to your app's default state (when your modal closes). Another benefit of doing things this way is you can use the view for this route as a layout to put any markup that doesn't change from page to page (header, sidebar, etc...).

officert
  • 1,232
  • 9
  • 12
  • Hmm, I'm not sure if this addresses my problem or not. Assuming I have a root state (url: '/') and my modal is a child of the root (url: 'my-modal') but I want to view the modal from, let's say, a user profile state (url: 'profile/{uid}') - would it be possible to go to the url "/profile/123/my-modal" (profile state and modal state) from "/profile/123" (profile state) and you would see the modal layered above the profile page? – Zac Seth Aug 05 '14 at 15:39
  • oh I see the problem now, sorry I skimmed your question cause I'm in a meeting at work :) let me think about that a bit more – officert Aug 05 '14 at 15:44
  • No problems - I appreciate you taking the time to answer. I've also updated the question so that the user story example is clearer. – Zac Seth Aug 05 '14 at 15:47