Dynamic web forms with AngularJS

by Frans van BuulApril 3, 2014


AngularJS-large

When we’re building web applications containing data entry forms, it’s sometimes a requirement that (part of) the form is dynamic, in the sense that the fields to be included in the form need to be determined at runtime. For instance, this may be required if application managers need to be able to add new data fields quickly through a management console, without support by a programmer.

In my previous blog post, I tried to show the merits of AngularJS versus other approaches to building web forms. The example case shown there was a completely static form. In this blog post, I’d like to show how AngularJS can be used to generate dynamic forms as well. The complete source code for this example can be downloaded here. It’s a Maven project based on Tomcat 7 (Servlet API 3.0) and JDK 6.

Just as a warning: creating dynamic forms may be very useful and very powerful, but it inheritly introduces some clutter and complexity in your code that you should probably avoid if you can. If you start out with ‘simple’ dynamic forms, and users start to ask for more and more functionality, you may end up building an entire web framework, which is a Bad Thing. Nevertheless, there are definite use cases for (partially) dynamic forms.

In our test case, I’ve assumed that the server provides a dynamic list of field definitions that must be included in the form. A field has the following attributes:

key
This is a unique id of the field in the form.
type
In our example, either TEXT or SELECT, indicating the type of input to be rendered.
label
The name of the field to be shown to the user.
required
true or false, indicating whether the field is required.
options
In case of type SELECT, a key-value map of select options.

What approach?

There’s more than one way to skin a cat, and this also applies to creating dynamic web forms using AngularJS.

  • One approach would be to generate the AngularJS-HTML dynamically on the server, using a view/templating technology like JSP, FreeMarker or Velocity. In this case, AngularJS wouldn’t “know” of the form being dynamic, it’s just a form with nothing special about it. While this approach may be perfectly valid in some cases, it comes at the price of introducing an additional technology and losing some of the flexibility we have when doing this client-side.
  • Another approach would be to code this completely in the AngularJS HTML file. AngularJS directive ngRepeat may be used to loop over the dynamic field definition and render the input rows. We might use ngIf directives to conditionally render a particular input row template. While this approach may seem “simple” at first, it actually tends to become messy and buggy as the logic that creates the form and the logic of the form get mixed up.

We will look into a third option. We will add the dynamic form fields programmatically from within the AngularJS controller code. This option has more flexibility than the server-side-templating option, but doesn’t have the problems of the everything-in-the-HTML approach.

The client-server ‘protocol’ and the server side code

The basic client-server interaction for posting a form can remain the same as in the static form case. The client POSTs a JSON-object of input field ids and their values, the server responds with either 204 (OK, no content), of 400 (bad request). In the latter case, the server will send a list of messages as a JSON array. To implement dynamic forms, the client will also need to be able to do a GET for the form definition. This we be a JSON list of objects, each object containing a field definition with the fields mentioned in the introduction.

To serve the form definition, we create an auxiliary class FormField, and then our Spring MVC controller may look something like:

List<FormField> formFields = new ArrayList<FormField>();
{
  formFields.add(new FormField("firstName", FormField.Type.TEXT,
                               "First name", true));
  formFields.add(new FormField("color", FormField.Type.SELECT,
                               "Favorite color", true)
                  .withOption("#FF0000", "Red")
                  .withOption("#00FF00", "Green")
                  .withOption("#FFFF00", "Yellow"));
}

@RequestMapping(method = RequestMethod.GET)
@ResponseBody
public List<FormField> getFormDefinition() {
  return formFields;
}

To received the form field values of a dynamic form, we should expect a generic map rather than a specific Java Bean:

static class FormData extends LinkedHashMap<String, String> {
  private static final long serialVersionUID = 1L;
}

@RequestMapping(method = RequestMethod.POST)
@ResponseBody
public ResponseEntity<?> postForm(@RequestBody FormData formData) {

In our sample Java code, validation is on the required attribute only. The FormField object has a validate method that takes the submitted form data and return a (possibly empty) list of messages to be returned to the method.

The client side code (simplified version)

Before looking at the real code found in the example project, let’s have a look at a somewhat simplified version. In our controller code, we issue a get request to get the form definition, and then create the form accordingly. The simplified version only handles simple text fields, we’ll deal with selects later on.

$http({
  method: 'GET', url: './form.do'
}).
  success(function(data, status, headers, config) {
    $.each(data, function(i, field) {
      var sourceHtml =
        '<div>' +
          '<label>' + field.label + '</label'> +
          '<input type="text" data-ng-model="data.' +
                field.key + '">' +
        '</div>';
      var compiledHtml = $compile(html)($scope);
      $('#dynamicForm').append(compiledHtml);
  });
})

In this example, we loop over the received form fields using the jQuery $.each method, and then add the AngularJS-HTML to the DOM. In this simplified version, we generate the HTML by concatenating strings. If we would simply append this HTML code to the DOM, the HTML would be displayed but non of the AngularJS-specific directives would be effective. We need to perform an additional step.

If a ‘complete’ AngularJS HTML page gets rendered, AngularJS will compile the HTML with AngularJS directives to the HTML as displayed in the browser, and an internal representation. If we want to add AngularJS-enabled HTML code later on, we will need to do this compilation ourselves. This is what the $compile function is for. This compile function returns a template function. By applying the template function to a particular scope, we get HTML that can be appended to the DOM, but this also makes sure that AngularJS has correctly bound this piece of HTML.

The client side code (more realistic version)

Our simplified version doesn’t support the select-inputs that we wanted to support. Also, adding strings to produce HTML isn’t really that great or maintainable; especially if the HTML becomes a little bit more elaborate than in our trivial example. Let’s try to do something better. We can start out by include template strings in the HTML file; this is a much better place to maintain multi-line strings:

<script type="text/html" id="inputRowTemplate_TEXT">
  <div>
    <label></label>
    <input type="text">
  </div>
</script>
<script type="text/html" id="inputRowTemplate_SELECT">
  <div>
    <label></label>
    <select>
  </div>
</script>

These text/html scripts dont’t do anything by themselves. But their content can be retrieved easily in JavaScript code and then be used for further processing.

Rather than manipulating this string directly, we parse this string to a tree of jQuery objects. These objects can easily be manipulated programmatically by the jQuery methods. Then, we dump these objects into a raw HTML string again, and then compile and bind it with AngularJS. The JavaScript code to do this looks like this:

$.each(data, function(i, field) {
  $scope.fields[field.key] = field;
  var template = $($.parseHTML(
    $('#inputRowTemplate_' + field.type).html()));
  template.find('label').append(field.label);
  if(field.type == 'TEXT') {
    template.find('input').attr({
      'data-ng-model': 'data.' + field.key
    });
  } else if(field.type == 'SELECT') {
    template.find('select').attr({
      'data-ng-model': 'data.' + field.key,
      'data-ng-options': 'key as label ' +
          'for (key, label) in ' +
          'fields["' + field.key + '"].options'
    });
  }
  var sourceHtml = $('<div>').append(template).html();
  var compiledHtml = $compile(sourceHtml)($scope);
  $('#dynamicForm').append(compiledHtml);
});

Here, we handle both the text and select cases, setting the correct options in the latter case. We bind the field definition data to the scope, so we can use it to have AngularJS populate the select options. We build our HTML by parsing one of the templates contained in the HTML document, and we then manipulate it by jQuery’s find, append and attr methods. The code $('<div>').append(template).html() is a bit of jQuery idiom to convert the jQuery object template to its full HTML string representation.

Note that we are using jQuery here to the HTML-source manipulation. Generally, it is (rightfully) considered an antipattern to do jQuery DOM manipulations in combination with AngularJS. You should let AngularJS ‘manage’ the DOM rather than manipulate it yourself with jQuery. But in this case, the primary use of jQuery is to build a HTML-source string that we pass to AngularJS for compilation. Manipulation this string with simple string functions is totally possible, but less maintainable.

As complexity grows by introducing more field types, more elaborate input row HTML, and more client side interaction, this programming model for dynamic forms can be expected to stay (reasonably) manageable.

If you want to learn more about AngularJS also check the training HTML5 Single-page applications with AngularJS (1,2 or 3 days)