Friday, August 4, 2006

XForms Tip: Creating a Configurable Error Summary

Web 1.0 applications typically perform client-side validation (when they do it at all) with JavaScript libraries, often checking only minimal aspects of the form. Upon submitting the form, server-side validation is performed as well (you can never trust the client), and in case of error the filled-out form is returned to the client.

This is where most well-behaved applications provide not only a nice highlighting of incorrect fields, but also an error summary, often at the top of the form, that provides details about what data fields are incorrectly filled-out or missing. Such an error summary typically looks like this:

OPS XForms Error Summary

In Orbeon PresentationServer (OPS), the old XForms Classic implementation (the engine without Ajax support, now considered legacy code) allowed you to create and customize such an error summary very easily with XSLT. But so far the new Ajax-based XForms NG engine did not provide this feature. With Web 2.0 and Ajax, you can't simply use cutomizable server-side XSLT upon page display as XForms Classic did: somehow, the server must provide the client with information about the current errors as the user types, since XForms provides just-in-time validation.

One way to solve the issue is to make the XForms engine aware of the concept of "error summary". While this is reasonably in line with the XForms philosophy, it is yet another feature to add to the XForms engine, and it is harder to allow the user to customize the appearance of the summary (for example with XSLT, whether running client-side or server-side). But this solution felt clunky to us.

This is where we realized that XForms itself had almost all the tools necessary for the form author to implement a functioning error summary! The XForms engine already dispatches events when the validity of controls change (in fact, of nodes to which controls are bound, but this is a technicality), or when controls become relevant and non-relevant. If those events provide enough context information to identify which control exactly became valid or invalid, you can maintain a list of errors yourself in an XForms instance. So this is exactly what we did:

  1. We made sure that validity events and the likes are sent as you can expect. The XForms specification is currently silent for example on what you do when a control becomes relevant or non-relevant because its binding to an instance data node appears or disappears after initialization, or when repeating controls are added or removed. In such cases, we have decided to send events so as to make them as useful as possible.

  2. We provided all those notification events with enough context information to allow creating an error summary. This includes: control id, list of enclosing repeat indexes, and alert message value.

After this long introduction, let's now look at how you create your error summary! First, define in your model an instance that contains the list of current errors, and a template for each error information:

<xforms:instance id="errors-instance">
  <errors xmlns=""/>
<xforms:instance id="error-template">
  <error xmlns="" id="" indexes="" label="" alert=""/>

Second, add event handlers that add and remove error information as validity events occur:

<xforms:action ev:event="xforms-invalid" if="normalize-space(event('alert')) != ''">
  <xforms:action if="not(instance('errors-instance')/error[@id = event('target-id')
        and @indexes = string-join(event('repeat-indexes'), '-')])">
      <xforms:insert context="instance('errors-instance')"
        nodeset="error" origin="instance('error-template')"/>
      <xforms:setvalue ref="instance('errors-instance')/error[index('errors-repeat')]/@id"
      <xforms:setvalue ref="instance('errors-instance')/error[index('errors-repeat')]/@indexes"
        value="string-join(event('repeat-indexes'), '-')"/>
  <xforms:setvalue ref="instance('errors-instance')/error[@id = event('target-id')
    and @indexes = string-join(event('repeat-indexes'), '-')]/@alert" value="event('alert')"/>
  <xforms:setvalue ref="instance('errors-instance')/error[@id = event('target-id')
    and @indexes = string-join(event('repeat-indexes'), '-')]/@label" value="event('label')"/>
<xforms:action ev:event="xforms-valid" if="instance('errors-instance')/error[@id = event('target-id')
  and @indexes = string-join(event('repeat-indexes'), '-')]">
  <xforms:delete nodeset="instance('errors-instance')/error[@id = event('target-id')
    and @indexes = string-join(event('repeat-indexes'), '-')]"/>

What the code above does is insert or update an <error> element into the list of errors when an xforms-invalid event occurs, and delete the appropriate <error> element upon receiving xforms-invalid. Note that this codes makes use of several XForms 1.1 features, including enhancements to <xforms:insert>, the use of the event() function, and conditional actions. It also uses for convenience one XPath 2.0 function, string-join(), but you could do without it especially if you don't have nested repeats to handle. The errors instance for the image above looks like this:

  <error id="xforms-element-133" indexes="" label="First Name" alert="Must contain..."/>
  <error id="xforms-element-227" indexes="" label="Zip Code" alert="Must contain..."/>
  <error id="xforms-element-207" indexes="" label="Street Name 1" alert="Must contain..."/>
  <error id="xforms-element-216" indexes="" label="City" alert="Must contain..."/>

Note that the alert messages are truncated for reason of space, and that the indexes attribute is used for handling repeating controls, not shown here.

Finally, you write XForms controls that look at the list of errors and displays them in a nice way. This is one way of doing it, with <xforms:repeat>:

<xforms:group ref="instance('errors-instance')/error">
  <table class="dmv-errors-table">
    <xforms:repeat nodeset="instance('errors-instance')/error" id="errors-repeat">
        <th><xforms:output value="@label"/></th>
          <xforms:output value="if (string-length(@indexes) > 0)
            then concat('(Row ', @indexes, ')') else ''"/>
        <td><xforms:output value="@alert"/></td>

That's it! The error summary looks just like the image above, and it updates just-in-time as you modify your form. The great thing is that all the code is 100% reusable between applications, and at the same time it gives you all the freedom you need to customize the appearance of the errors summary.

Incidentally, this also provides a very easy way of enabling and disabling a Save button: if errors-instance does not contain any error element and the data has been modified (as we have seen in a previous tip), then the form is valid and the Save button is enabled. Otherwise, the Save button is disabled:

<xforms:instance id="control-instance">
  <control xmlns="">
<xforms:bind nodeset="instance('control-instance')">
  <xforms:bind nodeset="save-trigger"
    readonly="not(../data-status = 'dirty'
                  and count(instance('errors-instance')/error) = 0)"/>
<xforms:trigger ref="instance('control-instance')/save-trigger">
  <xforms:label ref="instance('resources-instance')/labels/save-document"/>

There are certainly ways to improve the code above, but it is a good start already. A simple new feature that comes to mind is handling "required but empty" fields in addition to invalid ones.

All of the above is now included in the DMV Forms example available in the nightly builds of OPS.

No comments:

Post a Comment