Keyword - tutoriel

Fil des billets - Fil des commentaires

jeudi 13 mars 2008

Symfony 1.1 beta, tour du propriétaire - L'internationalisation (i18n)

Dans la liste des tâches nouvellement ajoutées en Symfony 1.1, on remarque une section dédiée à l'internationalisation :

i18n
  :extract            Extracts i18n strings from php files
  :find               Finds non "i18n ready" strings in an application

Et oui, la fonctionnalité dont tous les gens qui ont un jour travaillé sur des applications internationalisées en Symfony 1.0 ont rêvé a enfin été ajoutée : une tâche d'extraction des chaînes de caractères à traduire, avec génération et mise à jour des fichiers de traductions XLIFF :)

Si vous avez suivi les précédents tutoriels, vous devez disposer d'un projet sf11test, d'une application main et d'un module contact.

On va activer la gestion de l'internationalisation dans l'application en éditant le fichier de configuration apps/main/config/settings.yml comme suit :

[...]
all:
  [...]
  .settings:
    [...]
    i18n:                   on
    [...]
    standard_helpers:       [Partial, Cache, Form, I18N]
    [...]
    default_culture:        en

On part du principe que la langue par défaut sera l'anglais (en). Ajoutons quelques chaînes internationalisées dans le template apps/main/modules/contact/templates/indexSuccess.php au moyen du helper __() :

<h2><?php echo __('Contact us') ?></h2>
<p><?php echo __('Drop us a message using the form below:') ?></p>
<form action="<?php echo url_for('contact/index') ?>" method="post">
  <table>
    <?php echo $form ?>
    <tr>
      <td></td>
      <td><input type="submit" value="<?php echo __('Send your message') ?>" /></td>
    </tr>
  </table>
</form>

Maintenant, lançons la commande d'extraction des chaînes à traduire pour notre future version française, en lui demandant poliment de générer automatiquement le fichier de traduction et de supprimer automatiquement les entrées orphelines :

 $ ./symfony i18n:extract --auto-save --auto-delete main fr
>> i18n      extracting i18n strings for the "main" application
>> i18n      found "3" new i18n strings
>> i18n      found "0" old i18n strings
>> i18n      saving new i18n strings
>> i18n      deleting old i18n strings

Le fichier apps/main/i18n/fr/messages.xml a été généré, examinons son contenu :

<?xml version="1.0"?>
<xliff version="1.0">
  <file source-language="EN" target-language="fr" datatype="plaintext"
    original="messages" date="2008-03-13T11:13:45Z"
    product-name="messages">
    <body>
      <trans-unit id="1">
        <source>Contact us</source>
        <target></target>
      </trans-unit>
      <trans-unit id="2">
        <source>Drop us a message using the form below:</source>
        <target></target>
      </trans-unit>
      <trans-unit id="3">
        <source>Send your message</source>
        <target></target>
      </trans-unit>
    </body>
  </file>
</xliff>

Il ne nous reste plus qu'à traduire nos chaînes en remplissant les balises <target></target> en conséquence pour traduire notre application en français. Si l'on venait à modifier notre template en supprimant, modifiant ou ajoutant de nouvelles chaînes, la tâche d'extraction se chargerait de mettre à jour nos fichiers de traduction en conséquence, tout en préservant le travail déjà effectué :)

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 ;-)

lundi 10 mars 2008

Symfony 1.1 beta, tour du propriétaire - Installation

Il y a quelques jours, Fabien a annoncé la disponibilité de Symfony 1.1 beta 1 dans le dépôt du projet. Symfony 1.1 est un changement majeur au niveau architecture, et la compatibilité ascendante est rompue en de nombreux points. Pour un outil éminemment basé sur les conventions, cela implique une réappropriation de ces dernières quand elles ont changé - mais c'est là le prix à payer pour bénéficier des nouvelles fonctionnalités. Et elles valent le coup !

Ce billet sera donc le premier d'une série destinée à explorer les fonctionnalités phares de cette preversion. On commence par le commencement avec la procédure d'installation.

Remarques préliminaires

Nous ne verrons pas la procédure de mise à jour d'un projet en symfony 1.0 vers la version 1.1[1]. On verra ça plus en détail sur ce blog si les CNTP le permettent. Pour l'heure, on partira donc d'un projet vierge en 1.1.

Installation de Symfony 1.1

Il faut pour l'heure installer Symfony 1.1 à partir des sources subversion ; voici une démarche possible, en admettant que vous disposez d'un environnement Unix/Linux[2] :

$ mkdir vendor && cd vendor
$ svn co http://svn.symfony-project.com/branches/1.1/ symfony11
$ sudo ln -s `pwd`/symfony11/data/bin/symfony /usr/bin/symfony11

Je crée ici un lien symbolique symfony11 accessible depuis /usr/bin, ce qui permettra de gérer aussi bien des projets en 1.0 qu'en 1.1[3].

Pour vérifier que tout s'est bien déroulé, vous pouvez lancer cette commande :

$ symfony11 -V
symfony version 1.1.0-DEV (/Users/niko/www/vendor/symfony11/lib)

Création d'un nouveau projet

Grâce à notre nouvelle installation isolée de Symfony 1.1, on peut créer un projet et une nouvelle application main via cette série de commandes :

$ cd /path/to/workspace
$ mkdir sf11test && cd sf11test
$ symfony11 generate:project sf11test
$ ./symfony generate:app main

Vous noterez que toutes les commandes ont été renommées par rapport à la version 1.0 et qu'elles utilisent désormais des espaces de noms spécifiques à certains domaines : generate:, propel:, plugins:, log:, etc. Pour lister l'ensemble des tâches en lignes de commande disponibles, vous pouvez lancer la commande symfony11 telle quelle, ou utiliser l'executable symfony disponible à la racine de votre projet :

$ ./symfony

Notez que l'emploi de symfony11 ou ./symfony a la racine de votre projet ont ici strictement le même effet, puisque les deux exécutables référencent la même installation de Symfony.

Il nous reste à créer un VHost Apache[4] minimaliste pour accéder à notre projet au travers de notre navigateur :

<VirtualHost *>
  ServerName   local.sf11test.org
  DocumentRoot %PROJECT_ROOT%/web
  <Directory "%PROJECT_ROOT%/web">
    AllowOverride All 
    Allow from All 
  </Directory>
  Alias /sf    %VENDOR_ROOT%/symfony11/data/web/sf
  ErrorLog     %PROJECT_ROOT%/log/error.log
  CustomLog    %PROJECT_ROOT%/log/access.log common
</VirtualHost>

N'oubliez pas de remplacer les chaînes %PROJECT_ROOT% et %VENDOR_ROOT% par les chemins système correspondant (respectivement la racine du projet et la racine de votre répertoire vendor créé précédemment).

On ajoutera également une entrée dans le fichier /etc/hosts pour avoir la résolution du nom local.sf11test.org localement :

127.0.0.1       local.sf11test.org

Si toutes les étapes ont été correctement suivies et après avoir rechargé la configuration d'Apache, en lançant notre navigateur préféré sur l'adresse local.sf11test.org, nous obtenons :

Symfony 1.1 default homepage

Ça vous rappelle quelque chose ? ;-)

La suite au prochain épisode, avec les formulaires dont la gestion a été entièrement revue en Symfony 1.1.

Edit : J'ai modifié l'url du dépôt pour faire pointer vers la branche 1.1, qui évolue constamment, comme suggéré par Fabien en commentaire :)

Pour ceux qui veulent mettre à jour du tag vers la branche, il faut lancer cette commande à la racine de votre répertoire vendor :

$ svn switch http://svn.symfony-project.com/branches/1.1/ symfony11

Un petit ./symfony cc s'imposera dans vos projets utilisant le dépôt.

Notes

[1] La procédure est pour le moment documenté dans ce fichier sur le dépôt.

[2] Enfin je veux dire, je ne m'occuperai pas de Windows ;)

[3] En admettant bien sûr que vous disposiez déjà d'une installation fonctionnelle de la 1.0 ;)

[4] Comme toujours, mod_rewrite doit être activé.

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 :)

samedi 28 avril 2007

Gagnez du temps avec Symfony 1.0 et son générateur de back-office

Lors du dernier petit-déjeuner Clever Age que j'ai animé sur les frameworks PHP, j'ai effectué une démonstration des fonctionnalités de génération de back-office existantes dans Symfony : la plupart des gens présents - pour la plupart découvrant la notion même de framework - ont été très impressionnés par la facilité déconcertante avec laquelle il était possible de développer une application complète en très peu de temps et d'étapes techniques grâce au générateur d'admin...

Personnellement, je suis tellement habitué à travailler avec de tels outils désormais que j'oublie parfois comme la vie est plus difficile sans eux... Je vais donc faire une démonstration afin que chacun puisse se faire son idée, sur sa propre machine :)

Pour faire très original, on va créer une petite application sommaire de gestion de weblog, doté des fonctionnalités suivantes :

  • Un auteur par billet
  • Billets multi-catégoriques (on pourrait aussi parler de tags)
  • Commentaires pour chaque billet

L'avantage est que la plupart des frameworks web proposent ce type de tutoriaux, donc ainsi vous pourrez plus aisément comparer :)

Installation de Symfony

Je vous renvoie à la documentation officielle ou à ce tutoriel pour installer Symfony sur votre machine et configurer un vhost apache pour votre nouveau projet. On partira sur la version 1.0.2, soit la dernière version stable disponible à l'heure où sont écrites ces quelques lignes.

Création d'un nouveau projet

Imaginons que votre projet soit créé dans /var/www :

$ sudo -s
# cd /var/www
# mkdir sftest && cd sftest
# symfony init-project sftest

Note: Si la page web par défaut du projet n'affiche pas d'images, il se peut que votre vhost ne trouve pas les éléments médias génériques de Symfony ; dans ce cas, un lien symbolique comme ci-dessous devrait régler le problème :

# ln -s /usr/share/php/data/symfony/web/sf web/sf

Note : Vous pourriez tout autant utiliser un alias apache dans votre vhost.

Créons maintenant nos deux applications front et back qui recevront le front-office et la console d'administration de notre projet :

# symfony init-app front 
# symfony init-app back

On crée une base de données dédiée au projet :

# mysql -uroot -p
> CREATE DATABASE sftest CHARACTER SET utf8 COLLATE utf8_general_ci;
> GRANT ALL ON sftest.* TO sftest@localhost IDENTIFIED BY '1234567'
> FLUSH PRIVILEGES;
> \q

Configuration de l'accès à la base de données

D'abord, on renseigne notre DSN MySQL dans le fichier config/databases.yml :

all:
  propel:
    class:          sfPropelDatabase
    param:
      dsn:          mysql://sftest:1234567@localhost/sftest

On fait la même chose pour Propel, dans le fichier config/propel.ini :

propel.database.url = mysql://sftest:1234567@localhost/sftest

Configuration du modèle de données

On crée le schéma de base notre de données, dans le fichier config/schema.yml :

propel:

  blog_authors:
    _attributes:    { phpName: Author }
    id: 
    name:           varchar(255)
    email:          varchar(255)

  blog_posts:
    _attributes:    { phpName: Post }
    id: 
    title:          varchar(255)
    excerpt:        longvarchar
    body:           longvarchar
    author_id:
    created_at:
    updated_at:
  
  blog_comments:
    _attributes:    { phpName: Comment }
    id: 
    post_id:
    author:         varchar(255)
    email:          varchar(255)
    site:           varchar(255)
    content:        longvarchar
    created_at:

  blog_sections:
    _attributes:    { phpName: Section }
    id: 
    name:           varchar(255)

  blog_posts_sections:
    _attributes:    { phpName: PostSection }
    id: 
    post_id:
    section_id:

Il y a beaucoup de magie dans la syntaxe de ce fichier. Retenez juste que les champs id, *_id et *_at sont nommés en vertus de conventions Symfony pour gérer automatiquement clés primaires, clés étrangères et les champs de type DATETIME.

Création d'un jeu de données de test

De même et parce qu'on est des gens sérieux (mais surtout parceque c'est pratique), on crée d'emblée un jeu de données de test (ou fixtures), dans un nouveau fichier data/fixtures/data.yml :

Author:
  NiKo:
    name:    NiKo
    email:   tepafou@fai.com

Post:
  FirstPost:
    title:   Mon premier post !
    excerpt: Un premier billet prometteur...
    body: >
      Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Integer 
      consectetuer congue diam. Sed eu enim. Cras fringilla, erat et pretium 
      tincidunt, elit nibh imperdiet lectus, vel viverra erat velit in  
      metus. Ut ipsum ante, ornare luctus, hendrerit in, ultricies id, est.
    author_id: NiKo
  
  SecondPost:
    title:   Mon deuxième billet
    excerpt: Un deuxième billet tout aussi savoureux.
    body: >
      Suspendisse potenti. Mauris id risus. Cras urna. Etiam vel enim nec 
      dui ultrices condimentum. Curabitur bibendum ultrices quam. Nulla 
      sodales risus eget nunc.
    author_id: NiKo

Section:
  Humeurs:
    name:    Humeurs
  Geek: 
    name:    Geek
  Ubuntu:
    name:    Ubuntu

PostSection:
  # First post categories
  FirstPost_Humeurs:
    post_id: FirstPost
    section_id: Humeurs
  FirstPost_Geek: 
    post_id: FirstPost
    section_id: Geek

  # Second post categories
  SecondPost_Geek:
    post_id: SecondPost
    section_id: Geek
  SecondPost_Geek: 
    post_id: SecondPost
    section_id: Ubuntu

Comment:
  Comment1:
    post_id: FirstPost
    author:  Jean-Paul
    email:   "jp@fai.com"
    site:    "http://blog.jeanpaul.com"
    content: Bravo, belle intervention.

  Comment2:
    post_id: SecondPost
    author:  Jean-Luc
    email:   "jl@fai.com"
    site:    "http://blog.jeanluc.org"
    content: Exactement, vous avez raison.

Ceci fait, on va lancer la génération du fichier SQL et des classes représentant notre modèle, créer les tables physiquement dans notre base et insérer notre jeu de données de test :

# symfony propel-build-all-load back

Vous noterez qu'un seule ligne de commande est à appeller, ce qui simplifie grandement les phases de prototypage.

Note : En cas de modification profonde du modèle de données, il est vivement conseillé de vider le cache symfony :

# symfony cc

Ajout des méthodes __toString() aux objets de données

Afin d'avoir facilement un descripteur texte pour notre objet Author, nous allons implémenter une méthode __toString() dans sa classe associée située dans le fichier lib/model/Author.php :

class Author extends BaseAuthor
{
  public function __toString()
  {
    return $this->getName();
  }
}

Cette méthode retournera le contenu du champs name de l'enregistrement de la table blog_authors correspondant pour identifier l'objet PHP sous la forme d'une chaîne de caractère descriptive. Vous pouvez aussi adapter ce principe pour les objets Post et Section, par exemple.

Génération d'un back-office d'administration des billets

Maintenant, on va générer une interface d'administration de nos objets Post, accessible par un contrôleur /posts depuis notre application back :

# symfony propel-init-admin back posts Post

L'interface d'administration est maintenant accessible via /back_dev.php/posts derrière la racine de l'url de votre instance projet Symfony :-)

Administration des billets

C'est un peu sec par défaut et les sections associées aux billets du blog ne sont pas gérées, il nous faut donc adapter le fichier de configuration du générateur d'admin de Symfony pour ce module, situé dans le fichier apps/back/modules/posts/config/generator.yml :

generator:
  class:               sfPropelAdminGenerator
  param:
    model_class:       Post
    theme:             default

    # Customisation des colones de la vue en liste, lien d'édition sur le titre
    list:
      display:         [=title, excerpt, Author, created_at, updated_at]

    # Customisation du formulaire d'ajout/édition
    edit:
    
      # Champs personnalisés
      fields:
        # Création d'un champs d'administration des sections associées
        post_sections: { type: admin_check_list, params: through_class=PostSection }
    
      # Spécification des champs de formulaire à afficher
      display:         [author_id, title, excerpt, body, post_sections]

Un raffraîchissement des interfaces en mode développement (en appellant le contrôleur back_dev.php dans l'url) affichera nos interfaces modifiées en conséquences. Si vous utilisez le contrôleur de production (/back.php), n'oubliez pas de vider le cache symfony pour visualiser vos modifications :

# symfony cc

On reproduira exactement la même opération pour nos autres objets à administrer (Author, Comment et Section):

# symfony propel-init-admin back authors Author
# symfony propel-init-admin back sections Section
# symfony propel-init-admin back comments Comment

Voila, une vingtaine de minutes nous auront suffit pour générer une application en ligne relativement complète. Il restera à gérer notamment :

Amusez-vous bien :-)

samedi 3 mars 2007

Ruby on Rails 1.2.2, les migrations

Voici la suite du précédent tutoriel sur Ruby on Rails publié sur ce même blog ou nous avons mis en place une application de gestion de contacts rudimentaire.

Migration de schéma de base de données

Imaginons que nous voulions gérer une liste de sociétés, et lier chacun de nos contacts à sa société... Il va nous falloir modifier notre base de données en ajoutant une table companies et une clé company_id dans la table contacts. Cela peut s'avérer compliqué puisqu'elle contient déjà des données... même si en l'occurence il ne s'agit pour l'heure que de données de test.

Rails et plus particulièrement ActiveRecord proposent un outil de gestion des évolutions du modèle de données intelligent, les migrations. Au même titre que nous avions créé le fichier db/migrate/001_contacts_development.rb pour définir le schéma initial lors du tutoriel précédent, nous allons maintenant créer son évolution incrémentale dans le fichier db/migrate/002_contacts_development.rb :

class ContactsDevelopment < ActiveRecord::Migration
  
  def self.up
    # On renomme le champs name en first_name (ne devra contenir que le prénom)
    rename_column :contacts, "name",       "first_name"
    
    # Ajout d'un champs pour stocker le nom de famille
    add_column    :contacts, "last_name",  :string
    
    # Ajout d'un champs pour stocker la société du contact
    add_column    :contacts, "company_id", :integer
    
    # Création de la table des sociétés
    create_table "companies", :force => true do |t| 
      t.column "name",        :string
      t.column "description", :text
      t.column "url",         :string
    end 
  end 
 
  def self.down
    # Rétablissement de la colonne name
    rename_column :contacts, "first_name", "name"
 
    # Destruction de la colonne last_name
    remove_column :contacts, :last_name
    
    # Destruction de la colonne contenant la référence à une société
    remove_column :contacts, :company
    
    # Destruction de la table contenant les sociétés
    drop_table :companies
  end 
 
end

Dans chaque classe de migration de schéma, une méthode up appliquera des modifications au modèles tandis qu'une méthode down permettra un rollback des modifications vers une version antérieure. Pour le détail des opérations effectuées par le script de migration ci-dessus, je crois que les commentaires parlent d'eux-mêmes ;) (j'en ai profité pour répartir le patronyme sur deux champs, nom et prénom)

La mise à jour effective du modèle dans la base de données s'effectue grâce à la commande :

$ rake db:migrate 
(in /home/niko/ww2/rails/contacts)
== ContactsDevelopment: migrating =============================================
-- rename_column(:contacts, "name", "first_name")
   -> 0.0750s
-- add_column(:contacts, "last_name", :string)
   -> 0.0153s
-- add_column(:contacts, "company_id", :integer)
   -> 0.0343s
-- create_table("companies", {:force=>true})
   -> 0.0304s
== ContactsDevelopment: migrated (0.1694s) ====================================

Nous n'avons pas d'outils d'administration pour notre nouvelle table companies, créons-les :

$ ruby script/generate scaffold Company

Il faut maintenant mettre à jour notre jeu de données ; on édite d'abord le nouveau fichier ./test/fixtures/companies.yml :

Nanonical:
  id:          1
  name:        Nanonical
  description: On vous préfère libres
  url:         http://www.nanonical.com
  
Crimosoft:
  id:          2
  name:        Crimosoft
  description: Notre monopole, c'est trop lol
  url:         http://www.crimosoft.com

Toute ressemblance avec des sociétés existantes ou ayant existé serait un sacré coup de pot

Puis, dans le fichier ./test/fixtures/contacts.yml :

John:
  id:         1
  first_name: John
  last_name:  Doe
  email:      john@doe.com
  address:    12, rue des champs
  zip:        75009
  city:       Paris
  country:    France
  company_id: 1 # John -> Nanonical
 
Bob:
  id:         2
  first_name: Bob 
  last_name:  Doe 
  email:      bob@doe.com
  address:    1, place du pré 
  zip:        35000
  city:       Rennes
  country:    France
  company_id: 2 # Bob -> Crimosoft

On réinsère nos nouvelles fixtures :

$ rake db:fixtures:load

On peut se rendre successivement aux adresses http://0.0.0.0:3000/contacts et http://0.0.0.0:3000/companies afin de s'assurer que tout va bien.

companies_scaffolding.png

Mise à jour des classes modèles

Pour que Rails prenne en compte les modifications structurelles de notre schéma, nous allons éditer les fichiers des modèles Contact et Company, notamment pour spécifier leur type de relation grace aux méthodes de classe ActiveRecord.

Le fichier ./apps/models/contact.rb :

class Contact < ActiveRecord::Base
  # Chaque employé appartient à une compagnie
  belongs_to :company
end

Et le fichier ./apps/models/company.rb :

class Company < ActiveRecord::Base
  # Une société possède plusieurs employés
  has_many :contact
end

Mise à jour du contrôleur

On récupère la liste des sociétés depuis le contrôleur gérant les contacts (situé dans le fichier ./app/controllers/contacts_controller.rb), d'abord pour la création :

class ContactsController < ApplicationController
  [...]
  def new
    @contact = Contact.new
    @companies = Company.find_all
  end

Puis pour l'édition:

class ContactsController < ApplicationController
  [...]
  def edit
    @contact = Contact.find(params[:id])
    @companies = Company.find_all
  end

Mise à jour de la vue

Enfin, modifions notre formulaire d'édition/création de contacts, situé dans app/views/contacts/_form.rhtml, comme suit :

<%= error_messages_for 'contact' %>
 
<!--[form:contact]-->
<p><label for="contact_company_id">Company</label>
<%= select 'contact', 'company_id', @companies.collect {|c| [c.name, c.id]} %></p>
 
<p><label for="contact_name">First name</label><br/>
<%= text_field 'contact', 'first_name'  %></p>
 
<p><label for="contact_name">Last name</label><br/>
<%= text_field 'contact', 'last_name'  %></p>
 
<p><label for="contact_email">Email</label><br/>
<%= text_field 'contact', 'email'  %></p>
 
<p><label for="contact_address">Address</label><br/>
<%= text_field 'contact', 'address'  %></p>
 
<p><label for="contact_city">City</label><br/>
<%= text_field 'contact', 'city'  %></p>
 
<p><label for="contact_zip">Zip</label><br/>
<%= text_field 'contact', 'zip'  %></p>
 
<p><label for="contact_country">Country</label><br/>
<%= text_field 'contact', 'country'  %></p>
<!--[eoform:contact]-->

Ce qui donne à peu-près ceci :

Nouveau formulaire de création/édition de contacts

Et le template d'affichage de notre liste de contacts, dans le fichier ./app/views/contacts/list.rhtml pour y ajouter la colonne affichant le nom de la société pour chaque contact :

<h1>Listing contacts</h1>
 
<table>
  <tr>
  <% for column in Contact.content_columns %>
    <th><%= column.human_name %></th>
  <% end %>
    <th>Société</th>
  </tr>
  
<% for contact in @contacts %>
  <tr>
  <% for column in Contact.content_columns %>
    <td><%=h contact.send(column.name) %></td>
  <% end %>
    <td><%= h contact.company.name %></td>
    <td><%= link_to 'Show', :action => 'show', :id => contact %></td>
    <td><%= link_to 'Edit', :action => 'edit', :id => contact %></td>
    <td><%= link_to 'Destroy', { :action => 'destroy', :id => contact }, :confirm => 'Are you sure?', :method => :post %></td>
  </tr>
<% end %>
</table>
 
<%= link_to 'Previous page', { :page => @contact_pages.current.previous } if @contact_pages.current.previous %>
<%= link_to 'Next page', { :page => @contact_pages.current.next } if @contact_pages.current.next %> 
 
<br />
 
<%= link_to 'New contact', :action => 'new' %>

Ce qui donne au final quelque chose comme ceci :

Nouvelle liste de contacts

To be continued

Dans un prochain tutoriel, nous aborderons la validation de formulaires.

Ruby on Rails 1.2.2, premier contact

Je dois préparer une réunion technique sur Ruby on Rails, aussi je m'y remets doucement. J'en profite donc pour passer en revue la version 1.2.2 du framework par le biais d'un petit tutoriel. Je prends pour exemple la gestion d'un carnet d'adresses simplissime.

Installation sur Ubuntu

L'installation de rails sur Ubuntu est toujours aussi simple.

Création de l'application

Vos pouvez créer votre projet rails n'importe où sur votre disque dur, par exemple dans ~/rails :

$ cd ~ && mkdir rails
$ rails contacts

Le squelette de l'application contacts est créé sous la forme d'un répertoire contenant l'arborescence par défaut d'un projet vierge.

$ cd contacts

Pour tester votre projet vierge, vous pouvez lancer Webrick, un petit serveur HTTP écrit en ruby inclus dans les librairies du framework :

$ script/server

Faites pointer votre navigateur à l'adresse http://0.0.0.0:3000/ pour admirer la page par défaut du projet.

Capture d'écran

Configuration de l'accès aux données

Dans le fichier config/databases.yml, on définit nos paramètres de connexion au SGBD, ici MySQL :

development:
  adapter:  mysql
  database: contacts_development
  username: root
  password: password
  host:     localhost
  socket:   /var/run/mysqld/mysqld.sock

N'oubliez pas de créer une base MySQL contacts_development, hein ;)

Création d'une table dans la base données

On va utiliser le système de migration propre à rails pour garantir une évolutivité de notre modèle de données :

$ ruby script/generate migration contacts_development

On édite le fichier généré dans db/migrate/001_contacts_development.rb :

class ContactsDevelopment < ActiveRecord::Migration
 
  def self.up
    create_table "contacts" do |t| 
      t.column "name", :string
      t.column "email", :string
      t.column "address", :string
      t.column "city", :string
      t.column "zip", :string
      t.column "country", :string
    end 
  end 
 
  def self.down
    drop_table :contacts
  end 
 
end

On lance la migration, ce qui pour l'heure aura pour conséquence et pour l'heure de créer notre table contacts dans la base de données :

$ rake db:migrate

Création d'un jeu de données

Nous allons créer quelques fixtures pour peupler notre table et avoir quelque chose à se mettre sous la dent. Pour cela, on crée (ou modifie) un fichier test/fixtures/contacts.yml contenant les deux entrées :

John:
  name:    John Doe 
  email:   john@doe.com
  address: 12, rue des champs
  zip:     75009
  city:    Paris
  country: France
 
Bob:
  name:    Bob Doe 
  email:   bob@doe.com
  address: 1, place du pré 
  zip:     35000
  city:    Rennes
  country: France

On insère les données dans la base :

$ rake db:fixtures:load

Ces données permettront par la suite de tester plus facilement l'application.

Scaffolding

On crée un module basique de gestion de nos contacts :

$ ruby script/generate scaffold Contact

Faites pointer votre navigateur sur http://0.0.0.0:3000/contacts pour tester les scripts générés automatiquement.

Scaffolding basique dans rails 1.2.2

Ce n'est effectivement pas très beau, vous avez raison, mais c'est déjà fonctionnel... et surtout, cela ne nous a pas pris plus de 5 minutes !

À suivre

On ne s'arrêtera pas en si bon chemin, le prochain volet de ce tutoriel fera notamment évoluer notre schéma de base de données... stay tuned !

dimanche 7 janvier 2007

Utiliser Doctrine au lieu de Propel dans Symfony sous Ubuntu

Doctrine est une couche ORM rapide et performante, basée sur le pattern ActiveRecord bien connu des adeptes de Ruby on Rails, pouvant être utilisée en lieu et place de Propel, l'ORM natif embarqué avec Symfony par défaut.

In