symfony advent calendar day ten: 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](7.txt) 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](http://www.symfony-project.com/book/1_0/06-Inside-the-Controller-Layer) 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: [php] public function executeAdd() { } public function handleErrorAdd() { return sfView::SUCCESS; } Both actions will output the `addSuccess.php` template: [php]
get('title')) ?>
get('body')) ?>
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](/images/askeet/add_question_error.gif) 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](6.txt) or read the [form validation chapter](http://www.symfony-project.com/book/1_0/10-Forms) of the symfony book. ## Handle the form submission Now edit again the `question/add` action to handle the form submission: [php] 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: [php] ...
getAnswers() as $answer): ?>
$answer)) ?>
'@add_answer', 'update' => array('success' => 'add_answer'), 'loading' => "Element.show('indicator')", 'complete' => "Element.hide('indicator');".visual_effect('highlight', 'add_answer'), )) ?>
isAuthenticated()): ?> getNickname() ?>
get('body')) ?>
getId()) ?>
### A little refactoring The `link_to_login()` function must be added to the `UserHelper.php` helper: [php] 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/defaultSuccess.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](8.txt). 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: [php] ...
...
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](8.txt), 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: [php] 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] $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](http://svn.askeet.com/tags/release_day_10/) 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](http://www.symfony-project.com/forum/index.php/f/8/).