Prendre un Café

L'espace d'expression de Nicolas Perriault

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

Recherche - getid

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.

dimanche 29 avril 2007

Symfony, Admin-Generator et Composants

Dans un précédent billet, nous avons vu comment créer un weblog basique avec l'admin-generator de Symfony. Nous allons reprendre là où nous en étions et ajouter la liste des commentaires à notre back-office de gestion des billets grâce à un simple appel de composant, qui n'est autre qu'un template partiel associé à un contrôleur dédié.

Création du module de gestion des commentaires en back-office

Si ce n'est déjà fait, on crée notre module de gestion des commentaires

# symfony propel-init-admin back comments Comment

Création du composant qui va lister les commentaires d'un billet

Bien. On va maintenant créer un composant postcomments qui affichera la liste des commentaires associés à un objet Post, en créant un nouveau fichier apps/back/modules/posts/actions/components.class.php et en y insérent le contenu ci-dessous :

<?php
class postsComponents extends sfComponents 
{
  
  /**
   * List comments for a given Post
   * 
   */
  public function executePostcomments()
  {
    $post_id = $this->getRequestParameter('id');
    if (!is_null($post_id))
    {
      $c = new Criteria();
      $c->add(CommentPeer::POST_ID, $post_id);
      $c->addDescendingOrderByColumn(CommentPeer::CREATED_AT);
      $this->comments = CommentPeer::doSelect($c);
    }
  }
  
}

On crée maintenant le template partiel associé au contrôleur, dans le fichier apps/back/modules/posts/templates/_postcomments.php :

<?php if (isset($comments)): ?>
  <?php if (count($comments) > 0): ?>
    <?php use_helper('Comment', 'Text') ?>
    <dl>
    <?php foreach ($comments as $comment): ?>
      <dt>
        <?php echo sprintf('Posté le <strong>%s</strong> par <strong>%s</strong> :', 
                           format_date($comment->getCreatedAt(), 'd/MM/yyyy à H:m'),
                           format_comment_author($comment->getAuthor(),
                                                 $comment->getSite())) ?>
      </dt>
      <dd>
        <?php echo simple_format_text($comment->getContent()) ?>
      </dd>
      <dd>
        [<?php echo link_to('Éditer', 'comments/edit?id='.$comment->getId()) ?>]
        [<?php echo link_to('Supprimer', 
                            'comments/delete?id='.$comment->getId(),
                            'confirm=Êtes-vous sûr ?') ?>]
      </dd>
    <?php endforeach; ?>
    </dl>
  <?php else: ?>
    <p>No comment yet.</p>
  <?php endif; ?>
<?php endif; ?>

Utilisation d'un helper pour des tâches courantes de templating

La fonction format_comment_author est un helper maison, que nous définissons dans un nouveau fichier lib/helper/CommentHelper.php :

<?php
/**
 * Returns a formatted html string for author including website link if any
 * 
 * @param  string   $name
 * @param  string   $site
 * @param  boolean  $nofollow
 * @return string 
 */
function format_comment_author($name, $site=null, $nofollow=true)
{
  if (!is_null($site) && preg_match('/^http/i', $site))
  {
    return sprintf('<a href="%s"%s>%s</a>', 
                   $site, 
                   $nofollow === true ? ' rel="nofollow"' : '',
                   $name);
  }
  else
  {
    return $name;
  }
}

Mise à jour du back-office de gestion des billets

Ceci fait, modifions le fichier de configuration du back-office d'administration des billets créé précemment et situé dans le fichier apps/back/modules/posts/config/generator.yml, afin d'appeller notre nouveau composant postcomments pour la vue d'édition d'un billet :

  [...]

    # Customisation du formulaire d'ajout/édition
    edit:
      
      [...]

      # Affichage d'une sélection de champs
      display:         [author_id, title, excerpt, body, post_sections, ~postcomments]

Vous noterez le caractère ~ précedant le nom du composant : encore une convention Symfony qui nous simplifie la vie :)

Gestion de l'échappement

Vous avez tous déjà entendu parler des failles XSS ? Pour nous en prémunir partiellement, nous allons activer l'échappement automatique des contenus passés à une vue, en ajoutant ces directives dans le fichier apps/back/config/settings.yml :

all:
  .settings:
    escaping_strategy: both
    escaping_method:   ESC_ENTITIES

Ainsi, dans notre cas toutes les chaînes seront échappées en entités HTML pour plus de sécurité. Pour plus d'information, rendez-vous dans la section dédiée de la documentation officielle.

Au final, voici ce que ça donne quand on édite un billet :

Affichage des commentaires d'un billet

Champs virtuels

Notre liste de billets, pour l'instant, ne nous informe pas pour chacun le nombre de commentaires associés. On va y remédier simplement en créant un getter ad-hoc dans notre objet de donnée Post et en appellant le champs virtuel associé. Dans le fichier lib/model/Post.php, on rajoute la méthode suivante :

public function getCommentsNumber()
  {
    return $this->countComments();
  }

Et dans le fichier apps/back/modules/posts/config/generator.yml, on affiche le champs virtuel comments_number dans la vue en liste :

  [...]

    # Customisation des colones de la vue en liste
    list:
      display:         [=title, excerpt, Author, comments_number, created_at, updated_at]

Voici ce que ça donne :

Liste des billets, avec nombre de commentaires associés

En conclusion

Voila, notre application prend forme doucement. On pourrait passez des heures à ajouter des détails par-ci par-là, mais je vous laisse le faire en compagnie de la documentation :)