Previously on Jobeet
Yesterday, we implemented a very powerful search engine for Jobeet, thanks to the Zend Lucene library.
Today, to enhance the responsiveness of the search engine, we will take advantage of AJAX to convert the search engine to a live one.
As the form should work with and without JavaScript enabled, the live search feature will be implemented using unobtrusive JavaScript. Using unobtrusive JavaScript also allows for a better separation of concerns in the client code between HTML, CSS, and the JavaScript behaviors.
Installing JQuery
Instead of reinventing the wheel and managing the many differences between browsers, we will use a JavaScript library, JQuery. The symfony framework itself is agnostic and can work with any JavaScript library.
Go to the JQuery website, download the latest version,
and put the .js
file under web/js/
.
Including JQuery
As we will need JQuery on all pages, update the layout to include it in the
<head>
. Be careful to insert the use_javascript()
function before the
include_javascripts()
call:
<!-- apps/frontend/templates/layout.php --> <?php use_javascript('jquery-1.2.6.min.js') ?> <?php include_javascripts() ?> </head>
We could have included the JQuery file directly with a <script>
tag, but
using the use_javascript()
helper ensures that the same JavaScript file
won't be included twice.
Adding Behaviors
Implementing a live search means that each time the user types a letter in the search box, a call to the server need to be triggered; the server will then return the needed information to update select regions of the page without refreshing the whole page.
Instead of adding the behavior with an on*()
HTML attributes, the main
principle behind JQuery is to add behaviors to the DOM after the page is
fully loaded. This way, if you disable JavaScript support in your browser,
no behavior is registered, and the form still works as before.
The first step is to intercept whenever a user types a key in the search box:
$('#search_keywords').keyup(function(key) { if (this.value.length >= 3 || this.value == '') { // do something } });
Don't add the code for now, as we will modify it heavily. The final JavaScript code will be added to the layout in the next section.
Every time the user types a key, JQuery executes the anonymous function defined in the above code, but only if the user has typed more than 3 characters or if he removed everything from the input tag.
Making an AJAX call to the server is as simple as using the load()
method on
the DOM element:
$('#search_keywords').keyup(function(key) { if (this.value.length >= 3 || this.value == '') { $('#jobs').load( '<?php echo url_for('@job_search') ?>', { query: this.value + '*' } } ); } });
To manage the Ajax call, the same action as the "normal" one is called. The needed changes in the action will be done in the next section.
But first, let's remove the search button if JavaScript is enabled:
$('.search input[type="submit"]').hide();
User Feedback
Whenever you make an AJAX call, the page won't be updated right away. The browser will wait for the server response to come back before updating the page. In the meantime, you need to provide visual feedback to the user to inform him that something is going on.
A convention is to display a loader icon during the AJAX call. Update the layout to add the loader image and hide it by default:
// apps/frontend/templates/layout.php <div class="search"> <h2>Ask for a job</h2> <form action="<?php echo url_for('@job_search') ?>" method="get"> <input type="text" name="query" value="<?php echo $sf_request->getParameter('query') ?>" id="search_keywords" /> <input type="submit" value="search" /> <img id="loader" src="/images/loader.gif" style="vertical-align: middle; display: none" /> <div class="help"> Enter some keywords (city, country, position, ...) </div> </form> </div>
You can download the loader image in today's repository.
The loader is optimized for the current layout of Jobeet. If you want to create your own, you will find a lot of free online services like http://www.ajaxload.info/.
Now that you have all the pieces needed to make the HTML work, open the layout
file and add the following JavaScript code at the end of the <head>
section:
// apps/frontend/templates/layout.php <script type="text/javascript"> $(document).ready(function() { $('.search input[type="submit"]').hide(); $('#search_keywords').keyup(function(key) { if (this.value.length >= 3 || this.value == '') { $('#loader').show(); $('#jobs').load( '<?php echo url_for('@job_search') ?>', { query: this.value + '*' }, function() { $('#loader').hide(); } ); } }); }); </script>
AJAX in an Action
If JavaScript is enabled, JQuery will intercept all keys typed in the search
box, and will call the search
action. If not, the same search
action is
also called when the user submits the form by pressing the "enter" key or by
clicking on the "search" button.
So, the search
action now needs to determine if the call is made via AJAX or not.
Whenever a request is made with an AJAX call, the isXmlHttpRequest()
method
of the request object returns true
.
The
isXmlHttpRequest()
method works with all major JavaScript libraries like Prototype, Mootools, or jQuery.
// apps/frontend/modules/job/actions/actions.class.php public function executeSearch(sfWebRequest $request) { if (!$query = $request->getParameter('query')) { return $this->forward('job', 'index'); } $this->jobs = JobeetJobPeer::getForLuceneQuery($query); if ($request->isXmlHttpRequest()) { return $this->renderPartial('job/list', array('jobs' => $this->jobs)); } }
As JQuery won't reload the page but will only replace the #jobs
DOM element
with the response content, the page should not be decorated by the layout. As
this is a common need, the layout is disabled by default when an AJAX request
comes in.
Moreover, instead of returning the full template, we only need to return the
content of the job/list
partial. The renderPartial()
method used in the
action returns the partial as the response instead of the full template.
If the user removes all characters in the search box, or if the search returns
no result, we need to display a message instead of a blank page. We will use
the renderText()
method to render a simple test string:
// apps/frontend/modules/job/actions/actions.class.php public function executeSearch(sfWebRequest $request) { if (!$query = $request->getParameter('query')) { return $this->forward('job', 'index'); } $this->jobs = JobeetJobPeer::getForLuceneQuery($query); if ($request->isXmlHttpRequest()) { if ('*' == $query || !$this->jobs) { return $this->renderText('No results.'); } else { return $this->renderPartial('job/list', array('jobs' => $this->jobs)); } } }
You can also return a component by using the
renderComponent()
method.
JavaScript as an Action
Although putting the JavaScript directly into the <head>
tag makes perfect
sense for the Jobeet search engine, it is sometimes better to create a
dedicated file. But as the most JavaScripts that make AJAX calls need some
URLs, they need to use the url_for()
helper, and therefore should be
dynamic.
JavaScript is just another format like HTML, and as seen some days ago,
symfony makes format management quite easy. As the JavaScript file will
contain behavior for a page, you can even have the same URL as the page for
the JavaScript file, but ending with .js
. For instance, if you want to
create a file for the search engine behavior, you can modify the job_search
route as follows:
job_search: url: /search.:sf_format param: { module: job, action: search, sf_format: html } requirements: sf_format: (?:html|js)
As the URLs on a website are stable, the JavaScript files are mostly static, and don't change over time. This is a perfect candidate for caching, as we will see in a coming day.
Testing AJAX
As the symfony browser cannot simulate JavaScript, you need to help it when testing AJAX calls. It mainly means that you need to manually add the header that JQuery and all other major JavaScript libraries send with the request:
// test/functional/frontend/jobActionsTest.php $browser->setHttpHeader('X_REQUESTED_WITH', 'XMLHttpRequest'); $browser-> info('5 - Live search')-> get('/search?query=sens*')-> with('response')->begin()-> checkElement('table tr', 3)-> end() ;
The setHttpHeader()
method set an HTTP header for the very next request made
with the browser.
See you Tomorrow
Yesterday, we used the Zend Lucene library to implement the search engine. Today, we have used JQuery to make it more responsive. The symfony framework provides all the fundamental tools to build MVC applications with ease, and also plays well with other components. As always, try to use the best tool for the job.
Tomorrow, we will see how to internationalize the Jobeet website.
It is interesting to use $.getJSON in jquery for executing the search because it sets the header Http-Accept to application/json and then symfony will automatically try to use the .json.php template for the action without specifying the sf_format at the end of the url.
Your JavaScript code should be completely static and located in an external JS file. Benefits: caching, separation of content and behaviors, no mix between PHP and JS.
As for the URL to do Ajax to, it can be easily retrieved by inspecting the form action.
Last but not least: you should definitely put the
include_javascripts()
call at the end of the</body>
, not of the</head>
, for obvious client performance reasons.was expecting something more rjs-like from this..
but there's still a big problem:
$('#search_keywords').keyup(function(key) { if (this.value.length >= 3 || this.value == '') { // do something } });
this is absolutely wrong. You can't do the server request each time a key is pressed.
Insted you should each time first clearTimeout and setTimeout for the ajax request. It will be executed only after a few seconds after pressing the last key. See it in action on www.maniacina.com
hello all, keep in mind that this is a) a tutorial and b) intended for php developers learning symfony
There is tons of documentation on how to write top notch javascript with any framework. So please bear with us that we did not describe the ultimate js solution. Thanks :-)
Yeah, very good idea to have chosen jQuery :D
@Sylvain: Please, could you elaborate on the "inspecting the form action" thing?
@HiDDeN
You will be able to get it like this in keyup method:
var url = $(this).parents("form").attr("action");
or you can also add a "id" attribute to the form and use it:
var url = $("#job_search_form").attr("action");
fingers crossed :-)