Friday 17 January 2014

AngularJS and Bootstrap form-group (control-group) error highlighting–globally

bootstrap_errorIf you use angularJS with Bootstrap, you would probably like to use bootstrap error highlighting using form-group (ex control-group). You could use ng-class directive with an expression to add the class on every form-group you use in your application. Or you could create a directive to add this feature on one place. In this post, I’ll explain how to do it.

Let’s start with an simple example. (Most advanced is at the end of the article.) In this simple JSFiddle you can see two directives which add the functionality. First is the form-group directive:

app.directive("formGroup", function () {
return {
restrict: "C",
link: function ($scope, element, attributes) {
var errorList = {};
$scope.$on("inputError", function () {
element.addClass("has-error");
});
$scope.$on("inputValid", function () {
element.removeClass("has-error");
});
},
//own scope, so emitted messages don't get mixed up
scope: true
};

It simply listens in its own scope to two events: “inputError” on which it adds has-error class to the formGroup and inputValid on which it removes the same class. So whenever you add a form group, the directive listens on the scope to messages.


Here is how and when I send them. I use another directive for that.

app.directive("input", function () {
return {
restrict: "E",
//require the access to the controller of ngModel
//its injected as the 4th parameter of link function
require: "?ngModel",
link: function ($scope, element, attributes, modelController) {
if (!modelController)
return;
// Watch the validity of the input
$scope.$watch(function () {
return modelController.$invalid;
}, function () {
// $emit messages to the control group
if (modelController.$invalid) $scope.$emit("inputError");
else $scope.$emit("inputValid");
});
}
};
});

It’s name is input. I suggest you make a function first and use it as input, textarea and selectbox directive. That way you can achieve same behaviour on more controls. The directive itself monitors the validity of the model connected to the input–if there is ngModel directive present on the same element. When there’s a change of the validity, it emits a message to the control group. Check the JSFiddle if you want to see it in action.

But wait! I don’t want to see the input red just when I haven’t started filling something out! Of course not, so lets check the validity first after the user has left the input. This is how I do it:

            var hasBeenVisited = false;
// check if user has left the field
element.on("blur", function () {
$scope.$apply(function () {
hasBeenVisited = true;
});
});
// Watch the validity of the input
$scope.$watch(function () {
return modelController.$invalid && hasBeenVisited;
}, function () {
// $emit messages to the control group
if (modelController.$invalid && hasBeenVisited)
$scope.$emit("inputError");
else $scope.$emit("inputValid");
});

I add a hasBeenVisited variable and change it on “blur” on the element. Notice that I need to wrap it in an $scope.$apply block because I also $watch it. I need it to be detected during digest. I extended the condition on which I notify the form group that there is an error. Check the updated JSFiddle to see it live.

But what if i don’t want the formGroup directive to have its own scope, because I find it unnecessary and can’t bind primitive types in the form group? I use a directive controller, which is allows the input directive to notify the parent directive about it’s state. This is the code of formGroup directive.

        //get the controller in the link function
require: "formGroup",
link: function ($scope, element, attributes, controller) {
var errorList = {};
controller.inputError = function () {
element.addClass("has-error");
};
controller.inputValid = function () {
element.removeClass("has-error");
};
},
//the controller is initialized in link function
controller: function() {return {};}

I removed the scope of the formGroup and instead I specified a function to return a controller object. In the link function I assign two functions to it, both used for notification about input state. To be able to access the controller in the link function, I have to specify the directive name as the require property.

Then in the input directive I have to require the formGroup controller and instead of emitting events on $scope I call the controller methods:

        //require controlllers of ngModel and parent directive
//formGroup
//they're injected as array parameter of link function
require: ["?ngModel", "^?formGroup"],
link: function ($scope, element, attributes, controllers) {
var modelController = controllers[0];
var formGroupController = controllers[1];
if (!modelController || !formGroupController) return;
var hasBeenVisited = false;
// check if user has left the field
element.on("blur", function () {
$scope.$apply(function () {
hasBeenVisited = true;
});
});
// Watch the validity of the input
$scope.$watch(function () {
return modelController.$invalid && hasBeenVisited;
}, function () {
// notify the control group
if (modelController.$invalid && hasBeenVisited) {
formGroupController.inputError();
} else {
formGroupController.inputValid();
}
});
}

This is how I add the has-error class to the form group (bootstrap 3) or control group (bootstrap 2). You can try it out in a JSFiddle.

Feel free to use the code in your project. Do you have an different approach? Did I miss something? Is anything unclear? Leave a comment! Did you like the post? +1 or share it! Thank you for your contributions.

No comments:

Post a Comment