Welcome Guest, Not a member yet? Register   Sign In
Flash Ajax effect seen in Rails
#11

[eluser]Nick Husher[/eluser]
Part 2. Adding AJAX

Now that we have a basic working prototype that allows a user to add and remove comments via regular HTTP transactions, let's look at how to transplant this process into an asynchronous data flow. We're applying concepts of unobtrusive javascript here, so we want to change the markup as little as possible. My own personal preference is to try to use as few id="..." attributes as possible, mainly because I find that subconsciously I like to use the same ids a lot, and it's bad Web2.0 Quarma to have multiple elements with the same id on the same page. It also means I can quickly spot elements of relative structural or scripting importance. I had the asynchronous piece of the code already in mind when I wrote the markup, and applied ids to the parts of the code I'd most-likely need to be doing javascript with. Let's review:
Code:
form_open('comments',array('id'=>'add_comment_form'))
<input type="checkbox" id="use-ajax-toggle" checked="checked" />
<input type="submit" id="add-comment-button" value="Add Comment" />
<div id="errors">
<div id="comments-list">
Each of these elements has a role to play in ajaxing our new application. Now let's take a look at our ajax toolkit. I'm using YUI for various reasons, but mainly because I'm the most comfortable with it. The same effect is possible in just about all of the other JS toolkits out there so don't drop the framework you know for the framework you don't just because of this example.

YUI has two library files we'll be using for this part of the tutorial, yahoo-dom-event.js and connection-min.js. Yahoo-dom-event.js is a minimized collection of three sets of YUI functionality, the first provides convenience methods for the javascript language and YAHOO's library (yahoo.js), the second provides convenience methods and browser normalization for DOM tree interactions (dom.js), and the last one provides normalization of event handling across-browsers. Connection-min.js is a utility that allows for cross-browser use of asynchronous interaction, and some related convenience tools related to that. We're using the -min variants to save bandwidth.

I'm assuming that you, the reader, are reasonably well-versed in CodeIgniter, but probably aren't as well-versed in javascript, so I'll walk through the javascript code in somewhat more depth than the previous part's CI code. I'll start with the basic setup of the inline script:
Code:
(function() {
    var Dom = YAHOO.util.Dom, Connect = YAHOO.util.Connect, Event = YAHOO.util.Event,
        siteURL = "&lt;?=base_url() ?&gt;";
        asyncUrl = siteURL + 'index.php'+'/comments/ajax_action/';
    // ... //
})();
In the above code, I've started by encapsulating the whole thing within a function. The only way to introduce new levels of scope in javascript is by wrapping code in a function, so instead of dumping my code into the global namespace, I've hidden it inside a function. I've declared a new anonymous function with function(){} and executing it with (...)();. The rest of the code is a bunch of aliases to objects within the YAHOO library, and some configuration for the ajax urls I'm going to use later.

The first real code I want to do is wire up the necessary event listeners to make sure they're working right. There are two onClick handers we need to create, the first to listen on the form's submit button, and the other to listen for clicks to the [-] links in the comments field. Events in DOM trees bubble upwards--if you click on a node in the tree, parent elements will also fire their click events--so instead of attaching an event listener to every single delete link, we're going to attach one and then use some creative telepathy to do the work for us.
Code:
(function() {
    // some aliases for fast access
    var Dom = YAHOO.util.Dom,
        Connect = YAHOO.util.Connect,
        Event = YAHOO.util.Event,
        siteURL = "&lt;?=base_url() ?&gt;";
        asyncUrl = siteURL + 'index.php' + '/comments/ajax_action/';
                        
    Event.addListener('add-comment-button', 'click', function(eventObject) {
        // if we don't want to use ajax, bail out now.
        if(document.getElementById('use-ajax-toggle').checked == false) {
            alert("AJAX turned off. Submitting form normally.");
            return;
        }
        // prevents the button from submitting the form
        Event.preventDefault(eventObject);
        alert('You pressed the button, but the form wasnt submitted!');
    });
    
    Event.addListener('comments-list', 'click', function(eventObject) {
        // if we don't want to use ajax, bail out now.
        if(document.getElementById('use-ajax-toggle').checked == false) {
            alert("AJAX turned off. Submitting form normally.");
            return;
        }

        var target = (eventObject.target) ? eventObject.target : eventObject.srcElement;

        if(target.className == 'delete') {
            Event.preventDefault(eventObject);
            alert('You pressed a delete button, but nothing will happen!');
        }
    });
})(); // parens at the end are important.
So now we should have a bunch of links that pop annoying alert messages, but don't cause the page to refresh. The magic there is in Event.preventDefault(eventObject), which intercepts the default action taken by the element that was clicked. The other bit of magic is in checking to see if the target (the element that was actually clicked, not the bubbled-up event we're listening to) has a class of 'delete', which would mean that it's the delete link. In this way, we filter out all the clicks on comments-list element that aren't on delete buttons.
#12

[eluser]Nick Husher[/eluser]
It was brought to my attention that deleting comments in IE6 and IE7 wasn't working properly, this has been fixed. I had forgotten that the IE family has a different Event object than everyone else. I've fixed it on the live demo and above in the example. More tutorial to follow soon, so stay tuned.
#13

[eluser]Nick Husher[/eluser]
Part 2: Adding AJAX (cont'd)

We have the basic javascript hooks in place for listening to the necesary events that will bring about asynchronous actions. Right now they don't do anything, but sit tight, we'll see some awesomeness soon. The two pieces I now have to build are the CodeIgniter ajax action part and the javascript to handle sending a request to a server and handling the data that's sent back. Let's start with the server side, where I'm added a new function to my Comments controller:
Code:
function ajax_action($action) {
    if($action == 'add_comment') {
        $this->load->library('validation');

        $rules['comment_title'] = 'trim|required';
        $rules['comment_author'] = 'trim|required|callback__username_check';
        $rules['comment_text'] = 'trim|required';

        $fields['comment_title'] = "Comment Title";
        $fields['comment_author'] = "Comment Author";
        $fields['comment_text'] = "Comment Text";

        $this->validation->set_rules($rules);
        $this->validation->set_fields($fields);

        if($this->validation->run()) {
            $this->db->insert('comments',$_POST);
            
            $comment = $this->db->getwhere('comments', $_POST);
            $data['comment'] = $comment->first_row('array');
            $html = $this->load->view('single-comment', $data, true);
            $html = str_replace(array("\n","\t",'"'),array("","","'"), $html);
            
            // need to escape this string...
            echo '{ ajaxResponse: "success", ajaxAction: "add_comment", ajaxHTML: "'.$html.'" }';
        } else {
            $error = $this->validation->error_string;
            $error = str_replace(array("\n","\t",'"'),array("","","'"), $error);
            echo '{ ajaxResponse: "validation_error", ajaxAction: "add_comment", ajaxHTML: "'.$error.'"}';
        }
    } else if($action == 'remove_comment') {
        $id = $this->input->post('id');
        
        $query = $this->db->delete('comments', array('id' => $id));
        
        echo '{ "ajaxResponse": "success" }';
    }
}
From a CI perspective, this is pretty simple. In fact, most of the code in this function is in both the index and delete_comment functions. I'm repeating it entirely for clarity's sake, ideally the repeated code would be generalized and stuck in a model, library, or just some private functions. In any case, the function takes one parameter as the action to be taken. The rest of the inputs are pulled from the POST object. The controller then returns a string that's a valid JSON object containing three properties: ajaxResponse, ajaxAction, and ajaxHTML. These will be converted into a javascript object on the client side for processing. The only important property is ajaxHTML, which returns a chunk of HTML (notice I'm replacing doublequotes with single quotes, as well as nonprinting whitespace). I've generatd this HTML by retrieving the new comment from the database and running the single-comment view on it. The advantage of this is that if I ever wanted to update the static and ajax/dynamic markup on my site, I would only be altering the code in one place.

Also note that validation errors are returned with a separate ajaxResponse. Later, I'll use the ajaxResponse field to decide where to put the ajaxHTML content. Now let's look at the javascript.

To add a new comment, we need to make an asynchronous request to '<ci_base_dir>/index.php/comments/ajax_action/create_comment', which we will do with YAHOO.util.Connect, which is Yahoo's excellent connection library. I added the following code to the event listener listening for clicks on the form's submit button; whenever the button is pressed the following code is executed.
Code:
var callback = {
    success: function(o) {
        var serverResponse = eval('('+o.responseText+')');
        
        if(serverResponse.ajaxResponse == 'validation_error') {
            // show a validation error
            document.getElementById("errors")[removed] = serverResponse.ajaxHTML;
            document.getElementById("errors")[removed].className = 'errors';
        } else {                        
            // insert the new post
            
            var commentList = document.getElementById('comments-list');
            var newPost = document.createElement('div');
            newPost[removed] = serverResponse.ajaxHTML;

            // insert new post before the old ones.
            commentList.insertBefore(newPost, commentList.firstChild);
        }        
    },
    // we can handle failures later.
    failure: function(o) {},
    timeout: 3000
};

Connect.setForm('add_comment_form');
var c = Connect.asyncRequest('POST', asyncUrl + 'add_comment', callback);
Yahoo's connect library takes at least three arguments. One is whether the request is a post or get request, the second is the URL, and the last is a JSON callback object that it expects will contain some functions and data. The only one we're really interested in right now is the success function. if your server has a chance of not responding in 3 seconds (or whatever you set your timeout to), the failure case should be defined. I'll leave that up to you.

Really, there's not a lot of complicatedness going on here. The setForm function is binding the next AJAX request to pass along whatever form data is present in the named form to the server. Connect is making the connection, and the callback object is waiting for a response. The response checks if the server returned an error; if not, it inserts the new comment and if so, displays the appropriate validation errors.
#14

[eluser]Nick Husher[/eluser]
Okay, it's been a while since I posted to this, but there's not a lot left to do. First, dynamically deleting entries through AJAX, and second how to do animation in YUI. Let's get (re)started.

Part 2: Adding Ajax (cont'd x2)
In the last part, I wrote the code for deleting a post by passing 'delete_comment' as the first parameter while id parameter present in the postdata variable will determine which element gets deleted. In a previous section, I also created an event listener that captures the onclick events to those links. I can use the references to the relevant HTML element and its properties to figure out which ID needs to be deleted, and then perform the operation asynchronously. I then need to hide or remove the post from the DOM tree, I chose the former but the latter is just as easy.

Basically, the code is set up very similarly to the previous part. Define a callback with a success function that performs an action when the request is complete, then set up the request with YAHOO.util.Connect.asyncRequest. There are two things different about this aspect, however; one is that I'm extracting the comment ID from the href attribute in the delete link with a regular expression. The other is that I'm formulating the POST body without a form. Yahoo's connect library allows you to generate your own post body as a fourth argument to the asyncRequest function, which allows a user of the function to perform POST-based requests independently of any form elements. In this case, I'm taking the ID I parsed from the delete link's URL and using that in my POST body.

When the success function is called, the element is hidden. (target[removed] will select the parent element of the clicked link, which happens to be the div element containing the comment.) You can just as easily remove the element from the DOM tree at this point, it makes little difference.
Code:
var callback = {
    success: function(o) {
        target[removed].style.display = 'none';
        // var parentNode = target[removed]
        // parentNode[removed].removeChild(parentNode);
        // the preceeding code should remove the element from the DOM tree. Not tested, though.
    },
    failure: function(o) {},
    timeout: 3000
}

var reg = /\/(\d+)$/; // extracts the last segment ofthe given URL
var commentId = reg.exec(target.href)[1];

var c = Connect.asyncRequest('POST', asyncUrl + 'remove_comment', callback, 'id='+commentId);

Up next: Animation.
#15

[eluser]Nick Husher[/eluser]
Part 3: Adding Animation

Finally, we're on the spit 'n polish bit. Having elements pop in and vanish instantly is really quite jarring. Sometime's it's not clear what's new, or what's been deleted. Animation, while being very very pretty, has a definite user interface benefit; when a user interface animates from one state to another, it's clear to the user exactly how the state of the system has changed. Paradoxically, sudden changes in state can sometimes go unnoticed by the eye.

With that in mind, let's get to animat'n.

The first thing I want to do is animate new comments into the tree. At the moment they sorta pop in as if by magic. The objective is to simulate the yellow flash as seen on Signal vs. Noise, 37Signal's excellent (if a bit full-of-itself) weblog. To do this in YUI, we need to use its ColorAnim function in the Animation library. ColorAnim allows us to change any color-based aspect of an element over time, which means we can start the element's background at a yellow, then fade to white. ColorAnim takes three arguments, the element to animate, the properties to animate, and the duration of the animation. There's more to it than that, but for 90% of purposes, those three things do the job perfectly. The properties object takes a little getting used to, it's a JSON literal of CSS properties keying how those properties are to change, so in our case:
Code:
var animProperties = {
    backgroundColor: {
        to: '#ffffff', // we want to animate to a white background
        from: '#ffcc00' // from a fairly hideous yellow
    }
}

YAHOO.util.ColorAnim(newPost, animProperties, 1);
So, integrating this code with our previous javascript work, we get this:
Code:
var commentList = document.getElementById('comments-list');
var newPost = document.createElement('div');
newPost[removed] = serverResponse.ajaxHTML;

// create animation
var yellowFlash = new YAHOO.util.ColorAnim(newPost,
    { backgroundColor: { to: '#ffffff', from: '#ffcc00' } }, 1);


// insert new post before the old ones.
commentList.insertBefore(newPost, commentList.firstChild);
// the moment it's inserted, start the animation
yellowFlash.animate();
Fairly simple, right? Let's move on to deleting the elements, because that's a little more involved (and more fun).

The delete animation does two things; it fades the element out by shifting the element's opacity, then it hides the element by reducing the height to zero. If both of these things happen at once, it's not as clear that the element has been deleted more than it has been hidden (which is strictly true, but not the effect we want), so I opted to do one thing completely then the other. The whole thing takes half a second, so it's not a huge timewaster.

So, in order to do one animation after the other, I need to know when the first animation is complete. YUI's animations fire events in certain situations, which you can subscribe functions to. For my purposes, I want to subscribe one animation's animate() function to to the other animation's onComplete event. Our simple target[removed].style.display = 'none' now becomes this:

Code:
var fadeOut = new YAHOO.util.Anim(target[removed],
    { opacity: { to: 0 }}, 0.3);
var slideUp = new YAHOO.util.Anim(target[removed],
    { height: { to: 0 }}, 0.2);

// when the fadeout animation is complete, run the slideup animation
fadeOut.onComplete.subscribe(function() { slideUp.animate(); });

// when the slideup animation is complete, hide the element entirely from view.
slideUp.onComplete.subscribe(function() { target[removed].style.display = 'none' });    

fadeOut.animate();

YAHOO.util.Anim behaves exactly like ColorAnim, except that it applies to non-color properties like opacity, height, width, border-width, etc.


Well, I'm finally done. It took a while to get here, but sometimes good things take time. Really, the workflow becomes much faster once you have a clear workflow in mind of exactly how you want your client and server interacting. Doing AJAX things can sometimes be daunting because they're so multifaceted, thinking about how the client should behave in asynchronous and non-asynchronous cases is one aspect of the puzzle, another is in how the javascript and server code should communicate--is sending HTML snippets back and forth a good idea? Hm--and finally normalizing your results cross-browser can be tricky at best and murderously frustrating at worst. Sometimes the process consists of more than a little black magic; so many times something that looks like it should work perfectly inexplicably won't work at all, while something that's bodged together without thought for architecture or maintainability works simply and flawlessly. All I can say is that think ahead before writing code; think about how you're formulating transactions and how those transactions are getting handled. I've also found Firefox's Firebug plugin indespensible for when an asychronous piece of code is misbehaving; the Net->XHR tab is designed for debugging such transactions, and it's excellent at it.

I wrote this code in about two hours of free time including CSS, javascript, databasing, etc, with another 20 minutes of polish based off of suggestions from a fellow developer. If I built a generalized interface for handling asynchronous transactions, I could cut that development time down significantly.

Note: I also cut some corners; I do zero database and input checking, and in a production environment where user input could not only be flawed, but maliciously genererated, I would want to be much more careful. The obvious solution would be to pass session tokens along with requests, as well as strict sanitizing of inputs and database checking and graceful failing. I'll let you figure that part out, though. Wink
#16

[eluser]Nick Husher[/eluser]
NOTE: Some of my code was automatically altered by the forum's XSS checker. Anything with [removed] in it isn't by design.

Keywords that were removed: innner_HTML, parent_Node. (Obviously minus the underscores)
#17

[eluser]peterphp[/eluser]
Hey Nick,

thanks for this excellent tutorial!

Just one thing: the download link (http://demos.nickol.us/codeigniter/ajax_...script.zip) is not working anymore, maybe you can update the link?

Cheers,

Peter




Theme © iAndrew 2016 - Forum software by © MyBB