Prendre un Café

L'espace d'expression de Nicolas Perriault

Aller au contenu | Aller au menu | Aller à la recherche

mercredi 17 février 2010

User Dependent Forms with Symfony

While sadly not being at Symfony Live 2010 with pals, I’m drowning my sorrow into tech tutorials writing.

Sometimes you want to design forms and contextualize them regarding the current user session. For example:

  • Manage the display of some fields regarding the user authentication status and credentials;
  • Filter some queries used to get the values available in a <select/> tag;
  • Allow multiple steps form validation with step state persistence (wizzard);
  • etc.

So how can we achieve this without using an evilish sfContext::getInstance()->getUser() call in the configure() method of the form?

Symfony 1.2 1.3 and 1.4 introduced the generation of a project-wide BaseForm class we can use here to setup some convenient methods to allow user setters and getters:

<?php
class BaseForm extends sfFormSymfony
{
  static protected $user = null;
 
  static public function getUser()
  { 
    return self::$user;
  }
 
  static public function getValidUser()
  {
    if (!self::$user instanceof sfBasicSecurityUser)
    {
      throw new RuntimeException('No valid user instance available');
    }
    
    return self::$user;
  }
 
  static public function setUser(sfBasicSecurityUser $user)
  {
    self::$user = $user;
  }
}

Okay, we now can set a user instance as a static property of all available forms within the project, nice. Wait, how the hell will we set the instance, and when?

Symfony dispatch a very useful context.load_factories event when all the factories - including the user one - are instanciated and available in the context. So we can listen to this event and set the user instance to the forms when it’s ready. We’ll do this in the ProjectConfiguration.class.php:

<?php
class ProjectConfiguration extends sfProjectConfiguration
{
  public function setup()
  {
    // ...
    
    $this->dispatcher->connect('context.load_factories', array($this, 'listenToLoadFactoriesEvent'));
  }
 
  public function listenToLoadFactoriesEvent(sfEvent $event)
  {
    BaseForm::setUser($event->getSubject()->getUser());
  }
}

So now we’ve set a static user instance in every form at project configuration time, we can use it in any form.

For example, imagine a form where only authenticated users having an admin credential can see, fill and submit an hypothetic is_published field:

class myForm extends BaseForm
{
  public function configure()
  {
    // will throw an exception if no user is available
    $user = self::getValidUser();
    
    $this->setWidgets(array(
      'title' => new sfWidgetFormInputText(),
      'body' => new sfWidgetFormTextarea(),
    ));
    
    $this->setValidators(array(
      'title' => new sfValidatorString(array('min_length' => 5)),
      'body' => new sfValidatorString(array('min_length' => 20)),
    ));
    
    if ($user->isAuthenticated() && $user->hasCredential('admin'))
    {
      $this->widgetSchema['is_published'] = new sfWidgetFormInputCheckbox();
      $this->validatorSchema['is_published'] = new sfValidatorBoolean(array(
        'required' => false,
      ));
    }
    
    // ...
  }
}

Here a basic user won’t see and won’t be able to submit any value for the is_published field. Note that all those changes won’t ever affect the controllers or the model, and the forms will still be easily testable because the forms and the user instance are not tightly coupled.

As a side note, you can also use the form options to eventually pass a user instance and fallback on the static instance in case it’s not available:

class myForm extends BaseForm
{
  public function configure()
  {
    // will throw an exception if no user is available
    $user = $this->getOption('user', self::getValidUser());
    
    // ...
  }
}

In a controller, you would use it this way:

<?php 
class fooActions extends sfActions
{
  public function executeBar(sfWebRequest $request)
  {
    $this->form = new myForm(array('user' => $this->getUser()));
    
    // ...
  }
}

A typical unit test suite of the form would be:

$t = new lime_test(2, new lime_output_color());
 
$user = new sfBasicSecurityUser(new sfEventDispatcher(), new sfSessionTestStorage(array(
  'session_path' => sys_get_temp_dir(),
)));
 
$form = new myForm(array('user' => $user));
$t->is(count($form->getWidgetSchema(), 2, '->configure() displays 2 fields when user is not authenticated'));
$user->setAuthenticated(true);
$user->addCredential('admin');
$t->is(count($form->getWidgetSchema(), 3, '->configure() displays 3 fields when user is authenticated and is an admin'));
// ... I'll let you complete the suite by yourself 

Now imagine we want to filter the choices offered by a one to many relationship field of a Doctrine form regarding user credentials:

class ArticleForm extends BaseArticleForm
{
  public function configure()
  {
    // will throw an exception if no user is available
    $user = $this->getOption('user', self::getValidUser());
    
    // ...
    
    // Category choices
    $categoryQuery = $this->getUserCategoryQuery($user);
    $this->widgetSchema['category_id'] = new sfWidgetFormDoctrineChoice(array(
      'model' => 'Category',
      'query' => $categoryQuery,
    ));
    $this->validatorSchema['community_id'] = new sfValidatorDoctrineChoice(array(
      'model' => 'Category',
      'query' => $categoryQuery,
    ));
  }
  
  /**
   * Please note that this method would naturally better fit in the model, in the 
   * ArticleTable class; We put this here for conciseness
   */
  protected function getUserCategoryQuery(sfBasicSecurityUser $user)
  {
    $query = Doctrine:getTable('Category')->createQuery('c');
    
    // If user is not an admin, only allow choice of published categories
    if (!$user->hasCredential('admin'))
    {
      $query->where('c.is_published = 1');
    }
    
    return $query;
  }
}

There are many more cool things you can achieve by offering user session access to a form, while keeping all the stuff easy to test.

As usual, if you have a better way, feel free to tell us about it in the comments.

dimanche 29 novembre 2009

Embedding Relations in Forms with Symfony 1.3 and Doctrine

Symfony 1.3 and 1.4 have been released some days ago in RC2 and you should really take a look at it, because they improve a lot the way you work with the framework, especially the forms one. It’s been a couple of days I started to implement a new bookmarks management feature on a project I’m working on currently, and the new model relationship embedding feature of 1.3’s forms framework just saved me a lot of days.

Here’s a quick and dirty example on how to setup a willingly simplistic bookmarks management system using it

The schema

Here’s the naive Doctrine schema I’ll use for this example:

# in config/doctrine/schema.yml
User:
  columns:
    id:
      type: integer(4)
      primary: true
      autoincrement: true
    name:
      type: string(255)
      notnull: true
  relations:
    Bookmarks:
      type: many
      class: Bookmark
      local: id
      foreign: user_id
      onDelete: CASCADE
 
Bookmark:
  columns:
    id:
      type: integer(4)
      primary: true
      autoincrement: true
    name:
      type: string(255)
      notnull: true
    url:
      type: string(255)
      notnull: true
    user_id:
      type: integer(4)
      notnull: true
  relations:
    User:
      type: one
      local: user_id
      foreign: id

As you can see, it’s really naive. Anyway.

Some fixtures

What would be a Doctrine schema without fixtures? Quite nothing I guess, so here we go:

# in data/fixtures/fixtures.yml
User:
  niko:
    name: niko
 
Bookmark:
  niko_bookmark1:
    User: niko
    name: Slashdot
    url: http://slashdot.org/
  niko_bookmark2:
    User: niko
    name: Delicious
    url: http://delicious.com/
  niko_bookmark3:
    User: niko
    name: Digg
    url: http://digg.com/

Building stuff

You guessed it, after having suited the config/databases.yml file to set up a database of our choice, we can now build the database, all the classes we need and load the previously defined fixtures:

$ ./symfony doctrine:build --all --and-load

A basic user’s bookmarks management form

What we want do do now is to manage one User’s bookmarks in a single form. Let’s do it now in a new UserBookmarksForm.class.php file where we’ll build a form extending the autogenerated UserForm one:

<?php
// lib/form/doctrine/UserBookmarksForm.class.php
class UserBookmarksForm extends UserForm
{
  public function configure()
  {
    // We don't want to edit the User object
    unset($this['name']);
 
    // Existing bookmark forms
    $this->embedRelation('Bookmarks');
    
    $this->widgetSchema->setNameFormat('user_bookmarks[%s]');
  }
}

All the magic is done by calling the embedRelation() method here: all BookmarkForm instances will be embedded and managed automatically in and by our form.

Let’s use a simple controller to manage the form[1]:

<?php
class testActions extends sfActions
{
  public function executeBookmarks(sfWebRequest $request)
  {
    $this->form = new UserBookmarksForm($user = Doctrine::getTable('User')->findOneByName('niko'));
    if ($request->isMethod('post') && $this->form->bindAndSave($request->getParameter('user_bookmarks')))
    {
      $this->getUser()->setFlash('notice', 'Bookmarks list updated');
      $this->redirect('test/index');
    }
  }
}

And a basic template:

<form action="." method="post">
  <?php echo $form->renderHiddenFields() ?>
  <?php echo $form->renderGlobalErrors() ?>
  <table>
    <?php echo $form ?>
    <tr>
      <td></td>
      <td><input type="submit"/></td>
    </tr>
  </table>
</form>

You should see something like this:

simple.png

The form is functional, you can edit several bookmarks at one. Neat? But wait, we can’t add a new one!

Embedding a bookmark creation form

Let’s enhance our form by embedding an empty BookmarkForm form instance in order to add a new bookmark:

<?php
// lib/form/doctrine/UserBookmarksForm.class.php
class UserBookmarksForm extends UserForm
{
  public function configure()
  {
    unset($this['name']);
    
    // Bookmark creation form
    $newBookmarkForm = new BookmarkForm();
    $newBookmarkForm->setDefault('user_id', $this->object->id);
    $this->embedForm('new', $newBookmarkForm);
    
    // Existing bookmark forms
    $this->embedRelation('Bookmarks');
    
    $this->widgetSchema->setNameFormat('user_bookmarks[%s]');
  }
  
  protected function doBind(array $values)
  {
    if ('' === trim($values['new']['name']) && '' === trim($values['new']['url']))
    {
      unset($values['new'], $this['new']);
    }
    
    parent::doBind($values);
  }
}

Here I just override the doBind() method to remove the bookmark creation embedded form if nothing has been submitted by the user in its fields; that’s probably because he just wants to edit existing bookmarks[2].

By refreshing the page you should now be able to see the new form:

withAdd.png

So now you’re able to add a new Bookmark and/or edit existing instances within the same simple form. Neat? Wait, we can’t delete existing bookmarks!

Deleting existing bookmarks

So you would like to have a checkbox next to every existing bookmark embedded form to schedule its deletion on saving the form? Wow, that’s getting tricky, I like that.

The Symfony forms framework has evolved a lot, but what we’re trying to achieve here will involve some fine-tunning. Don’t be too much afraid seeing the code ;)

First, we’ll have to add the checkbox widget to the BookmarkForm form, but only if an existing Bookmark object has been bound to it:

<?php
// lib/form/doctrine/BookmarkForm.class.php
class BookmarkForm extends BaseBookmarkForm
{
  public function configure()
  {
    $this->widgetSchema['user_id'] = new sfWidgetFormInputHidden();
    $this->validatorSchema['url'] = new sfValidatorAnd(array(
      new sfValidatorString(array('max_length' => 255)),
      new sfValidatorUrl(),
    ));
    
    if ($this->object->exists())
    {
      $this->widgetSchema['delete'] = new sfWidgetFormInputCheckbox();
      $this->validatorSchema['delete'] = new sfValidatorPass();
    }
  }
}

Refreshing the form page should display something like that:

withAddAndDelete.png

The UserBookmarksForm now holds this code:

<?php
// lib/form/doctrine/UserBookmarksForm.class.php
class UserBookmarksForm extends UserForm
{
  /**
   * Bookmarks scheduled for deletion
   * @var array
   */
  protected $scheduledForDeletion = array();  
  
  /**
   * Configures the form
   *
   */
  public function configure()
  {
    unset($this['name']);
    
    // Bookmark creation form
    $newBookmarkForm = new BookmarkForm();
    $newBookmarkForm->setDefault('user_id', $this->object->id);
    $this->embedForm('new', $newBookmarkForm);
    
    // Existing bookmark forms
    $this->embedRelation('Bookmarks');
    
    $this->widgetSchema->setNameFormat('user_bookmarks[%s]');
  }
  
  /**
   * Here we just drop the bookmark embedded creation form if no value has been 
   * provided for it (it somewhat simulates a non-required embedded form)
   *
   * @see sfForm::doBind()
   */
  protected function doBind(array $values)
  {
    if ('' === trim($values['new']['name']) && '' === trim($values['new']['url']))
    {
      unset($values['new'], $this['new']);
    }
    
    if (isset($values['Bookmarks']))
    {
      foreach ($values['Bookmarks'] as $i => $bookmarkValues)
      {
        if (isset($bookmarkValues['delete']) && $bookmarkValues['id'])
        {
          $this->scheduledForDeletion[$i] = $bookmarkValues['id'];
        }
      }
    }
    
    parent::doBind($values);
  }
  
  /**
   * Updates object with provided values, dealing with evantual relation deletion
   *
   * @see sfFormDoctrine::doUpdateObject()
   */
  protected function doUpdateObject($values)
  {
    if (count($this->scheduledForDeletion))
    {
      foreach ($this->scheduledForDeletion as $index => $id)
      {
        unset($values['Bookmarks'][$index]);
        unset($this->object['Bookmarks'][$index]);
        Doctrine::getTable('Bookmark')->findOneById($id)->delete();
      }
    }
 
    $this->getObject()->fromArray($values);
  }
  
  /**
   * Saves embedded form objects.
   *
   * @param mixed $con   An optional connection object
   * @param array $forms An array of forms
   */
  public function saveEmbeddedForms($con = null, $forms = null)
  {
    if (null === $con)
    {
      $con = $this->getConnection();
    }
 
    if (null === $forms)
    {
      $forms = $this->embeddedForms;
    }
    
    foreach ($forms as $form)
    {
      if ($form instanceof sfFormObject)
      {
        if (!in_array($form->getObject()->getId(), $this->scheduledForDeletion))
        {
          $form->saveEmbeddedForms($con);
          $form->getObject()->save($con);
        }
      }
      else
      {
        $this->saveEmbeddedForms($con, $form->getEmbeddedForms());
      }
    }
  }
}

The code should be self-explanatory… or not. But anyway, you should now be able to add a new bookmark and delete existing ones as well, in a single form and process, and we’re still using the very same controller from the beginning!

Okay, let’s admit it, we’re reaching the limits of Symfony and probably those of my knowledge of it as well, but… IT WORKS.

But, what about the templating?

As of now we’ve used the convenient <?php echo $form ?> trick, but we now want to have full fine-grained control over the way our embedded form fields will look like.

Let’s update the form template accordingly:

<form action="." method="post">
  <?php echo $form->renderHiddenFields() ?>
  <?php echo $form->renderGlobalErrors() ?>
  <!-- Embedded new bookmark form -->
  <fieldset>
    <legend><?php echo __('Create a new bookmark') ?></legend>
    <?php echo $form['new']->renderError() ?>
    <div class="form-row">
      <?php echo $form['new']['name']->renderLabel() ?>
      <div class="form-field">
        <?php echo $form['new']['name']->render() ?>
        <?php echo $form['new']['name']->renderError() ?>
      </div>
    </div>
    <div class="form-row">
      <?php echo $form['new']['url']->renderLabel() ?>
      <div class="form-field">
        <?php echo $form['new']['url']->render() ?>
        <?php echo $form['new']['url']->renderError() ?>
      </div>
    </div>
  </fieldset>
  <!-- /Embedded new bookmark form -->
  <!-- Embedded existing bookmark forms -->
  <?php foreach ($form['Bookmarks'] as $i => $eForm): ?>
    <fieldset>
      <legend><?php echo sprintf(__('Edit bookmark #%d'), $i+1) ?></legend>
      <?php echo $eForm->renderError() ?>
      <div class="form-row">
        <?php echo $eForm['name']->renderLabel() ?>
        <div class="form-field">
          <?php echo $eForm['name']->render() ?>
          <?php echo $eForm['name']->renderError() ?>
        </div>
      </div>
      <div class="form-row">
        <?php echo $eForm['url']->renderLabel() ?>
        <div class="form-field">
          <?php echo $eForm['url']->render() ?>
          <?php echo $eForm['url']->renderError() ?>
        </div>
      </div>
      <div class="form-row">
        <div class="form-field">
          <?php echo $eForm['delete']->render() ?>
          <?php echo $eForm['delete']->renderError() ?>
          <?php echo $eForm['delete']->renderLabel(__('Delete this bookmark')) ?>
        </div>
      </div>
    </fieldset>
  <?php endforeach; ?>
  <!-- /Embedded existing bookmark forms -->
  <input type="submit" value="<?php echo __('Update your bookmarks') ?>"/>
</form>

Now you can really control the semantics of the HTML code of the form, field by field.

As always, if you have better solutions in mind, feel free to post them in the comments.

Notes

[1] I’ll leave you to create an app, module and route, okay?

[2] Of course this can be a lot improved, but the example is kept as simple as possible to ease understanding.

jeudi 16 juillet 2009

Little Symfony Forms Tricks

Hey, it’s been a long time I didn’t blog something clever here on Symfony, let me try to remedy this.

I’ve just stumbled upon this blog post about the use of sfValidatorCallback, which is quite cool because by using this particular validator you can virtually employ any kind of php callable to validate something. But as the author warned, it can be problematic to tie the symfony form validation framework to your model classes.

Personnaly, I rather prefer to declare a method in the form class itself to validate some value without boring myself writing each time a new sfValidatorBase derived class[1] :

<?php
class myForm extends sfForm
{
  public function configure()
  {
    $this->setWidgets(array(
      'name' => new sfWidgetFormInput(),
    ));
    
    $this->setValidators(array(
      'name' => new sfValidatorCallback(array('callback' => array($this, 'validateChuckNorris'))),
    ));
  }
  
  public function validateChuckNorris(sfValidatorBase $validator, $value)
  {
    // you can't validate chuck norris, but chuck norris can invalidate you
    if ('Chuck Norris' === $value)
    {
      throw new sfValidatorError($validator, 'invalid');
    }
  }
}

Of course, if you got the exact same need in another form, you should create a dedicated validator class. If you want custom error messages and options, you’ll have to create a dedicated class as well. But for simple and casual needs, this is just enough.

The neat thing with the sfValidatorCallback validator is you can even validate form schema values (say, an array containing all the values bound to the form) the same way, eg. using a post validator. Let’s see an example reusing the form shown previously:

<?php
class myForm extends sfForm
{
  static protected $choices = array(
    'none' => 'No emails', 
    'commercials' => 'Commercial emails', 
    'news' => 'Annoucement emails', 
    'alerts' => 'Alert emails',
  );
 
  public function configure()
  {
    $this->setWidgets(array(
      //...
      'email' => new sfWidgetFormInput(),
      'opt_in' => new sfWidgetFormSelectRadio(array('choices' => self::$choices)),
    ));
    
    $this->setValidators(array(
      //...
      'email' => new sfValidatorEmail(array('required' => false)),
      'opt_in' => new sfValidatorChoice(array('choices' => array_keys(self::$choices))),
    ));
    
    $this->validatorSchema->setPostValidator(new sfValidatorCallback(array(
      'callback' => array($this, 'validateSchema'),
    )));
  }
  
  // We want the user to provide his email if he choosed to receive stuff by email
  public function validateSchema(sfValidatorBase $validator, array $values)
  {
    if ($values['opt_in'] !== 'none' && !$values['email'])
    {
      throw new sfValidatorErrorSchema($validator, array(
        'email' => new sfValidatorError($validator, 'required'),
      ));
    }
  }
}

As a cool side effect, it will also make testing your form validation very easy, because you just have to test the callable method of your form.

Here’s another trick I stole to Kris on a project we worked together on lately, using a formatter callback to alter the presentation of a widget. Here’s an example showing how to get rid of the unordered list displaying a collection of checkboxes by default, by inlining them instead:

<?php
class myForm extends sfForm
{
  static protected $choices = array(
    'none' => 'No emails', 
    'commercials' => 'Commercial emails', 
    'news' => 'Annoucement emails', 
    'alerts' => 'Alert emails',
  );
 
  public function configure()
  {
    $this->setWidgets(array(
      //...
      'opt_in' => new sfWidgetFormSelectRadio(array(
        'choices' => self::$choices,
        'formatter' => array($this, 'formatInline'),
      )),
    ));
    
    $this->setValidators(array(
      //...
      'opt_in' => new sfValidatorChoice(array('choices' => array_keys(self::$choices))),
    ));
  }
  
  public function formatInline($widget, $inputs)
  {
    $formatted = array();
    
    foreach ($inputs as $input)
    {
      $formatted[] = $input['input'].' '.$input['label'];
    }
 
    return join(' ', $formatted);
  }
}

There are tons of little tricks like these which make the life of a developer using the forms framework easier, I’ll try to share them with you progressively.

Notes

[1] All my examples are using latest Symfony 1.2.

mardi 11 mars 2008

Symfony 1.1 beta, tour du propriétaire - Les formulaires

Nous venons de voir la procédure d'installation de la beta1 de Symfony 1.1. Nous allons maintenant rentrer un peu plus dans les détails des nouvelles fonctionnalités en commençant par les formulaires.

La gestion des formulaires avec Symfony 1.1

La nouvelle gestion des formulaires est l'une des fonctionnalités majeures de cette nouvelle mouture du framework. Elle propose une séparation claire entre couche de contrôle, couche de définition et couche de présentation des données, soit un bon vieux pattern MVC des familles.

Pour illustrer ces fonctionnalités, nous allons retrousser nos manches et créer un formulaire de contact basique. On commence par initialiser un nouveau module contact dans l'application main de notre projet sf11test initié précédemment :

$ ./symfony generate:module main contact

Création d'une classe de formulaire

Nous allons créer une classe qui représentera notre formulaire de contact, que nous stockerons dans le fichier apps/main/lib/ContactForm.class.php. Cette classe étendra la classe de base sfForm et surchargera sa méthode de configuration afin de définir les champs de formulaire et les différents validateurs associés. Nous définirons quatre champs (appelés widgets en Symfony 1.1) :

  • Le nom de l'expéditeur, sous la forme d'un champs de saisie textuelle (<input type="text"/>)
  • Son adresse email, également sous la forme d'un champs de saisie textuelle
  • Le sujet de son message, sous la forme d'une boîte de sélection (<select/>)
  • Le texte de son message, sous la forme d'un champs texte multilignes (<textarea/>)
<?php
class ContactForm extends sfForm
{
  public function configure()
  {
    // Widgets
    $topics = sfConfig::get('app_contact_topics', array());
    $widgetSchema = new sfWidgetFormSchema(array(
      'topic'   => new sfWidgetFormSelect(array('choices' => $topics)),
      'name'    => new sfWidgetFormInput(),
      'email'   => new sfWidgetFormInput(),
      'message' => new sfWidgetFormTextarea()
    ));
    $widgetSchema->setNameFormat('contact[%s]'); // HTML field names format
    $this->setWidgetSchema($widgetSchema);
 
    // Validators
    $this->setValidators(array(
      'topic'   => new sfValidatorRegex(array('pattern' => '/[a-z_]/')),
      'name'    => new sfValidatorString(array('min_length' => 2,
                                               'max_length' => 45)),
      'email'   => new sfValidatorAnd(array(new sfValidatorEmail(),
                                            new sfValidatorString(array('max_length' => 100)))),
      'message' => new sfValidatorString(array('min_length' => 10)),
    ));
  }
}

Au passage, nous ajoutons les sujets possibles de message dans le fichier de configuration apps/main/config/app.yml :

all:
  contact:
    topics:
      carrots_request: Do you have carrots?
      eggs_request:    Do you have eggs?

Marquons un arrêt pour examiner de plus près ce que nous venons d'écrire :

  • Nous avons ajouté 4 widgets à notre formulaires,
  • Nous avons défini et paramétré leurs validateurs associés,
    • Au passage, vous noterez qu'il est possible de spécifier plusieurs validateurs pour un même champs, comme c'est ici le cas pour le champs email
  • Nous avons défini le format de nommage des champs de formulaire et choisi une syntaxe à base de tableau pour manipuler plus aisément les données dans notre action,
  • Nous avons déporté une partie de la configuration textuelle dans un fichier externe dédié, pour en faciliter la maintenance.

N'oublions pas de purger le cache de Symfony, car nous venons d'ajouter un nouvel objet php :

$ ./symfony cc

Interaction avec le formulaire depuis le contrôleur de l'application

Éditons maintenant le fichier apps/main/modules/contact/actions/actions.class.php pour y définir l'action par défaut du module[1] :

<?php
class contactActions extends sfActions
{
  public function executeIndex(sfWebRequest $request)
  {
    $form = new ContactForm();
    if ($request->isMethod('post')) // If HTTP method is POST
    {
      // Bind submitted values to the contact form instance
      $form->bind($request->getParameter('contact'));
      // Validate the form
      if ($form->isValid()) 
      {
        // Retrieve submitted values
        $values = $form->getValues(); 
        // ... Send your message here using submitted values
        // Then redirect user to the homepage with a one-shot message
        $this->getUser()->setFlash('notice', 'Message sent');
        $this->redirect('@homepage');
      }
    }
    // Publish form instance to the view
    $this->form = $form;
  }
}

Ici, dans l'ordre :

  • On instancie un objet de formulaire de contact
  • Si la méthode HTTP est POST :
    • On assigne les paramètres de la requête correspondant aux champs du formulaire de contact à ce dernier
    • On lance la validation, et si c'est valide :
      • On effectue les opérations nécessaires (traitement, redirection, etc.)

Vous noterez que tous les sinon sont gérés automatiquement par le framework de façon transparente (mais ces comportements par défaut sont toujours surchargeables) :

  • Par défaut le formulaire présenté sera vierge[2],
  • Les champs seront automatiquement préremplis en cas d'erreur de validation,
  • Les erreurs seront contextualisées par rapport aux champs.

Rendu du formulaire dans la vue

Enfin, il nous reste à créer un template pour présenter le formulaire et éventuellement afficher un message à l'utilisateur, dans notre vue gérée au travers du fichier apps/main/modules/contact/templates/indexSuccess.php :

<?php if ($sf_user->hasFlash('notice')): ?>
  <p class="notice"><?php echo $sf_user->getFlash('notice') ?></p>
<?php endif; ?>
 
<form action="<?php echo url_for('contact/index') ?>" method="post">
  <table>
    <?php echo $form ?>
    <tr>
      <td></td>
      <td><input type="submit" /></td>
    </tr>
  </table>
</form>

Si on charge la page /main_dev.php/contact, on a le résultat suivant :

Symfony 1.1 test form

Au passage si vous regardez la source, une protection anti CSRF est automatiquement gérée de façon transparente.

Bien entendu, le mode de rendu par défaut utilise un tableau pour la mise en forme, mais il existe un autre décorateur plus sémantique à base de liste. Pour l'utiliser, modifions notre classe ContactForm :

<?php
class ContactForm extends sfForm
{
  public function configure()
  {
   // ...
    $this->widgetSchema->setFormFormatterName('list');
  }
}

Il conviendra bien entendu d'adapter le template pour enlever les balises tables. On peut aussi imaginer de créer notre propre décorateur HTML. Par exemple, créeons la classe sfWidgetFormSchemaFormatterDiv comme suit :

<?php
class sfWidgetFormSchemaFormatterDiv extends sfWidgetFormSchemaFormatter
{
  protected
    $rowFormat       = "<div class=\"form-row\">\n  %error%%label%\n  %field%%help%\n%hidden_fields%</div>\n",
    $errorRowFormat  = "<div class=\"form-errors\">\n%errors%</div>\n",
    $helpFormat      = '<div class="form-help">%help%</div>',
    $decoratorFormat = "<div>\n  %content%</div>";
}

Encore une fois, modifions notre classe ContactForm pour l'utiliser (après avoir purgé le cache symfony, comme il se doit) :

<?php
class ContactForm extends sfForm
{
  public function configure()
  {
   // ...
    $this->widgetSchema->setFormFormatterName('div');
  }
}

Rendu des widgets

Vous me direz, un simple <?php echo $form ?> n'est pas très satisfaisant du point de vue de l'intégration HTML, qui nécessite bien souvent de prendre la main finement sur le code html généré. Le framework de formulaires de Symfony 1.1 apporte une solution en permettant de générer le rendu de chacun des widgets de façon indépendante

Imaginons par exemple que nous souhaitions gérer spécifiquement la présentation du champ name de notre formulaire ; notre template devient alors :

<?php if ($sf_user->hasFlash('notice')): ?>
  <p class="notice"><?php echo $sf_user->getFlash('notice') ?></p>
<?php endif; ?>
 
<form action="<?php echo url_for('contact/index') ?>" method="post">
  <table>
    <?php echo $form['topic']->renderRow() ?>
    <tr>
      <th><?php echo $form['name']->renderLabel() ?></th>
      <td>
        <?php echo $form['name']->renderError() ?>
        <?php echo $form['name']->render(array('class' => 'toto')) ?>
      </td>
    </tr>
    <?php echo $form['email']->renderRow() ?>
    <?php echo $form['message']->renderRow() ?>
    <tr>
      <td></td>
      <td><input type="submit" /></td>
    </tr>
  </table>
</form>

Vous noterez qu'on accède ici aux différents widgets du formulaire au moyen de clés de tableaux classiques ; c'est tout simplement car la classe sfForm implémente l'interface ArrayAccess de la SPL. C'est très pratique !

Le nerf de la guerre : la génération de formulaires à partir d'objets Propel

Ayant été à une lointaine époque un fervent adepte de PEAR::FormBuilder, brique permettant de générer des formulaires à partir d'instance d'objets de données ORM, j'étais curieux de voir comment cette fonctionnalité demandée par les développeurs depuis longtemps allait être implémentée dans Symfony 1.1 au travers de l'ORM Propel, bundlé par défaut[3].

Après avoir configuré un accès à notre SGBD préféré[4], nous allons définir une table contact_demand dans notre fichier config/schema.yml pour y stoker nos demandes de contacts :

propel:
  contact_demand:
    id:
    name:    { type: varchar, size: 45, required: true }
    email:   { type: varchar, size: 100, required: true }
    topic:   { type: varchar, size: 255, required: true }
    message: { type: longvarchar, required: true }
    created_at:

On lance rapidement la tâche de création de la table et des objets ORM associés :

$ ./symfony propel:build-all
$ ./symfony cc

Maintenant, allons faire un tour dans le répertoire lib/model pour voir ce qui a été généré. Surprise, des objets de formulaires ont été automatiquement créés pour nous !

Jetons un oeil plus particulièrement au fichier lib/form/base/BaseContactDemandForm.class.php :

<?php
class BaseContactDemandForm extends BaseFormPropel
{
  public function setup()
  {
    $this->setWidgets(array(
      'id'         => new sfWidgetFormInputHidden(),
      'name'       => new sfWidgetFormInput(),
      'email'      => new sfWidgetFormInput(),
      'topic'      => new sfWidgetFormInput(),
      'message'    => new sfWidgetFormTextarea(),
      'created_at' => new sfWidgetFormDateTime(),
    ));
 
    $this->setValidators(array(
      'id'         => new sfValidatorPropelChoice(array('model' => 'ContactDemand', 'column' => 'Id', 'required' => false)),
      'name'       => new sfValidatorString(),
      'email'      => new sfValidatorString(),
      'topic'      => new sfValidatorString(),
      'message'    => new sfValidatorString(),
      'created_at' => new sfValidatorDateTime(array('required' => false)),
    ));
 
    $this->widgetSchema->setNameFormat('contact_demand[%s]');
 
    $this->errorSchema = new sfValidatorErrorSchema($this->validatorSchema);
 
    parent::setup();
 
  }
 
  public function getModelName()
  {
    return 'ContactDemand';
  }
}

Cela ne vous rappelle rien ? C'est presque quasiment ce que nous avions écrit manuellement précédemment dans notre classe ContactForm. On notera également la présence dans le fichier lib/form/ContactDemandForm.class.php de la classe ContactDemandForm, cette dernière héritant de BaseContactDemandForm.class.php, ce qui nous permettra de surcharger tout ou partie de ses méthodes pour adapter le formulaire généré à nos besoins.

En l'occurrence, il nous faut adapter un peu la méthode configure() pour retrouver notre sélecteur de sujets et réappliquer nos validateurs. Voici le code de la classe ContactDemandForm modifiée en conséquence :

<?php
class ContactDemandForm extends BaseContactDemandForm
{
  public function configure()
  {
    // Widgets
    $topics = sfConfig::get('app_contact_topics', array());
    $this->setWidgets(array(
      'id'      => new sfWidgetFormInputHidden(),
      'topic'   => new sfWidgetFormSelect(array('choices' => $topics)),
      'name'    => new sfWidgetFormInput(),
      'email'   => new sfWidgetFormInput(),
      'message' => new sfWidgetFormTextarea()
    ));
 
    // Validators
    $this->setValidators(array(
      'id'         => new sfValidatorPropelChoice(array('model'    => 'ContactDemand',
                                                        'column'   => 'Id',
                                                        'required' => false)),
      'topic'      => new sfValidatorRegex(array('pattern' => '/[a-z_]/')),
      'name'       => new sfValidatorString(array('min_length' => 2,
                                                  'max_length' => 45)),
      'email'      => new sfValidatorAnd(array(new sfValidatorEmail(),
                                               new sfValidatorString(array('max_length' => 100)))),
      'message'    => new sfValidatorString(array('min_length' => 10)),
      'created_at' => new sfValidatorDateTime(array('required' => false)),
    ));
 
    $this->widgetSchema->setNameFormat('contact_demand[%s]');
    $this->errorSchema = new sfValidatorErrorSchema($this->validatorSchema);
  }
}

On va maintenant modifier notre action pour prendre en compte notre nouvelle classe de formulaire liée à notre objet Propel :

<?php
class contactActions extends sfActions
{
  public function executeIndex(sfWebRequest $request)
  {
    $form = new ContactDemandForm();
    if ($request->isMethod('post')) // If HTTP method is POST
    {
      // Bind submitted values to the contact form instance
      $form->bind($request->getParameter('contact_demand'));
      // Validate the form
      if ($form->isValid())
      {
        // Save submitted values in form's ContactDemand object
        $form->save();
        // Then redirect user to the homepage with a one-shot message
        $this->getUser()->setFlash('notice', 'Message sent');
        $this->redirect('@homepage');
      }
    }
    // Publish form instance to the view
    $this->form = $form;
  }
}

Ainsi, un simple $form->save() nous permet de persister en base les données soumises par l'utilisateur au travers du formulaire autogénéré. Un sacré gain de productivité en phase de prototypage, assurement !

En conclusion

Voilà, ce n'est bien évidemment là qu'une infime partie de ce que peut faire le nouveau système de gestion de formulaires de Symfony 1.1, mais c'est un peu à reculons que l'on retourne à l'ancien système ;-)

Notes

[1] Nous ne créons pas de route pour le moment, même si ce serait là une bonne pratique, afin de ne pas surcharger inutilement le contenu de ce tutorial, déjà bien assez dense comme ça :-)

[2] On aurait bien entendu pu proposer des valeurs par défaut.

[3] Le mécanisme de génération de code devrait permettre de proposer facilement la même fonctionnalité pour Doctrine prochainement.

[4] Je vous laisse le soin de vous référer au tutoriel existant sur ce même blog ;-)