SymfonyWorld Online 2021 Winter Edition December 9 – 10, 2021 100% Online +20 talks and workshops
Caution: You are browsing the legacy symfony 1.x part of this website.

Day 10: Alter data with Ajax forms


Previously on symfony

After yesterday's review of known techniques, some of you have a hunger for interaction. Displaying rich formatted questions and lists, even paginated, is not enough to make an application live. And the heart of the askeet concept is to allow any registered user to ask a new question, and any user to answer an existing one. Isn't it time we get to it?

Add a new question

The sidebar built during day seven already contains a link to add a new question. It links to the question/add action, which is waiting to be developped.

Restrict access to registered users

First of all, only registered users can add a new question. To restrict access to the question/add action, create a security.yml in the askeet/apps/frontend/modules/question/config/ directory:

  is_secure:   on
  credentials: subscriber

  is_secure:   off

When an unregistered user tries to access a restricted action, symfony redirects him/her to the login action. This action must be defined in the application settings.yml, under the login_module and login_action keys:

    login_module:           user
    login_action:           login

More information about action access restriction can be found in the security chapter of the symfony book.

The addSuccess.php template

The question/add action will be used to both, display the form and handle the form. This means that as of now, to display the form, you only need an empty action. In addition, the form will be displayed again in case of error in the data validation:

public function executeAdd()
public function handleErrorAdd()
  return sfView::SUCCESS;

Both actions will output the addSuccess.php template:

<?php use_helper('Validation') ?>
<?php echo form_tag('@add_question') ?>
  <div class="form-row">
    <?php echo form_error('title') ?>
    <label for="title">Question title:</label>
    <?php echo input_tag('title', $sf_params->get('title')) ?>
  <div class="form-row">
    <?php echo form_error('body') ?>
    <label for="label">Your question in details:</label>
    <?php echo textarea_tag('body', $sf_params->get('body')) ?>
  <div class="submit-row">
    <?php echo submit_tag('ask it') ?>

Both title and body controls have a default value (the second argument of the form helpers) defined from the request parameter of the same name. Why is that? Because we are going to add a validation file to the form. If the validation fails, the form is displayed again, and the previous entries of the user are still in the request parameters. They can be used as the default value of the form elements.

error in the form with previous entries kept

The previous entry is not lost in case of a failed form validation. That is the least you can expect of a user-friendly application.

But, in order to achieve that, you need a form validation file.

Form validation

Create a validate/ directory in the question module, and add in a add.yml validation file:

  post:            [title, body]

    required:      Yes
    required_msg:  You must give a title to your question

    required:      Yes
    required_msg:  You must provide a brief context for your question
    validators:    bodyValidator

    class:         sfStringValidator
      min:         10
      min_error:   Please, give some more details

If you need more information about form validation, go back to day six or read the form validation chapter of the symfony book.

Handle the form submission

Now edit again the question/add action to handle the form submission:

public function executeAdd()
  if ($this->getRequest()->getMethod() == sfRequest::POST)
    // create question
    $user = $this->getUser()->getSubscriber();
    $question = new Question();
    return $this->redirect('@question?stripped_title='.$question->getStrippedTitle());

Remember that the ->setTitle() method will also set the stripped_title, and the ->setBody() method will also set the html_body field, because we overrode those methods in the Question.php model class. The user creating a question will be declared interested in it. This is intended to prevent questions with 0 interests, which would be too sad.

The end of the action contains a ->redirect() to the detail of the question created. The main advantage over a ->forward() in that if the user refreshes the question detail page afterwards, the form will not be submitted again. In addition, the 'back' button works as expected. That's a general rule: You should not end a form submission handling action with a ->forward().

The best thing is that the action still works to display the form, that is if the request is not in POST mode. It will behave exactly as the empty action written previously, returning the default sfView::SUCCESS that will launch the addSuccess.php template.

Don't forget to create the isInterestedIn() method in the User model:

public function isInterestedIn($question)
  $interest = new Interest();

As a minor refactoring, you can use this method in the user/interested action to replace the code snippet that does the same thing.

Go ahead, test it now. Using one of the test users, you can add a question.

Add a new answer

The answer addition will be implemented in a slightly different way. There is no need to redirect the user to a new page with a form, then to another page again for the answer to be displayed. So the new answer form will be in AJAX, and the new answer will appear immediately in the question detail page.

Add the AJAX form

Change the end of the modules/question/templates/showSuccess.php template by:

<div id="answers">
<?php foreach ($question->getAnswers() as $answer): ?>
  <div class="answer">
  <?php include_partial('answer/answer', array('answer' => $answer)) ?>
<?php endforeach; ?>
<?php echo use_helper('User') ?>
<div class="answer" id="add_answer">
  <?php echo form_remote_tag(array(
    'url'      => '@add_answer',
    'update'   => array('success' => 'add_answer'),
    'loading'  => "'indicator')",
    'complete' => "Element.hide('indicator');".visual_effect('highlight', 'add_answer'),
  )) ?>
    <div class="form-row">
      <?php if ($sf_user->isAuthenticated()): ?>
        <?php echo $sf_user->getNickname() ?>
      <?php else: ?>
        <?php echo 'Anonymous Coward' ?>
        <?php echo link_to_login('login') ?>
      <?php endif; ?>
    <div class="form-row">
      <label for="label">Your answer:</label>
      <?php echo textarea_tag('body', $sf_params->get('body')) ?>
    <div class="submit-row">
      <?php echo input_hidden_tag('question_id', $question->getId()) ?>
      <?php echo submit_tag('answer it') ?>

A little refactoring

The link_to_login() function must be added to the UserHelper.php helper:

function link_to_login($name, $uri = null)
  if ($uri && sfContext::getInstance()->getUser()->isAuthenticated())
    return link_to($name, $uri);
    return link_to_function($name, visual_effect('blind_down', 'login', array('duration' => 0.5)));

This function does something that we already saw in the other User helpers: it shows a link to an action if the user is authenticated, and if not, the link points to the AJAX login form. So replace the link_to_function() calls in the link_to_user_interested() and link_to_user_relevancy() functions by calls to link_to_login(). Don't forget the link to @add_question in the modules/sidebar/templates/_default.php. Yes, this is refactoring.

Handle the form submission

Even if it still involves a fragment, the method chosen here to handle the AJAX request is slightly different from the one described during the eighth day. This is because we want the result of the form submission to actually replace the form. That's why the update parameter of the form_remote_tag() helper points to the container of the form itself, rather than to an outer zone. The _answer.php fragment will be included in the result of the answer addition action, so that the final result can look like:

<div id="answers">
  <!-- Answer 1 -->
  <!-- Answer 2 -->
  <!-- Answer 3 -->
<div class="answer" id="add_answer">
  <!-- The new answer -->

You probably guessed how the form_remote_tag() javascript helper works: It handles the form submission to the action specified in the url argument through a XMLHttpRequest object. The result of the action replaces the element specified in the update argument. And, just like the link_to_remote() helper of day eight, it toggles the visibility of the activity indicator on and off according to the request submission, and highlights the updated part at the end of the AJAX transaction.

Let us add a few words about the user associated to the new answer. We previously mentioned that answers have to be linked to a user. If the user is authenticated, then his/her user_id is used for the new answer. In the other case, the anonymous user is used in place, unless the user chooses to login then. The link_to_login() helper, located in the GlobalHelper.php helper set, toggles the visibility of the hidden login form in the layout. Browse the askeet source to see its code.

The answer/add action

The @add_answer rule given as the url argument of the AJAX form points to the answer/add action:

  url:   /add_anwser
  param: { module: answer, action: add }

(In case you wonder, this configuration is to be added to the routing.yml application configuration file)

Here is the content of the action:

public function executeAdd()
  if ($this->getRequest()->getMethod() == sfRequest::POST)
    if (!$this->getRequestParameter('body'))
      return sfView::NONE;
    $question = QuestionPeer::retrieveByPk($this->getRequestParameter('question_id'));
    // user or anonymous coward
    $user = $this->getUser()->isAuthenticated() ? $this->getUser()->getSubscriber() : UserPeer::retriveByNickname('anonymous');
    // create answer
    $this->answer = new Answer();
    return sfView::SUCCESS;

First of all, if this action is not called in POST mode, that means that someone typed its URI in a browser address bar. The action is not designed for that type of (hacker) request, so it returns a 404 error in that case.

To determine the user to set as the answer's author, the action checks if the current user is authenticated. If this is not the case, the action uses the 'Anonymous Coward' user, thanks to a new ::retrieveByNickname() method of the UserPeer class. Check the code if you have any doubt about what this method does.

After that, everything is ready to create the new question and pass the request to the addSuccess.php template. As expected, this template contains only one line, the include_partial:

<?php include_partial('answer', array('answer' => $answer)) ?>

We also need to disable layout for this action in frontend/modules/answer/config/view.yml:

  has_layout: off

Lastly, if the user submits an empty answer, we don't want to save it. So the data handling part is bypassed, and the action returns nothing - this will simply erase the form of the page. We could have done error handling in this AJAX form, but it would imply putting the form itself in another fragment. That is not worth the effort for now.

Test it

Is that all? Yes, the AJAX form is ready to be used, clean and safe. Test it by displaying the list of answers to a question, and by adding a new answer to it. The page doesn't need a refresh, and the new answer appears at the bottom of the list of previous ones. That was simple, wasn't it?

See you Tomorrow

Classic forms and AJAX forms are equally easy to implement in a symfony application. And with these two additions, the askeet application has all the core features required to make it work.

One thing though: We didn't detail the way to register a new user. This feature was added to the current askeet SVN repository anyway, since it is very similar to what has been done today.

So ten days is all it takes to build a (very) beta version of an AJAX-enhanced FAQ with symfony. However, we want askeet to be more than that. To help build the askeet community, we need the site to deliver syndication feeds, so that a person asking a question can register to receive the answers in a feed aggregator. That will be tomorrow's tutorial.

Some of you already suggested a few ideas for the 21st day. Expand the list or support their suggestions by visiting the askeet forum.