Page 1 of 1

JavaScript Form Validator

Posted: Mon Oct 16, 2006 12:00 pm
by Chris Corbyn
It's not quite finished yet (i.e. I need to make the many tens of Validator classes to apply to it) but the interface is all there. The idea is that you create an instance of the FormValidator object, attach it to a form on your page, then apply validators in a strategy-like fashion to various elements in that form.

It's pretty much all pluggable; you apply validators following the correct interface and you provide reporters (the view part I guess) following the reporter interface.

Code: Select all

/**
 * FormValidator library for JavaScript
 * Uses a strategy based approach to form validation
 * @author Chris Corbyn
 * @param {object} HTMLFormElement
 * @throws Error
 * @constructor
 */
function FormValidator(formElement)
{
	if (!(formElement instanceof HTMLFormElement))
	{
		throw new Error('FormValidator can only operate on HTMLFormElements');
	}
	
	/**
	 * The DOM node of the Form to validate against
	 * @type Object HTMLFormElement
	 */
	var __form = formElement;
	/**
	 * The reporter object to use when displaying passes and fails
	 * @type Object FV_Reporter
	 */
	var __reporter;
	/**
	 * For Unit Testing only -- disables strict interface type checking
	 * @type Boolean
	 */
	var __internalTypeCheck = true;
	/**
	 * Error messages unique to relevant form fields
	 * @type Array
	 */
	var __messages = new Array();
	/**
	 * Validator objects applied to each form field
	 * @type Array
	 */
	var __validators = new Array();
	/**
	 * Stupid hack needed to get internal callbacks working
	 * @type Object FormValidator
	 */
	var myInstance = this;
	
	/**
	 * This is only used for Unit Testing (unless you know what you're doing!)
	 */
	this.__disableTypeChecks = function()
	{
		__internalTypeCheck = false;
	}
	/**
	 * Get the actual DOM node of the element with name attribute 'field'
	 * @param {string} fieldName
	 * @returns {mixed} Element, Array
	 */
	this.getFieldElement = function(field)
	{
		return __form[field];
	}
	/**
	 * Return a string representation of a field's type
	 * @param {string} fieldName
	 * @returns {string} type
	 */
	this.getFieldType = function(field)
	{
		if (f = this.getFieldElement(field))
		{
			switch (f.constructor)
			{
				case HTMLInputElement:
					return f.type.toLowerCase();
				case HTMLSelectElement:
					return 'select';
				case HTMLTextAreaElement:
					return 'textarea';
				default:
					if ((typeof f.length == 'undefined') && (typeof f.type != 'undefined')) return f.type.toLowerCase();
					else if (typeof f.length != 'undefined') return f.item(0).type.toLowerCase();
			}
		}
	}
	/**
	 * Get the value at a given field name
	 * @param {string} fieldName
	 * @returns {mixed} String, Array
	 */
	this.getFieldValue = function (field)
	{
		switch (this.getFieldType(field))
		{
			case 'text':
			case 'password':
			case 'hidden':
			case 'submit':
			case 'textarea':
				return this.getFieldElement(field).value;
			case 'checkbox':
				if (this.getFieldElement(field).checked)
				{
					return this.getFieldElement(field).value;
				}
				break;
			case 'select':
				if (!this.getFieldElement(field).multiple)
				{
					return this.getFieldElement(field).options[this.getFieldElement(field).selectedIndex].value;
				}
				else
				{
					var ret = new Array();
					for (var i = 0, len = this.getFieldElement(field).options.length; i < len; i++)
					{
						if (this.getFieldElement(field).options[i].selected)
						{
							ret.push(this.getFieldElement(field).options[i].value);
						}
					}
					return ret;
				}
				break;
			case 'radio':
				for (var i = 0, len = this.getFieldElement(field).length; i < len; i++)
				{
					if (this.getFieldElement(field).item(i).checked) return this.getFieldElement(field).item(i).value;
				}
				break;
			case 'checkbox':
				if (this.getFieldElement(field).checked)
				{
					return this.getFieldElement(field).value;
				}
				else return false;
		}
	}
	/**
	 * Apply a new Strategy object to the FormValidator
	 * @param {object} FV_Validator
	 * @param {string} fieldName
	 * @param {string} errorMessage
	 */
	this.apply = function(validatorObj, field, message)
	{
		if (__internalTypeCheck && !(validatorObj instanceof FV_Validator))
		{
			throw new Error('FormValidator.apply requires parameter 1 to be object of type FV_Validator');
		}
		
		if (typeof field != 'string')
		{
			throw new Error('FormValidator.apply requires parameter 2 to be string');
		}
		
		__setMessage(field, message);
		__setValidator(field, validatorObj, this);
	}
	/**
	 * Set the reporter to use when displaying errors (or passes)
	 * @param {object} FV_Reporter
	 */
	this.setReporter = function(reporter)
	{
		if (__internalTypeCheck && !(reporter instanceof FV_Reporter))
		{
			throw new Error('FormValidator.setReporter requires objects of type FV_Reporter');
		}
		
		__reporter = reporter;
	}
	/**
	 * Set an error message for a given form field
	 * @param {string} fieldName
	 * @param {string} errorMessage
	 */
	var __setMessage = function(field, message)
	{
		__messages[field] = message;
	}
	/**
	 * Set a validator object to use for a given form field
	 * @param {string} fieldName
	 * @param {object} FV_Validator
	 * @param {object} FormValidator
	 */
	var __setValidator = function(field, obj, parentObj)
	{
		obj.setValidator(parentObj);
		__validators[field] = obj;
	}
	/**
	 * Execute all checks
	 */
	this.run = function()
	{
		var passed = true;
		
		if (!__reporter)
		{
			throw new Error('FormValidator.run cannot be invoked before a reporter has been set');
		}
		
		for (var field in __validators)
		{
			if (!myInstance.getFieldElement(field)) continue;
			
			if (__validators[field].check(field)) __reporter.pass(field);
			else
			{
				__reporter.fail(field, __messages[field]);
				passed = false;
			}
		}
		return passed;
	}
	/**
	 * Attach to the form's onsubmit event and optionally set the reporter
	 * @param {object} FV_Reporter
	 */
	this.activate = function(reporter)
	{
		if (document.captureEvents) document.captureEvents(Event.SUBMIT);
		
		__form.onsubmit = this.run;
		
		if (reporter) this.setReporter(reporter);
	}
}

/**
 * Abstract class FV_Validator
 * Provides some common functionality for strategy-based validators
 * and enforces an interface
 * @constructor
 */
function FV_Validator()
{
	/**
	 * The Parent validator object
	 * @type Object FormValidator
	 */
	this.__validator;
	
	/**
	 * Set the FormValidator instance
	 * @param {object} FormValidator
	 */
	this.setValidator = function(validatorObj)
	{
		this.__validator = validatorObj;
	}
	/**
	 * Abstract method check()
	 * Return a boolean response corresponding to the validity of the field
	 * @param {string} fieldName
	 * @returns {boolean}
	 */
	this.check = function(field)
	{
		throw new Error('FV_Validator.check is abstract and must be overridden by child');
	}
}

/**
 * Abstract class FV_Reporter
 * Enforces the interface any reporter classes must implement
 * @constructor
 */
function FV_Reporter()
{
	/**
	 * Paint, or deal with a passing validation
	 */
	this.pass = function(field) {}
	/**
	 * Paint, or deal with a failed validation (abstract)
	 */
	this.fail = function(field, message)
	{
		throw new Error('FV_Reporter.fail is abstract and most be overridden by child');
	}
}

////////////////////////////////////////////////////////
// FormValidator validation classes. All classes must //
// inherit from FV_Validator and implement            //
// abstract method FV_Validator.check()               //
////////////////////////////////////////////////////////

/**
 * A basic check to ensure a value has been given
 * @constructor
 */
function FV_NonEmptyValidator()
{
	this.check = function(field)
	{
		if (v = this.__validator.getFieldValue(field))
		{
			if (typeof v.length != 'undefined' && v.length > 0) return true;
		}
		else return false;
	}
} FV_NonEmptyValidator.prototype = new FV_Validator;

/**
 * A regular expression validation
 * @constructor
 */
function FV_RegexValidator(pattern)
{
	if (!(pattern instanceof RegExp))
	{
		throw new Error('FV_RegexValidator expects constructor parameter 1 to be of type RegExp');
	}
	
	this.check = function(field)
	{
		if (v = this.__validator.getFieldValue(field))
		{
			if (typeof v.length != 'undefined' && pattern.test(v.toString())) return true;
		}
		else return false;
	}
} FV_RegexValidator.prototype = new FV_Validator;

////////////////////////////////////////////////////////
// A handful of reporters to use with FormValidator   //
// All reporters must inherit from abstract class     //
// FV_Reporter and implement at least fail()          //
////////////////////////////////////////////////////////

/**
 * Display error messages in a DOM element on the page
 * @constructor
 * @param {object} Element
 */
function FV_PageElementReporter(element)
{
	if (!(element instanceof Element))
	{
		throw new Error('FV_PageElementReporter expects constructor parameter 1 to be object of type Element');
	}
	
	/**
	 * The DOM element to display errors in
	 * @type Element
	 */
	this.element = element;
	/**
	 * Errors already outputted to page
	 * @type Array
	 */
	this.outputted = new Array();
	/**
	 * A list of active field errors
	 * @type Array
	 */
	this.active = new Array();
	
	/**
	 * Remove errors if they now pass validation
	 * @param {string} field
	 */
	this.pass = function(field)
	{
		if (typeof this.outputted[field] != 'undefined')
		{
			try {
				this.element.removeChild(this.outputted[field]);
				delete this.active[field];
			} catch (e) {
				//
			}
		}
	}
	/**
	 * Overridden abstract method fail()
	 * @param {string} field
	 * @param {string} message
	 */
	this.fail = function(field, message)
	{
		if (typeof this.outputted[field] == 'undefined')
		{
			this.outputted[field] = document.createElement('div');
			this.outputted[field].style.padding = '4px';
		}
		
		if (!this.active[field])
		{
			this.element.appendChild(this.outputted[field]);
			this.active.push(field);
		}
		
		this.outputted[field].innerHTML = message;
	}
} FV_PageElementReporter.prototype = new FV_Reporter;

Here's teaser Unit Test Case (I say teaser because non of you have access to the Unit Test framework as I haven't released it yet :P) which should reveal a few things about it's workings if you read the code.

Code: Select all

function TestOfFormValidator()
{
	this.getTestForm = function()
	{
		var f = document.createElement('form');
		
		var i1 = document.createElement('input');
		i1.type = 'text';
		i1.name = 'text_input';
		i1.value = 'some_value';
		f.appendChild(i1);
		
		var i2 = document.createElement('input');
		i2.type = 'password';
		i2.name = 'password_input';
		i2.value = '';
		f.appendChild(i2);
		
		var i3 = document.createElement('select');
		i3.name = 'select_input';
		i3_o1 = document.createElement('option');
		i3_o1.value = '42';
		i3_o1.text = 'Option 42';
		i3.appendChild(i3_o1);
		i3_o2 = document.createElement('option');
		i3_o2.value = '10';
		i3_o2.text = 'Option 10';
		i3.appendChild(i3_o2);
		i3.selectedIndex = 1;
		f.appendChild(i3);
		
		var i4_o1 = document.createElement('input');
		i4_o1.type = 'radio';
		i4_o1.name = 'radio_input';
		i4_o1.value = 'value 1';
		var i4_o2 = document.createElement('input');
		i4_o2.type = 'radio';
		i4_o2.name = 'radio_input';
		i4_o2.value = 'value 2';
		i4_o2.checked = true;
		f.appendChild(i4_o1); f.appendChild(i4_o2);
		
		var i5 = document.createElement('input');
		i5.type = 'checkbox';
		i5.name = 'checkbox_input';
		i5.value = 'my value';
		i5.checked = false;
		f.appendChild(i5);
		
		var i6 = document.createElement('input');
		i6.type = 'checkbox';
		i6.name = 'checkbox_input2';
		i6.value = 'my value 2';
		i6.checked = true;
		f.appendChild(i6);
		
		var i7 = document.createElement('textarea');
		i7.name = 'textarea_input';
		i7.value = 'my text < more text';
		f.appendChild(i7);
		
		var i8 = document.createElement('select');
		i8.name = 'multi_select_input';
		i8.multiple = true;
		var i8_o1 = document.createElement('option');
		i8_o1.value = '1';
		i8_o1.text = 'option 1';
		i8_o1.selected = true;
		i8.appendChild(i8_o1);
		var i8_o2 = document.createElement('option');
		i8_o2.value = '2';
		i8_o2.text = 'option 2';
		i8.appendChild(i8_o2);
		var i8_o3 = document.createElement('option');
		i8_o3.value = '3';
		i8_o3.text = 'option 1';
		i8_o3.selected = true;
		i8.appendChild(i8_o3);
		f.appendChild(i8);
		
		return f;
	}
	
	this.testFormElementsCanBeAccessedByName = function()
	{
		var f = this.getTestForm();
		var validator = new FormValidator(f);
		this.assertIsA(validator.getFieldElement('text_input'), HTMLInputElement);
		this.assertIsA(validator.getFieldElement('password_input'), HTMLInputElement);
		this.assertIsA(validator.getFieldElement('select_input'), HTMLSelectElement);
		this.assertIsA(validator.getFieldElement('radio_input').item(0), HTMLInputElement);
		this.assertIsA(validator.getFieldElement('radio_input').item(1), HTMLInputElement);
		this.assertIsA(validator.getFieldElement('checkbox_input'), HTMLInputElement);
		this.assertIsA(validator.getFieldElement('checkbox_input2'), HTMLInputElement);
		this.assertIsA(validator.getFieldElement('textarea_input'), HTMLTextAreaElement);
		this.assertIsA(validator.getFieldElement('multi_select_input'), HTMLSelectElement);
		this.assertUndefined(validator.getFieldElement('__not_present'));
	}
	
	this.testFormElementTypesAreFound = function()
	{
		var f = this.getTestForm();
		var validator = new FormValidator(f);
		this.assertEqual('text', validator.getFieldType('text_input'));
		this.assertEqual('password', validator.getFieldType('password_input'));
		this.assertEqual('select', validator.getFieldType('select_input'));
		this.assertEqual('radio', validator.getFieldType('radio_input'));
		this.assertEqual('checkbox', validator.getFieldType('checkbox_input'));
		this.assertEqual('checkbox', validator.getFieldType('checkbox_input2'));
		this.assertEqual('textarea', validator.getFieldType('textarea_input'));
		this.assertEqual('select', validator.getFieldType('multi_select_input'));
		this.assertUndefined(validator.getFieldType('__not_present'));
	}
	
	this.testFieldValuesAreReturnedAccordingToType = function()
	{
		var f = this.getTestForm();
		var validator = new FormValidator(f);
		this.assertEqual('some_value', validator.getFieldValue('text_input'));
		this.assertEqual('', validator.getFieldValue('password_input'));
		this.assertEqual('10', validator.getFieldValue('select_input'));
		this.assertEqual('value 2', validator.getFieldValue('radio_input'));
		this.assertFalse(validator.getFieldValue('checkbox_input')); //Not checked, so should be false
		this.assertEqual('my value 2', validator.getFieldValue('checkbox_input2'));
		this.assertEqual('my text < more text', validator.getFieldValue('textarea_input'));
		this.assertEqual(new Array('1', '3'), validator.getFieldValue('multi_select_input'));
		this.assertUndefined(validator.getFieldValue('__non_existent'));
	}
	
	this.testValidatorCheckMethodIsInvoked = function()
	{
		var f = this.getTestForm();
		var validator = new FormValidator(f);
		validator.__disableTypeChecks(); //For testing only
		
		function TestValidator()
		{
			this.check = function() {}
		}
		TestValidator.prototype = new FV_Validator;
		
		var mockFactory = new Mock(new TestValidator(), this);
		var mockValidator = mockFactory.generate();
		
		validator.apply(mockValidator, 'text_input', 'My message');
		
		function TestReporter()
		{
			this.pass = function() {}
			this.fail = function() {}
		}
		TestReporter.prototype = new FV_Reporter;
		
		validator.setReporter(new TestReporter());
		
		validator.run();
		
		mockValidator.assertCalledOnce('check', new Array('text_input'));
		
		validator.apply(mockValidator, 'password_input', 'My message');
		
		validator.run();
		
		mockValidator.assertCallCount('check', 3); //The one from earlier, and now 2 on top
	}
	
	this.testReporterReceivesFailNotifications = function()
	{
		function TestReporter()
		{
			this.pass = function() {}
			this.fail = function() {}
		}
		TestReporter.prototype = new FV_Reporter;
		
		var f = this.getTestForm();
		var validator = new FormValidator(f);
		validator.__disableTypeChecks();
		
		var mockFactory = new Mock(new TestReporter(), this);
		var mockReporter = mockFactory.generate();
		
		validator.setReporter(mockReporter);
		
		function TestValidator()
		{
			this.check = function() {}
		}
		TestValidator.prototype = new FV_Validator;
		
		var mockFactory = new Mock(new TestValidator(), this);
		var mockValidator = mockFactory.generate();
		
		validator.apply(mockValidator, 'text_input', 'My first message');
		validator.apply(mockValidator, 'password_input', 'My second message');
		
		mockReporter.assertCalledNever('pass');
		mockReporter.assertCalledNever('fail');
		
		mockValidator.setReturnValueAt('check', true, 1);
		mockValidator.setReturnValueAt('check', false, 2);
		
		validator.run();
		
		mockReporter.assertCallSequence(new Array('pass', 'fail'));
		mockReporter.assertCalledOnce('pass', new Array('text_input'));
		mockReporter.assertCalledOnce('fail', new Array('password_input', 'My second message'));
	}
}

And a stupidly basic use case:

Code: Select all

<html>
<head>
<script type="text/javascript" src="FormValidator.js"></script>
</head>
<body>
<div>
<div id="errors" style="font-weight: bold; color: red; font-size: 0.8em; font-family: arial,sans-serif"></div>
<form id="foo">
Text: <input type="text" name="my_field" /><br />
Check: <input type="checkbox" name="my_check" />
<input type="submit" name="submit" value="Submit" />
</form>
</div>
<script type="text/javascript">

var validator = new FormValidator(document.getElementById('foo'));
validator.apply(new FV_RegexValidator(/^foobar\d$/), 'my_field', 'You fool, you supposed to use the regex');
validator.apply(new FV_NonEmptyValidator(), 'my_check', 'Tick the box!');
validator.activate(new FV_PageElementReporter(document.getElementById('errors')));

</script>
</body>
</html>
May possibly post in snippets depending upon interest.