JavaScript
Article

How to Create Form-Based Directives in AngularJS

By Chad Smith

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.

<form name="form" novalidate>
  Size:
  <input type="text" ng-model="size" name="size" ng-required="true" />
  <span ng-show="form.size.$error.required">The value is required!</span>
</form>

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:

$scope.formsValid = false;

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

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

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).

$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;
}

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.

$scope.checkout = function () {
  if($scope.parentForm.$valid) {
    // Connect with the server to POST data
  }
  $scope.formsValid = $scope.parentForm.$valid;
};

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:

scope: {
  registerFormScope: '=',
  giftData: '=',
  validateChildForm: '=',
  product: '='
},

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.

Method: validateChildForm

This method is the same one that’s defined inside of the Controller. This method will be invoked when the user is updating information in the directive’s view.

Object: product

This object contains information about the product that’s being purchased. Our demo uses a relatively small object with only a handful of properties. My team’s real world application has a large amount of information that is used to make decisions within the application. It’s passed into the validateChildForm to provide context to what is being validated.

Directive Linking

Our directives will use a postLink function, passing it a scope object. In addition to this, the postLink function accepts several other parameters. These are as follows:

  1. scope – Used to gain access to the isolated scope that’s created per directive instantiation.
  2. iElement – Used to gain access to elemental items. It’s only safe to update and modify the element that it was assigned to, from within the postLink function.
  3. iAttrs – Used to gain access to the attributes that are on the same tag that instantiated the directive.
  4. controller – Can be used in the linking functions if there are external controller dependencies. These have to correspond to the require property for the Directive Object.
  5. transcludeFn – The function is the same as those listed in the $transclude parameter of the Directive Object.

link is responsible for attaching all DOM listeners and updating the DOM with the view elements.

link: function postLink(scope) {
  // Indicates if the form is disabled
  scope.disabled = true;

  scope.saveForm = function () {
    // Code for saving the form data
  };

  // Register form scope
  $timeout(function() {
  });
}

Register the Form Scope

$timeout(function () {
  scope.form.fields = ['name','amount'];
  scope.registerFormScope(scope.form, scope.$id);
});

Wrapping the method registerFormScope within a $timeout defers the execution to the end of the execution stack. This provides ample time for the the compiler to complete all the necessary linkings between the controller and directive. scope.form.fields is an array that is the name of the properties that are found in the Form Controller this is important for setting server side validation errors. The purpose of registerFormScope is to send the Form Controller to the parent controller allowing the newly created form to be set as a child of the parentForm.

Validate when information changes

scope.saveForm = function () {
  scope.validateChildForm(scope.form, scope.giftData, scope.product)
  .then(function () {
    angular.forEach(scope.form.fields, function (val) {
      scope.form.$setValidity(val, true);
      scope.form[val].$error.server = false;
    });
    scope.disabled = true;
  }, function (invalidFields) {
    angular.forEach(invalidFields, function (val) {
      if (angular.isDefined(scope.form[val])) {
        scope.form[val].$error.server = true;
        scope.form.$setValidity(val, false);
      }
    });
    scope.disabled = false;
  });
};

When the form changes and the user is ready for it to be validated, the saveForm method within the directive is invoked. This method will in turn call the controller’s validateChildForm method passing in the Form Controller, scope.giftData, and scope.product. The controller returns a promise which will be resolved or rejected depending on the additional validation rules.

When the promise is rejected, the controller will return the fields which were invalid. This is used to invalidate the form (and parentForm) along with setting additional field level errors. In our demo we use a simple post-validation on the amount field, and we don’t return the reason it failed. The rejection from validateChildForm could be as complex or as simple as your application requires.

When the promise returns successfully the directive needs to set the validity of the fields in the form. The code must also clear any previously identified server errors. This ensures that the Directive isn’t erroneously providing errors to the user. Setting all fields with $setValidity links to the parentForm in the controller to also set its validity, providing all child forms are valid.

Setting Our Views

The views are not very complex and for our demo we have paired down the products to the following fields: name and amount. In the next step we will explore the views necessary to complete this application.

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

Route View

<div data-ng-app="myApp" ng-controller="stageController">
  <div id="main" class="container">
     <h1>Review Order</h1>

    <form name="parentForm" novalidate>
      <div ng-repeat="gift in gifts" class="row">
        <div class="col-lg-12"
             ng-if="gift.product.type == 'A'"
             product-A data-register-form-scope="registerFormScope"
             data-gift-data="gift.giftData"
             data-validate-child-form="validateChildForm"
             data-product="gift.product">
        </div>
        <div class="col-lg-12"
             ng-if="gift.product.type == 'B'"
             product-B data-register-form-scope="registerFormScope"
             data-gift-data="gift.giftData"
             data-validate-child-form="validateChildForm"
             data-product="gift.product">
        </div>
      </div>
    </form>

    <div class="row">
      <div class="col-lg-12">
        <button class="btn btn-primary" 
                data-ng-click="checkout()">Checkout</button>
        <div class="alert alert-success" 
             data-ng-show="formsValid">All forms are valid!</div>
      </div>
    </div>
  </div>
</div>

This view is important because it sets the parent form, that will be used to wrap all child forms being loaded from the product directives. Using ng-if within ng-repeat ensures that the DOM will not be populated incorrectly with an unused Form Controller.

Directive View

<form name="form" novalidate>
...
  <label for="amountInput">Amount</label>
  <input id="amountInput" name="amount"
         class="text-center form-control" type="tel"
         data-ng-model="giftData.amount" 
         data-ng-pattern="/^(?!\.?$)\d+(\.\d{0,2})?$/" 
         data-ng-required="true"
         data-ng-disabled="disabled"/>
...
  <label>Actions</label>
  <button class="btn btn-info" 
          ng-click="disabled=false;" 
          ng-show="disabled">Edit</button>
  <button class="btn btn-success" 
          ng-click="saveForm()" 
          ng-show="!disabled">Save</button>
...
  <div class="row" data-ng-show="form.$submitted">
    <div class="col-lg-12">
      <div class="alert alert-danger" 
           data-ng-show="form.name.$error.required && form.$submitted">
        Recipient Name is a required field.
      </div>
      <div class="alert alert-danger" 
           data-ng-show="form.amount.$error.pattern && form.$submitted">
        The amount is invalid.
      </div>
      <div class="alert alert-danger" 
           data-ng-show="form.amount.$error.server && form.$submitted">
        The amount is not accepted. Must be between 
        {{ product.minAmount }} and {{ product.maxAmount}}.
      </div>
    </div>
  </div>
...
</form>

Note: The view above has been truncated in spots relating to the layout of the demo and not important to this article.

The amountInput above sets a validation pattern which will be enforced by Angular’s ngPattern validator. The fields above will use the ngDisabled directive built by Angular which evaluates an expression and if true, the field will be disabled.

At the bottom of the view we show all the errors to provide feedback to the user when they click the Save button. This will set the $submitted property on the child form.

Wrapping It Up

Putting all of the pieces together, here’s what we end up with:

See the Pen AngularJS Directive Form Validation by SitePoint (@SitePoint) on CodePen.

And don’t forget, you can find all of the code on GitHub, too.

Conclusion

My team and I have learned a lot in constructing our latest application. Learning about the parent / child form relationship enabled us to simplify our review screen. Using directives allows us to develop one form which can be used in any context and promote good reusable code. Directives also allowed us to have unit tested code to ensure our forms are working as intended. Our application is in production and has facilitated over 100,000 orders.

I hope you enjoyed reading this article. If you have any questions or comments, I’ll be glad to hear them in the comments below.

  • Awesome!! Thanks for the share…

  • Chad,

    Nice article. Any reason for not using ng-form for the nested forms?

    • Hppycoder

      Thanks Ravi!

      It is using the nested forms child+parent relationship. It’s the delivery mechanism with an ng-repeat’ed iterator for the directives where this article shines. Because the compiler will already have made it’s pass the forms aren’t on the DOM yet. Therefore, they will not be connected to their parent form. This uses the nested forms approach, but delivers the child form via a directive and seeds it into the parent.

      • Thanks for the reply, Chad. That makes sense.

  • Lars Jeppesen

    Very cool stuff! Impressed…

  • Chris

    Hi Chad,

    Awesome article, thank you.

    I have a question. I can’t find the use for appending the child to the parent form in this way: $scope.parentForm[‘childForm’+id] = form; . Is this done for future use? I’ve commented out this line and the code continues to work fine. At first I thought it’s so that $validate on parentForm will also validate the children, but the $validate works just because all fields are wrapped by , and has nothing to do with the registering.

    It’s a good thing to have even though it’s not really being used, just thought that should be mentioned?

  • Great article!

  • dimamarksman

    Nice article.
    But I believe it should be method to unregister childForm.
    ngRepeat has a dynamic nature and usually items in the ngRepeat list can be added as well as deleted at any time. That’s why we need to register childform as well as to unregister it in order to keep parentFormInstance proper.

    • dimamarksman

      In this case we don’t need to register childForm. So it’s redundant.
      But it can be required in case to make each childForm $dirty/$pristine programmatically. So in this case we need to have access to each child instance form.

Recommended
Sponsors
Get the latest in JavaScript, once a week, for free.