Day 08: AJAX interactions
Previously on symfony
After seven hours of work, the askeet application has advanced well. The home page displays a list of questions, the detail of a question shows its answers, users have a profile page, and thematic lists are available from every page in the sidebar. Our community-enhanced FAQ is in the right direction (see the list of actions available as of yesterday), and yet the users cannot alter the data for now.
If the base of data manipulation in the web has long been forms, today the AJAX techniques and usability enhancements can change the way an application is built. And that applies to askeet, too. This tutorial will show you how to add AJAX-enhanced interactions to askeet. The objective is to allow a registered user to declare its interest about a question.
Add an indicator in the layout
While an asynchronous request is pending, users of an AJAX-powered website don't have any of the usual clues that their action was taken into account and that the result will soon be displayed. That's why every page containing AJAX interactions should be able to display an activity indicator.
For that purpose, add at the top of the <body>
of the global layout.php
:
<div id="indicator" style="display: none"></div>
Although hidden by default, this <div>
will be displayed when an AJAX request is pending. It is empty, but the main.css
stylesheet (stored in the askeet/web/css/
directory) gives it shape and content:
div#indicator { position: absolute; width: 100px; height: 40px; left: 10px; top: 10px; z-index: 900; background: url(/legacy/images/indicator.gif) no-repeat 0 0; }
Add an AJAX interaction to declare interest
An ajax interaction is made up of three parts: a caller (a link, a button or any control that the user manipulates to launch the action), a server action, and a zone in the page to display the result of the action to the user.
Caller
Let's go back to the questions displayed. If you remember the day four, a question can be displayed in the lists of questions and in the detail of a question.
That's why the code for the question title and interest block was refactored into a _interested_user.php
fragment. Open this fragment again, add a link to allow users to declare their interest:
<?php use_helper('User') ?> <div class="interested_mark" id="mark_<?php echo $question->getId() ?>"> <?php echo $question->getInterestedUsers() ?> </div> <?php echo link_to_user_interested($sf_user, $question) ?>
This link will do more than just redirect to another page. As a matter of fact, if a user already declared his/her interest about a given question, he/she must not be able to declare it again. And if the user is not authenticated... well, we will see this case later.
The link is written in a helper function, that needs to be created in a askeet/apps/frontend/lib/helper/UserHelper.php
:
<?php use_helper('Javascript'); function link_to_user_interested($user, $question) { if ($user->isAuthenticated()) { $interested = InterestPeer::retrieveByPk($question->getId(), $user->getSubscriberId()); if ($interested) { // already interested return 'interested!'; } else { // didn't declare interest yet return link_to_remote('interested?', array( 'url' => 'user/interested?id='.$question->getId(), 'update' => array('success' => 'block_'.$question->getId()), 'loading' => "Element.show('indicator')", 'complete' => "Element.hide('indicator');".visual_effect('highlight', 'mark_'.$question->getId()), )); } } else { return link_to('interested?', 'user/login'); } } ?>
The link_to_remote()
function is the first component of an AJAX interaction: The caller. It declares which action must be requested when a user clicks on the link (here: user/interested
) and which zone of the page must be updated with the result of the action (here: the element of id block_XX
). Two event handlers (loading
and complete
) are added and associated to prototype javascript functions. The prototype library offers very handy javascript tools to apply visual effects in a web page with simple function calls. Its only fault is the lack of documentation, but the source is pretty straightforward.
We chose to use a helper instead of a partial because this function contains much more PHP code than HTML code.
Don't forget to add the id id="block_<?php echo $question->getId() ?>"
to the question/_list
fragment.
<div class="interested_block" id="block_<?php echo $question->getId() ?>"> <?php include_partial('interested_user', array('question' => $question)) ?> </div>
note
This will only work if you properly defined the sf
alias in your web server configuration, as explained during day one.
Result zone
The update
attribute of the link_to_remote()
javascript helper specifies the result zone. In this case, the result of the user/interested
action will replace the content of the element of id block_XX
. If you are confused, take a look at what the integration of the fragment in the templates will render:
... <div class="interested_block" id="block_<?php echo $question->getId() ?>"> <!-- between here --> <?php use_helper('User') ?> <div class="interested_mark" id="mark_<?php echo $question->getId() ?>"> <?php echo $question->getInterestedUsers() ?> </div> <?php echo link_to_user_interested($sf_user, $question) ?> <!-- and there --> </div> ...
The result zone is the part between the two comments. The action, once executed, will replace this content.
The interest of the second id (mark_XX
) is purely visual. The complete
event handler of the link_to_remote
helper highlights the interested_mark
<div>
of the clicked interest... after the action returns an incremented number of interest.
Server action
The AJAX caller points to a user/interested
action. This action must create a new record in the Interest
table for the current question and the current user. Here is how to do it with symfony:
public function executeInterested() { $this->question = QuestionPeer::retrieveByPk($this->getRequestParameter('id')); $this->forward404Unless($this->question); $user = $this->getUser()->getSubscriber(); $interest = new Interest(); $interest->setQuestion($this->question); $interest->setUser($user); $interest->save(); }
Remember that the ->save()
method of the Interest
object was modified to increment the interested_user
field of the related User
. So the number of interested users about the current question will be magically incremented on screen after the call of the action.
And what should the resulting interestedSuccess.php
template display?
<?php include_partial('question/interested_user', array('question' => $question)) ?>
It displays the _interested_user.php
fragment of the question
module again. That's the greatest interest of having written this fragment in the first place.
We also have to disable layout for this template (modules/user/config/view.yml
):
interestedSuccess: has_layout: off
Final test
The development of the AJAX interest is now over. You can test it by entering an existing login/password in the login page, displaying the quesiton list and then clicking an 'interested?' link. The indicator appears while the request is passed to the server. Then, the number is incremented in a highlight when the server answers. Note that the initial 'interested?' link is now an 'interested!' text without link, thanks to our link_to_user_interested
helper:
If you want more examples about the use of the AJAX helpers, you can read the drag-and-drop shopping cart tutorial, watch the associated screencast or read the related book chapter.
Add an inline 'sign-in' form
We previously said that only registered users could declare interest about a question. This means that if a non-authenticated user clicks on an 'interested?' link, the login page must be displayed first.
But wait. Why should a user load a new page to login, and lose contact with the question he/she declared interest for? A better idea would be to have a login form appear dynamically on the page. That's what we are going to do.
Add a hidden login form to the layout
Open the global layout (in askeet/apps/frontend/templates/layout.php
), and add in (between the header
and the content
div):
<?php use_helper('Javascript') ?> <div id="login" style="display: none"> <h2>Please sign-in first</h2> <?php echo link_to_function('cancel', visual_effect('blind_up', 'login', array('duration' => 0.5))) ?> <?php echo form_tag('user/login', 'id=loginform') ?> nickname: <?php echo input_tag('nickname') ?><br /> password: <?php echo input_password_tag('password') ?><br /> <?php echo input_hidden_tag('referer', $sf_params->get('referer') ? $sf_params->get('referer') : $sf_request->getUri()) ?> <?php echo submit_tag('login') ?> </form> </div>
Once again, this form is hidden by default. The referer
hidden tag contains the referer
request parameter if it exists, or else the current URI.
Have the form appear when a non-authenticated user clicks an interested link
Do you remember the User
helper that we wrote previously? We will now deal with the case where the user is not authenticated. Open again the askeet/lib/helper/UserHelper.php
file and change the line:
return link_to('interested?', 'user/login');
with this one:
return link_to_function('interested?', visual_effect('blind_down', 'login', array('duration' => 0.5)));
When the user is not authenticated, the link on the 'interested?' word launches a prototype javascript effect (blind_down
) that will reveal the element of id login
- and that's the form that we just added to the layout.
Login the user
The user/login
action was already written during the fifth day, and refactored during day six. Do we have to modify it again?
public function executeLogin() { if ($this->getRequest()->getMethod() != sfRequest::POST) { // display the form $this->getRequest()->getParameterHolder()->set('referer', $this->getRequest()->getReferer()); return sfView::SUCCESS; } else { // handle the form submission // redirect to last page return $this->redirect($this->getRequestParameter('referer', '@homepage')); } }
After all, no. It works perfectly as it is, the handling of the referer will redirect the user to the page where he/she was when the link was clicked.
Test the AJAX functionality now. An unregistered user will be presented a login form without leaving the current page. If the nickname and the password are recognized, the page will be refreshed and the user will be able to click on the 'interested?' link he intended to click before.
note
In many AJAX interactions like this one, the template of the server action is a simple include_partial
. That's because an initial result is often displayed when the whole page is first loaded, and because the part that is updated by the AJAX action is also part of the initial template.
See you Tomorrow
The most difficult thing in designing AJAX interactions is to properly define
the caller, the server action, and the result zone. Once you know them, symfony
gives you the helpers that do the rest. To be sure that you understood how it
works, check out how we implement the same mechanism for adding answers in
chapter 10. This time, the AJAX action called is user/vote
, the _answer.php
partial is split up into two parts (thus creating a _vote_user.php
partial),
and two helpers link_to_user_relevancy_up()
and link_to_user_relevancy_down()
are created in the Answer
helper. The User
module also gains a vote
action
and a voteSuccess.php
template. Don't forget to set the layout to off
for
this template too.
Askeet is starting to look like a web 2.0 application. And it is just the beginning: In a few days, we will add some more AJAX interactions to it. Tomorrow we will take the occasion to do a general review of the MVC techniques in symfony, and to implement an external library.
If you come across a problem while trying to follow today's tutorial, you can
still download the full code from the release_day_8
tagged source in the
askeet SVN repository. If you don't
have any problems, come to the
askeet forum to answer
the other's questions.
This work is licensed under the Creative Commons Attribution-Noncommercial-No Derivative Works 3.0 Unported License license.