Automagic JavaScript Validation Helper

By Matt Curry (mattc)
This helper takes your model validation rules and converts them to JavaScript so they can be applied in the client's browser before submitting to the server.
This helper requires jQuery (http://www.jquery.com). Sorry to all you Prototype/script.aculo.us users.
Way back when CakePHP had a wiki there were a series of articles on "Advanced Validation." One aspect of these articles was using the Model rules in JavaScript to validate on the client side. The code (http://cakeforge.org/snippet/detail.php?type=snippet&id=109) was originally for CakePHP version .10 (iirc). I had been using a heavily modified version of this code even in my 1.2 projects, but it was due for a ground up re-write.

A zip file of the code is available at http://github.com/mcurry/cakephp/tree/master/helpers/validation or you can just copy and paste from below. A demo is available at http://sandbox2.pseudocoder.com/demo/validation_test.

Step 1

If you're not already using jQuery download it and include it in your layout/view.

Step 2

Create /app/webroot/js/validation.js
Download code
function validateForm(form, rules) {
  //clear out any old errors
  $("#messages").html("");
  $("#messages").slideUp();
  $(".error-message").hide();
  
  //loop through the validation rules and check for errors
  $.each(rules, function(field) {
    var val = $.trim($("#" + field).val());
    
    $.each(this, function() {
      console.log(this['rule']);
      
      //check if the input exists
      if ($("#" + field).attr("id") != undefined) {
        var valid = true;
        
        if (this['allowEmpty'] && val == '') {
          //do nothing
        } else if (this['rule'].match(/^range/)) {
          var range = this['rule'].split('|');
          if (val < parseInt(range[1])) {
            valid = false;
          }
          if (val > parseInt(range[2])) {
            valid = false;
          }
        } else if (this['negate']) {
          if (val.match(eval(this['rule']))) {
            valid = false;
          }
        } else if (!val.match(eval(this['rule']))) {
          valid = false;
        }
        
        if (!valid) {
          //add the error message
          $("#messages").append("<p>" + this['message'] + "</p>");
          
          //highlight the label
          //$("label[for='" + field + "']").addClass("error");
          $("#" + field).parent().addClass("error");
        }
      }
    });
  });
  
  if($("#messages").html() != "") {
    $("#messages").wrapInner("<div class='errors'></div>");
    $("#messages").slideDown();
    return false;
  }

  return true;
}


Step 3

Create /app/views/helpers/validation.php

Helper Class:

Download code <?php 
/*
 * Javascript Validation CakePHP Helper
 * Copyright (c) 2008 Matt Curry
 * www.PseudoCoder.com
 *
 * @author      mattc <matt@pseudocoder.com>
 * @version     0.1
 * @license     MIT
 *
 */

//feel free to replace these or overwrite in your bootstrap.php
if (!defined('VALID_EMAIL_JS')) {
  
define('VALID_EMAIL_JS''/^([a-zA-Z0-9_\.\-])+\@(([a-zA-Z0-9\-])+\.)+([a-zA-Z0-9]{2,4})+$/');
}
//I know the octals should be capped at 255
if (!defined('VALID_IP_JS')) {
  
define('VALID_IP_JS''/^[\d]{1,3}\.[\d]{1,3}\.[\d]{1,3}\.[\d]{1,3}$/');
}

//list taken from /cake/libs/validation.php line 497
if (!defined('DEFAULT_VALIDATION_EXTENSIONS')) {
  
define('DEFAULT_VALIDATION_EXTENSIONS''gif,jpeg,png,jpg');
}

class 
ValidationHelper extends Helper {
  var 
$helpers = array('Javascript');

  
//For security reasons you may not want to include all possible validations
  //in your bootstrap you can define which are allowed
  //Configure::write('javascriptValidationWhitelist', array('alphaNumeric', 'minLength'));
  
var $whitelist false;

  function 
rules($modelNames$options=array()) {
    
$scriptTags '';
    
    if (empty(
$options) || !is_array($options)) {
      
$options = array();
    }

    
$defaultOptions = array('formId' => false'inline' => true);
    
$options array_merge($defaultOptions$options);

    
//load the whitelist
    
$this->whitelist Configure::read('javascriptValidationWhitelist');

    if (!
is_array($modelNames)) {
      
$modelNames = array($modelNames);
    }

    
//catch the form submit
    
$formId 'form';
    if (
$options['formId']) {
      
$formId '#' $formName;
    }
    
$scriptTags      .= "$(document).ready(function(){ $('"$formId "').submit( function() { return validateForm(this, rules); }); });\n";

    
//filter the rules to those that can be handled with JavaScript
    
$validation = array();
    foreach(
$modelNames as $modelName) {
      
$model = new $modelName();

      foreach (
$model->validate as $field => $validators) {
        if (
array_intersect(array('rule''required''allowEmpty''on''message'), array_keys($validators))) {
          
$validators = array($validators);
        }

        foreach(
$validators as $key => $validator) {
          
$rule null;

          if (!
is_array($validator)) {
            
$validator = array('rule' => $validator);
          }

          
//skip rules that are applied only on created or updates
          
if (!empty($validator['on'])) {
            continue;
          }

          if (!isset(
$validator['message'])) {
            
$validator['message'] = sprintf('%s %s',
                                            
__('There was a problem with the field'true),
                                            
Inflector::humanize($field)
                                           );
          }


          if (!empty(
$validator['rule'])) {
            
$rule $this->convertRule($validator['rule']);
          }

          if (
$rule) {
            
$temp = array('rule' => $rule,
                          
'message' => __($validator['message'], true)
                         );


            if (isset(
$validator['allowEmpty']) && $validator['allowEmpty'] === true) {
              
$temp['allowEmpty'] = true;
            }

            if (
in_array($validator['rule'], array('alphaNumeric''blank'))) {
              
//Cake Validation::_check returning true is actually false for alphaNumeric and blank
              //add a "!" so that JavaScript knows
              
$temp['negate'] = true;
            }

            
$validation[$modelName Inflector::camelize($field)][] = $temp;
          }
        }
      }
    }

    
//pr($validation); die;

    
$scriptTags     .= "var rules = eval(" json_encode($validation) . ");\n";

    if (
$options['inline']) {
      return 
sprintf($this->Javascript->tags['javascriptblock'], $scriptTags);
    } else {
      
$this->Javascript->codeBlock($scriptTags, array('inline' => false));
    }
    
    return 
true;
  }

  function 
convertRule($rule) {
    
$regex false;

    if (
$rule == '_extract') {
      return 
false;
    }

    if (
is_array($this->whitelist) && !in_array($rule$this->whitelist)) {
      return 
false;
    }

    
$params = array();
    if (
is_array($rule)) {
      
$params array_slice($rule1);
      
$rule $rule[0];
    }

    
//Certain Cake built-in validations can be handled with regular expressions,
    //but aren't on the Cake side.
    
switch ($rule) {
      case 
'between':
        return 
sprintf('/^.{%d,%d}$/'$params[0], $params[1]);
      case 
'date':
        
//Some of Cake's date regexs aren't JavaScript compatible. Skip for now
        
return false;
      case 
'email':
        return 
VALID_EMAIL_JS;
      case 
'equalTo':
        return 
sprintf('/^%s$/'$params[0]);
      case 
'extension':
        return 
sprintf('/\.(%s)$/'implode('|'explode(','DEFAULT_VALIDATION_EXTENSIONS)));
      case 
'ip':
        return 
VALID_IP_JS;
      case 
'minLength':
        return 
sprintf('/^.{%d,}$/'$params[0]);
      case 
'maxLength':
        return 
sprintf('/^.{0,%d}$/'$params[0]);
      case 
'money':
        
//The Cake regex for money was giving me issues, even within PHP.  Skip for now
        
return false;
      case 
'numeric':
        
//Cake uses PHP's is_numeric function, which actually accepts a varied input
        //(both +0123.45e6 and 0xFF are valid) then what is allowed in this regular expression.
        //99% of people using this validation probably want to restrict to just numbers in standard
        //decimal notation.  Feel free to alter or delete.
        
return '/^[+-]?[0-9]+$/';
      case 
'range':
        
//Don't think there is a way to do this with a regular expressions,
        //so we'll handle this with plain old javascript
        
return sprintf('range|%s|%s'$params[0], $params[1]);
    }

    
//try to lookup the regular expression from
    //CakePHP's built-in validation rules
    
$Validation =& Validation::getInstance();
    if (
method_exists($Validation$rule)) {
      
$Validation->regex null;
      
call_user_func_array(array(&$Validation$rule), array_merge(array(true), $params));

      if (
$Validation->regex) {
        
$regex $Validation->regex;
      }
    }

    
//special handling
    
switch ($rule) {
      case 
'postal':
      case 
'ssn':
        
//I'm not a regex guru and I have no idea what "\\A\\b" and "\\b\\z" do.
        //Is it just to match start and end of line?  Why not use
        //"^" and "$" then?  Eitherway they don't work in JavaScript.
        
return str_replace(array('\\A\\b''\\b\\z'), array('^''$'), $regex);
    }

    return 
$regex;
  }
}
?>


Step 4

Include the helper in any controller that will need it.

Controller Class:

Download code <?php 
var $helpers = array('Validation');
?>

Step 5

Include the Javascript files in your view. If you are already using jQuery throughout your app, and it is included in your layout, you can removed it from the line below.

View Template:

Download code
$javascript->link(array('jquery', 'validation'), false);

Step 6

Then in the views for your forms, call the helper. Replace "Model" with the model name for the form.

View Template:

Download code
echo $validation->rules('Model');  

Step 7

You can pass a second param to the method call above, which is an array of options. The available options are:
  • formId - The specific form id if you have multiple forms on a page and only want to target one.
  • inline - Setting this to true will return the ouput for direct echoing. If false then the codeblock will be added to the output of $scripts_for_layout for display in the HEAD tag.
  • messageId - The id of a div where all the validation messages will be displayed.

Step 8

If a particular field fails the input will be marked with the css class "form-error" and the message will be added after the field with the class "error-message". This is the same as Cake would do if you submitted to the server. In addition you can specify a div messageId and all the messages will be shown there as well.

Step 9

I wrote an article for PHPArch about JavaScript validation (http://c7y.phparch.com/c/entry/1/art,improved_javascript_validation), which raised some concerns (http://www.pseudocoder.com/archives/2008/02/12/article-on-javascript-validation/#comment-2667) that this approach may reveal too much about an application's security. If this is a concern for you, but you still want to use this helper, there is an option to whitelist rules can be applied on the client side. To use to this feature set the list in your bootstrap.php:
Download code
Configure::write('javascriptValidationWhitelist', array('alphaNumeric', 'minLength'));  

 

Comments 634

CakePHP Team Comments Author Comments
 

Comment

1 Errors in 1.2 RC2

I'm trying Cake 1.2 RC2 with the code you provided and I seem to be getting these errros strangely:


Warning (2): array_keys() [function.array-keys]: The first argument should be an array [APP\views\helpers\validation.php, line 65]

Code | Context

$modelNames    =    array(
    "User"
)
$options    =    array(
    "formId" => false,
    "inline" => true
)
$scriptTags    =    "$(document).ready(function(){ $('form').submit( function() { return validateForm(this, rules); }); });
"
$defaultOptions    =    array(
    "formId" => false,
    "inline" => true
)
$formId    =    "form"
$validation    =    array(
    "UserUsername" => array(
    array(),
    array()
),
    "UserPassword" => array(
    array()
)
)
$modelName    =    "User"
$model    =    User
User::$hasAndBelongsToMany = array
User::$validate = array
User::$useDbConfig = "default"
User::$useTable = "users"
User::$displayField = "id"
User::$id = false
User::$data = array
User::$table = "users"
User::$primaryKey = "id"
User::$_schema = array
User::$validationErrors = array
User::$tablePrefix = ""
User::$name = "User"
User::$alias = "User"
User::$tableToModel = array
User::$logTransactions = false
User::$transactional = false
User::$cacheQueries = false
User::$belongsTo = array
User::$hasOne = array
User::$hasMany = array
User::$actsAs = NULL
User::$Behaviors = BehaviorCollection object
User::$whitelist = array
User::$cacheSources = true
User::$findQueryType = NULL
User::$recursive = 1
User::$order = NULL
User::$__exists = NULL
User::$__associationKeys = array
User::$__associations = array
User::$__backAssociation = array
User::$__insertID = NULL
User::$__numRows = NULL
User::$__affectedRows = NULL
User::$__findMethods = array
User::$_log = NULL
User::$Group = Group object
User::$GroupsUser = AppModel object
$validators    =    "email"
$field    =    "email"
$validator    =    array(
    "rule" => array(
    "minLength",
    "8"
),
    "message" => "Mimimum 8 characters long"
)
$key    =    0
$rule    =    "/^.{8,}$/"
$temp    =    array(
    "rule" => "/^.{8,}$/",
    "message" => "Mimimum 8 characters long"
)

array_keys - [internal], line ??
ValidationHelper::rules() - APP\views\helpers\validation.php, line 65
include - APP\views\users\test.ctp, line 1
View::_render() - CORE\cake\libs\view\view.php, line 646
View::render() - CORE\cake\libs\view\view.php, line 368
Controller::render() - CORE\cake\libs\controller\controller.php, line 733
Dispatcher::_invoke() - CORE\cake\dispatcher.php, line 260
Dispatcher::dispatch() - CORE\cake\dispatcher.php, line 230
[main] - APP\webroot\index.php, line 90

Warning (2): array_intersect() [function.array-intersect]: Argument #2 is not an array [APP\views\helpers\validation.php, line 65]

Code | Context

$modelNames    =    array(
    "User"
)
$options    =    array(
    "formId" => false,
    "inline" => true
)
$scriptTags    =    "$(document).ready(function(){ $('form').submit( function() { return validateForm(this, rules); }); });
"
$defaultOptions    =    array(
    "formId" => false,
    "inline" => true
)
$formId    =    "form"
$validation    =    array(
    "UserUsername" => array(
    array(),
    array()
),
    "UserPassword" => array(
    array()
)
)
$modelName    =    "User"
$model    =    User
User::$hasAndBelongsToMany = array
User::$validate = array
User::$useDbConfig = "default"
User::$useTable = "users"
User::$displayField = "id"
User::$id = false
User::$data = array
User::$table = "users"
User::$primaryKey = "id"
User::$_schema = array
User::$validationErrors = array
User::$tablePrefix = ""
User::$name = "User"
User::$alias = "User"
User::$tableToModel = array
User::$logTransactions = false
User::$transactional = false
User::$cacheQueries = false
User::$belongsTo = array
User::$hasOne = array
User::$hasMany = array
User::$actsAs = NULL
User::$Behaviors = BehaviorCollection object
User::$whitelist = array
User::$cacheSources = true
User::$findQueryType = NULL
User::$recursive = 1
User::$order = NULL
User::$__exists = NULL
User::$__associationKeys = array
User::$__associations = array
User::$__backAssociation = array
User::$__insertID = NULL
User::$__numRows = NULL
User::$__affectedRows = NULL
User::$__findMethods = array
User::$_log = NULL
User::$Group = Group object
User::$GroupsUser = AppModel object
$validators    =    "email"
$field    =    "email"
$validator    =    array(
    "rule" => array(
    "minLength",
    "8"
),
    "message" => "Mimimum 8 characters long"
)
$key    =    0
$rule    =    "/^.{8,}$/"
$temp    =    array(
    "rule" => "/^.{8,}$/",
    "message" => "Mimimum 8 characters long"
)

array_intersect - [internal], line ??
ValidationHelper::rules() - APP\views\helpers\validation.php, line 65
include - APP\views\users\test.ctp, line 1
View::_render() - CORE\cake\libs\view\view.php, line 646
View::render() - CORE\cake\libs\view\view.php, line 368
Controller::render() - CORE\cake\libs\controller\controller.php, line 733
Dispatcher::_invoke() - CORE\cake\dispatcher.php, line 260
Dispatcher::dispatch() - CORE\cake\dispatcher.php, line 230
[main] - APP\webroot\index.php, line 90

Warning (2): Invalid argument supplied for foreach() [APP\views\helpers\validation.php, line 69]
Posted Jul 2, 2008 by uniacid
 

Comment

2 not working

var $validate = array(
'title' => array('/.+/'),
'text' => array('/.+/')
);
this does not work, in my case, validation does not work at all, see no errors in console
Posted Jul 3, 2008 by Vilen
 

Comment

3 Worked using a few fixes

Hi there!

I was able to make it work after a few fixes. You need to include a div with an id "message" in your view where you can dump the error messages. A call to console.log() in validation.js would cause errors on browsers other than Firefox that don't support this method.

Did some changes on validation.js to output/append the error messages to the corresponding input fields as what CakePHP usually does.

validation.js function validateForm(form, rules) {

var passed = true;



//clear out any old errors

$('.vhelper').remove();



//loop through the validation rules and check for errors

$.each(rules, function(field) {

var val = $.trim($("#" + field).val());



$.each(this, function() {



//check if the input exists

if ($("#" + field).attr("id") != undefined) {

var valid = true;



if (this['allowEmpty'] && val == '') {

//do nothing

} else if (this['rule'].match(/^range/)) {

var range = this['rule'].split('|');

if (val < parseInt(range[1])) {

valid = false;

}

if (val > parseInt(range[2])) {

valid = false;

}

} else if (this['negate']) {

if (val.match(eval(this['rule']))) {

valid = false;

}

} else if (!val.match(eval(this['rule']))) {

valid = false;

}

if (!valid) {

$("#" + field).after('
' + this['message'] + "
");

passed = false;

}



}

});

});
return passed;

}
Posted Jul 22, 2008 by Rolan
 

Comment

4 Updates

Posted Oct 23, 2008 by Matt Curry
 

Comment

5 'Last' option

Hi,

the code is great!

I've added something to work with 'last' option.

In vaidation.php :


// Added 'last'
if (array_intersect(array('rule', 'allowEmpty', 'on', 'message', 'last'), array_keys($validators))) {....

// After $temp = array('rule' => $rule, ....
if (isset($validator['last']) && $validator['last'] === true) {
    $temp['last'] = true;
}

In validate.js :

// After  $("#" + field).after('<div class="error-message">'  + this['message'] +  '</div>');
if (this['last'] === true) return false; 

It seems to work.
Posted Nov 6, 2008 by Marco
 

Comment

6 Re: Last Option

Hey Marco,
Thanks for the fix. I updated the version in GitHub to include your change.
-Matt
Posted Nov 14, 2008 by Matt Curry
 

Question

7 Invalid Regular expression?

In Firefox's error console I get the following error: "Invalid flag after regular expression".

My rule looks like this:

'rule' => array('custom','/^[a-z0-9\-äöüÄÖÜß\.&,\/+@() ]{3,40}$/iu'
Is /iu not allowed? Or is there something different wrong?

-Oliver
Posted Dec 1, 2008 by Oliver Hermanni
 

Comment

8 Re: Invalid Regular expression?

Hey Oliver,
As a general rule any regular expression syntax that is valid for JavaScript should work.

In your case I think the "i" modifier is fine, but not "u".
-Matt
Posted Dec 1, 2008 by Matt Curry
 

Comment

9 Re: Invalid Regular expression?

Thanks, that worked for me.

But is there a way to prevent the validation get called? I have a multipage formular and there is a "back to previous page"-button. I'm using a location.href function on that button. On IE6/7 everything works fine (what a surprise!), but on FF the validation errors are shown and then I go back to the called page. That's ugly. Any way to prevent this?
Posted Dec 2, 2008 by Oliver Hermanni
 

Comment

10 9 Re: Invalid Regular expression?

Hey Oliver,
The validation is only called when the form is submitted. Is your back button a submit button? Otherwise it shouldn't be calling the validation.
Posted Dec 3, 2008 by Matt Curry
 

Question

11 2 forms 1 view

I have got 2 form on 1 view.
I use rules('User',array('formId'=>'UsersRegisterForm')); ?> to show the form to validate but this doesn't seem to work. Am I doing something wrong?
Posted Apr 2, 2009 by Ruben Hoogervorst
 

Comment

12 Re: 2 forms 1 view

I have got 2 form on 1 view.
I use rules('User',array('formId'=>'UsersRegisterForm')); ?> to show the form to validate but this doesn't seem to work. Am I doing something wrong?

Can you send me a link? matt -at- pseudocoder.com
Posted Apr 3, 2009 by Matt Curry
 

Comment

13 I would really like to use this but it does not work for me.

I am using Cake 1.2 and have followed your instructions. My model has a validation as follows.

    var $validate = array( 
                                                'title' => array(
                                                                                    'required' => true,
                                                                                    'rule' => VALID_NOT_EMPTY,
                                                                                    'message' => 'Enter a title'    
                                                                                ),
                                                'description' => array(
                                                                                    'required' => true,
                                                                                    'rule' => VALID_NOT_EMPTY,
                                                                                    'message' => 'Enter a description'    
                                                                                            ),
                                                'feedback_type_id' => array    (
                                                                                                            'required' => true,
                                                                                                            'rule' => VALID_NOT_EMPTY,
                                                                                                            'message' => 'Enter a type'    
                                                                                                        )
                                                );

When I try it, it still uses the old submit method and does a server trip. I have jQuery installed and running, and have followed all the steps. If I right click my form I see this:

<script type="text/javascript">$(document).ready(function(){
$('form').submit( function() {
return validateForm(this, rules, eval({"messageId":"messages"}));
});
});
var rules = eval([]);
</script>

Makes me wonder if the validations are making it over to this js.
Posted Apr 20, 2009 by Michael Bourque
 

Comment

14 RE: I would really like to use this but it does not work for me.

Hey Michael,
It looks like you're using 1.1 validation rules. Check out: http://book.cakephp.org/view/740/notEmpty
When it's working you'll see all the validation rules in var rules = eval(...);

-Matt
Posted Apr 20, 2009 by Matt Curry