Angular UI Router - Parentless Child States - angularjs

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:
The user is viewing some search results (they've infinitely scrolled through several pages worth of results).
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.
A modal dialog opens which is layered above the search results they were viewing.
They search through the help and figure out what they need to do.
They press the device's back button.
The modal dialog closes, revealing the previous state they had been viewing without any loss of context.
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
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.
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.
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.

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:
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.
This depends upon using the bang part of the hash-bang (#! as opposed to #) when not in html5 mode.
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!
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.
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 :-)

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...).

Related

calling Backbone.history.navigate(...) leads to recursion?

I am using bootstrap, backbone, marionette in my app with require js support. This is a moderately large application with many views and sub views. As it consists of tabbed display, I am using bootstrap tabs.
In my layout view, I am handling the tab shown event and trying to do tab pane specific rendering as below...
var AppLayoutView = Backbone.Marionette.LayoutView.extend({
el : "body",
regions: {
headerRegion: "#ecp_header",
bodyRegion: "#ecp_body",
contentRegion: "#home"
},
events: {
'shown.bs.tab ul.nav-tabs a': 'onTabShown'
},
...
onTabShown: function(e) {
var self = this;
console.log("App layout view: 'onTabShown' executing...");
var tabId = $(e.currentTarget).attr('id');
if (tabId === 'home-tab') {
/** Show the dashboard (home) view */
require(['user_dashboard_layout'],
function(UserDashboardLayoutView) {
// update the URL in addressbar, so it can be available in history stack
Backbone.history.navigate('dashboard');
var dbLytView = new UserDashboardLayoutView();
dbLytView.render();
//self.bodyRegion.show(dbLytView);
//self.contentRegion.attachView(dbLytView);
});
} else if (tabId == "scheduling-tab") { ... }
All of this was working decently, until I added the line the history navigation line as shown in the above code.
Backbone.history.navigate('dashboard');
I also added app_controller to take care of routing. But after this, I observe a strange behavior. I see that the above fn "onTabShown" gets invoked multiple times, as seen in the browser console window (screenshot attached), whenever I perform login/logout on my app.
BTW, in my SPA (single page app), when user logs in, I show dashboard (if the user is logged in), or show welcome page (if not logged in).
If the offending line (history.navigate(...)) is present, I can see that tabShown is invoked multiple times, that is, for each login/logout it gets accumulated (some sort of strange recursion, or stack is not unwound).
But, if I comment out the history.navigate line, it doesn't perform page refresh after logout.
My basic question is...
"does the backbone.history.navigate(...) fn play any role in actual page navigation/refresh, apart from just updating the history stack?
From the documentation, it appeared that we need to call bb.h.navigate(...) just to keep the url in addressbar in sync with our current state of app. However, I am experiencing this strange behavior?
Since the app is a bit fairly complex, I may not have provided all relevant details for soliciting a proper answer to this qn.
May someone point me in right direction...?

Backbone.js change url without reloading the page

I have a site that has a user page. On that page, there are several links that let you explore the user's profile. I'd like to make it so that, when one of those links is clicked on, the url changes, but the top third of the page containing the user's banner doesn't reload.
I'm using Backbone.js
I have a feeling that I'm in one of those situation where I have such a poor understanding of the problem I'm dealing with that I'm asking the wrong question, so please let me know if that appears to be the case
My mistake was assuming that there was a special, built-in way of doing this in backbone. There isn't.
Simply running the following line of code
window.history.pushState('object or string', 'Title', '/new-url');
will cause your browser's URL to change without reloading the page. You can open up the javascript console in your browser right now and try it with this page. This article explains how it works in more detail (as noted in this SO post).
Now I've just bound the following event to the document object (I'm running a single page site):
bindEvents: () ->
$(document).on('click', 'a', #pushstateClick)
pushstateClick: (e) ->
href = e.target.href || $(e.target).parents('a')[0].href
if MyApp.isOutsideLink(href) == false
if e.metaKey
#don't do anything if the user is holding down ctrl or cmd;
#let the link open up in a new tab
else
e.preventDefault()
window.history.pushState('', '', href);
Backbone.history.checkUrl()
See this post for more info.
Note that you CAN pass the option pushstate: true to your call to Backbone.history.start(), but this merely makes it so that navigating directly to a certain page (e.g. example.com/exampleuser/followers) will trigger a backbone route rather than simply leading to nowhere.
Routers are your friend in this situation. Basically, create a router that has several different routes. Your routes will call different views. These views will just affect the portions of the page that you define. I'm not sure if this video will help, but it may give you some idea of how routers interact with the page: http://www.youtube.com/watch?v=T4iPnh-qago
Here's a rudimentary example:
myapp.Router = Backbone.Router.extend({
routes: {
'link1': 'dosomething1',
'link2': 'dosomething2',
'link3': 'dosomething3'
},
dosomething1: function() {
new myapp.MyView();
},
dosomething2: function() {
new myapp.MyView2();
},
dosomething3: function() {
new myapp.MyView3();
}
});
Then your url will look like this: www.mydomain.com/#link1.
Also, because <a href=''></a> tags will automatically call a page refresh, make sure you are calling .preventDefault(); on them if you don't want the page to refresh.

why angular-ui new state router transitions when only parameters changes?

I'm using the new angular-ui router, the one that has a state-machine included (https://github.com/angular-ui/ui-router). This great router allows a user to specify parameters as part of the URL.
for example:
$stateProvider
.state('contacts.detail', {
url: "/contacts/:contactId",
templateUrl: 'contacts.detail.html',
controller: function ($stateParams) {
// If we got here from a url of /contacts/42
expect($stateParams).toBe({contactId: 42});
}]
})
(see here)
this means, that when the user navigates to /contacts/42, the state is changed to 'contacts.details' and the 42 parameter is injected into the controller
There is a problem though. If ONLY the url parameter changes, the transitionTo function is still being called (could happen if the url is changed manually, for example, or bound to a input box). this in turn leads to the view directive of that state, to be re-created, both a waste of time and a problem if we only wanted to update something in that state.
it seems to be on purpose. from the code:
// Starting from the root of the path, keep all levels that haven't changed
var keep, state, locals = root.locals, toLocals = [];
for (keep = 0, state = toPath[keep];
state && state === fromPath[keep] && equalForKeys(toParams, fromParams, state.ownParams);
keep++, state = toPath[keep]) {
locals = toLocals[keep] = state.locals;
}
equalForKeys is what compares the params, and return false if there's a difference.
My question: do you know why the author would have written it this way? do you think its safe to change, so that there's no transition when only parameters would change?
thanks very much for reading all the way till here, and for any idea
Lior
EDIT: Seems that this is by design. just found: https://github.com/angular-ui/ui-router/issues/46
I generally solve problems of this nature with abstract states and nesting. Place the pieces that don't change based on the url parameter into the abstract parent state and you'll avoid the extra server hit. I prefer to place them into the resolve or custom data section in the parent state, but if necessary you can retrieve them via scope inheritance. Be sure you read and understand the rules of scope inheritance as there are some things that (at least for me) were unexpected.
You can read more details here: https://github.com/angular-ui/ui-router/wiki/Nested-States-%26-Nested-Views. In addition, the sample application included with angular-ui is a good place to start understanding state nesting.

multiple matching routes

I've got a backbone.js application that defines two controllers, and the controllers both define route patterns which match the location.hash. I'm having trouble getting both of them to fire - e.g.
ManagerController = Backbone.Controller.extend({
routes: {
":name": "doStuff"
},
doStuff : function(name) {
console.log("doStuff called...");
}
});
Component1Controller = Backbone.Controller.extend({
routes: {
"xyz123": "doMoreStuff"
},
doMoreStuff : function() {
console.log("doMoreStuff called...");
}
});
so if the url is "http://mysite.com/#xyz123", then I am seeing 'doStuff()' called, or if I comment out that route, then 'doMoreStuff()' is called. But not both.
I'm using this architecture because my page is highly component oriented, and each component defines its own Controller. A 'component manager' also defines a Controller which does some house keeping on all routes.
Should I be able to configure two controllers that both respond to the same route? Cheers,
Colin
Short answer: No, you can't do that. One Controller per page.
Long answer: When you instantiate a new Controller, it adds its routes to the History singleton. The History singleton is monitoring the hash component of the URL, and when the hash changes, it scans the routes for the first expression that matches its needs. It then fires the function associated with that route (that function has been bound to the controller in which it was declared). It will only fire once, and if there is a conflict the order in which it fires is formally indeterminate. (In practice it's probably deterministic.)
Philosophical answer: The controller is a "view" object which affects the presentation of the whole page based on the hash component of the URL. Its purpose is to provide bookmark-capable URLs that the user can reach in the future, so that when he goes to a URL he can start from a pre-selected view among many. From your description, it sounds like you're manipulating this publicly exposed, manually addressable item to manipulate different parts of your viewport, while leaving others alone. That's not how it works.
One of the nice things about Backbone is that if you pass it a route that's already a regular expression, it will use it as-is. So if you're trying to use the controller to create a bookmarkable description of the layout (component 1 in the upper right hand corner in display mode "A", component 2 in the upper left corner in display mode "B", etc) I can suggest a number of alternatives-- allocate each one a namespace in the hash part of the URL, and create routes that ignore the rest, i.e.
routes: {
new RegExp('^([^\/]*)/.*$'): 'doComponent1stuff',
new RegExp('^[^\/]*/([^\/]*)\/.*$': 'doComponent2stuff',
}
See how the first uses only items after the first slash, the second after the second slash, etc. You can encode your magic entirely how you want.
I suggest, though, that if you're going to be doing something with the look and feel of the components, and you want that to be reasonably persistent, that you look into the views getting and setting their cookies from some local store; if they're small enough, cookies will be enough.
I have a very similar issue. At present, backbone stops after the first matching route. I have a dirty workaround where I am overriding the loadUrl method of Backbone History. Here I am iterating through all of the registered routes and triggering callback for all of the matching routes .
_.extend(Backbone.History.prototype, {
loadUrl : function() {
var fragment = this.fragment = this.getFragment();
var matched = false;
_.each(this.handlers, function(handler) {
if (handler.route.test(fragment)) {
handler.callback(fragment);
matched = true;
}
});
return matched;
}
})
Philosophically, I am fine with having single controller per page. However, in a component based view framework, it will be nice to have multiple views per route rendering different parts of a view state.
Comments are welcome.
I've used namespacing to deal with a similar problem. Each module comes with it's own module controller, but is restricted to handle routes that start with /moduleName/ this way modules can be developed independently.
I haven't fully tested this yet, if you take a look at the Backbone.js source, you can see this at line 1449:
// Attempt to load the current URL fragment. If a route succeeds with a
// match, returns `true`. If no defined routes matches the fragment,
// returns `false`.
loadUrl: function(fragment) {
fragment = this.fragment = this.getFragment(fragment);
return _.any(this.handlers, function(handler) {
if (handler.route.test(fragment)) {
handler.callback(fragment);
return true;
}
});
}
The any method will stop as soon as it matches a handler route (with the "return true"), just comment the return and the short-circuit will never happend, and all the handlers will be tested. Tested this with a marionette app with two modules, each one having it's own router and controller, listening same routes anb both fired up.
I think this is the simplest way of resolving it
routes: {
'': 'userGrid',
'users': 'userGrid',
}

How to stop Silverlight browser history recording NavigationState/bookmark changes?

We have a Silverlight Prism project with complex state information stored in the browser bookmark. This allows us to share bookmarks/links which will restore the application to the exact same visual state.
We do not however want trivial bookmark changes (i.e. non-navigation changes) to result in a browser history entry. Otherwise the browser back/forward buttons would also make simple changes (things like simple list selections, tab selections etc).
Q: Is there a way to still change the browser bookmark URL, but exclude it from the browser history, or (failing that) is it possible to remove entries from the browser history?
Our visual states are prioritised, so we know which ones actually should affect navigation and which are just for decoration. This can be determined either before the URL is changed or after, so your answer can use either situation. We can add a specific marker to the bookmark, indicating it should not be archived, if that would also help your solution.
Thanks
I created one answer myself, but would prefer not to rely on JavaScript features. I also need to check that window.location.replace is supported by all browsers.
I now call this method with a flag, to say if I want the bookmarking ignored in the browser history:
public void NavigateToBookmark(string bookMarkUrl, bool replaceUrl)
{
if (replaceUrl)
{
HtmlPage.Window.Invoke("NavReplace", bookMarkUrl);
}
else
{
HtmlPage.Window.NavigateToBookmark(bookMarkUrl);
}
}
then add this JavaScript in the Silverlight hosting page:
function NavReplace(url) {
var newurl = parent.location.href;
var index = newurl.indexOf("#");
if (index > 0) {
newurl = newurl.substr(0, newurl.indexOf("#"));
}
window.location.replace(newurl + "#" + url);
}
Any takers for a better way to do this?

Resources