In all the webapps we write, we usually write forms. And forms usually require some lots and lots and lots of boilerplate and duplicated code. Let's take a look at a code fragment taken from the AngularJS documentation where just two simple inputs are declared:
Name:
<input type="text" ng-model="user.name" name="uName" required="" />
<br />
<div ng-show="form.$submitted || form.uName.$touched">
<div ng-show="form.uName.$error.required">Tell us your name.</div>
</div>
E-mail:
<input type="email" ng-model="user.email" name="uEmail" required="" />
<div ng-show="form.$submitted || form.uEmail.$touched">
<span ng-show="form.uEmail.$error.required">Tell us your email.</span>
<span ng-show="form.uEmail.$error.email">This is not a valid email.</span>
</div>
Each input part consist mainly of 3 parts:
- A label
- The actual input
- Validation error messages
It is not angular-specific, it is the way probably 99% of all the forms on the Internet are created. Now, let's imagine in our app we have like 20 forms with 10 input parts each. And on some beautiful day our Boss tells us that the labels should no more contain the ":" at the end. Hmmm sounds like 200 places to change the code :) sweet :) And what if they request us to do some more sophisticated modification - like display the validation messages only after user tries to submit the form? Sounds not only like 200 places to change the code, but also 200 inputs to check validation display logic, too :)
Yes, code duplication is horrible. Therefore, we should always transform self-contained concepts such as an input + label + validation message into an abstraction. E.g. like that one:
<my-input model="user.name" required="true" type="text" label="My label" name="name"></my-input>
This looks a bit nicer. The good news, it is actually possible with help of an angularjs
custom directive. The final code looks as follows, the explanations for the most important parts are in the code comments:
'use strict';
/*
dependencies on:
- ngMessages to display validation error messages
- validation.match - this is angular-validation-match module which provides validation whether one field matches
value of another field, e.g. password vs confirm password
*/
angular.module('input', ['ngMessages', 'validation.match'])
/*
* the directive will be called meInput - we will use me prefix for the directives.
* Using a prefix for custom
* directives is recommended by angularJs developers. And of course don't use ng
* prefix as it is reserved for the built-in AngularJs directives.
*/
.directive('meInput', function() {
return {
/* template-building function is used. I don't use static template because of required DOM manipulations.
While AngularJs docs suggest compile and link functions to do the DOM manipulation, I wasn't able to
add custom input to the parent form - the input was in the HTML but was not added to the parent form's
controller
*/
template: buildTemplate,
//transclusion will make it possible to override the default error messages
transclude: true,
//the input should be wrapped in a form. And we're gonna need the form's controller to build input's id
require: '^form',
//link function - refer to the function itself for detailed description
link: link,
//we are creating isolated scope. @ means that we are adding the String literal provided as the directive
//attribute value and = creates a bidirectional binding between the parent scope property and the directive
//isolated scope's property. By bidirectional biding we can e.g. bind ng-Model in our directive to a parent
//scope property, so that we can read and update it
scope: {
name: '@',
model: '=',
label: '@',
required: '@',
meMatch: '=',
//it is not used as a scope value, but I specify it explicity for the sake of IDE validations and auto-complete
type: '@'
}
};
/* modifying the template DOM in compile function was not adding the input to the parent form. After some
* googling it turned out the template would need to be compiled against parent scope. Building the template
* string turned out to be a nicer solution
*
* The function 2nd parameter is of importance - it provides the values for attributes provided for the
* directive function. And values here mean the String literals, not actual value they point to in bidirectional
* (=) bindings in the parent scope. The bidirectional binding values will be populated only after the directive
* is compiled
*/
function buildTemplate(tElement, tAttributes) {
//{{::}} - one-time interpolation. The value for inputId is created in the link function, while label and
// name are provided as the directive's attribute.
var ret = '<label for="{{::inputId}}">{{::label}}:</label>\n' +
'<input id="{{::inputId}}" name="{{::name}}" ';
//if and only if meMatch attribute is specified the angular-validation-match directive will be added to the
//input
if (tAttributes.meMatch) {
ret += 'match="meMatch" ';
}
//type needs to be set once and for all due to issues in IE - we can't interpolate with e.g. {{::}}
ret+= 'type="' + tAttributes.type + '" ng-model="model" ng-required="required">\n' +
//now some magic with displaying the error messages, with some CSS classes to format the output.
//formCtrl is populated by the link function (see below)
' <div ng-show="(formCtrl[name].$touched || formCtrl.$submitted) && !formCtrl[name].$valid" ng-messages="formCtrl[name].$error" class="alert-box error">\n' +
//here the transclusion - as the transcluded content is above the default error messages, any error
//message transcluded will override the default ones. And, of course, we can add new messages as well
' <ng-transclude></ng-transclude>\n' +
' <div ng-message="email">Please provide a valid e-mail</div>\n' +
//this one is very nice - instead of providing a generic 'This field is required' we will display
//the actual field label in the required error message for even better usability!
'<div ng-message="required">{{::label}} is required</div>\n' +
'</div>';
return ret
}
// the link function does two important things:
function link(scope, iElement, iAttrs, controller){
//1. Provides access to the parent form controller so that we can check if the form has been submitted -
//this is used in the error messages validation logic
scope.formCtrl = controller;
//provides a unique id for the input. At least as long you don't have two forms with the same name on the
//same page. But you dont' do you?
scope.inputId = controller.$name + '-' + iAttrs.name;
}
});
In the above code I would point your attention especially to:
<div ng-message="required">{{::label}} is required</div>
With this nice trick we have e.g.
Name is required message instead of ugly and generic
This field is required. A nice usability feature showing that you actually
care about your website and the users.
And most importantly, does this actually work? Well, here are the tests (in Jasmine):
'use strict';
describe('input directive', function() {
var compile;
var scope;
beforeEach(module('input'));
beforeEach(inject(function($compile, $rootScope) {
compile = $compile;
scope = $rootScope;
}));
it('should display error message when required and empty', function() {
//given
var testee = compile('<form name="testForm" >\n' +
' <me-input name="testName" type="email"\n' +
' model="user.test" required="true" label="label"></me-input>\n' +
'</form>')(scope);
//when
var input = testee.find('input');
input.triggerHandler('blur');
scope.$digest();
//then
expect(testee.html()).toContain('label is required');
});
it('should not display error message when not required', function() {
//given
var testee = compile('<form name="testForm">\n' +
' <me-input name="testName" type="email"\n' +
' model="user.test" label="label"></me-input>\n' +
'</form>')(scope);
//when
var input = testee.find('input');
input.triggerHandler('blur');
scope.$digest();
//then
expect(testee.html()).not.toContain('is required');
});
it('should override displayed error message with transcluded content', function() {
//given
var testee = compile('<form name="testForm">\n' +
' <me-input name="testName" type="email"\n' +
' model="user.test" required="true" label="label">' +
'<div ng-message="required">Custom required message</div> ' +
'</me-input>\n' +
'</form>')(scope);
//when
var input = testee.find('input');
input.triggerHandler('blur');
scope.$digest();
//then
expect(testee.html()).toContain('Custom required message');
});
it('should update model value when filled with proper data', function() {
//given
var testee = compile('<form name="testForm" >\n' +
' <me-input name="testName" type="email"\n' +
' model="modelTest" required="true" label="label"></me-input>\n' +
'</form>')(scope);
//when
testee.find('input').val('myemail@somedomain.com').triggerHandler('input');
scope.$apply();
//then
expect(scope.modelTest).toEqual('myemail@somedomain.com');
});
it('should contain the label with appropriate text', function() {
//given
var testee = compile('<form name="testForm" >\n' +
' <me-input name="testName" type="email"\n' +
' model="modelTest" required="true" label="label text"></me-input>\n' +
'</form>')(scope);
//when
var label = testee.find('label');
scope.$digest();
//then
expect(label.html()).toContain('label text');
});
describe('match directive integration', function() {
it('should display error if match is specified and value not matched', function() {
//given
var testee = compile('<form name="testForm" >\n' +
' <me-input name="matchMe" type="text"\n' +
' model="matchMe" label="label text"></me-input>\n' +
' <me-input name="testName" type="text"\n' +
' model="modelTest" label="label text" me-match="testForm.matchMe">' +
' <div ng-message="match">No match</div>\n\n' +
' </me-input>\n' +
'</form>')(scope);
//when
angular.element(testee.find('input')[1]).val('myemail@somedomain.com').triggerHandler('input');
scope.$apply();
//then
expect(testee.html()).toContain('No match');
});
});
});