Tuesday, September 28, 2010

Form validation using custom attributes

Background

A couple months ago, a friend's daughter came to me for help with a website she was writing for a diabetes seminar registration page. The input form needed to ask the user if they had diabetes, and if so, whether it was type 1 or type 2. She was using Dreamweaver, and was having difficulty getting the form to behave correctly with Spry Widgets form validation.

I wasn't familiar with Spry Widgets (or Dreamweaver, for that matter), so I asked her to show me the source code. We hacked out a semi-functional solution in a few minutes, but I wasn't very happy with it. Spry had input validation that intercepted form submit events. You defined some JavaScript objects declaring what input elements were required, and what format they had to be in (e.g., the input must look like a phone number, or it must look like an email address). When you submitted the form, anything that was wrong gets highlighted red, and you have to go back and correct it.

The definition syntax for a single field looked like this:

<span id="phoneA">Primary Contact Number:
<input type="text" name="primary" id="primary" /></span>
<script>var phoneA = new Spry.Widget.ValidationTextField("phoneA", "phone_number");</script>

Messy, but I was prepared to accept it if I could make it do what I wanted. Unfortunately, I couldn't. I couldn't make Spry validate the diabetes type only if "yes" was selected on the "do you have diabetes" question. I also couldn't write my own onsubmit handler, as it would interfere with Spry's, and it was unclear where the entry point to its validation was located, as it was, I don't mind saying, arbitrarily complicated madness, not fit for distribution outside of a Lovecraft novel. OK, maybe that's a little harsh. But I didn't like it.

We settled on leaving the "type" selector disabled, and adding an "onchange" handler for the "do you have diabetes" selector that enabled it, defaulting to type 1, and hoping the registree would notice and change that if needed. It was functional enough for our purposes, but like all the solutions I write that look sloppy, it weighed on me.

I decided there was nothing for it but to re-write form validation from scratch, and abandon Spry. And so I did, along the way making a formal form validation system that could be imported as a JavaScript file and used with much simpler syntax than Spry. Additionally, I added a phone number formatter (entering 614.555.1212 would autocorrect to (614) 555-1212), a section duplicator (if you want a form that lets you add users, or more items to a cart, for example), and a "validate as you go" feature where tabbing off of an input field causes that field to be quick-checked.

My goal was to separate validation functions as much as possible from the HTML for the form itself, much like CSS separates layout design from content. The mechanism I used was simple custom attributes that could be inserted into input elements. Here's an example of a small form importing and using my validation script:

<!doctype html>
<html><head>
  <script src="http://sites.google.com/site/ceauterytest/Home/caFormTools.js"></script>
</head><body onload=init_caTools()>
<form>
  Please enter some people (up to 3):<br /><br />
  <div id=Person dup=1 add="Add a person" rem="Remove a person" max=3>
    Person 1:<br />
    <input type=text name=phone placeholder="Phone Number" req=1 phn=1>
    Phone number (w/area code)*
    <br />
    <input type=text name=email placeholder="Email address">Email address<br /><br />
  </div>
  <input type=submit value=Continue>
</form>
(* = Required)
</body></html>

This looks basically like any other HTML form, but with a suspicious <div> tag (which I'll get to later), and the use of the new HTML5 input attributes, "placeholder", and finally a single call to init_caTools to get things started.

The rewrite in action

Here is what the above HTML looks like before any action happens. Notice the greyed out "Email address" in the second input field: This is from the "placeholder" attribute, new in HTML5, and supported by Chrome. Notice that the "Add a person" link is not declared in the HTML above, but matches the "add" attribute of the div tag. More on that later.

In the phone number field, note that I have left off the area code, which should fail validation.



And it does! When I click "Continue", two things happen: An alert box opens describing the problem, and the input field in question gets highlighted red.



After dismissing the dialog box, I have added an area code to the phone number, but my format is "614.555-1212", which has all the right elements, but uses a non-standard format.



...but when I click to another field, the format is changed to one that is more standard. My phone validator has two regular expressions. One checks for a perfect match of the format I'm looking for, and if that fails to match, a second formatter checks to see if there are two groups of three digits, and a group of 4, separated by whitespace, periods, dashes, and optionally the area code having parenthesis. If the numbers are in there, I grab them, and build a new string in the correct format.

Note that not only did the number format get corrected, the field is no longer red. This wasn't a full form validation, just a quick check of one field.



In the next screenshot, I have clicked the "Add a person" link, left the second phone number blank, put an invalid address into the email address field, and clicked submit. The alert box tells me that I have left a required field blank, and that the email address isn't formatted correctly.

In the "Person 1" section, the email address is blank, but that passes validation since I have not also marked the field required. So that field is basically saying "you can leave me blank, but if you don't, put something in that makes sense."

Also notice the "Remove a person" link has appeared now that there is more than one person.



For the next screenshot, I have corrected all the errors, and added a third person, given a third phone number, and clicked "Continue" again. After that, validation passes, but the form does not yet get submitted.

Instead, the screen gets "lightboxed" using a quick lightbox-ish hack, and a final verification prompt is given. Note that the blank fields aren't included in the prompt.



Lastly, since the form had no declared action, it defaults to loading the same page again with the form data appended to the URL. Note here that the form data in the address bar skips from "phone" to "phone2" without the blank email address. The same routine that parses the blank fields out of the verification step disables the fields so they aren't included with the final form submission.



So, how does all this work? What follows is a lengthy explanation. Alternatively, you can scroll to the bottom for a link to the source code and a test page showing the validation code in action.

The Basics: Custom attributes

HTML tags use attributes to define behavior, or what type of control will be rendered in the browser. For example the <input> tag defines attributes of type, name, value, size, and others. A text input field intended to receive phone numbers may be declared thusly:

<input type=text name=phone1>

A custom attribute can be inserted into a tag which is not defined in the HTML standard, which is perfectly legal, and causes nothing more in the browser than complete indifference. JavaScript can read these non-standard attributes using the element's getAttribute() method. For example, if this input field exists in an HTML document:

<input id=phoneNbr type=text name=phone1 req=1 phn=1>

...I can read the phn attribute with the following:

document.getElementById("phoneNbr").getAttribute("phn")

This concept opens up a world of possibilities for form validation. Let's say that I want the "req" attribute to declare an input element to be required. A JavaScript function can then iterate through all the form elements and check for the attributes in question, and alert if anything is amiss with something like this:

//el = the current element
var name = el.placeholder || el.name; // Use the placeholder as the name, if it exists
if (el.getAttribute('req') && el.value == "") {
  errorMsg = name + " is a required field.\n";
}

Each type of custom attribute can be checked for, and if found the field's value can be checked with a regular expression. If I want the "zip" attribute to indicate a zip code, I can check fields of that type with this:

if (el.getAttribute('zip') && !/^\d{5}$/.test(el.value)) {
  errorMsg = name + " must be a zip code.\n";
}

I'm not going to go into a lengthy discussion of the regular expressions used in the final script, but I encourage you to learn regular expression syntax if you aren't familiar with it. They're a boon, and supported in many languages now. (The real zip code regex also checks for Zip+4, the one above is just a simple example to illustrate the point.)

Validating all element types

I more fleshed out element validation routine is shown below. Both of our two types (phn and zip) are checked for, and some more expansive "required" logic is performed (specifically, the idea that if a field isn't required, it's allowed to be blank). For phone number matching, since I want to do my auto-correct function, I'm calling a new function, validatePhone, instead of just doing a regex check.

The overall concept here is that the "errorMsg" string is dual purpose, serving as a flag and an error container. At the end, if errorMsg is populated, the background color of the element is set to the failedBG color.

var passedBG = 'white';
var failedBG = 'red';
var zipMatch = /^\d{5}($|\s*[-\.]?\s*\d{4}$)/; // US Zipcode or zip+4
var numMatch = /^\d+$/;
var emailMatch = /^.+\@.+\..{2}.?.?$/;         // Lazy, but accounts for most
function validateElem(el) {
  var errorMsg = "";
  var name = el.placeholder || el.name;
  if (!el.getAttribute('req') && el.value == "") { errorMsg = ""; }
  else if (el.getAttribute('phn') && !validatePhone(el)) {
    errorMsg = name + " must be a phone number with area code.\n";
  }
  else if (el.getAttribute('zip') && !zipMatch.test(el.value)) {
    errorMsg = name + " must be a zip code or zip+4 code.\n";
  }
  else if ((el.type == 'email' || el.getAttribute('eml')) && !emailMatch.test(el.value)) {
    errorMsg = name + " must be a properly formatted email address.\n";
  }
  else if ((el.type == 'number' || el.getAttribute('num')) && !numMatch.test(el.value)) {
    errorMsg = name + " must contain a number.\n";
  }
  else if (el.getAttribute('req') && el.value == "") {
    errorMsg = name + " is a required field.\n";
  }
  if (errorMsg == "") { el.style.background = passedBG; }
  else { el.style.background = failedBG; }
  return errorMsg;
}

Auto-correcting the phone number

Here we define a regular expression for exact (###) ###-#### phone numbers, and return success quickly if a match is found. Otherwise, we fail back to using the alternate regex, "phoneSimilar" (which looks crazy, and purists will hate my use of .* at the end, and the fact that 6 character TLDs aren't supported). If phoneSimilar matches, all the digits are collected and reformatted in a way that phoneMatch would accept.

var phoneMatch = /^\(\d{3}\) \d{3}\-\d{4}$/;
var phoneSimilar = /^\s*\(?(\d{3})\)?\s*[-\.]?\s*(\d{3})\s*[-\.]?\s*(\d{4}).*$/;
function validatePhone(el) {
  if(phoneMatch.test(el.value)) { return true; }
  if(phoneSimilar.test(el.value)) {
    el.value = el.value.replace(phoneSimilar, "($1) $2-$3");
    return true;
  }
  return false;
}

Validating the entire form

Now that we can validate elements, we need a routine to iterate through all of a form's elements. Rather than just validate each in turn and collect error messages, this function also keeps a list of blank elements that aren't required, and adds them to an array of elements to be disabled if validation succeeds. (More on that in part 2)

function formValidate(frm) {
  for (var e = 0; e < frm.elements.length; e++) {
    var el = frm.elements[e];
    var msg = validateElem(el);
    if (msg == "" && el.value == "") { elemsToDisable.push(el); }
    formErrors += msg;
  }
  if (formErrors == "") { return true; }
  alert ("Please correct the following fields\n" + formErrors);
  return false;
}

Intercepting onsubmit programmatically

I don't want users of this script to modify anything in their form other than adding attributes to the input fields. Rather than require users to also add an "onsubmit=formValidate(this)" on each form, the script iterates through all of a document's forms, and programmatically alters their onsubmit handler.

for (var f = 0; f < document.forms.length; f++) {
  document.forms[f].onsubmit =  function onsubmit() { return formValidate(this) };
}

onblur: Validate as you go

As shown above in the screenshots, element validations occur as you click from element to element. This is done via the handy "onblur" handler, which fires when an element loses focus (by clicking somewhere else, or tabbing to another field). These can be added individually to input elements like so:

<input type=text name=... onblur=validateElem(this)>

...but as with the form onsubmits, I'd rather have my script go out and find all the elements and set the onblur programmatically. Here is the iterator function from above, this time adding onblur functionality as well:

for (var f = 0; f < document.forms.length; f++) {
  var frm = document.forms[f];
  frm.onsubmit =  function onsubmit() { return formValidate(this) };

  // Iterate through each element, set it's onblur
  for (i = 0; i < frm.elements.length; i++ ) {
    frm.elements[i].onblur = function onblur() { validateElem(this) };
  }
}

The "Sign up for Diabetes camp" page I was helping modify allowed you to register up to 4 adults and 4 kids in your party. Spry Widgets attacked this problem in the form of collapsed sections of the page. "Collapsed", in this case, meant hidden, with a header section you could click to make the hidden elements visible. Click the header on Adult 2 if there is a second adult, and up it springs. Boing!

Unfortunately, all the hidden elements were still enabled on the form, meaning when the form is submitted they show up as blank inputs to be handled on the backend, which in this case was only formmail (don't get me started - unfortunately I was just being consulted for a quick hack on the validation, not to design the end-to-end process). The end result is that the site owner would get emails containing blank fields for all the unused collapsed sections every time a form was submitted. Every time. With no verification step.

So, two problems to solve: More intelligent handling of additional sections users can add to a form, and some method of confronting the user with what he's about to do, to give him a chance to fix errors.

Extending a form

My thinking was that it would be better to not have the "collapsed" elements in the HTML at all, but instead have an "add additional person" link that would use JavaScript to create new input tags as needed. A quick Google search on the matter brought me to this page on PPK's quirksmode.org site, which was a great introduction to programmatically extending forms and various problems you can run into. (In fact, PPK is someone I hold in high regard. I've had numerous unrelated web coding searches point me to his page, and always found the information therein useful.)

PPK suggested the JavaScript "cloneNode" approach was the way to go. What I wanted was a regular expression search/replace based on the name of the section you wanted to clone. Say the cloneable section was named "Person". I wanted every occurrence of "Person 1" in the text of the section to be replaced in the duplicate with "Person 2", then "Person 3" for the next addition, etc. The form input names would all keep count as well. An input field named "phone" should become "phone2", then "phone3". The problem was, every time I used JavaScript's cloneNode function and then tried to touch the raw HTML of the new node, bad things would happen. In IE8, the node's innards would disappear completely. Not the behavior I was looking for.

I instead wrote a framework using a closure to hold all the innerHTMLs of div nodes that contained an attribute of "dup". When encountering those, further attributes of "add" and "rem" are looked for to determine what the UI text for add and remove links should look like. Lastly, the "max" attribute is looked for to see when to stop letting the user add more nodes. Consider the following HTML:

<!doctype html>
<html><head>
  <script src="http://sites.google.com/site/ceauterytest/Home/caFormTools.js"></script>
</head><body onload=init_caTools()>
<form>
  Please enter some people (up to 3):<br /><br />
  <div id=Person dup=1 add="Add a person" rem="Remove a person" max=3>
    Person 1:<br />
    <input type=text name=phone placeholder="Phone Number" req=1 phn=1>
    Phone number (w/area code)*
    <br />
    <input type=text name=email placeholder="Email address">Email address<br /><br />
  </div>
  <input type=submit value=Continue>
</form>
(* = Required)
</body></html>

The form framework will find the boldface line above, and use the add= and rem= text to build links to the custom clone function. The links are then inserted into the page just below where the boldface closes the HTML block. The effect looks like this:



(In the above screenshot, I've mouse-overed... moused-over?... the add link to show the href that is generated.) Clicking the link clones the

block, substituting count numbers where appropriate, and then unhides the "remove" link:



(Here I've moused-over the remove link to show its href). Lastly, when you get to the maximum number of nodes, the add link is hidden. If for some reason the link is still visible, the cloning code traps for the count separately. Said code, in all its nakedness, with some explanatory comments:

function dupClosures() {
  var inners = new Array(); // Initial .innerHTML of duplicatable div sections
  var counts = new Array(); // How many of each duplicatable sections exist
  var divs = document.getElementsByTagName('div'); // All the <div> blocks on the page

  // Iterate through all the <div> blocks.
  for (var f = 0; f < divs.length; f++) {
    if (divs[f].getAttribute('dup')) { // If this is a duplicatable block
      var id = divs[f].id;             // Get the id attribute
      inners[id] = divs[f].innerHTML;  // Record raw HTML, give a starting count of 1
      counts[id] = 1;                  // (Yes, you can use JS arrays like hashes)
      // create a new <div> block in memory, but don't add it to the page
      var suffix = document.createElement('div');
      suffix.id = id + 'Dup'; // Set its ID
      // Build the links to the javascript add and remove functions
      suffix.innerHTML = "<span class=add id='" + id + "Add'>"
       + "<a href=\"javascript:dupAdd('" + id + "')\">"
       + divs[f].getAttribute('add') + "</a></span> "
       + "<span class=rem style=display:none id='" + id + "Rem'>"
       + " <a href=\"javascript:dupRem('" + id + "')\">"
       + divs[f].getAttribute('rem') + "</a></span>";
     /* Tricky insertBefore syntax. Add a new child to divs[f]'s parent, just before
        the DOM element that comes after divs[f]. We don't want to just add the links as
        children of the block to be duplicated, we want the duplicates to go there, and
        the add/remove links to appear after all the duplicates */
     divs[f].parentNode.insertBefore(suffix, divs[f].nextSibling);
   }
  }

  dupAdd = function(id) {
    var base = document.getElementById(id);

    // Get maximum number attribute of object, exit if we've reached it, else increment
    var max = base.getAttribute('max') || 2;
    if (counts[id] >= max) { return; }
    counts[id]++;

/*  Create a new div block, set it's HTML content to the inners[id] set previously.
    Replace instances of the ID with the ID followed by the current count.
    e.g., If the ID is "Adult", the following transforms will take place:
    "Adult" and "Adult1" become "Adult2"
    "Adult " and "Adult 1" become "Adult 2" */

    var child = document.createElement('div');
    child.id = id + counts[id];
    var re = new RegExp( "(" + id + "\\s?)1?", "g");
    /* In perl speak, the above regex would be /($id\s?)1?/g
       which means find the list of all strings that contain the id in question,
       possibly followed by a space, and possibly followed by a 1. Remember everything
       within the parenthesis and the entire match separately */
    // Replace matches with what was in the parenthesis, plus the current count
    child.innerHTML = inners[id].replace(re, "$1" + counts[id]);

/*  Catch any input elements that didn't get replaced by the above, and append the
    current count to their name attribute */

    var nodes = child.getElementsByTagName("*"); // Get a list of all elements in "child"
    for (var f = 0; f < nodes.length; f++) {
      setOnBlur(nodes[f]);
      if(nodes[f].name && nodes[f].name.indexOf(id) == -1) {
        nodes[f].name += counts[id];
      }
    }

    // Append the new div block to the live document
    base.appendChild(child);
    // Unhide the remove link, and see if the add link should now be hidden
    document.getElementById(id + 'Rem').style.display = 'inline';
    if (counts[id] == max) {
      document.getElementById(id + 'Add').style.display = 'none';
    }
  }

/* The remove function is much simpler. No regex or iterating through form elements,
   just remove the last child you added to the DOM from above, and manage the add and
   remove links as needed */

  dupRem = function(id) {
    if (counts[id] <= 1) { return; }
    var base = document.getElementById(id);
    base.removeChild(base.lastChild);

    counts[id]--;
    document.getElementById(id + 'Add').style.display = 'inline';
    if (counts[id] == 1) {
      document.getElementById(id + 'Rem').style.display = 'none';
    }
  }
}

Lightbox verification window

If you aren't familiar with Lightbox, it's a completely obvious and elegant UI technique that is currently pretty poopular. It basically "greys out" a page, creates a modal dialog box that the user must interact with before returning to the page. It is commonly used for images: Click on a thumbnail, and the full-sized image is displayed on the same page in a modal that you can then close, and return to the main page.

This link goes to the current version of the Lightbox project, and it is worth looking at (or at least worth playing with the sample pages), however it isn't what I used; I just achieved a similar effect with a couple div tags with z-indices, and building a table of form element names and their values. The final effect looks like this:



Above, I showed this simple form validation function:

function formValidate(frm) {
  for (var e = 0; e < frm.elements.length; e++) {
    var el = frm.elements[e];
    var msg = validateElem(el);
    if (msg == "" && el.value == "") { elemsToDisable.push(el); }
    formErrors += msg;
  }
  if (formErrors == "") { return true; }
  alert ("Please correct the following fields\n" + formErrors);
  return false;
}

The boldface line says that if there are no errors, return "true" to the caller, which is the form's onsubmit handler. For example:

<form action="/cgi-bin/doWhatever.cgi" onsubmit="return formValidate(this)">

When the user clicks the submit button, formValidate is called first. If it returns true, the form data is submitted to doWhatever.cgi; if it returns false, the submit event is canceled. The final version of formValidate modifies the boldface line above to the following:

if (formErrors == "") {
    setPrompt(promptHeader + promptItems + promptTrailer);
    openPromptBox();
    return false;
  }

Now formValidate will always return false, so the form will not get submitted. If I didn't do this, displaying the "are you sure" dialog would be followed immediately by the form getting submitted... whether or not you are sure.

"Why not just change the <input type=submit> to <input type=button onclick=formValidate(this)>?"

Indeed. The driving idea behind caFormTools is that the validation and verification functions would attach themselves to an existing standard HTML form. I didn't want to require page authors to do much other than add appropriate attributes to input elements, and optionally create a div area for content that can be duplicated. Needing to cancel the submit event to show the verification modal isn't a bad thing - the "Yes, I'm sure" link can take care of the rest with a little ingenuity:

/* Holds "this" variable when form onsubmit fires, which is overridden to return
   false so a verification dialog can be displayed. After verification step, onsubmit
   is reset to null, and the form is submitted */
var frmToSubmit;

function finalSubmit() {
  frmToSubmit.onsubmit = null; // Stop validation from starting over again.
  frmToSubmit.submit();        // ...and submit the form
}

function formValidate(frm) {
  frmToSubmit = frm;

/* ... validation steps omitted ... */

  if (formErrors == "") { openPromptBox(); }
  else { alert ("Please correct the following fields\n" + formErrors); }
  return false;
}

// Override onsubmit handler on all forms
for (var f = 0; f < document.forms.length; f++) {
  document.forms[f].onsubmit =  function onsubmit() { return formValidate(this) };
}

With the introduction of the global variable "frmToSubmit" to hold the DOM reference to the form that just got validated, we need only add a link to the the verification dialog which invokes the finalSubmit function. But first we need a framework to create the verification dialog itself, a method of setting its HTML content, and a method of displaying and hiding it.

The dialog consists of two div blocks, one that is empty, obscuring the entire page and preventing interaction with anything on it; and one containing the prompt itself, centered, taking up half the page, with a z-index one higher. The framework is a closure that holds references to the div blocks, and setters to set the HTML content of the prompt, and making it visible or hidden.

var highestZ = 0; // Set this higher if page has positioned attributes with z-indices

function promptBoxClosures() {
  var fullScreenBox = document.createElement('div');
  var fs = fullScreenBox.style;
  fs.zIndex     = highestZ;
  fs.position   = "absolute";
  fs.top        = "0px";
  fs.left       = "0px";
  fs.width      = "100%";
  fs.height     = "100%";
  fs.opacity    = "0.7";
  fs.filter     = "alpha(opacity=70)";
  fs.background = "black";
  fs.border     = "0px";
  fs.display    = "none";
  document.body.appendChild(fullScreenBox);

  var promptBox = document.createElement('div');
  var ps = promptBox.style;
  ps.display    = "none"
  ps.position   = "absolute"
  ps.top        = "25%"
  ps.left       = "25%"
  ps.width      = "50%"
  ps.height     = "50%"
  ps.border     = "3px solid #A4A4A4"
  ps.background = "#E4E4E4"
  ps.padding    = "10px"
  ps.zIndex     = highestZ + 1;
  document.body.appendChild(promptBox);

  openPromptBox = function() {
    fs.display = 'inline';
    ps.display = 'inline';
  }

  closePromptBox = function() {
    fs.display = 'none';
    ps.display = 'none';
  }

  setPrompt = function(html) {
    promptBox.innerHTML = html;
  }
}

The last step to bring everything together is to have the validation function iterate through all the form elements and build the prompt box's HTML to show the non-blank elements and their values, and to call openPromptBox:

function formValidate(frm) {
  frmToSubmit = frm;
  var formErrors = "";
  elemsToDisable = new Array();
  var promptHeader = "Is this information correct?<br /><br /><table>";
  var promptTrailer = "</table><br><a href=# onclick=finalSubmit()>Yes, continue</a>"
      + "&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;"
      + "<a href=# onclick=closePromptBox()>No, let me make changes</a>";
  var promptItems = "";

  for (var e = 0; e < frm.elements.length; e++) {
    var el = frm.elements[e];
    if (textInput.test(el.type) || /select/.test(el.type)) {
      var msg = validateElem(el);
      if (msg == "" && el.value == "") { elemsToDisable.push(el); }
      formErrors += msg;
    }
    if (formErrors == "" && el.value != "" && /hidden|submit/.test(el.type) == false) {
      promptItems += getPromptItem(el);
    }
  }
  if (formErrors == "") {
    setPrompt(promptHeader + promptItems + promptTrailer);
    openPromptBox();
    return false;
  }
  alert ("Please correct the following fields\n" + formErrors);
  return false;
}


Test it out or Download the source code!

No comments:

Post a Comment