Friday, May 1, 2009

CakePHP Form Validation with Ajax Using jQuery

Friday, May 1, 2009

Data validation, an important part of any application, is useful to ensure that your users enter data properly. When you create forms, you will need to determine which validation strategy to use, client-side, server-side or maybe mixed. Client-side validation has advantages for the server, the user and the developer, but the problem is that anything running on the client end can never be trusted.

Speaking of server-side validation in CakePHP, its built-in validation mechanism provides a powerful, yet easy-to-use environment. However, when it comes to Ajax form submission, the reality is that it involves a lot of technologies. There are already a number of examples on the web on how to implement data validation with the Prototype JavaScript framework and CakePHP's AJAX helper. But so far there are not so many examples of an integration with CakePHP + jQuery.

Now, we'll take a look at how to do Ajax form validation with CakePHP and jQuery.

Before we start, let's set some goals first:

  • We need to ensure that the user enter data properly by using CakePHP's validation class
  • We would like to display an individual error message below each field
  • We would like to display flash messages on success and failure
  • We would like to validate multiple models in a single form view
  • We would like to switch languages based on Localization (shortened to 'l10n')
  • If the data is saved, we want the user to redirect to another location

Whew... That's a lot, isn't it?

Let's start with our typical Post Model with some validation rules.

<?php
class Post extends AppModel {
    var $name = 'Post';
    var $validate = array(
        'title' => array(
            'required' => array('rule' => 'notEmpty'),
            'maxlength' => array('rule' => array('maxLength', 30))
        ),
        'body' => array(
            'required' => array('rule' => 'notEmpty'),
            'maxlength' => array('rule' => array('maxLength', 200))
        )
    );
}
?>

We have two fields, “title” and “body” to be validated. We'll ensure that the fields are not empty, and the data stays within a maximum length requirement.

Let's do a simple view (posts/edit.ctp) which we'll use for both “add” and “edit” actions:

<?php
    if (isset($javascript)) {
        $javascript->link(array(
            'jquery/jquery.min',
            'page_specific/posts_edit'
        ), false);
    }
?>

<div class="posts form">
<?php echo $form->create();?>
    <fieldset>
        <legend><?php $this->action == 'add' ? __('Add Post') : __('Edit Post'); ?></legend>
    <?php
        echo $form->input('id');
        echo $form->input('title');
        echo $form->input('body');
        echo $form->input('published', array('checked' => true));
    ?>
    </fieldset>
    
    <div id="loadingDiv" style="display:none;">
        <?php echo $html->image('ajax-loader.gif', array('alt' => 'Loading...')); ?>
    </div>
 
<?php echo $form->end(__('Submit', true));?>
</div>

We have included the jQuery core and a file that will be specific to this view (No worries, we'll create this file next).

Also, we have added a loading div element with an image inside to handle the loading status of the request. When the user submit the form, the loading image will be displayed until the Ajax call is completed.

Okay, now let's create the page specific .js file (posts_edit.js):

$(function() {

    var _loadingDiv = $("#loadingDiv");

    $('#PostAddForm, #PostEditForm').submit(function(){
        _loadingDiv.show();
        $.post('/posts/ajax_edit',
            $(this).serializeArray(),
            afterValidate,
            "json"
        );
        return false;
    });
 
    function afterValidate(data, status)  {
        $(".message").remove();
        $(".error-message").remove();

        if (data.errors) {
            onError(data.errors);
        } else if (data.success) {
            onSuccess(data.success);
        }
    }
 
    function onSuccess(data) {
        flashMessage(data.message);
        _loadingDiv.hide();
        window.setTimeout(function() {
            window.location.href = '/posts';
        }, 2000);
    };
 
    function onError(data) {
        flashMessage(data.message);
        $.each(data.data, function(model, errors) {
            for (fieldName in this) {
                var element = $("#" + camelize(model + '_' + fieldName));
                var _insert = $(document.createElement('div')).insertAfter(element);
                _insert.addClass('error-message').text(this[fieldName])
            }
            _loadingDiv.hide();
        });
    };
 
    function flashMessage(message) {
        var _insert = $(document.createElement('div')).css('display', 'none');
        _insert.attr('id', 'flashMessage').addClass('message').text(message);
        _insert.insertBefore($(".posts")).fadeIn();
    }

    function camelize(string) {
        var a = string.split('_'), i;
        s = [];
        for (i=0; i<a.length; i++){
            s.push(a[i].charAt(0).toUpperCase() + a[i].substring(1));
        }
        s = s.join('');
        return s;
    }

});

Our jQuery code might be confusing to some of you. Yeah, it's kind of a bit long too. If we see those method names, we'll find out briefly what each method does.

We'll submit the form data to /posts/ajax_edit with jQuery's $.post method, and receive data in JSON format.

Let's take a look at the onError method. What's most important here is that the default DOM ID of each field is different from the name we gave it by default. For example, our “title” field corresponds to the DOM ID “PostTitle” (Camel-cased model and field name). So, we need to modify a little the returned JSON data for error messages to show.

NOTE: You may want to change the URL path to meet your Cake settings. We can't use $this->webroot to point to the web root in a separated .js file, so...for example:

// We can use an absolute URL like this:
$.post('/app/posts/ajax_edit', ...

window.location.href = '/app/posts';

// Or simply use a full URL:
$.post('http://www.example.com/posts/ajax_edit', ...

window.location.href = 'http://www.example.com/posts';

Now let's create a scenario for our controller.

class PostsController extends AppController {

    var $name = 'Posts';
    var $uses = array('Post');
    var $helpers = array('Html', 'Form', 'Javascript');
    var $components = array('RequestHandler');

    function index() {}

    function add() {
        $this->render('edit');
    }
 
    function edit($id = null) {
        if (!$id && empty($this->data)) {
            $this->Session->setFlash(__('Invalid Post', true));
            $this->redirect(array('action'=>'index'));
        }
        if (empty($this->data)) {
            $this->data = $this->Post->read(null, $id);
        }
    } 

    function ajax_edit() {
        Configure::write('debug', 0);
        $this->layout = 'ajax';
        if ($this->RequestHandler->isAjax()) {
            if (!empty($this->data)) {
                $this->Post->create();
                $this->Post->set($this->data['Post']);
                if($this->Post->validates()) {
                    if ($this->Post->save($this->data)) {
                        $message = __('The Post has been saved.', true);
                        $data = $this->data;
                        $this->set('success', compact('message', 'data'));
                    }
                } else {
                    $message = __('The Post could not be saved. Please, try again.', true);
                    $Post = $this->Post->invalidFields();
                    $data = compact('Post');
                    $this->set('errors', compact('message', 'data'));
                }
            }
        }
    }
 
}

The index() is just an empty action which we send the user to after successful form submission, so just skip it. You can also skip the edit and add actions, but notice that we use the same view template (edit.ctp) for both actions just for convenience.

By the way, don't forget to add the RequestHandler component to the $components array, and the Html, Form and Javascript helpers to the $helpers array.

Let's look at our ajax_edit() closely.

We put the actual form elements in our edit (or add) view. Indeed, we post all the data to our Post's controller ajax_edit action.

Technically, there's no big difference with the way we do normally (other than the message handling). The point is that we simply attempt to validate the data, and send all the infomation (no matter what messages we receive after validating or saving the data) to our view.

Now we need some view to return the success/error messages back to jQuery script.

So, let's make the ajax_edit.ctp:

<?php
$output = array();
if($this->validationErrors) {
    $output = Set::insert($output, 'errors', array('message' => $errors['message']));
    $errorMessages = array(
        'Post' => array(
            'title' => array(
                'required' => __("This field cannot be left blank.", true),
                'maxlength' => sprintf(__("Maximum length of %d characters.", true), 30)
            ),
            'body' => array(
                'required' => __("This field cannot be left blank.", true),
                'maxlength' => sprintf(__("Maximum length of %d characters.", true), 200)
            )
        )
    );
    foreach ($errors['data'] as $model => $errs) {
        foreach ($errs as $field => $message) {
            $output['errors']['data'][$model][$field] = $errorMessages[$model][$field][$message];
        }
    }
} elseif ($success) {
    $output = Set::insert($output, 'success', array(
        'message' => $success['message'],
        'data' => $success['data']
    ));
}
echo $javascript->object($output);
?>

What does the output look like? We should get JSON output, something like this:

// Error output
{"errors":{
    "message":"The Post could not be saved. Please, try again.",
    "data":{
        "Post":{
            "title":"This field cannot be left blank.",
            "body":"This field cannot be left blank."
        }
    }
}}

// Success output 
{"success":{
    "message":"The Post has been saved.",
    "data":{
        "Post":{
            "id":"",
            "title":"Lorem ipsum dolor sit amet",
            "body":"Lorem ipsum dolor sit amet, aliquet ...",
            "published":"1"
        }
    }
}}

Alright, let's take another look at a part of our jQuery code:

function onError(data) {
    flashMessage(data.message);
    $.each(data.data, function(model, errors) {
        for (fieldName in this) {
            var element = $("#" + camelize(model + '_' + fieldName));
            var _insert = $(document.createElement('div')).insertAfter(element);
            _insert.addClass('error-message').text(this[fieldName])
        }
        _loadingDiv.hide();
    });
};

If any errors came back from our ajax_edit() action, we create a div element on the fly with the error message using createElement(), a DOM method that creates new document elements.

function flashMessage(message) {
    var _insert = $(document.createElement('div')).css('display', 'none');
    _insert.attr('id', 'flashMessage').addClass('message').text(message);
    _insert.insertBefore($(".posts")).fadeIn();
}

Well, the flashMessage(), which is used by both success and failure, uses the createElement() as well to flash messages just above the form area.

function onSuccess(data) {
    flashMessage(data.message);
    _loadingDiv.hide();
    window.setTimeout(function() {
        window.location.href = '/posts';
    }, 2000);
};

If there is no error, we happily send the user to /posts/index action. We added one gimmick here. We ask the script to wait for 2 seconds before the redirect so that the user can see the success message.

Alright, all done? Did we fulfill the goals we had in the beginning?

There might be some items that are not clear cut:

  • We would like to validate multiple models in a single form view
  • We would like to switch languages based on Localization (l10n)

Here is a tip for the first one. Write a proper code for saving multiple models (we don't go through the multiple save stuff here). We can add as many models as we want by doing something like:

// Our Post's controller ajax_edit action
$Post = $this->Post->invalidFields();
$AnotherModel = $this->AnotherModel->invalidFields();
$data = compact('Post', 'AnotherModel');

And don't forget to add error messages for “AnotherModel” to the $errorMessages array (ajax_edit.ctp)

Okay, let's move to the second one. Since we do view-side validation messages instead of putting error messages into our Post Model, it enables support for multiple languages (l10n).

Phew! All done.

You can download the code of this tutorial at http://code.google.com/p/jamnite/

29 comments:

  1. Nice tutorial!
    But I have one question. When I want to add or edit a post, I see the ajax-loader.gif but thats all. No redirect, no flash message and nothing changed in the database, Do you know how to solve this?

    ReplyDelete
  2. It seems like good stuff, although a demo of this working would be very useful.

    Just one thing: as far as I know, it's not a good practice to use $this->RequestHandler->isAjax in most of the cases, since it always returns false in IE7. Yeah, I know I hate IE too, but we all have to deal with the fact that it's very common, so...

    Here's the Trac Ticket of the issue

    https://trac.cakephp.org/ticket/1935

    ReplyDelete
  3. Thanks, that's a great article! It would help me a lot.

    www.mariuzzo.com

    ReplyDelete
  4. @Marco
    If you want to run the demo app to see it working:
    1) Copy all files into the app dir of a clean install
    2) Run the sql (found in app/config/sql/posts.sql) to create the posts table
    3) Go to /posts to add or edit your posts
    Good luck!

    @Aku Ma
    It's a bug fixed already. At least it works on my IE7. Thanks for the info, anyway.

    @Zuquirio Ámaur
    Glad you liked, no problem.

    ReplyDelete
  5. Cheers for this man, very useful :)

    ReplyDelete
  6. @Anonymous
    Thanks & Have Fun!

    ReplyDelete
  7. Thanks for this, I can see how this would work theoretically, but I am having no luck so far. I'm having the same problem as Marco, where the ajax-loader div is display:block'd and then nothing... At first I tried coding it myself from your instructions above and then just downloaded your zip and installed into a fresh install that way. No luck on IE7, Chrome or FF. Any ideas?

    Thanks very much

    ReplyDelete
  8. @dannyboy
    Probably the URLs in the js file are pointing to a wrong location.
    Try this:
    $.post('/app/posts/ajax_edit',
    And
    window.location.href = '/app/posts';

    ReplyDelete
  9. @Kyo
    Thanks for your suggestion. I finally got it working by making the paths complete URLs:

    I made a globals.js file with this code (for instance):
    var appurl = "http://localhost/cake";

    Called it in the view before all the other js...

    Then in the page js file:
    $.post(appurl + '/posts/ajax_edit',
    And
    window.location.href = appurl + '/posts';

    So glad it's all working now, Cake+JQuery ROCKS!

    ReplyDelete
  10. @dannyboy
    Glad you made it.

    In the head section of the layout, you can also do something like this:
    <script type="text/javascript">
    var webroot = '<? echo $this->webroot; ?>';
    </script>
    And then in the .js file:
    $.post(webroot + 'posts/ajax_edit', ...
    And
    window.location.href = webroot + 'posts';

    Yeah, they rocks and make our lives easier!

    ReplyDelete
  11. @kyo
    I've made some visual improvements in the JS file which I thought I would share with you guys as a token of thanks. I've basically added some nice effects from the jqueryui and made things a touch slicker. Edit your code with the following, try submitting the add posts form a number of times and you'll see what I mean. Here are the mods:

    First of all, go to http://jqueryui.com/download and build a custom download making sure all the effects are selected. Then include the jquery-ui-1.7.1.custom.min.js file from the zip in edit.ctp like so:

    if (isset ($javascript)) {
        $javascript->link(array(
            'globals', // containing var appurl = "http://localhost/cake";
            'jquery/jquery-1.3.2.min',
            'jquery/jquery-ui-1.7.1.custom.min',
            'page_specific/posts_edit'
        ), false);
    }

    And this is the modified posts_edit.js file:

    // Page Specific JavaScript Document

    $(function() {

        var _loadingDiv = $("#loadingDiv");

        $('#PostAddForm, #PostEditForm').submit(function(){
            _loadingDiv.show();
            $(":submit").attr('disabled', 'disabled');
            $.post(APPPATH + '/posts/ajax_edit',
                $(this).serializeArray(),
                afterValidate,
                "json"
            );
            return false;
        });
        
        // Post-submit callback
        function afterValidate(data, status) {
            if (data.errors) {
                onError(data.errors);
            } else if (data.success) {
                onSuccess(data.success);
            }
            $(":submit").removeAttr('disabled');
        }
        
        function onSuccess(data) {
            flashMessage(data.message);
            _loadingDiv.show();
            window.setTimeout(function() {
                window.location.href = APPPATH + '/posts';
            }, 2000);
        };
        
        function onError(data) {
            flashMessage(data.message);
            $("div.error-message").attr('title', 'error');
            $.each(data.data, function(model, errors) {
                for (fieldName in this) {
                    var inputID = camelize(model + '_' + fieldName);
                    var element = $("#" + inputID);
                    var elementError = $("#" + inputID + "_error");
                    if (elementError.length > 0) {
                        elementError.text(this[fieldName]);
                        elementError.effect('shake', {times:2, distance:10}, 150);
                        elementError.removeAttr('title');
                    } else {
                        var _insert = $(document.createElement('div')).addClass('error-message');
                        _insert.attr('id', inputID + "_error")
                        _insert.text(this[fieldName]).css('display', 'none').insertAfter(element);
                        _insert.slideDown(200);
                    }
                }
                _loadingDiv.hide();
            });
            $("div.error-message[title=error]").slideUp(200, function(){$(this).remove();});
        };
        
        function flashMessage(message) {
            if ($("#flashMessage").length > 0) {
                $("#flashMessage").text(message);
            } else {
                var _insert = $(document.createElement('div')).css('display', 'none');
                _insert.attr('id', 'flashMessage').addClass('message').text(message);
                _insert.insertBefore($(".posts")).slideDown(200);
            }
        }

        function camelize(string) {
            var a = string.split('_'), i;
            s = [];
            for (i=0; i<a.length; i++){
                s.push(a[i].charAt(0).toUpperCase() + a[i].substring(1));
            }
            s = s.join('');
            return s;
        }

    });

    Any problems, please share them on here and i'll do my best...
    Cheers and have fun!

    ReplyDelete
  12. @dannyboy
    I ran your code, and liked it with the shake effect very much. Thanks for sharing!

    ReplyDelete
  13. Hi dannyboy,
    your code looks very cool, but its throwing a failure in my firebug:
    G is undefined
    [Break on this error] (function(){var l=this,g,y=l.jQuery,p=l.....each(function(){o.dequeue(this,E)})}});
    Its in line 12 on jquery-1.3.2.min.js

    Do have an idea how I can fix it?

    greetz,
    al

    ReplyDelete
  14. This looks great but I don't understand why you duplicate the validation messages in ajax_edit.ctp and not in the model where they normally would go?

    ReplyDelete
  15. @Lurker
    Yes, good point! if you are working on a single-language site, the validation messages should simply be placed in the model.
    Since this tutorial covers the L10N for multi-language, I did it that way.

    ReplyDelete
  16. How can I bring other validations like email , number format etc in this scripts.

    ReplyDelete
  17. Is it possible to update a datagrid of posts reside in the same page. It is placed in a div. Can I reload that grid with updated data?

    ReplyDelete
  18. @vinodkalpaka
    1) Set other rules in your model, and then add error messages to the $errorMessages array (ajax_edit.ctp)

    2) Sure. You can use returned data in the onSuccess() method (posts_edit.js) to update some div's content dynamically. If you have Firebug enabled in Firefox, use console.info(data) to see what data comes back.

    ReplyDelete
  19. This is is by FAR the best solution for AJAX validation I have found, will implement it asap.

    The only question I have right now is if it would be possible to extend this to check each individual field on the fly instead of when clicking Submit?

    ReplyDelete
  20. @Oscar
    Thanks. I'm not sure what you are trying to do, but it should be possible as long as you get correct return values via ajax.

    ReplyDelete
  21. Great Post, I found it in the next link, for lucky:

    http://www.ajaxlines.com/ajax/stuff/article/cakephp_form_validation_with_ajax_using_jquery.php

    And so only with read it, I'd can to implement this solution in my own project, my congratulations for your work you're very good web developer : )

    Greetings from Chihuahua, Mexico

    ReplyDelete
  22. Thanks dude, this awesome, Keep rocking.

    Greetings from Ahmed Kamal, Egypt

    ReplyDelete
  23. how can we validate duplicate value using jquery

    ReplyDelete
  24. @aa
    Depending on your needs, you need to elaborate the js code.

    ReplyDelete
  25. Thanks for the great code. I modified the code to fit my needs. I needed to use the validation model cakephp, validation for single fields and multiple models. Theerfore, when the user left a field the model validates the input and returns an error message for the field. Apparently, it does not work with checkboxes. So here is my code:

    ---------------- JS

    // Page Specific JavaScript Document

    $(function() {

    var _loadingDiv = $("#loadingDiv");


    $("input").change(function(event){
    // _loadingDiv.show();
    $.get('bestellen/ajax_single_validate',
    $(this).serializeArray(),
    afterValidate,
    "json"
    );
    return false;
    });

    // Post-submit callback
    function afterValidate(data, status) {
    // $(".message").remove();
    // $(".error-message").remove();

    if (data.errors) {
    onError(data.errors);
    } else {
    onSuccess(data.success);
    }
    }

    function onSuccess(data) {
    $.each(data.data, function(model, success) {
    for (fieldName in this) {
    var elementErr = $("#" + model + camelize(fieldName) + "Error");
    elementErr.remove();
    }
    });
    };

    function onError(data) {
    $.each(data.data, function(model, errors) {
    for (fieldName in this) {
    var elementErr = $("#" + model + camelize(fieldName) + "Error");
    elementErr.remove();
    var element = $("#" + model + camelize(fieldName));
    var _insert = $(document.createElement('div')).insertAfter(element);
    _insert.addClass('error-message').text(this[fieldName]);
    _insert.attr('id', model + camelize(fieldName)+"Error" );
    }
    });
    };

    function camelize(string) {
    var a = string.split('_'), i;
    s = [];
    for (i=0; ilayout = 'ajax';
    if (true || $this->RequestHandler->isAjax()) {
    //if (empty($this->data) && !empty($this->params['url']['data']))
    // $this->data = $this->params['url']['data'];
    if (!empty($this->data)) {
    // get model name to validate
    foreach ($this->uses as $model) {
    $modelLowerCase = strtolower(Inflector::underscore($model));
    if (array_key_exists($modelLowerCase, $this->data)) {
    $this->{$model}->create();
    $this->{$model}->set($this->data[$modelLowerCase]);
    $toValidate = key($this->data[$modelLowerCase]);
    $valid = $this->{$model}->validates();
    $invalidFields = $this->{$model}->invalidFields();
    if ($valid) {
    $successful = array();
    $successful[$modelLowerCase] = $this->data[$modelLowerCase];
    $this->set('success', $successful);
    } else {
    $errors = array($modelLowerCase => $this->{$model}->invalidFields());
    var_dump($errors);
    $this->set('errors', $errors);
    }
    }
    }
    }
    }
    }


    view ---------------------

    $errs) {
    foreach ($errs as $field => $message) {
    $output['errors']['data'][$model][$field] = $message;
    }
    }
    } elseif ($success) {
    foreach ($success as $model => $succs) {
    foreach ($succs as $field => $message) {
    $output['success']['data'][$model][$field] = $message;
    }
    }
    }
    echo $javascript->object($output);
    ?>


    ----

    ReplyDelete
  26. Amazing tutorial.. thanks a lot!

    ReplyDelete
  27. Its the best article for ajax validation.. I will definately use this one..

    ReplyDelete
  28. how to set language in all fields in cakephp..?

    ReplyDelete
    Replies
    1. @jack
      It's easy and simple.
      Check the manual at:
      http://book.cakephp.org/2.0/en/core-libraries/internationalization-and-localization.html

      Delete

Please feel free to post your comment about this article.

 
JamNite ◄Design by Pocket, BlogBulk Blogger Templates