Skip to content
Caution: You are browsing the legacy symfony 1.x part of this website.

Day 08: AJAX interactions

Language

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;
}

activity indicator

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.

list of 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:

ajax

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.

login form revealed

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.