sfForm meet AJAX, AJAX meet sfForm
Let's introduce our players. First, meet sfForm, Symfony's base class for its powerful (relatively new) form framework. Known best for kicking your ass, sfForm is revered by many (like me) and feared by most (like me). He's a looming, dark presence, like having a 6 foot security guard standing in the corner at your daughter's 2nd birthday party. He guards you while you sleep. Do not... fuck with him.
Now, meet AJAX, the javascript method that brings hope to average web applications everywhere. She's the reason that your girlfriend (and you) loves facebook and she's always ready to make your web experience feel more comfortable. When AJAX is around, everything just feels faster, easier...better. She's the Obama of the internet: Yes we can.
Of course, in order to have a well-written server-side application as well as a rich user experience, these two components must be joined together. In reality, it's really not too hard, it just takes a little bit of practice.
The Goal
Simple: first, take a look at my previous article about
building a simple todo list. Below is our target application
(click to actually use it). The big addition now is that we've added
a link you can click to add more input boxes. This is done through AJAX
and our form is able to handle the "extra" elements.
Our advanced todo list
The Very Simple Setup (same as the simple todo list)
We're using the exact same setup as with the simple todo list. Some things have different names, but to start, the code is all the same. Click the link below to view the code.
Show/Hide codepublic function executeAdvanced(sfWebRequest $request) { $this->form = new advancedTodoForm(); } public function executeAdvancedProcess(sfWebRequest $request) { $this->form = new advancedTodoForm(); if($request->isMethod('post')) { $this->form->bind($request->getParameter('todo_list')); if($this->form->isValid()) { $this->form->save(); $this->redirect('todo/advanced'); } } $this->setTemplate('advanced'); }
class advancedTodoForm extends sfForm { public function configure() { $todoWrapperForm = new sfForm(); foreach(TodoPeer::doSelect(new Criteria()) as $todo) { $todoWrapperForm->embedForm('existing_'.$todo->getId(), new TodoForm($todo)); } $todoWrapperForm->embedForm('new_1', new TodoForm()); // add one blank todo to start $this->embedForm('todos', $todoWrapperForm); $this->widgetSchema->setNameFormat('todo_list[%s]'); } public function save() { $values = $this->getValues(); foreach($this->embeddedForms['todos']->getEmbeddedForms() as $key=>$todoForm) { if ($values['todos'][$key]['task']) { // only save todos that aren't blank $todoForm->updateObject($values['todos'][$key]); $todoForm->getObject()->save(); } elseif(!$todoForm->getObject()->isNew()) { // delete any existing todos that are now blank $todoForm->getObject()->delete(); } } } }
<form action="<?php echo url_for('todo/advancedProcess'); ?>" method="post"> <?php echo $form->renderGlobalErrors(); ?> <div id="todo-wrapper"> <?php foreach($form['todos'] as $fieldSchema): ?> <?php echo $fieldSchema['task']->render(); ?> <?php echo $fieldSchema['task']->renderError(); ?> <?php endforeach; ?> </div> <?php echo $form['_csrf_token']; ?> <input type="submit" value="save" /> </form>
Adding the Magic
So far, all we have is the same thing as our simple todo form. To add additional input boxes, we're going to make an AJAX request. I use Jquery and post that code here. The most important thing, however, is that you make an ajax call (whatever your preferred method) to the action todo/advancedAddTodoAjax and spit out the result somewhere inside your form. As you can see below, I keep a count of my new todos and send it along to our action under the parameter name 'todo_index'.
<form...> ... <a href="#" onclick="return add_todo();" class="add-another" title="add another todo item box">Add another</a> </form> <script type="text/javascript"> var new_todo_index=2; add_todo = function() { $.post( '<?php echo url_for('todo/advancedAddTodoAjax'); ?>', { todo_index: new_todo_index }, function(data, textStatus) { $('#todo-wrapper').append(data); } ); new_todo_index = new_todo_index+1; return false; } </script>
The AJAX Action
The job of the AJAX action is simple: to return a new todo widget that will function nicely with our form when it's finally submitted. Doing this is no different than any other situation where we output form elements. Like always, we setup our form, then render its elements. The only difference is that in this case, we only need to render 1 element (our new todo widget) instead of the entire form.
public function executeAdvancedAddTodoAjax(sfWebRequest $request) { $todoIndex = $request->getParameter('todo_index'); $this->forward404Unless($todoIndex); $embedName = 'new_'.$todoIndex; // the unique name to give this form $form = new advancedTodoForm(); $form->addNewTodoField($embedName); $output = $form['todos'][$embedName]['task']->render(); $this->renderText($output); return sfView::NONE; }
Notice that we used the todo_index request parameter to form a unique name for our new embedded form. Since the first argument in embedForm is used to construct the name attribute of each element, it's important that each be unique.
The action is simple: build your form like normal, then add in a new field. This ensures that our new input element is output as if it's been a part of the original form all along. The action introduces a new method in our form, seen here:
class advancedTodoForm extends sfForm { ... public function addNewTodoField($name) { $this->embeddedForms['todos']->embedForm($name, new TodoForm()); $this->embedForm('todos', $this->embeddedForms['todos']); // re-embed the form } }
This method embeds a new blank TodoForm into the already-embedded 'todos' form. The second line "re-embed"s the 'todos' form into the main form. This line may/should look redundant to you. It's required due to the way that sfForm embeds forms. The first line alone doesn't fully embed our new blank TodoForm.
Progress Check - AJAX is up and running
At this point, you should add the following to your view right after $form->renderGlobalErrors(). Whether it be a bug or a feature, you'll need it or you won't see certain validation errors. In my opinion, if this isn't a bug, I'd like to hear the explanation as this seems like very sloppy behavior.
<?php echo $form['todos']->renderError(); ?>
You should now be able to click your "Add another" link as many times as you want to add more and more blank todo boxes. If you click save with any extra boxes, you'll get an "extra fields" error. Here's what's happening:
- You click "add another" and a request is made to the server, which inserts a new blank todo box into your HTML
- You click "save" and are sent to the advancedProcess action
- This action sets up your default advancedTodoForm, which DOES NOT include the new todo element that was present in your HTML as a result of the AJAX call
- The Symfony form validates your data and sees the additional AJAX element as "extra" data
Getting romantic with sfForm and AJAX, aka binding to the dynamic elements
As described above, the advancedTodoForm isn't aware of your new todo input fields and thus rejects them. This is where the rigidity of sfForms (good for security) meets the dynamic requirements of AJAX. Fortunately, the solution was quite simple.
class advancedTodoForm extends sfForm { ... /** * Override to add extra form fields for new todo items (otherwise they fail as extra fields) */ public function bind(array $taintedValues = null, array $taintedFiles = null) { foreach($taintedValues['todos'] as $key=>$newTodo) { if (!isset($this['todos'][$key])) { $this->addNewTodoField($key); } } parent::bind($taintedValues, $taintedFiles); } ... }
In order to avoid validation errors, we need to properly add the missing form elements before our form is bound. To do this, we override the bind() method with the above code. The idea is stupidly simple: If our input data contains a todo field that doesn't correspond to an embedded TodoForm form, add it. Notice we use the addNewTodoField method - the same one used in our AJAX action. We're constructing our form (adding todo forms to it) to reflect the new fields that were added via AJAX.
And that's it. Now that our dynamic todo fields are detected and their forms are embedded, each form is automatically and correctly saved via our existing save function.
Is It Love?
All that I know for sure is that I love both the Symfony forms framework and sites that use AJAX to make the web function smoothly. With the forms framework being young and the importance of AJAX only being compounded, I expect that the relationship between the forms framework and AJAX will only improve. Personally, I'm actually very happy with the above solution - but I'm pretty easy to please. If not love, it's certainly the start to a beautiful relationship: it has to be.
And Happy Valentine's day in a week or so.
I've follow all the steps, but in my pc I've some errors.. Can you publish an archive with all the project, please?
Thanks in advance
Let me know if anybody has any problems with the above.
Now, however, I want to apply your guide to my case, but I don't obtain what I would..
I've create a topic in the symfony forum; your help is appreciated!! :)
You can find the topic here: http://forum.symfony-project.org/index.php/m/71886/#msg_71886
Thanks for your guide!
***********************
add_todo = function()
{
$.post(
'',
{ todo_index: new_todo_index },
function(data, textStatus) {
$('#todo-wrapper').append(data);
}
);
new_todo_index = new_todo_index+1;
return false;
}
thanks for any help and for the guide
The variable new_todo_index is simply a javascript variable initialized just before the javascript function add_todo(). It's purpose is to ensure that each new todo field has a unique name (e.g. todo_list[todos][new_1], todo_list[todos][new_2], etc). The advancedAddTodoAjax action looks for this value to be passed with the request via a parameter called 'todo_index'.
I hope that clarifies!
-Ryan
$.post is not a function .
What am I doing wrong? have I forgotten to include any helper? or I am just missing something?
Regards
Be sure to include the Jquery library in your header - I may not have made this as clear as I should have:
<script type="text/javascript" src="http://ajax.googleapis.com/ajax/libs/jquery/1.2.6/jquery.min.js"></script>
Great tuto ;)
I am beginner in symfony and I would like to kno if there is anyway to limit the number of adding fields to 5.
Thanks
Absolutely. To do this, you'd have to do a few things:
1) your action method executeAdvancedAddTodoAjax needs to be smart enough NOT to return a new field if there are already 5 fields. Specifically, something like this in there:
...
$todoIndex = $request->getParameter('todo_index');
$this->forward404Unless($todoIndex);
$save_todo_count = TodoPeer::doCount(new Criteria());
if (($save_todo_count + $todoIndex) > 5)
{
$this->renderText('You cannot add more than 5 todos');
return sfView::NONE;
}
...
2) You'll need to add code into your advancedTodoForm::configure method to NOT automatically embed a blank todo form if there are already 5 todo objects.
3) If you already have 5 todos, you should hide the "Add Another" link in your template
4) You should add a custom post validator to your advancedTodoForm that tests to make sure that there wasn't more than 5 todos forms embedded.
So, it's not simple, not hard. As it wasn't originally designed for that, there's just several things that need tweaked for it to work out.
Let me know if that helps.
I will try it. I don't know how to do point 2 and 4 but i will seearch.
Again, thanks you.
Best regards
Mike
First, I(d like to say : Nice tutorial.
I tried your tricks on ma case.
I used it upload multiple files. Pictures are saved but I always have this error, in the bind function:
Warning: Invalid argument supplied for foreach() in ...
This works for a rather simple embedded forms.
But how do you handle a little more complicated one:
I have Owner who owns several buildings which have several tenant.
how to handle the binding of the tenant subforms, since there is no binding?
And I want to avoid to put some "tenant" logic into the "owner form".?
Why you are embeding a form if the only thing you want to do is adding an input widget?...maybe i am wrong but the idea of embeding forms is, for example, if the user want to upoad multiple pictures...so i embed as many upload forms as the user requests...but in this "todo" case every "todo" you add is not a form...am i right?
Yes, you are correct. More generally, you'd want to embed a form anytime you want to create multiple database objects in one form.
In this case, the Todo items are in fact a separate object, which were kept purposefully simple just for illustration. If you truly has a model with only one field, there may be an easy way to do this, or perhaps that model shouldn't be a model.
So, you're understanding is correct - this is just a oversimplified example.
If you are interested, feel free to contct me for a discussion.
Can't see any solution but overriding the saveEmbeddedForms method in the embedding form.
Yes, you've hit on a negative aspect of embedding forms. There are several things you can override in the parent form to perform things on the child, embedded forms. All of these things fail, however, as soon as the parent form itself embedded. This is just a known drawback. The best thing you can do would be to break out the special functionality into its own method. Then, in the parent form, you could call that method from doSave() (for example). If your parent form is itself embedded, you could still call this method from doSave() of the super-parent form. It's far from perfect for sure.
Who and where declares class TodoForm ??? You use it, but I can not find the declaration of it...
plz
If there are blank todo forms, you discard them by overriding the save() method, but, What happens in the isValid() method? Why doesn't it validate blank embedded forms?
The other thing is what Caron comments. That is my case, and I don't understand your proposed solutions. Could you clarify them more please? Here's my mail just in case: rdbvictor19@gmail.com