01.20.08

Symfony 1.1 Forms - Customizing your Form in the View (Part2)

Posted by ryan in symfony


Note: This article is up to date through revision 7136. No promises after that - but I will try to stay up to date.

In part1 of this article, we set up a very simple form that allowed us to approve an article by checking a box. What we were really after, however, is the ability to list all of our articles on the same screen, and approve several at once.

To do this, I instantly thought to use a new feature that I haven't yet explained. The feature is called embedding forms and is available via the sfForm method embedForm();

Unfortunately, as I got further into figuring out this solution, I discovered that the solution wasn't as neat as I had hoped. In fact, my solution leaves me quite dissatisfied and, quite honestly, I'm hoping Fabien has something more genius planned for this functionality.

EmbedForm()

The purpose of embedForm() is to be able to place multiple forms inside one form. One simple example (and then one that Fabien uses in the unit tests) is that of an author who writes several articles. Imagine that we have both an Author model object and child Article model object. Then, we can do the following and get some pretty great functionality.

public function executeEmbed()
  {
  	$author = AuthorPeer::retrieveByPk(1);
  	$this->form = new AuthorForm($author);
  	foreach($author->getArticle2s() as $article)
  	{
  		$form = new Article2Form($article);
  		$widgets = $form->getWidgetSchema();
  		$widgets['author_id'] = new sfWidgetFormInputHidden();
  		$this->form->embedForm('article-'.$article->getId(), $form);
  	}
  	if ($this->getRequest()->getMethod()==sfRequest::POST)
    {
    	$this->form->bind($this->getRequestParameter('author'));
    	if ($this->form->isValid());
    	{
    		$this->form->updateObject();
    		$this->form->save();
    	}
    }
  }
<form action="<?php echo url_for('article/embed'); ?>" method="post">
	<table>
		<?php echo $form; ?>
		<tr>
			<td><input type="submit" value="update status" />
		</tr>
	</table>
</form>

The result:
forms-template-customization-simple-form-embed.jpg

That's a fully functional form, Symfony takes care of everything. I think it's pretty neat, and quite useful.

Approving Multiple Articles

In part1, we coded up a form to approve one article. I really thought hard about the best way to modify this to be able to handle multiple articles. There are many ways to do it, but here's what I came up with. It's actually quite a difference from the code yesterday. My suggestion is to start from scratch.

  public function executeApprove()
  {
    $articles = ArticlePeer::doSelect(new Criteria());    
    $this->forms = array();
    $this->main_form = new sfForm();
    $this->main_form->getWidgetSchema()->setNameFormat('article[%s]');
    foreach($articles as $article)
    {
    	$form = new ArticleForm($article);
    	$this->forms[] = $form;
    	$this->main_form->embedForm($article->getId(), $form);
    }
    
    if ($this->getRequest()->isMethod('post'))
    {
    	$this->main_form->bind($this->getRequestParameter('article'));
    	if ($this->main_form->isValid())
    	{
    		$values = $this->main_form->getValues();
    		foreach($this->forms as $form)
    		{
    			$form->getObject()->setApproved($values[$form->getObject()->getId()]['approved']);
    			$form->getObject()->save();
    		}
    	}
    }
  }

Wow, that got big. Let's walk through this. First, setting up the form:

    $articles = ArticlePeer::doSelect(new Criteria());
    $this->forms = array();
    // This will hold all the "subforms", one for each article object
    $this->main_form = new sfForm();
    // I set up a main_form that will represent the true 
    $this->main_form->getWidgetSchema()->setNameFormat('article[%s]');
    // set a naming schema so that we can get at our data view the request parameter 'article'
    foreach($articles as $article)
    {
    	$form = new ArticleForm($article);
    	// create a new form based on the pre-existing article object
    	$this->forms[] = $form;
    	// put our subform into our forms array so that we can keep track of them
    	$this->main_form->embedForm($article->getId(), $form);
    	// embed the form into our main form, we only want to be dealing with one true form
    	// the first arg ($article->getId()) controls the name of the element (e.g. name=article[1][approved])
    	// by using the above name, we'll be able to tie our incoming data to the appropriate article object
    }

Here's the basic idea. First, we need one global form object, as there's really only one form on the page. This form will handle the csrf token and handle the validation of all the forms we embed into it. The reason I put the "normal" example of how embedForm() is used is because the use seen here is a little limited. Next, let's look at the saving of our forms.

    if ($this->getRequest()->getMethod()==sfRequest::POST)
    {
    	$this->main_form->bind($this->getRequestParameter('article'));
    	// since we set our naming schema to article[%s], all of our form data is in the article object (using element names like name="article[...]"
    	if ($this->main_form->isValid())
    	{
    		$values = $this->main_form->getValues();
    		// this gets us all of our cleaned, validated data
    		foreach($this->forms as $form)
    		{
    			// since we only want to set one field of our object, we do so by manually setting the approved field
    			$form->getObject()->setApproved($values[$form->getObject()->getId()]['approved']);
    			// the keys in $values are the ids of the articles (see embedForm() above)
    			$form->getObject()->save();
    		}
    	}
    }

Validate the main form, then iterate and update through the main forms. One of the most unsettling things about this method is the fact that there's no way to iterate through the subforms via the main form. After all, we did embed these forms in the main form, I felt like I should be able to get at them from the $this->main_form object. The reality that I can't is the reason for the additional $this->forms array to keep track of them.

Why was this so unsettling? Well, from my point of view, this seemed like a super obvious thing to have. From my perspective, I tend to think that this wasn't an oversight on the side of Fabien, but rather a signal that the above method may not be the preferred way to handle this situation. Indeed, I CAN think of other ways to handle the above, in particular without the step of embedding the forms. However, it's still not terribly clean, and I wanted to highlight the power of having only one form, with which you can validate easily.

Next up is the template, which has also taken a slightly different form.

<form action="<?php echo url_for('article/approve'); ?>" method="post">
	<table>
		<tr>
			<th>Author</th>
			<th>Body</th>
			<th>Approved?</th>
		</tr>
			<?php foreach($forms as $form): ?>
				<tr>
					<td><?php echo $form->getObject()->getAuthor(); ?></td>
					<td><?php echo $form->getObject()->getBody(); ?></td>
					<td><?php echo $main_form[$form->getObject()->getId()]['approved']->render(); ?></td>
				</tr>
			<?php endforeach; ?>
		<tr>
			<?php echo $main_form['_csrf_token']; ?>
			<td><input type="submit" value="update status" />
		</tr>
	</table>
</form>

The result:
forms-template-customization-simple-form-full.jpg

Let's first look closer at the printing of the forms.

			<?php foreach($forms as $form): ?>
				<tr>
					<td><?php echo $form->getObject()->getAuthor(); ?></td>
					<td><?php echo $form->getObject()->getBody(); ?></td>
					<td><?php echo $main_form[$form->getObject()->getId()]['approved']->render(); ?></td>
				</tr>
			<?php endforeach; ?>

It starts very simple - iterate through all of our forms. To print out any data, we use the $form->getObject() method to return the article object, then just get the variables that we need.

Next, we print out the actual form element. Why not use $form['approved']->render()? Good question, and there's 2 reasons. First, it won't have the right name. What I mean is, it won't be wrapped in the article[element_name] type of name style, meaning we won't be able to get at it like we want. Secondly, the value of the checkbox (checked or unchecked) would be wrong. This is tricky. The value of our checkbox is a result of the data being added to the form via the bind() method. Even though we update the object related to the individual embedded forms, we don't actually call their bind methods (we just call the bind method on the main form). Thus, the value would be wrong.

Most importantly, in the good model sense, we only have 1 form on our page, and we should do our best to treat it as one form.

$main_form[$form->getObject()->getId()]['approved']->render()

So, back to the task at hand. Since we named our forms using $article->getId() in our embedForm function, we get at them now by using the article id as a key on the $main_form object. Then, we simply go one level deeper in the "array" by using the key 'approved', which gets us to the correct approved form element. We render this and get our checkbox.

<?php echo $main_form['_csrf_token']; ?>

This was mentioned in part1, but I'll say it here again. As soon as you stop using echo $form, and instead start printing out your form elements selectively, you need to manually include the _csrf_token. If you don't your form validation will fail. Worse yet, I've been a bad programmer and failed to put in a spot where validation errors are displayed. Let's fix that now.

in my template:

<div class="errors">
	<ul>
		<?php foreach($main_form->getErrorSchema() as $error): ?>
			<li><?php echo $error; ?></li>
		<?php endforeach; ?>
	</ul>
</div>

It's not exactly brilliant, in fact the error message for forgetting to include my _csrf_token is simply "Required.". For a truly robust form, you'll want to remember to include $form['element_name']->renderError() for each of your form elements that aren't included via $form['element_name']->renderRow() (which includes the error).

Well, that's it. As I said before, I'm not really sure if what you've just seen will end up being "best practice." I actually kinda hope it's not, because it's a bit ugly. Of course, my idea here wasn't to write the fastest solution, but rather the one that took advantage of and illustrated the most new Symfony Form features possible. Either way, I feel like it's going to be "close" to best practice. We'll see.

Thanks for the shares!
  • StumbleUpon
  • Sphinn
  • del.icio.us
  • Facebook
  • TwitThis
  • Google
  • Reddit
  • Digg
  • MisterWong

Leave a reply

Enter the text you see here

saving...