It’s not uncommon to have complex forms with a large number of fields in the line of business applications. In one of the projects at work, we recently implemented such form with 60+ fields. Behind the scene, this application uses sophisticated business rules to determine whether the field is mandatory or optional based on user inputs. We wanted to give our users a visual feedback about the fields - whether it is required/mandatory, and the provided value passes validation. There fields are divided into 6 different sections, so we wanted to give easy way for the user to navigate around the sections/fields. This was implemented using KnockoutJS, this post takes a stab at implementing it using AngularJS.
The pane on the left side contains tree-like structure listing sections and fields underneath each. Sections could be nested, so we would need the ability to create tree structure that could go multiple levels deep. Required fields show exclamation mark with red, which turns green tick-mark as soon as the provided input passes validation. Clicking on the field name focuses the input element on the form. You could play with the live demo in the results pane below
Following sections summarizes implementation
Defining Fields & Hierarchy
List and hierarchy of the fields could be defined as static JavaScript object as it doesn’t change often. This object could be bound to tree view. However, the ability to define it in a declarative fashion, just like you declare other input elements in HTML would be much easier to maintain in long run. In the following snippet, you could see a div with attribute field
‘Contact Information’ containing two field
elements for ‘First Name’ and ‘Last Name’. We would need a directive that could read these attributes and creates hierarchical field map. In addition to the name of the field to be displayed, it also includes id, which could be used for validation and navigation on click of the tree node.
<div field="Contact Information" field-id="contactInfo">
<div field="First Name" field-id="firstName">
<label for="firstName">First Name</label>
<input type="text" ng-model="model.firstName">
</div>
<div field="Last Name" field-id="lastName">
<label for="lastName">Last Name</label>
<input type="text" ng-model="model.lastName">
</div>
</div>
Following is the directive that would read the field name, id and pass it to the navigator
object to add it to the tree map. We also need to check for the parent element so that field could be added to the appropriate location in the hierarchy. One important thing to note about this directive is that it is doing all its work in pre-link
phase. The reason for this is the order in which Angular binds nested directives. Angular performs pre-linking parsing in the top-down fashion - parent elements would be parsed before the child elements. The actual linking happens in the bottom-up fashion - the child elements are bound before the parent elements. In our case, we would like to add the parent element to our field tree before the child elements, so that it could be added to an appropriate location. Pre-link phase is not safe for performing DOM manipulation for event binding, in our case we just need to read the attributes. About the naming convention, you could see how Angular is converting snake-case
to camel-case
- field-id
is read as ‘fieldId’.
app.directive("field", function () {
return{
restrict: 'A',
scope: false,
compile: function (element, attr) {
return {
pre: function (scope, element, attr, fieldCtrl) {
if (attr.field && attr.fieldId) {
parentFieldId = element.parents('[field]').first().attr('field-id');
scope.navigator.addField(attr.field, attr.fieldId, parentFieldId);
}
else {
throw new Error('Name and Id are required for field.');
}
}
};
}
};
});
Fields will be added to an array, with child fields in its own array. Object loaded with fields would look similar to shown below.
[{name: 'Contact Information', id: 'contactinfo',
fields: [{name: 'First Name', id: 'firstName'},
{name: 'Last Name', id: 'lastName'},
{name: 'Address', fields:[{name: 'Line #1', id: "line1"}, {name: 'State', id: "state"},{name: 'zip', id: "zip"}]}]},
{name:'Medical Information', fields:[{name: 'Hospital', id: "hospital"}, {name: 'Physicians Name', id: "physiciansName"}]}];
Creating Tree
Once we have the fields loaded into the object, we need to visualize it as tree-like structure. Angular runs into infinite loop if you try to make the directive recursive. The solution is to remove the element during compile phase, manually compile and add it back during the link phase. More discussion this approach could be found in this angular forum topic.
In the template for the tree directive, you could see it using tree directive within itself. In addition to the label for the field, it includes the icons for showing the require/ completed state of the field.
<ul class="tree">
<li ng-repeat="field in fields">
<div focus-input="{{field.id}}">
<i class="glyphicon glyphicon-exclamation-sign required" ng-show="model.isFieldRequired(field.id) && model.isFieldValid(field.id) == false"></i>
<i class="glyphicon glyphicon-ok-sign valid" ng-show="model.isFieldValid(field.id)"></i>
{{field.name}}
</div>
<tree ng-if="field.fields" fields="field.fields" model="model"></tree>
</li>
</ul>
Following is the tree
directive definition.
app.directive("tree", function ($compile) {
return{
restrict: 'E',
scope: {fields: '=',
model: '='
},
templateUrl: 'partials/tree.html',
compile: function (element) {
var contents = element.contents().remove();
var compiledContent;
return function (scope, element) {
if (!compiledContent) {
compiledContent = $compile(contents);
}
compiledContent(scope, function (clone) {
element.append(clone);
});
};
}
};
});
Validation Rules and Instant Feedback
Sometimes the business logic around field validation and requirements could become rather complicated. In such situations, the validation service provided by the Angular forms might not be sufficient. To encapsulate business logic within the model, I defined PersonalInfo
model with the constructor function, an instance of this model is injected in the PersonalInfoCtrl
. In this sample, I have kept the rules very simple but this approach could scale well when logic becomes more elaborate.
models.PersonalInfo = (function () {
function PersonalInfo() {
this.requiredFields = [];
this.require('firstName', 'lastName', 'email', 'addressLine1', 'city', 'state', 'zip', 'insuranceCompany', 'plicyNumber', 'emergencyContact', 'emergencyPhone');
}
PersonalInfo.prototype.require = function () {
this.requiredFields.push.apply(this.requiredFields, arguments);
};
PersonalInfo.prototype.isFieldValid = function (field) {
if (this.isFieldRequired(field)) {
return this[field] != undefined && this[field] != null && this[field].length > 0;
}
else {
return false;
}
};
PersonalInfo.prototype.isFieldRequired = function (field) {
return this.requiredFields.indexOf(field) != -1;
};
return PersonalInfo;
})();
Navigating the Form
On click of the field in the tree, we would like the focus to be set on the input element. If you noticed, we are using another directive focus-input
in the tree
directive’s template.
<div focus-input="{{field.id}}">
Following is the definition for this little directive.
app.directive('focusInput', ['$timeout', function ($timeout) {
return function (scope, element, attrs) {
element.bind('click', function () {
$timeout(function () {
$("input", "[field-id='" + attrs.focusInput + "']").focus()
});
});
}
}]);