Suppose I have an Angular app for editing eCards. Creating a new eCard uses a path like #/ecard/create
and editing an existing eCard uses a path like #/ecard/:id
. A tabbing system lets us have multiple eCards open for editing at a time.
We'd like an autosave feature like what users would expect from e.g. modern webmail or wiki software (or StackOverflow itself). We don't want to save an eCard draft the moment the user opens the Create form, which would give us a lot of drafts of blank eCards, so we start autosaving once the user starts typing.
I'd like to write code like this in our controller (this is simplified to not include e.g. error handling or stopping the autosave when the tab is closed, etc):
$scope.autosave = function () {
ECardService.autosave($scope.eCard).then(function (response) {
$location.path('/ecard/' + response.id).replace();
$timeout($scope.autosave, AUTOSAVE_INTERVAL);
});
};
$timeout($scope.autosave, AUTOSAVE_INTERVAL);
The above code works great, except for one thing: when the location changes, our controller reloads and the view re-renders. So if the user is in the middle of typing when the autosave pletes, there's a brief flicker, and they lose their place.
I've considered several approaches to mitigate this problem:
1) Change the path to use the search path and set reloadOnSearch
to false
in the ngRoute
configuration. So the path would change from #/ecard?id=create
to e.g. #/ecard/id=123
and thus not force a reload. The problem is that I might have multiple eCards open and I do want changing from e.g. #/ecard/id=123
to #/ecard/id=321
to trigger a route change and reload the controller. So this isn't really feasible.
2) Don't bother editing the URL and deal with the back button giving a weird behavior in this case. This is tempting, but if a user opens their list of existing eCards and tries to open the specific eCard that has been saved, we want the tabbing system to recognize that it should just display the currently existing tab rather than open a new tab.
We could theoretically address this by updating our tabbing system to be smarter; instead of just checking the path, it could check both the path and the persistent id, which we could store somewhere. This would make the tabbing system significantly more plex, and that seems like overkill for this feature.
3) Only change the URL when the user is not actively editing, e.g. write a $scope.userIsIdle()
function which returns true
if it's been at least 10 seconds since the user made any edits, then update the path based on that. A simplified version of this would look something like:
$scope.updatePathWhenSafe = function (path) {
if ($scope.userIsIdle()) {
$location.path(path).replace();
} else {
$timeout(function () {
$scope.updatePathWhenSafe(path);
}, 1000);
}
};
I ended up going with option #3; it was significantly simpler than option #2, but a lot more plicated to implement and test than I'd like, especially once I account for edge cases such as "what if the tab is no longer the active tab when this timeout fires?" I'd love for option #4 to be possible.
4) Go outside Angular to edit the current location and history, assuming this is necessary and possible. This would be my preferred solution, but my research indicates it's not safe/advisable to try to go around the $location
service for changing your path or editing history. Is there some safe way to do this? It would make things so much simpler if I could just say, "Change the current path but don't reload the controller."
Is option #4 possible/feasible? If not, then is there a better way? Maybe some magical "Do it the angular way but somehow don't refresh the controller"?
Suppose I have an Angular app for editing eCards. Creating a new eCard uses a path like #/ecard/create
and editing an existing eCard uses a path like #/ecard/:id
. A tabbing system lets us have multiple eCards open for editing at a time.
We'd like an autosave feature like what users would expect from e.g. modern webmail or wiki software (or StackOverflow itself). We don't want to save an eCard draft the moment the user opens the Create form, which would give us a lot of drafts of blank eCards, so we start autosaving once the user starts typing.
I'd like to write code like this in our controller (this is simplified to not include e.g. error handling or stopping the autosave when the tab is closed, etc):
$scope.autosave = function () {
ECardService.autosave($scope.eCard).then(function (response) {
$location.path('/ecard/' + response.id).replace();
$timeout($scope.autosave, AUTOSAVE_INTERVAL);
});
};
$timeout($scope.autosave, AUTOSAVE_INTERVAL);
The above code works great, except for one thing: when the location changes, our controller reloads and the view re-renders. So if the user is in the middle of typing when the autosave pletes, there's a brief flicker, and they lose their place.
I've considered several approaches to mitigate this problem:
1) Change the path to use the search path and set reloadOnSearch
to false
in the ngRoute
configuration. So the path would change from #/ecard?id=create
to e.g. #/ecard/id=123
and thus not force a reload. The problem is that I might have multiple eCards open and I do want changing from e.g. #/ecard/id=123
to #/ecard/id=321
to trigger a route change and reload the controller. So this isn't really feasible.
2) Don't bother editing the URL and deal with the back button giving a weird behavior in this case. This is tempting, but if a user opens their list of existing eCards and tries to open the specific eCard that has been saved, we want the tabbing system to recognize that it should just display the currently existing tab rather than open a new tab.
We could theoretically address this by updating our tabbing system to be smarter; instead of just checking the path, it could check both the path and the persistent id, which we could store somewhere. This would make the tabbing system significantly more plex, and that seems like overkill for this feature.
3) Only change the URL when the user is not actively editing, e.g. write a $scope.userIsIdle()
function which returns true
if it's been at least 10 seconds since the user made any edits, then update the path based on that. A simplified version of this would look something like:
$scope.updatePathWhenSafe = function (path) {
if ($scope.userIsIdle()) {
$location.path(path).replace();
} else {
$timeout(function () {
$scope.updatePathWhenSafe(path);
}, 1000);
}
};
I ended up going with option #3; it was significantly simpler than option #2, but a lot more plicated to implement and test than I'd like, especially once I account for edge cases such as "what if the tab is no longer the active tab when this timeout fires?" I'd love for option #4 to be possible.
4) Go outside Angular to edit the current location and history, assuming this is necessary and possible. This would be my preferred solution, but my research indicates it's not safe/advisable to try to go around the $location
service for changing your path or editing history. Is there some safe way to do this? It would make things so much simpler if I could just say, "Change the current path but don't reload the controller."
Is option #4 possible/feasible? If not, then is there a better way? Maybe some magical "Do it the angular way but somehow don't refresh the controller"?
Share Improve this question edited Mar 4, 2014 at 17:09 Eli Courtwright asked Mar 4, 2014 at 16:25 Eli CourtwrightEli Courtwright 193k68 gold badges223 silver badges257 bronze badges 6-
To me option (2) sounds like the obvious way to go. But without understanding how your
tabbing system
works it difficult to look into this option further. You say the tabbing system can have multiple eCards open for editing, how do you distinguish between the different eCards if you say you use the path/ecard/:id
for editing? – Beyers Commented Mar 6, 2014 at 21:54 -
Why are you using
$location.path
inside thethen
function ofautosave
? As I understand it, the location shouldn't change unless the tab changes. I would assume the autosave function is responding with a response from the current tab, so theid
ofresponse
should be the same as the currentid
. – Tyler Eich Commented Mar 8, 2014 at 23:18 -
@TylerEich: New eCards have no id. We could theoretically start every eCard with an id, but then there's a blank eCard draft if you open the pose form and then close it without making any changes. That's why we need to change the location from e.g.
/ecard/new
to/ecard/123
. – Eli Courtwright Commented Mar 10, 2014 at 14:21 -
@Beyers: To clarify, the url for an existing eCard is e.g.
/ecard/123
or/ecard/42
- I was using:id
to indicate that we actually put the id in the path, which we then get with the$routeParams
service. – Eli Courtwright Commented Mar 10, 2014 at 14:23 - @EliCourtwright I understood that, what I'm asking is that you say you can have multiple ecards open for editing at the same time, so what does your url look like when you have multiple open cards at the same time? – Beyers Commented Mar 10, 2014 at 15:32
5 Answers
Reset to default 3This is not angular way, but it can be useful. After receiving data you can check whether there is an focused element (user is typing). If so, then you need to define a function that is performed once when element lose focus. If no focused element, the change url immediately.
Like this:
ECardService.autosave($scope.eCard).then(function (response) {
if($(':focus').length){ //if there is focused element
$(':focus').one('blur', function(){ //
$location.path('/ecard/' + response.id).replace(); //perform once
});
}
else{
$location.path('/ecard/' + response.id).replace();
}
});
Of course this is not the most elegant solution, but it seems to solve your problem.
If you have code that needs to run across multiple view controllers AngularJS provides a root scope for such instances. You can find the documentation here.
However I would remend against having a tabbing system that is actually multiple views. Having multiple items open means to have them all in your work space.
You might want to consider a single view with Angular directives for your e-cards. That way they could each have their own scope and would be available at an instance without re-rendering the page.
They would also be able to share the functions defined in the controller's $scope
, without the need for an app wide root scope. Note that scope has to be enabled on directives. scope: true
Check out the AngularJS site for tutorial and documentation on this.
It seems that the best solution for the problem you're describing would be to use a state machine like ui-router.
With a library like that one, you can have a single page app that has multiple states (that you can also make part of the url), so whenever the state changes, you can save your e-card and you'll never have any visible reloads because you're working on a single page application.
So I understand the path wants to reflect the id of the latest version, so in that case you would need to refresh every save.
But, what about if the path was something like ecard/latest
as a alias for the latest version. That way you wouldn't have to refresh your view since you don't have to change your path, and just implement something in the back-end directs the param latest to the id of the latest version.
It turns out there's a way to do exactly what I want, although it's not officially blessed by Angular. Someone opened an Angular ticket for this exact use case: https://github./angular/angular.js/issues/1699
The proposed change was submitted as a pull request and rejected: https://github./angular/angular.js/pull/2398
Based on the ments in the original ticket, I implemented a workaround that looks like this:
app.factory('patchLocationWithSkipReload', function ($location, $route, $rootScope) {
$location.skipReload = function () {
var prevRoute = $route.current;
var unregister = $rootScope.$on('$locationChangeSuccess', function () {
$route.current = prevRoute;
unregister();
});
return $location;
};
});
I'm then able to basically (error handling omitted for brevity) say
ECardService.autosave($scope.eCard).then(function (response) {
$location.skipReload().path('/ecard/' + response.id).replace();
$scope.resetAutosaveTimeout();
});
Basic testing shows this works great!