14

following the DRY principal, i want to write a button directive which keeps button disabled for the duration of $http class.

I want to do this so as to forbid user from clicking the buttons multiple times, but i am not able to think on how to get function promise status inside a directive, given that the function resides on $scope

the scenario is very generic, buttons ng-click does call a function which in turn makes $http calls. on user click : button should get disabled and should be enabled only after the $http call is resolved, either success or failure.

harishr
  • 17,807
  • 9
  • 78
  • 125
  • 1
    Possible duplicate of [Preventing / dealing with double button clicks in angular](http://stackoverflow.com/questions/18130808/preventing-dealing-with-double-button-clicks-in-angular) – pridegsu Dec 04 '16 at 15:09

9 Answers9

38

Why not just make it easier.

<button ng-click="save()" ng-disabled="isProcessing">Save</button>


$scope.save = function(){
  $scope.isProcessing = true;
  $http.post('Api/Controller/Save', data).success(
    $scope.isProcessing = false;
  );
}

Sure it's the case if you need this logic in very few places across your app.

If you have such logic repeating many times (and if you are not lazy :) ), so in order to follow SOLID principles it definetely better to wrap this functionality into directive (check out other answers for this question to see examples of such directive).

Artyom Pranovich
  • 6,814
  • 8
  • 41
  • 60
  • 6
    i am currently doing what u suggested, but if then i have to manage one Boolean per button.. creating a directive, will handle that automatically throughout the project – harishr Mar 28 '14 at 11:46
  • 1
    I recommend this way. Its the only way if you want it to be flexible. listening to requests with a interceptor wont include the client side stuff you might want to do before and after a request. I seen some click directives but they will all need a returned promise, which will affect the controller code – Jens Alenius Oct 16 '15 at 09:18
  • 2
    This is so no DRY, because you have to take care of async state for each individual button. Accepted answer is a much better solution. This one is simple enough for simple projects but when things become more complex this become a heavy burden. – Robert Koritnik Nov 24 '15 at 10:25
  • In this case the button could be clicked more than once before the digest cycle disables it. – Hristo Enev Jan 17 '16 at 08:41
29

Although I would be careful of over-engineering, a way to do this would be by using a custom directive. This directive

  • Accepts an option, passed by attribute, of a function in the scope that must return a promise
  • On click of the button, calls this function, and disables the button
  • On finally of the promise, it re-enables the button

-

app.directive('clickAndDisable', function() {
  return {
    scope: {
      clickAndDisable: '&'
    },
    link: function(scope, iElement, iAttrs) {
      iElement.bind('click', function() {
        iElement.prop('disabled',true);
        scope.clickAndDisable().finally(function() {
          iElement.prop('disabled',false);
        })
      });
    }
  };
});

This can be used on a button as follows:

<button click-and-disable="functionThatReturnsPromise()">Click me</button>

You can see this in action at http://plnkr.co/edit/YsDVTlQ8TUxbKAEYNw7L?p=preview , where the function that returns the promise is:

$scope.functionThatReturnsPromise = function() {
  return $timeout(angular.noop, 1000);
} 

But you could replace $timeout with a call to $http, or a function from any service that returns a promise.

Michal Charemza
  • 25,940
  • 14
  • 98
  • 165
  • The problem with this is, I removed the timeout function, but I can't get it to perform default action after click (for example, submit form once the button is clicked), it just disables button. Is that possible to make so form is also being submitted? – Javatar Nov 18 '14 at 17:13
  • I had problems with this solution. When the HTTP call returned an error code, the button was not reenabled. @codef0rmer solution below worked better in this case. – Chris Nava Jul 14 '15 at 16:15
  • I made a slight modification to not have to use an isolate scope. This allowed me use translate on the same element. scope.$eval(attrs.clickAndDisable).finally(...) and remove the scope property. – Jerry Nov 19 '15 at 13:46
  • Imagine that on click of button we want to fetch some data from server - This will call our function from directive, not from view which is IMO bad practice. Anyway, nice idea ;) – Ivan Pandžić Nov 20 '15 at 08:10
  • This is very elegant. It could also utilize an additional attribute to set a temporary button label while button is inactive like *working...* or similar. – Robert Koritnik Nov 24 '15 at 10:26
  • ...although this directive should also check for other directives that may manipulate *disabled* state like `ngDisabled`. But that's rather easy as we only have to add a watcher for `iElement.prop("disabled")` and detect int this watcher that it's not our directive that is changing this state. If it's not, we have to save this change state value and in the `finally` function set disabled value to last saved state value that others have set. Apart from that, this works great! – Robert Koritnik Nov 24 '15 at 13:17
9

I like @codef0rmer 's solution because it is centralized--that is there's no additional code needed for each HTTP request, and you just need to check the global progress flag in your HTML. However, using transformResponse to determine when the request has completed is unreliable because the server may not return anything; in that case the handler isn't called and progress is never set back to false. Also, as written that answer doesn't account for multiple simultaneous requests (progress may return false before all requests have completed).

I've come up a similar solution which uses interceptors to address these issues. You can put it in your Angular app's config function:

.config(function ($httpProvider) {
    $httpProvider.interceptors.push(function($q, $rootScope) {
        var numberOfHttpRequests = 0;
        return {
            request: function (config) {
                numberOfHttpRequests += 1;
                $rootScope.waitingForHttp = true;
                return config;
            },
            requestError: function (error) {
                numberOfHttpRequests -= 1;
                $rootScope.waitingForHttp = (numberOfHttpRequests !== 0);
                return $q.reject(error);
            },
            response: function (response) {
                numberOfHttpRequests -= 1;
                $rootScope.waitingForHttp = (numberOfHttpRequests !== 0);
                return response;
            },
            responseError: function (error) {
                numberOfHttpRequests -= 1;
                $rootScope.waitingForHttp = (numberOfHttpRequests !== 0);
                return $q.reject(error);
            }
        };
    });
})

Now you can just use the waitingForHttp to disable buttons (or show a "busy" page). Using interceptors gives an added bonus that now you can use the error functions to log all HTTP errors in one place if you want.

Alvin Thompson
  • 5,388
  • 3
  • 26
  • 39
7

You can use this in the run block. This will make sure all the buttons will be disabled whenever there is an active XHR call.

myApp.run(function($rootScope, $http) {
    $http.defaults.transformRequest.push(function (data) {
        $rootScope.progress = true;
        return data;
    });
    $http.defaults.transformResponse.push(function(data){ 
        $rootScope.progress = false;
        return data;
    }) 
});

And then use the same model anywhere you want.

 <button ng-click="save()" ng-disabled="progress">Save</button>
codef0rmer
  • 10,284
  • 9
  • 53
  • 76
  • 1
    I like this approach, but I recommend modifying your answer to account for the possibility of multiple simultaneous requests. Without this, when the first request returns (but before any other requests return) `progress` will be incorrectly set to `false`. I worked around this by adding a counter `numberOfHttpRequests` which is incremented/decremented in the functions; only set `progress` to `false` when `numberOfHttpRequests` is 0. – Alvin Thompson Mar 02 '16 at 15:12
  • On further testing, there seem to be some responses which do not go through the "transformResponse" handler (errors maybe, and those services that don't return anything?), so the `progress` flag may not ever be reset. I've come up with a similar solution which instead uses interceptors. Please see that answer. – Alvin Thompson Mar 02 '16 at 17:49
4

You can create a directive that could be used on the element you want disabled.

 var loadingDisabled = function ($http) {
    return {
      restrict: "a",
      link: function (scope, elem, attr) {
                scope.isLoading = function () {
                    return $http.pendingRequests.length > 0;
                }
                scope.$watch(scope.isLoading, function(v) {
                    if (v) {
                        elem.disabled = true;
                    } else {
                        elem.disabled = false;
                    }
                });

            }

    };
  };
Mike Lunn
  • 2,310
  • 3
  • 21
  • 24
3

You could also consider setting a flag, and using the html tag fieldset and ng-disabled. Then you can control how long the yourDisableFlag is true based on $http calls, a $timeout, etc.

<form name="myForm">
  <fieldset ng-disabled="yourDisableFlag">
    <button id="b1" ng-click="b1function">B1</button>
    <button id="b2" ng-click="b2function">B2</button>
    <button id="b3" ng-click="b3function">B3</button>
  </fieldset>
</form>
brendan
  • 877
  • 8
  • 9
  • ofcourse I can do that, but as stated in question, i am looking for generic way. – harishr Nov 14 '14 at 03:54
  • Right you are! I thought it might still be helpful for groups of buttons, but you wouldn't want to have a separate fieldset for each button. – brendan Jan 10 '15 at 02:32
1

I was trying different approaches including directives and came up with a simple filter that transforms promise to status object. You can then use the status object to disable related button (or anything else) as well as show some progress messages. So the filter code is:

function promiseStatus() {
  return function(promise) {
    var status = {
      inProgress: true,
      resolved: false,
      rejected: false
    };
    promise
      .then(function() {
        status.resolved = true;
      })
      .catch(function() {
        status.rejected = true;
      })
      .finally(function() {
        status.inProgress = false;
      });
    return status;
  }
}

Suppose you have controller like this

function SubmitController($http) {
  var vm = this;

  vm.submitData = function() {
    return $http.post('....')
      .then(function() {
        //Doing something usefull
      });
  }
}

Then the template will be like this

<button 
  ng-disabled='status.inProgress' 
  ng-click='status = (vm.submitData() | promiseStatus)'>Submit</button>
<span class='ng-hide' ng-show='status.inProgress'>Submitting...</span>
<span class='ng-hide' ng-show='status.resolved'>Submitted succsssfully!</span>
<span class='ng-hide' ng-show='status.rejected'>Failed to submit!</span>

Full source code is on a Github

evgeny.myasishchev
  • 4,141
  • 1
  • 20
  • 15
1

This directive will disable the button until the save/promises is not fulfilled. single-click must return promises otherwise it will not disable the button.

In case there is no promises but still want to disable the button, then it is recommended to write own logic of disabling the button

app.directive('singleClick', function ($parse) {
    return {
        compile: function ($element, attr) {
            var handler = $parse(attr.singleClick);
            return function (scope, element, attr) {
                element.on('click', function (event) {
                    scope.$apply(function () {
                        var promise = handler(scope, { $event: event }); /// calls and execute the function specified in attrs.
                        if (promise && angular.isFunction(promise.finally)) { /// will execute only if it returns any kind of promises.
                            element.attr('disabled', true);
                            promise.finally(function () {
                                element.attr('disabled', false);
                            });
                        }
                    });
                });
            };
        }
    };
});
Athafoud
  • 2,898
  • 3
  • 40
  • 58
0

I've prepared directive on plnkr which overrides default ngClick directive

<button ng-click="loadData($notify)">submit</button>

and controller:

$scope.loadData = function($notify) {
    $timeout(function() {
      $notify && $notify();
    }, 1000);
  }
  • not all ng-click does return the promises?? how to handle that part in this case...?? your assumption that all ng-clicks must return promises does not hold true.. and in that case buttons remain disabled forever – harishr Feb 24 '15 at 05:49
  • hi @entre, i'm not sure what promises you think about? i think the concept of passing custom argument to ng-click method ($notify in this case) is pretty flexible and could give you more control over managing button state. – Adam Płócieniak Feb 26 '15 at 17:02