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:
add: is_secure: on credentials: subscriber all: 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:
all: .actions: 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') ?> <fieldset> <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> <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> </fieldset> <div class="submit-row"> <?php echo submit_tag('ask it') ?> </div> </form>
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.
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:
methods: post: [title, body] names: title: required: Yes required_msg: You must give a title to your question body: required: Yes required_msg: You must provide a brief context for your question validators: bodyValidator bodyValidator: class: sfStringValidator param: 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(); $question->setTitle($this->getRequestParameter('title')); $question->setBody($this->getRequestParameter('body')); $question->setUser($user); $question->save(); $user->isInterestedIn($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(); $interest->setQuestion($question); $interest->setUserId($this->getId()); $interest->save(); }
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)) ?> </div> <?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' => "Element.show('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> <div class="form-row"> <label for="label">Your answer:</label> <?php echo textarea_tag('body', $sf_params->get('body')) ?> </div> <div class="submit-row"> <?php echo input_hidden_tag('question_id', $question->getId()) ?> <?php echo submit_tag('answer it') ?> </div> </form> </div> </div>
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); } else { 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> <div class="answer" id="add_answer"> <!-- The new answer --> </div>
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:
add_answer: 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')); $this->forward404Unless($question); // user or anonymous coward $user = $this->getUser()->isAuthenticated() ? $this->getUser()->getSubscriber() : UserPeer::retriveByNickname('anonymous'); // create answer $this->answer = new Answer(); $this->answer->setQuestion($question); $this->answer->setBody($this->getRequestParameter('body')); $this->answer->setUser($user); $this->answer->save(); return sfView::SUCCESS; } $this->forward404(); }
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
:
addSuccess: 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.
This work is licensed under the Creative Commons Attribution-Noncommercial-No Derivative Works 3.0 Unported License license.