AngularJS: Automatically Disable Groups of Elements During HTTP Calls…or Any Call

There are many instances where you need to collect data from a user and post it to a service. AngularJS makes this pretty easy using the $http service. However when you are submitting data, your app is in a sort of suspended state. Because of the issue of confusion and multiple submissions, you don’t want the user to interact with certain elements until the submission is complete. So we created what we think is a relatively simple way to disable/block the UI during form submission. If you are less concerned about the “why” and the “how” and just want to see some code, head on over to our GitHub and look at our rcDisabled module.

When developing directives it is sometimes helpful to begin with how you want that directive to be used. If you just start coding the directive, the resulting implementation can be less intuitive. We want a simple way to disable our UI when a form is submitting. Ideally we want to accomplish that with some simple mark-up like so:

   <form name="loginForm" novalidate="" ng-app="LoginApp" ng-controller="LoginController" 
         rc-submit="login()" rc-disabled="rc.loginForm.submitInProgress">
     <div class="form-group" 
          ng-class="{'has-error': rc.loginForm.needsAttention(loginForm.username)}">
       <input class="form-control" name="username" type="text" 
              placeholder="Username" required="" ng-model="session.username" />
       <span class="help-block" ng-show="loginForm.username.$error.required">Required</span>
     </div>
     <div class="form-group" 
          ng-class="{'has-error': rc.loginForm.needsAttention(loginForm.password)}">
       <input class="form-control" name="password" type="password" 
              placeholder="Password" required="" ng-model="session.password" />
       <span class="help-block" ng-show="loginForm.password.$error.required">Required</span>
     </div>
     <div class="form-group">
       <a class="btn btn-primary pull-right">
         Link Action
       </a>
       <span class="pull-right">&nbsp;</span>
       <button type="submit" class="btn btn-primary pull-right" value="Login" title="Login">
         <span>Login</span>
       </button>
     </div>
   </form>

Add here is how we get there…

In one of our earlier posts we explored advanced AngularJS form validation, using a simple login form. This post builds on that. On a login form (or any data entry form for that matter), you usually submit data to a service and wait for a response. Some people handle this by creating a busy flag and manipulating styles or the DOM to disable the interface until a result is returned. We saw two separate problems here: One, we wanted an intuitive way to easily provide this busy state information on any form we were submitting. And Two, a simple way to disable the interface while we were waiting.

The first problem involves tapping into the form submission process. Luckily we already started down this path in our previous blog with the rcSubmit directive. We bind to the form’s submit event then execute the specified method if the form is valid. However this just the beginning of the submit process. If we are calling a service method we need to know when its completed. In AngularJS it is common to use the $http service to call web service methods. This service utilizes promises which can be very useful to us. If we have a promise, we can respond when it is resolved or rejected. At the same time, we don’t want to require the use of promises in every instance. If you don’t need to know when it completes or if your method is synchronous, it still should just work. Bearing all that in mind, here is the resulting rcSubmit directive with our changes:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
   var rcSubmitDirective = {
     'rcSubmit': ['$parse', '$q', '$timeout', function ($parse, $q, $timeout) {       return {
         restrict: 'A',
         require: ['rcSubmit', '?form'],
         controller: ['$scope', function ($scope) {
 
           var formController = null;
           var submitCompleteHandlers = []; 
           this.attempted = false;
           this.submitInProgress = false; 
           this.setAttempted = function() {
             this.attempted = true;
           };
 
           this.setFormController = function(controller) {          
             formController = controller;
           };
 
           this.needsAttention = function (fieldModelController) {
             if (!formController) return false;
 
             if (fieldModelController) {
               return fieldModelController.$invalid && 
                      (fieldModelController.$dirty || this.attempted);
             } else {
               return formController && formController.$invalid && 
                      (formController.$dirty || this.attempted);
             }
           };
 
           this.onSubmitComplete = function (handler) {              submitCompleteHandlers.push(handler);           }; 
           this.setSubmitComplete = function (success, data) {              angular.forEach(submitCompleteHandlers, function (handler) {               handler({ 'success': success, 'data': data });             });           };         }],
         compile: function(cElement, cAttributes, transclude) {
           return {
             pre: function(scope, formElement, attributes, controllers) {
 
               var submitController = controllers[0];
               var formController = (controllers.length > 1) ? controllers[1] : null;
 
               submitController.setFormController(formController);
 
               scope.rc = scope.rc || {};
               scope.rc[attributes.name] = submitController;
             },
             post: function(scope, formElement, attributes, controllers) {
 
               var submitController = controllers[0];
               var formController = (controllers.length > 1) ? controllers[1] : null;
               var fn = $parse(attributes.rcSubmit);
 
               formElement.bind('submit', function () {
                 submitController.setAttempted();
                 if (!scope.$$phase) scope.$apply();
 
                 if (!formController.$valid) return false;
 
                 var doSubmit = function () {                    submitController.submitInProgress = true;                   if (!scope.$$phase) scope.$apply();                    var returnPromise = $q.when(fn(scope, { $event: event }));                    returnPromise.then(function (result) {                     submitController.submitInProgress = false;                     if (!scope.$$phase) scope.$apply();                     // This is a small hack.  We want the submitInProgress                     // flag to be applied to the scope before we actually                     // raise the submitComplete event. We do that by                     // using angular's $timeout service which even without                     // a timeout value specified will not fire until after                     // the scope is digested.                     $timeout(function() {                       submitController.setSubmitComplete(true, result);                     });                    }, function (error) {                     submitController.submitInProgress = false;                     if (!scope.$$phase) scope.$apply();                     submitController.setSubmitComplete(false, error);                   });                 }; 
                 if (!scope.$$phase) {                   scope.$apply(doSubmit);                 } else {                   doSubmit();                   if (!scope.$$phase) scope.$apply();                 }               });
             }
           };
         }
       };
     }]
   };

Starting in the post method, we have wrapped the result of the rcSubmit method using $q.when which gives us a promise no matter what. This allows us to handle the method the same way in all cases: First we set our state flag, submitInProgress. If it’s just a normal non-promisey method, it will execute and when its completed, we will unset the flag. If the method returns a promise, it will wait for the promise to be resolved/rejected and then unset the flag.

We also added a basic event infrastructure for onSubmitComplete. Although we may not use it in this instance, having a way to handle this event may be useful in the future.

On to our second problem. To accomplish the disabling, we first looked at ngDisabled. However, it is only supported directly on elements that support the disabled attribute (i.e. input and button elements). This is understandable but we needed more. We don’t want to have to repeat the same logic on every input/button on the form. In this instance it’s only a few elements, but it can get a little crazy on a more complex input form. In a perfect world, we want to be able to apply the directive on any element and the directive is smart enough to only apply the disabled attribute on the children that support it. Which brings us to ‘rcDisabled‘:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
   var rcDisabledDirective = {
     'rcDisabled': function () {
       return {
         restrict: 'A',
         link: function (scope, element, attributes) {
 
           scope.$watch(attributes.rcDisabled, function(isDisabled) {
             var jqElement = jQuery(rootElement);
 
             return jqElement
                      .find(':not([rc-disabled])')
                      .filter(function(index) {
                        return jQuery(this)
                                 .parents()
                                 .not(jqElement)
                                 .filter('[rc-disabled]').length === 0;
                      })
                     .filter('input:not([ng-disabled]), button:not([ng-disabled])')
                     .prop('disabled', isDisabled);
           });
         }
       }
     }
   };

Great! It does exactly what we wanted: added/removed the disabled attribute from all supported child elements. We even cleverly ignore sub items that are using their own rcDisabled or ngDisabled directive. This allows for more complex scenarios where nested disabling directives might have different logic. Also, you may notice the explicit use of jQuery. AngularJS includes a minimal version of jqLite, however we are using some methods (namely ‘filter‘ and ‘not‘) that are only available in the full version of jQuery. So when using this directive we have to recognize that jQuery is a dependency. Although we still require even more functionality. We here at RealCrowd also use Twitter Bootstrap (which is awesome by the way). And Bootstrap gives us the ability for links/anchors (‘<a>‘) to look and behave like buttons. However links do not support a disabled attribute. Bootstrap was smart about this and added a .disabled CSS class. We could modify our directive to take this into account, but we try to design our directives to be implementation-agnostic when possible. Instead of putting Bootstrap-specific logic in the directive, we want it to be configurable. In AngularJS, when you want something to be configurable, you can use a provider. We therefore created a rcDisabledProvider:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
   var rcDisabledProvider = function () {
 
     var defaultDisableHandler = function(rootElement, isDisabled) {
       var jqElement = jQuery(rootElement);
 
       return jqElement
               .find(':not([rc-disabled])')
               .filter(function(index) {
                 return jQuery(this)
                          .parents()
                          .not(jqElement)
                          .filter('[rc-disabled]').length === 0;
               })
               .filter('input:not([ng-disabled]), button:not([ng-disabled])')
               .prop('disabled', isDisabled);
     };
 
     var customDisableHandler;
 
     this.onDisable = function (customHandler) {
       customDisableHandler = customHandler;
     };
 
     this.$get = function () {
       return {
         disable: function (rootElement, isDisabled) {
           return (customDisableHandler) ? 
                  customDisableHandler(rootElement, isDisabled) : 
                  defaultDisableHandler(rootElement, isDisabled);
         }
       }
     };
   };

For those of you unfamiliar with providers, simply view them as a configurable service with state. In AngularJS you can configure a provider for a module/application and use it in any controllers or directives you want just as you would a service. The methods used for configuring are defined in the root provider function. In this case we have one such method called onDisabled. The actual rcDisabled service instance is defined by the special $get method. For the rcDisabledProvider, the common state is our method for disabling/re-enabling DOM objects. We have a default method that is the same logic as our original rcDisabled directive. However, we also have the option to configure the provider with a custom method using onDisabled. We now need to update the rcDisabled directive to use the rcDisabledProvider:

1
2
3
4
5
6
7
8
9
10
11
12
13
   var rcDisabledDirective = {
     'rcDisabled': ['rcDisabled', function (rcDisabled) {
       return {
         restrict: 'A',
         link: function (scope, element, attributes) {
 
           scope.$watch(attributes.rcDisabled, function(isDisabled) {
             rcDisabled.disable(element, isDisabled);           });
         }
       }
     }]
   };

A quick note on the above code. You’ll notice that the included dependency for the rcDisabledProvider just says ‘rcDisabled‘. The way providers work in AngularJS is they are defined without a ‘Provider‘ suffix just as you would a service. The only time the suffix is used is when you configure it (which we will show below). This may also cause confusion as the directive is also called ‘rcDisabled‘. This doesn’t cause any conflicts in Angular that I’m aware, but admittedly this may not be the best design. I have personally justified it thinking that confusion should not occur often in the actual use of this directive and provider. You will either be placing and rc-disabled attribute on an element or configuring this rcDisabledProvider in the application or module. Hopefully that clears things up.

The directive will now use either the default method or whatever custom method has been defined. If you were to use a custom method as we wish to do for our Bootstrap implementation, you would configure it like so:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
   angular.module('rcDisabledBootstrap', ['rcDisabled'])
   .config(['rcDisabledProvider', function(rcDisabledProvider) {
     rcDisabledProvider.onDisable(function(rootElement, isDisabled) {
       var jqElement = jQuery(rootElement);
 
       return jqElement
               .find(':not([rc-disabled])')
               .filter(function(index) {
                 return jQuery(this)
                          .parents()
                          .not(jElement)
                          .filter('[rc-disabled]').length === 0;
               })
               .filter('input:not([ng-disabled]), button:not([ng-disabled]), .btn, li')
               .add(jqElement)
               .toggleClass('disabled', isDisabled)
               .filter('input, button')
               .prop('disabled', isDisabled);
     });
   }]);

The above code creates an augmented module called ‘rcDisabledBootstrap‘ which just takes in our rcDisabled module and configures it using the rcDisabledProvider. Now our custom method not only toggles the supported disabled attributes, but also toggles the CSS disabled class on appropriate elements. The logic we used to set the disabled class was based on how and where we saw it used in the bootstrap.css file. With this code, we can apply this directive to the following markup:

   <form name="loginForm" novalidate="" ng-app="LoginApp" ng-controller="LoginController" 
         rc-submit="login()" rc-disabled="rc.loginForm.submitInProgress">
     <div class="form-group" 
          ng-class="{'has-error': rc.loginForm.needsAttention(loginForm.username)}">
       <input class="form-control" name="username" type="text" 
              placeholder="Username" required="" ng-model="session.username" />
       <span class="help-block" ng-show="loginForm.username.$error.required">Required</span>
     </div>
     <div class="form-group" 
          ng-class="{'has-error': rc.loginForm.needsAttention(loginForm.password)}">
       <input class="form-control" name="password" type="password" 
              placeholder="Password" required="" ng-model="session.password" />
       <span class="help-block" ng-show="loginForm.password.$error.required">Required</span>
     </div>
     <div class="form-group">
       <a class="btn btn-primary pull-right">
         Link Action
       </a>
       <span class="pull-right">&nbsp;</span>
       <button type="submit" class="btn btn-primary pull-right" value="Login" title="Login">
         <span>Login</span>
       </button>
     </div>
   </form>

This is a relatively simplified example. You may want to change the UI a bit more to better inform the user what is happening. But one of the great things about this directive is that its easily customizable. If you want to do additional DOM manipulation, you can add whatever you want in the configuration. And there you have it; a nice simple group disabler. We have created a Plunker to show this in action and as always the code and examples are available on our GitHub. (Please note, all solutions presented in this post are based on AngularJS 1.0.8 and Bootstrap 3.0.0)

Bonus

If using Bootstrap, you have the additional option of using some of their custom jQuery plug-ins. One of those is the “Button” plug-in which has some functionality that works nicely with the rcDisabled module. Specifically, it is stateful and allows you to define a data-loading-text attribute on your button. Then, using the plug-in, you can toggle the text when the UI is busy. We can make a slight modification to our Bootstrap version of the module and add our attribute to the markup:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
   angular.module('rcDisabledBootstrap', ['rcDisabled'])
   .config(['rcDisabledProvider', function(rcDisabledProvider) {
     rcDisabledProvider.onDisable(function(rootElement, isDisabled) {
       var jqElement = jQuery(rootElement);
 
       jqElement = jqElement
                     .find(':not([rc-disabled])')
                     .filter(function(index) {
                       return jQuery(this)
                                .parents()
                                .not(jqElement)
                                .filter('[rc-disabled]').length === 0;
                     })
                     .filter('input:not([ng-disabled]), button:not([ng-disabled]), .btn, li')
                     .add(jqElement);
 
       // if the Bootstrap "Button" jQuery plug-in is loaded, use it on those       // that have it configured       if (jqElement.button) {         jqElement.find('[data-loading-text]').button((isDisabled) ? 'loading' : 'reset');       } 
       jqElement.toggleClass('disabled', isDisabled)
       .filter('input, button')
       .prop('disabled', isDisabled);
     });
   }]);

16
17
18
19
20
21
22
23
24
25
26
     <div class="form-group">
       <a class="btn btn-primary pull-right">
         Link Action
       </a>
       <span class="pull-right">&nbsp;</span>
       <button type="submit" class="btn btn-primary pull-right" value="Login" title="Login"
         data-loading-text="Please Wait...">
         <span>Login</span>
       </button>
     </div>
   </form>

Now our button text will automatically change when we are in a busy state. Keep in mind that using this plugin adds the additional dependency of the Bootstrap javascript file. Though we have coded it so the module will still fallback if it isn’t loaded. And there you have a nice little bonus.

P.S. RealCrowd is always hiring. If you like what you see and are interested in a job, contact me or JD.