Question

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 completes, 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 complex, 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 complicated 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"?

Was it helpful?

Solution 5

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.com/angular/angular.js/issues/1699

The proposed change was submitted as a pull request and rejected: https://github.com/angular/angular.js/pull/2398

Based on the comments 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!

OTHER TIPS

This 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 recommend 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.

Licensed under: CC-BY-SA with attribution
Not affiliated with StackOverflow
scroll top