Monday, July 13, 2015

Form-Based Directives in AngularJS

Enforcing complex business constraints against user submitted data poses unique challenges to a significant number of developers. Recently, my team and I were faced with such a challenge while writing an application at GiftCards.com. We needed to find a way to allow our customers to edit multiple products in a single view within our application, where each product had a unique set of validation rules.

This proved challenging because it required us to have multiple <form> tags within the HTML source and maintain a validation model per form instance. We tried many approaches, such as using ngRepeat to display the child forms, before settling on a solution. We would create one directive per product type (where each directive would have a <form> in its view) and have the directive bind to its parent controller. This allowed us to take advantage of Angular’s child / parent form inheritance to ensure the parent form was only valid if all child forms were valid.

In this tutorial we will build a simple product review screen (which highlights the key components of our current application). We will have two products, each with their own directive and each with unique validation rules. There will be a simple checkout button that will ensure that both forms are valid.

If you’re anxious to see this in action, you can jump straight to our demo, or download the code form our GitHub repo.

A Word about Directives

A directive is a block of HTML code that runs through AngularJS’s HTML compiler ($compile) and is appended to the DOM. The compiler is responsible for traversing the DOM looking for components it can turn into objects using other registered directives. Directives work within an isolated scope and maintain their own view. They are powerful tools that promote reusable components that can be shared across an entire application. For a quick refresher check out this SitePoint article or the AngularJS docs.

Directives solved our fundamental issue in two ways: first, each instance has an isolated scope, and second, the directive uses a compiler pass, whereby the compiler identifies a form element in the view’s HTML using Angular’s ngForm directive. This inbuilt directive allows multiple nested form elements, accepts an optional name attribute to instantiate a Form Controller, and will return with the form object.

And a Word about Form Controllers

When the compiler identifies any form object in the DOM, it will use the ngForm directive to instantiate a Form Controller object. This controller will scan for all input select and textarea elements and create the appropriate controls. The controls require a model attribute to set up two-way data binding and allow instant user feedback via various pre-built validation methods. Providing instant feedback to the consumer allows them to know which information is valid before making a HTTP request.

Pre-Built Validation Methods

Angular comes packaged with 14 standard validation methods. These include validators for min, max, required to name but a few. They are built to understand and operate with nearly all HTML5 input types and are cross-browser compliant.

[code language="js"]

Size:

The value is required!

[/code]

The example above shows the usage of the ngRequired directive validator in Angular. This validation ensures that the field is filled out before it is considered valid. It does not validate any of the data, just that the user has entered something. Having the attribute novalidate indicates that the browser should not validate upon submission.

Pro Tip: Do not set an action attribute on any Angular form. This will prevent Angular’s attempts to ensure the form is not submitted in a round trip manner.

Custom Validation Methods

Angular provides an extensive API to assist in the creation of custom validation rules. Using this API gives you the ability to create and extend your own validation rules for complex inputs not covered in the standard validations. My team and I rely on a few custom validation methods to run complex RegEx patterns that are used by our server. Without the ability to run the complex RegEx matchers we would potentially be sending incorrect data to our backend server. This would present the user with errors which causes a undesirable user experience. Custom validators use the directive syntax and require ngModel to be injected. More information can be found by consulting AngularJS’s Documentation.

Creating the Controller

With that out of the way, we can make a start on our application. You can find an overview of the controller code here.

The controller will be the heart of things. It only has a handful of responsibilities—its view will have a form element named parentForm, it will have only one property and its methods will consist of registerFormScope, validateChildForm, and checkout.

Controller Properties

We will need one property in the controller:

[code language="js"]
$scope.formsValid = false;
[/code]

This property is used to maintain a boolean state of the overall validity of the forms. We are using this property to disable the state of the “Checkout” button after it has been clicked.

Method: registerFormScope

[code language="js"]
$scope.registerFormScope = function (form, id) {
$scope.parentForm['childForm'+id] = form;
};
[/code]

When registerFormScope is called it will be passed a Form Controller along with the unique directive id created in the directive instantiation. This method will then append the form scope to the parent Form Controller.

Method: validateChildForm

This is the method that will be used to coordinate with the backend server which performs validation. It is is invoked when the user is editing content and it needs to go through additional validation. We conceptually don’t allow directives to perform any external communication.

Please note that I have omitted the backend component for the purposes of this tutorial. Instead I am rejecting or resolving a promise, based on whether the amount a user enters falls within a certain range (10 - 50 for product A and 25-500 for product B).

[code language="js"]
$scope.validateChildForm = function (form, data, product) {
// Reset the forms so they are no longer valid
$scope.formsValid = false;
var deferred = $q.defer();

// Logic to validate the form and data
// Must return either resolve(), or reject() on the promise.
$timeout(function () {
if (angular.isUndefined(data.amount)) {
return deferred.reject(['amount']);
}

if ((data.amount < product.minAmount) ||
(data.amount > product.maxAmount)) {
return deferred.reject(['amount']);
}

deferred.resolve();
});
return deferred.promise;
}
[/code]

Using the $q service allows the directives to adhere to an interface with a success and failure state. The nature of the application interface alters between “Edit” and “Save” depending on the editing of the model data. It should be noted that the model data is updated as soon as the user starts typing.

Method: Checkout

Clicking “Checkout” indicates a user has finished editing and desires to checkout. This actionable item will need to validate that all the forms loaded within the directives pass validation, before sending the model data to the server. The scope of this article will not cover the methods used to send data through to the server. I encourage you to explore using the $http service for all your client to server communications.

[code language="js"]
$scope.checkout = function () {
if($scope.parentForm.$valid) {
// Connect with the server to POST data
}
$scope.formsValid = $scope.parentForm.$valid;
};
[/code]

This method uses Angular’s ability for a child form to invalidate a parent form. The parent form is named parentForm to clearly illustrate its relationship to the child forms. When a childForm uses its $setValidity method, it will automatically ascend to the parent form to set the validity there. All forms within the parentForm must be valid for its internal $valid property to be true.

Creating Our Directives

Our directives must follow a common interface that allows complete interoperability and extensibility. The names of our directives depend on the product they contain.

You can find an overview of the directive code here (Product A) and here (Product B).

Isolated Directive Scope

Every directive that’s instantiated will obtain an isolated scope which is localized to the directive and has no knowledge of external attributes. AngularJS does however allow directives to be created that utilize parental scope methods and properties. When passing external attributes into the localized scope, you can indicate you want two-way data binding to be setup.

Our application will need a handful of external two-way data bound methods and properties:

[code language="js"]
scope: {
registerFormScope: '=',
giftData: '=',
validateChildForm: '=',
product: '='
},
[/code]

Method: registerFormScope

The first property in the directive’s local scope is the method which registers the local scope.form with the controller. The directive needs a conduit to pass the local Form Controller object to the main Controller.

Object: giftData

This is the centralized model data that will be used within the directive views. This information will be two-way data bound to ensure that the updates that happen in the Form Controller will propagate to the main Controller.

Continue reading %Form-Based Directives in AngularJS%


by Chad Smith via SitePoint

No comments:

Post a Comment