I have long been annoyed with doing form validations in Angular, it just never
felt right. I’d manually link every input to a form with some convoluted
ng-class
conditions, not to mention track form state in controllers and
polluting the scopes with loading indicators.
I just recently found
an excellent writeup
on using the new ngMessages
module in Angular >=v1.3.0.beta.8
have your
error messages prioritized and easily customized. This made me rethink and
rewrite the way I use forms.
Which brought me back to rcSubmit
and the source.
With rcSubmit
the state tracking and loading indicators are handled for us,
but spamming my forms with
<div ng-class="{has-error: rc.form.needsAttention(form.field)}">
...
</div>
feels too repetitive.
If we make a few assumptions on form structure this can easily be avoided with a few directives.
Too much template logic, too repetitive
With even simple validations, there is much too much logic written in markup. Notice how many references we’re making to the form in order to validate a single field.
<form rc-submit="myAction()" name="myForm">
<div class="form-group"
ng-class="{has-error: rc.myForm.needsAttention(myForm.myField)}">
<label for="emailAddress">Enter your email address:</label>
<input type="email"
name="emailAddress"
ng-model="data.email"
ng-minlength="5"
ng-maxlength="30"
required />
<div ng-messages="">...</div>
</div>
<button class="btn btn-default"
type="submit"
ng-disabled="rc.myForm.submitInProgress">Reset password</button>
</form>
Since we’re making this for bootstrap forms, we’ll make some assumptions on the structure of our forms:
<form rc-submit="myAction()" name="myForm">
<div class="form-group">
<input class="form-control" ng-model="myModel" />
<div ng-messages="">...</div>
</div>
<button class="btn" type="submit">Submit</button>
</form>
Which will let us create a chain of directives.
Disabling submit button on load
Let’s start simple, and get rid of the ng-disabled
attribute on our submit
button. We’ll probably want this on all our forms, so creating a directive with
a semantic name will make the template cleaner to read.
By require’ing ^rcSubmit
we’ll get access to rcSubmitController
of the
parent form.
angular.module('myApp')
.directive('formSubmit', function() {
return {
restrict: 'C',
require: '^rcSubmit',
link: function(scope, element, attrs, controller) {
scope.$watch(function() {
return controller.submitInProgress;
}, function(val) {
element.toggleClass('disabled', val);
element.attr('disabled', !!val);
});
}
};
});
This will make any button.form-submit
disabled when the form it belongs to is
submitting. Note that this only works if your submit action returns a promise
for rcSubmit
to wait for.
With this directive in place our submit button no longer references the form in the template. One reference down, two to go.
<form rc-submit="myAction()" name="myForm">
...
<button class="btn btn-default form-submit"
type="submit">Reset password</button>
</form>
Setting error class on the form group
Let’s get rid of the ng-class
on the form group, and two more form references.
<div class="form-group"
ng-class="{has-error: rc.myForm.needsAttention(myForm.myField)}">
<input class="form-control" ng-model="myModel" />
</div>
We can use the form[rc-submit] > .form-group
relation in the same way we just
did for the form submit button.
Unlike our formSubmit directive, we’ll want to make this directive fail
silently, since .form-group
might have nothing to do with an [rc-submit]
form. So we’ll make the controller optional in require
.
We want to create a directive controller that allows the inputs to register themselves as part of a validation (form-)group.
angular.module('myApp')
.directive('formGroup', function() {
return {
restrict: 'C',
require: ['^?rcSubmit', 'formGroup'],
controller: function() {
this.inputs = [];
this.addInput = function(input) {
this.inputs.push(input);
};
},
link: function(scope, element, attrs, controllers) {
// ...
}
};
});
An input can refenrence the formGroup
controller and call addInput
to
register. Let’s hook up the bootstrap .form-control
to do this.
Again, keeping the dependency soft, so we won’t break any forms that don’t use
rcSubmit.
We’ll just load up the ngModel
and register it with formGroup
if possible.
angular.module('myApp')
.directive('formControl', function() {
return {
restrict: 'C',
require: ['ngModel', '^?formGroup'],
link: function (scope, element, attr, controllers) {
var modelCtrl = controllers[0];
var groupCtrl = controllers[1];
if(groupCtrl)
groupCtrl.addInput(modelCtrl);
}
};
});
So now our formGroup
directive should contain a list of all it’s contained
formControl
elements.
Now we need to validate them all. Recall the rcSubmit
needsAttention method,
let’s just apply that to each model and set the error if any of them fail.
// ...
link: function(scope, element, attrs, controllers) {
var formCtrl = controllers[0];
var groupCtrl = controllers[1];
// option to disable via novalidate attribute
// and only run if the rcSubmit controller was found
if(typeof attrs.novalidate !== 'undefined' || !formCtrl) return;
var fn = function() {
return groupCtrl.inputs.reduce(function(accum, field) {
return accum || formCtrl.needsAttention(field);
}, false);
};
scope.$watch(fn, function(val) {
if(typeof val === 'undefined') return;
element.toggleClass('has-error', val);
});
}
Now we will have the error indication with just the markup:
<form rc-submit="myAction()" name="myForm">
<div class="form-group">
<input class="form-control" ng-model="myModel" />
</div>
</form>
Which has made our markup entirely free of form references.
<form rc-submit="myAction()" name="myForm">
<div class="form-group">
<label for="emailAddress">Enter your email address:</label>
<input type="email"
name="emailAddress"
ng-model="data.email"
ng-minlength="5"
ng-maxlength="30"
required />
<div ng-messages="">...</div>
</div>
<button class="btn btn-default form-submit"
type="submit">Reset password</button>
</form>
What about ng-messages?
We can get the list of fields belonging to a form group by requireing the
formGroup
controller. And then loop the equivalent formField.$error
objects in an ng-repeat.
If your group contains multiple fields and you want to customize the message
for each of them, you should be able to override this discovery and just tell
it which field to use. For this we’ll use the for
attribute.
If we attempt to do
scope.fields = [formCtrl[attr.for]];
we’ll run into issues if we try to define a message before it’s field in the form. We can solve this by using a watcher.
angular.module('myApp')
.run(function($templateCache) {
$templateCache.put('validations.default',
'<div ng-message="required">You left the field blank...</div>'+
'<div ng-message="minlength">Your field is too short</div>'+
'<div ng-message="maxlength">Your field is too long</div>'+
'<div ng-message="email">Your field has an invalid email address</div>'
);
})
.directive('validationMessages', function() {
return {
restrict: 'EA',
require: ['^form', '^formGroup'],
scope: {},
priority: 100,
link: function(scope, element, attr, controllers) {
var formCtrl = controllers[0];
var formGroup = controllers[1];
scope.fields = formGroup.inputs
.map(function(i) {
return formCtrl[i.$name];
});
if(attr.for)
scope.$watch(
function() { return formCtrl[attr.for]; },
function(field) { scope.fields = [field]; }
);
},
transclude: true,
template:
'<div ng-repeat="field in fields" ng-messages="field.$error" '+
'ng-messages-include="validations.default" ng-transclude></div>'
};
});
Your form is now completely free of name ties, and you’ve put your directives to work for you.
<form rc-submit="myAction()" name="myForm">
<div class="form-group">
<label for="emailAddress">Enter your email address:</label>
<input type="email"
name="emailAddress"
ng-model="data.email"
ng-minlength="5"
ng-maxlength="30"
required />
<!-- equivalent -->
<div validation-messages for="email"></div>
<div validation-messages></div>
</div>
<button class="btn btn-default form-submit"
type="submit">Reset password</button>
</form>
Source
angular.module('myApp')
.directive('formGroup', function() {
return {
restrict: 'C',
require: ['^?rcSubmit', 'formGroup'],
controller: function() {
this.inputs = [];
this.addInput = function(input) {
this.inputs.push(input);
};
},
link: function(scope, element, attrs, controllers) {
var formCtrl = controllers[0];
var groupCtrl = controllers[1];
if(typeof attrs.novalidate !== 'undefined' || !formCtrl) return;
var fn = function() {
return _.reduce(groupCtrl.inputs, function(accum, field) {
return accum || formCtrl.needsAttention(field);
}, false);
};
scope.$watch(fn, function(val) {
if(typeof val === 'undefined') return;
element.toggleClass('has-error', val);
});
}
};
})
.directive('formControl', function() {
return {
restrict: 'C',
require: ['ngModel', '^?formGroup'],
link: function (scope, element, attr, controllers) {
var modelCtrl = controllers[0];
var groupCtrl = controllers[1];
if(groupCtrl)
groupCtrl.addInput(modelCtrl);
}
};
})
.directive('formSubmit', function() {
return {
restrict: 'C',
require: '^rcSubmit',
link: function(scope, element, attrs, controller) {
scope.$watch(function() {
return controller.submitInProgress;
}, function(val) {
element.toggleClass('disabled', val);
element.attr('disabled', !!val);
});
}
};
})
.run(function($templateCache) {
$templateCache.put('validations.default',
'<div ng-message="required">You left the field blank...</div>'+
'<div ng-message="minlength">Your field is too short</div>'+
'<div ng-message="maxlength">Your field is too long</div>'+
'<div ng-message="email">Your field has an invalid email address</div>'
);
})
.directive('validationMessages', function() {
return {
restrict: 'EA',
require: ['^form', '^formGroup'],
scope: {},
priority: 100,
link: function(scope, element, attr, controllers) {
var formCtrl = controllers[0];
var formGroup = controllers[1];
scope.fields = formGroup.inputs
.map(function(i) {
return formCtrl[i.$name];
});
if(attr.for)
scope.$watch(
function() { return formCtrl[attr.for]; },
function(field) { scope.fields = [field]; }
);
},
transclude: true,
template:
'<div ng-repeat="field in fields" ng-messages="field.$error" '+
'ng-messages-include="validations.default" ng-transclude></div>'
};
});
Posted in Programming with : Angular, Validation