Yesterday, we implemented a very powerful search engine for Jobeet, thanks to the Zend Lucene library. In the following lines, 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 framework, 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.4.2.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.
note
For
performance reasons,
you might also want to move the include_javascripts()
helper call just
before the ending </body>
tag.
Adding Behaviors
Implementing a live search means that each time the user types a letter in the search box, a call to the server needs to be triggered; the server will then return the needed information to update some 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 } });
note
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( $(this).parents('form').attr('action'), { 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.
Last but not least, if JavaScript is enabled, we will want to remove the search button:
$('.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="/legacy/images/loader.gif" style="vertical-align: middle; display: none" /> <div class="help"> Enter some keywords (city, country, position, ...) </div> </form> </div>
note
The default 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, create a
search.js
file that contains the JavaScript we have written so far:
// web/js/search.js $(document).ready(function() { $('.search input[type="submit"]').hide(); $('#search_keywords').keyup(function(key) { if (this.value.length >= 3 || this.value == '') { $('#loader').show(); $('#jobs').load( $(this).parents('form').attr('action'), { query: this.value + '*' }, function() { $('#loader').hide(); } ); } }); });
You also need to update the layout to include this new file:
<!-- apps/frontend/templates/layout.php --> <?php use_javascript('search.js') ?>
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
.
note
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) { $this->forwardUnless($query = $request->getParameter('query'), 'job', 'index'); $this->jobs = Doctrine_Core::getTable('JobeetJob')->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) { $this->forwardUnless($query = $request->getParameter('query'), 'job', 'index'); $this->jobs = Doctrine_Core::getTable('JobeetJob')->getForLuceneQuery($query); if ($request->isXmlHttpRequest()) { if ('*' == $query || !$this->jobs) { return $this->renderText('No results.'); } return $this->renderPartial('job/list', array('jobs' => $this->jobs)); } }
tip
You can also return a component in an action by using the renderComponent()
method.
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', 2)-> end() ;
The setHttpHeader()
method sets an HTTP header for the very
next request made with the browser.
Final Thoughts
In day 17, we used the Zend Lucene library to implement the search engine. Today, we 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 explain how to internationalize the Jobeet website.
This work is licensed under the Creative Commons Attribution-Share Alike 3.0 Unported License license.