By Michael Schilli
Web developers were rudely awakened when Google introduced its Maps service. All of a sudden users could move maps dynamically, as though the application were running as a local GUI rather than in a browser. All of a sudden, time-consuming client-server round trips were hardly noticeable, since the current page didn't need to be reloaded in order to reflect state changes in the application. Today, Ajax applications are sprouting all over the web. The beta release of Yahoo! Webmail, for example, looks very much like a desktop application; you have to take a very close look to see that your web browser is running the show.
AJAX (Asynchronous JavaScript and XML) is based on dynamic HTML and client-side JavaScript. The XMLHttpRequest object, originally added by Microsoft and flying under the radar until Google helped it to fame, allows a JavaScript script downloaded from a website to exchange data asynchronously with the web server. It then dynamically smuggles this data into the HTML page, meaning that only minor changes need to take place on the page.
Figure 1 shows a sample application that manages text snippets commonly used in email and serves them up in a text field for cutting and pasting. You can select Add new topic to add a new text snippet to be stored on the server. Update sends the corresponding text to the server and Remove deletes the selected entry.
The page is loaded just once by the browser. The underlined links may look like hyperlinks, and they are clickable, but they won't cause the browser to jump to a new URL. Instead, they simply execute the JavaScript code specified by the OnClick handler, talking to the server behind the scenes.
The CGI::Ajax Perl module by Brent Pedersen makes this mechanism quite simple to implement. The module defines a client-server protocol (in JavaScript and Perl), which the client-side JavaScript code can use to call server-side Perl functions by referencing their names and parameter lists. A XMLHttpRequest (ActiveXObject("Microsoft.XMLHTTP") on IE) JavaScript object enables the browser to sends a GET request to the server-side CGI script. The request triggers a previously specified Perl function. which returns one or more values. The JavaScript code picks them up and refreshes predefined fields in the browser GUI.
The CGI::Ajax object created by CGI::Ajax->new(`display' => \&display, ...); in the CGI script ensures that the HTML sent back will contain a JavaScript section. The web application uses a JavaScript function named display to fill the browser's text area with the currently selected text snippet. Correspondingly, a Perl function named display() function is defined on the server; the JavaScript handler later calls it using an HTTP request. A radio button defined in HTML with the OnClick handler, OnClick="display([`Lame Excuse'], [`tarea', `statusdiv'])", calls the JavaScript function display() and passes the two specified arrays to the function. The first array contains the id attribute of the selected radio button; its text value (Lame Excuse) gets passed to the server function. The second array contains the id attributes of the HTML tags, which the handler refreshes with the return value from the server function after completing the request. This way, both the text area and the status text field get updated with fresh server data.
In our case, the radio button with the id "Lame Excuse" also has the property VALUE="Lame Excuse", meaning that the server-side Perl display() function (line 12 in Listing snip) is passed this text string as its first parameter. display() does nothing apart from retrieving the text block to match "Lame Excuse" from the server-side cache and sending it back to the browser along with a status message. This is where the JavaScript event handler cuts in again, refreshing the large text field (id=`tarea') and the status field down at the bottom (id=`statusdiv') with the strings returned by display(). All of this is neatly abstracted by CGI::Ajax, which sends the required JavaScript code to the browser, and sets up the server-side handler for accessing the Perl functions.
Client-side, snip does more than just refreshing text fields. If the user deletes one of the headings by clicking on Remove, this not only deletes the radio button with the heading, but also selects the first heading in the remaining list and loads the matching text block from the server. CGI::Ajax can't handle client-side trickery like this yet, but additionally defined JavaScript functions will help.
On a positive note, CGI::Ajax is extremely easy to use. As Listing 1 shows, you only need to define functions for the various client actions (remove/update/display) and provide a show_html function, which returns the client application HTML on initial loading.
Listing 1: snip |
01 #!/usr/bin/perl -w 02 use strict; 03 use CGI; 04 use CGI::Ajax; 05 use Cache::FileCache; 06 use Template; 07 08 my $cache = 09 Cache::FileCache->new(); 10 11 ############################# 12 sub display { 13 ############################# 14 my ($topic) = @_; 15 16 return $cache->get($topic), 17 "Retrieved $topic"; 18 } 19 20 ############################# 21 sub remove_me { 22 ############################# 23 my ($topic) = @_; 24 25 $cache->remove($topic); 26 return "Deleted $topic"; 27 } 28 29 ############################# 30 sub update_me { 31 ############################# 32 my ($topic, $text) = @_; 33 34 $cache->set($topic, $text); 35 36 my $disptext = $text; 37 $disptext = 38 substr($text, 0, 60) 39 . "..." 40 if length $text > 60; 41 return 42 "Topic '$topic' updated " 43 . "with '$disptext'"; 44 } 45 46 ############################# 47 sub show_html { 48 ############################# 49 my $template = 50 Template->new(); 51 52 my @keys = 53 sort $cache->get_keys(); 54 55 $template->process( 56 "snip.tmpl", 57 { topics => \@keys }, 58 \my $result) 59 or die $template->error(); 60 61 return $result; 62 } 63 64 ############################# 65 # main 66 ############################# 67 my $cgi = CGI->new(); 68 $cgi->charset("utf-8"); 69 70 my $pjx = CGI::Ajax->new( 71 'display' => \&display, 72 'update_me' => \&update_me, 73 'remove_me' => \&remove_me 74 ); 75 print $pjx->build_html($cgi, 76 \&show_html); |
The snip Perl script takes the HTML to be sent from the snip.tmpl template, which is shown in Figure 3. The template toolkit loads the template and provides a number of constructs that let you embed simple for loops or conditions.
It uses [% FOREACH topic = topics %] to iterate over the @topics array previously provided by snip with the text block headings and outputs a number of radio buttons, each in a separate table row. [% topic %] returns the value of the topic template variable each time. The id property of each radio button is set to the text string in the heading, and the OnClick handler calls the display() function described previously, which exists both in the client-side JavaScript and in the Perl universe on the server.
The select_first() function selects the first entry in the list of radio buttons and requests the text block for the appropriate heading. It first calls the method document.getElementById to search for the HTML tag with the ID of form (the HTML form in snip.tmpl). All radio buttons are named r, so form.r should be an array, holding entries for each radio button found. If the list of headings is empty, select_first() obviously selects nothing; nor does it talk to the server.
The tag type <A> hyperlinks used in snip should have return false as the last action in their OnClick handlers. This ensures that the browser executes the JavaScript code assigned to the link, rather than following the bogus HREF attribute.
The template loads the JavaScript library snip.js first (Listing 2); the library provides a number of functions to allow the GUI to run properly. The JavaScript topic_add() function in snip.js expects the string from a heading and appends a new entry with this name to the end of the radio button table.
Listing 2: snip.js |
001 // ########################## 002 function topic_add(topic) { 003 // ########################## 004 var itemTable = document.getElementById("topics"); 005 var newRow = document.createElement("TR"); 006 var newCol1 = document.createElement("TD"); 007 var newCol2 = document.createElement("TD"); 008 var input = document.createElement("INPUT"); 009 010 if(topic.length == 0) { 011 alert("No topic name specified."); 012 return false; 013 } 014 015 input.name = "r"; 016 input.type = "radio"; 017 input.id = topic; 018 input.value = topic; 019 input.onclick = function() { 020 display([topic], ['tarea', 'statusdiv']); 021 }; 022 input.checked = 1; 023 newCol1.appendChild(input); 024 025 var textnode = document.createTextNode(topic); 026 newCol2.appendChild(textnode); 027 028 itemTable.appendChild(newRow); 029 newRow.appendChild(newCol1); 030 newRow.appendChild(newCol2); 031 032 document.getElementById ('tarea').value = ""; 033 document.getElementById ('new_topic').value = ""; 034 035 return false; 036 } 037 038 // ########################## 039 function topic_update() { 040 // ########################## 041 if(!id_selected()) { 042 alert("Create a new topic first"); 043 return; 044 } 045 update_me( [ id_selected(), 'tarea' ], 046 'statusdiv'); 047 } 048 049 // ########################## 050 function topic_remove() { 051 // ########################## 052 var sel = id_selected(); 053 054 if(!sel) { alert("No topic available"); 055 return; 056 } 057 058 remove_me([sel], 'statusdiv'); 059 060 var node = document.getElementById(sel); 061 var row = node.parentNode.parentNode; 062 row.parentNode.removeChild(row); 063 select_first(); 064 } 065 066 // ########################## 067 function select_first() { 068 // ########################## 069 var form = document.getElementById("form"); 070 if(! form.r) { return; } 071 if(! form.r.length) { 072 form.r.checked = 1; 073 if(! document.getElementById(id_selected()) ) { 074 document.getElementById ('tarea').value = ""; 075 return; 076 } 077 display([id_selected()], ['tarea', 'statusdiv']); 078 } 079 080 for(var i = 0; i < form.r.length; i++) { 081 form.r[i].checked = 1; 082 break; 083 } 084 display([id_selected()], ['tarea', 'statusdiv']); 085 } 086 087 // ########################## 088 function id_selected() { 089 // ########################## 090 sel = id_selected_first_pass(); 091 092 if(! document.getElementById(sel) ) { 093 document.getElementById ('tarea').value = ""; 094 return; 095 } 096 return sel; 097 } 098 099 // ########################## 100 function id_selected_first_pass() { 101 // ########################## 102 var form = document.getElementById("form"); 103 if(! form.r) { return 0; } 104 if(! form.r.length) { return form.r.id; } 105 106 for(var i = 0; i < form.r.length; i++) { 107 if(form.r[i].checked) { 108 return form.r[i].id; 109 } 110 } 111 alert("Selected ID is unknown"); 112 return 0; 113 } |
topic_remove() deletes a heading from the list of radio buttons and sends a request to the server, which in turn deletes the text snippet from the cache.
id_selected() outputs the id property of the selected radio button, that is, the heading of the entry the user wants to see. If the list is empty, Firefox can become confused and still return a value. To work around this bug, id_selected() rechecks the result of id_selected_first_pass() and returns undefined if it catches Firefox cheating.
If the list of radio buttons has two or more entries, form.r.length returns the list size. If the list only has a single entry, form.r.length returns an undefined value. If the list is empty, form.r is undefined. The checked flag can then either be verified via form.r.checked, or via element "i" in the array: form.r[i].checked. After this, select_first() then calls the display() function to get the text for the selected heading from the server.
This script is only intended as an example. To keep it simple, code for handling error conditions has been left out of the script. You might experience compatibility problems with browsers other than Firefox.
The script requires the Class::Accessor, CGI::Ajax, and Template modules from CPAN. Then add the snip script (executable!) and the snip.tmpl template (in the cgi-bin on the web server) and the snip.js JavaScript script directly below the document root (typically htdocs).
If the terminal used for cutting and pasting does not speak UTF-8, you might like to comment out line 68 in snip to tell the web server to send charset=iso-8859-1 in the CGI header instead. And if you prefer not to have the document cache reside below /tmp, you will need to specify your preferred directory by setting my $cache = Cache::FileCache->new({cache_root => "/path"}) in line 9. Finally, point your browser to http://server/cgi-bin/snip, and - assuming that JavaScript is enabled - the text snippet function should run, talk to the server, and keep the snippet repository on the server up to date.
INFO |
[1] Listings for this article: http://www.linux-magazine.com/Magazine/Downloads/62/Perl
|