Embauchez-moi

Je suis freelance ! Besoin d'un professionnel du développement web ? Pourquoi ne pas me passer un coup de fil ?

Plus d'infos sur… http://thibault.jouannic.fr

mots-cles : Ingénieur web freelance Symfony eZ Publish Solr

Montpellier web : parlons-en !

Salut tout le monde,

Suite au premier post sur Montpellier web, quelques mesures concrètes ont été mises en place.

  • La mise en place d’un groupe de discussion, histoire de permettre aux bonnes volontés d’échanger ailleurs que via twitter.
  • Mise en place d’un blog, pour centraliser les actus et infos sur l’événement. (La propagation dns est en cours, vous n’y aurez peut-être pas accès immédiatement)

Premier débat : décider du nom (mtpweb vs sudweb). Le débat penche assez fortement vers mtpweb (d’où les noms de domaines choisis), j’ai toutefois créé un premier sujet de discussion, afin de permettre à tout le monde de s’exprimer correctement.

Avis à tous les gens qui avaient l’air motivés : la discussion précède l’action. Donnez votre avis.

Pour un Montpellier web en 2011 ?

Ni queue - Ni tête
Creative Commons License photo credit : littlepois

Suite au dernier sfPot Montpellierain, qui s’est déroulé entre autre en compagnie d’honorables membres du non moins honorable regroupement particul.es, l’idée a été lancée d’organiser un Montpellier web, pendant sudiste au désormais fameux Paris-web.

À voir la tempète de tweets qui s’ensuivit, il semble que l’idée soulève bel et bien l’enthousiasme des foules. En fait, on dirait bien que l’idée couvait dans pas mal de têtes, mais sans avoir réellement germé, par manque d’enthousiasme, de motivation, ou tout bêtement de temps.

En vrac, quelques pistes de motivations qui justifieraient d’organiser un tel événement :

  • Permettre au sud le la France de se doter de son propre événement, et par là même, contribuer à donner une visibilité aux professionnels du web de la région (et oui, il y a des gens qui travaillent en province)  ;
  • Paris-web, c’est à Paris :) . Montpellier, c’est quand même mieux pour un rendez-vous sur la plage après une journée de conférence  ;

L’idée n’est donc pas de concurrencer Paris-web, mais au contraire d’en être complémentaire. Autant que possible, il me semble intéressant de faire intervenir en priorité des gens de la région et des alentours, les compétences ne manquent pas. Le projet pourrait donc intéresser du monde, et recruter des bonnes volontés pour participer à l’organisation semble possible.

Histoire de lancer quelques pistes de réflexions, voici une liste d’idées de trucs à faire / à penser en vrac :

  • Lancer un appel aux bonnes volontés, et créer une équipe d’organisation. Définir des dates de réunions (autour de bonnes pizzas / bières, pour joindre l’utile à l’agréable), parce qu’en twitter, c’est bien, mais ce voir, c’est quand même mieux ;
  • Définir le format de l’évenement. Un jour ? Deux jours ? Dans l’idéal, pour une première édition, je verrai bien s’étaler sur deux jours des conférences, avec des workshops en parallèle, et un espace stands pour les sponsors.
  • Définir la date. Le printemps me semble une bonne date, pour faire un pendant aux conférence sur Paris en automne. En plus, Montpellier au printemps, qu’est-ce que c’est bien !
  • Définir la philosophie de l’événement. Quels sujets privilégier ? Qui inviter ? À qui veut-on s’adresser ? Comment se démarquer des événements similaires existants ?
  • Définir les modalités d’inscription. Est-ce un événement gratuit ? Payant ? Cher ? Pas cher ? Il me semble important d’en faire un événement accessible au plus grand nombre, et par conséquent de limiter le tarif au minimum nécessaire pour ne pas en être de notre poche.
  • Trouver un lieu. Voir avec les universités, la ville ou l’agglo si elles peuvent prêter un espace approprié.
  • Trouver des sponsors qui cautionneraient et financeraient l’événement. Définir les modalités d’adhésion des sponsors. Décider à quelle contrepartie leur sponsoring leur donne droit (stands ? Logos sur les affiches ?)
  • Définir et créer une structure juridique (association ?) pour servir d’interface avec les diverses et inévitables administrations  ;
  • Lancer un appel aux conférenciers, recruter les intervenants, définir le programme  ;
  • Démarcher les hôtels des alentours pour proposer des formules d’hébergements attractives aux visiteurs. S’organiser pour héberger chez l’habitant les conférenciers (couch-surfing power) ?
  • Mettre en place le site, avec backend et outils de communication pour les GO (gentils organisateurs). Montpellier-web.fr est déjà pris. À tout hasard, j’ai réservé mtp-web.fr :)
  • Créer un visuel pour des affiches et des tracts. S’occuper de la com’ de l’événement. Contacter la presse ;
  • Trouver une formule pour la bouffe lors de l’hébergement. Prévoir un buffet ? Un apéro ? Démarcher les restos aux alentours pour proposer des formules attractives ?

Je dresse une liste de tout ce à quoi je pense, en me projettant volontairement assez loin dans l’avenir. L’idée étant de reccueillir les premiers avis, lancer les discussions, et peut-être établir une liste des premières bonnes volontés intéressées à donner de leur temps pour participer à l’organisation.

Si vous avez des avis utiles, n’hésitez pas à les balancer en commentaire, où à les tweeter assortis du tag #mtpweb. Sur ce, bonne fin de week-end à tout le monde.

Comment PHP me rends fou

Chers amis développeurs bonsoir. Je vous écris car je souhaite soumettre à votre sagacité un problème étrange. À défaut de pouvoir me venir en aide (seule ma dignité m’empêche encore de verser des larmes de frustration), peut-être au moins cela vous fera-t-il marrer. Je suis victime d’une version particulièrement vicieuse du bug de 17h30 (avec un combo « bug du vendredi de fin d’itération » compte triple).

Projet symfony. Je code, tout se déroule bien, fin de semaine, je balance mon code sur le serveur de recette, et je jette un coup d’œil sur l’environnement de prod. Horreur ! Page blanche ! Je vérifie la même page sous l’environnement de dev. Elle s’affiche correctement.

Je jette un coup d’œil dans la log apache :

child pid 16813 exit signal Segmentation fault (11)

Un sentiment d’immense lassitude m’envahit aussitôt.

Après une longue et pénible session profilage / debugage avec xdebug, je finit par circonvenir plus ou moins l’endroit du problème, et parviens même à le reproduire à l’envie. Vous allez voir, c’est surprenant :

$category = Article->getCategory(); // objet de classe 'Category'
echo $category;  // Affiche le titre de la catégorie
printf('%s', $category); // Page blanche, segfault, sacrifice de chatons, etc.

Amusant, non ? Après quelques recherches, il semblerait que dans le contexte d’une fonction *printf, php n’utilise pas la fonction magique __toString().

Je teste donc :

<?php
 
class Toto
{
  private $tata;
 
  public function __construct($tata)
  {
    $this->tata = $tata;
  }
 
  public function __toString()
  {
    return (string) $this->tata . "\n";
  }
}
 
$toto = new Toto('tutu');
echo $toto;
printf('%s', $toto);
 
// tutu
// tutu

WTF ? Mais ça marche trés bien ! C’est donc un problème spécifique à mon code ? Je poursuis mon débuggage, et j’arrive ici :

$category = $article->getCategory();
// $category est de la classe sfOutputEscaperIteratorDecorator
// qui hérite de sfOutputEscaperObjectDecorator
// qui définit une fonction __toString()
 
printf('%s', $category);
// Appelle sfOutputEscaperObjectDecorator::__toString

Bon, allons voir sur place ce qui ne va pas.

class sfOutputEscaperObjectDecorator
{public function __toString()
  {
    // Jusqu'ici, tout va bien, mais dés l'instruction return, ça plante
    return $this->escape($this->escapingMethod, $this->value->__toString());
  }
 
  // Je modifie donc la fonction pour obtenir ceci :
  public function __toString()
  {
    $value = $this->escape($this->escapingMethod, $this->value->__toString());
    var_dump(gettype($value));
    var_dump($value);
    die();
    return $value;
  }
 
// J'obtiens pour affichage :
// 'String'
// 'Titre de ma catégorie'
 
// En revanche :
 
  public function __toString()
  {
    $value = $this->escape($this->escapingMethod, $this->value->__toString());
    var_dump(gettype($value));
    var_dump($value);
    // die();
    return $value;
  }
 
// Page blanche. Continuons dans l'étrange, avec quelques modifications de la même méthode :
 
  public function __toString()
  {
    $value = $this->escape($this->escapingMethod, $this->value->__toString());
    die('ici'); // Affiche 'ici'
    return 'toto';
  }
 
  public function __toString()
  {
    return 'toto'; // Affiche 'toto'
  }
 
  public function __toString()
  {
    $value = $this->escape($this->escapingMethod, $this->value->__toString());
    return 'toto'; // Page blanche
  }
}

Tout ça, bien sûr, c’est sur l’environnement de prod. En dev, tout se passe toujours normalement.

J’en suis là. Si quelqu’un a une bonne explication. En attendant, je crois que je vais aller me coucher de bonne heure. Bon week-end à tous.

Symfony form : pick or create

Here’s an other tutorial about the Symfony form framework. Last time, we managed to make embedded i18n forms optionals. In today’s howto, we will talk about embedded forms again.

In the last tutorials, we created a form to edit news. Today we will create another form to write interviews. An interview is just an article about a celebrity. So our Interview class will inherit from our Article one, so we can reuse our last time work.

Here’s the complete schema we will work with :

Article:
  actAs:
    Timestampable: ~
    I18n:
      fields: [ title, body ]
      actAs:
        Sluggable: { fields: [ title ], uniqueBy: [ lang, title ] }
 
  columns:
    title: { type: string(255), notnull: true }
    body: { type: clob, notnull: true }
    author: { type: string(255), notnull: false }
 
News:
  inheritance:
    extends: Article
    type: concrete
 
Celebrity:
  actAs:
    Sluggable:
      fields: [ firstname, lastname ]
  columns:
    firstname: { type: string(255), notnull: true }
    lastname: { type: string(255), notnull: true }
 
Interview:
  inheritance:
    extends: Article
    type: concrete
 
  columns:
    celebrity_id: { type: integer, notnull: true }
 
  relations:
 
    Celebrity:
      local: celebrity_id
      foreign: id
      type: one
      foreignType: one
      foreignAlias: Interviews

And the fixtures :

Interview:
  i1:
    Author: 'Thibault J.'
    Celebrity:
      firstname: Thibault
      lastname: Jouannic
    Translation:
      fr:
        title: Auto-interview
        body: |
          Bla bla bla question et réponses

As usual…

php symfony build --all --and-load
php symfony doctrine:generate-admin backend Interview

Before we start

Before we start, let’s stop and think a few seconds. How do we want to manage our celebrity relation into our form ? Take a look at the default form, without any modification :

01_celebrity
Creative Commons License

Quite bad, isn’t it ? We could make a little better by adding a toString method.

// lib/model/doctrine/Celebrity.class.php
 
class Celebrity extends BaseCelebrity
{
  public function __toString()
  {
    return sprintf('%s %s', $this->getFirstname(), $this->getLastname());
  }
}
02_celebrity
Creative Commons License

That’s a bit better. The problem with this form is that you cannot add a new celebrity. If you want to create a new interview, you’ll have to chose among the existing celebrities. Of course, you could create an other admin module to manage celebrities, but a good programmer is a lazy one. And that would be great if we could create a new celebrity directly in the interview form.

In fact, that’s quite easy, using the embedRelation function.

// lib/form/doctrine/InterviewForm.class.php
class InterviewForm extends BaseInterviewForm
{
  /**
   * @see ArticleForm
   */
  public function configure()
  {
    parent::configure();
 
    unset($this['celebrity_id']);
    $this->embedRelation('Celebrity');
  }
}
03_celebrity
Creative Commons License

Here’s the result, which is quite relevant to what we wanted. But !? Wait ! There is a regression. What if we want to choose among existing celebrities, the way we used to do ? What we really need is a way to combine both methods actually. We need to be able to choose an existing celebrity OR to create a new one.

Pick or create

We will have to use an intermediate form.

// lib/form/doctrine/InterviewForm.class.php
class InterviewForm extends BaseInterviewForm
{
  /**
   * @see ArticleForm
   */
  public function configure()
  {
    parent::configure();
 
    unset($this['celebrity_id']);
 
    $celebrityId = $this->getObject()->isNew() ? NULL : $this->getObject()->getCelebrity()->getId();
    $form = new InterviewCelebrityForm(array(), array('celebrity_id' => $celebrityId));
    $this->embedForm('Celebrity', $form);
  }
}
 
// lib/form/doctrine/InterviewCelebrityForm.class.php
class InterviewCelebrityForm extends sfForm
{
  public function configure()
  {
    $this->widgetSchema['celebrity_id'] = new sfWidgetFormDoctrineChoice(array(
      'model' => 'Celebrity',
      'add_empty' => true
    ));
 
    $this->setDefault('celebrity_id', $this->getOption('celebrity_id'));
 
    $form = new CelebrityForm();
    $this->embedForm('new_celebrity', $form);
 
    $this->widgetSchema->setLabel('celebrity_id', 'Choose one…');
    $this->widgetSchema->setLabel('new_celebrity', 'or create a new.');
  }
}
04_celebrity
Creative Commons License

Try to edit the existing interview, and look at the nice result. However, if you try to save the form, you’ll have a lot of validations errors. There is still some work to do before we can get rid of all those red alerts.

05_celebrity
Creative Commons License

Form validation

The form validation strategy is quite simple. If an existing celebrity is chosen with the drop down list, the firstname and lastname should be ignored. If the list is empty, we must validate the firstname and lastname. At last, we must throw an error if all the fields are empty.

// lib/form/doctrine/InterviewCelebrityForm.class.php
 
    // add this  at the end of the configure() function
    $this->validatorSchema['celebrity_id'] = new sfValidatorPass();
 
    $celebrityValidatorSchema = clone $this->validatorSchema['new_celebrity'];
    $this->validatorSchema['new_celebrity'] = new sfValidatorPass();
 
    $this->validatorSchema->setPostValidator(
      new sfValidatorAnd(array(
        new sfValidatorOr(array(
          new sfValidatorSchemaFilter('celebrity_id', new sfValidatorDoctrineChoice(array(
            'model' => 'Celebrity',
            'required' => true
          ))),
          new sfValidatorSchemaFilter('new_celebrity', $celebrityValidatorSchema)
        )),
        new sfValidatorCallback(array(
          'callback' => array($this, 'checkCelebrity')
        ))
      ))
    );
 
  // add this function into the class
  /**
   * Either who choose an existing celebrity, either we create a new one
   *
   * Unset values according to the choice we made
   **/
  public function checkCelebrity($validator, $values, $argument)
  {
    if(!empty($values['celebrity_id']))
    {
      unset(
        $this['new_celebrity'],
        $values['new_celebrity']
      );
    }
    else
    {
      unset(
        $this['celebrity_id'],
        $values['celebrity_id']
      );
    }
 
    return $values;
  }

Try differents combinations to submit your form. The validation should behave correctly now.

Saving your form

You may have noticed that our interview isn’t updated correctly after we submit the form. We will have to override the doUpdateObject method in order to update the object correctly on form validation.

// lib/form/doctrine/InterviewForm.class.php
  public function doUpdateObject($values)
  {
    if(!empty($values['Celebrity']['celebrity_id']))
    {
      $this->getObject()->setCelebrityId($values['Celebrity']['celebrity_id']);
    }
    else
    {
      $celebrity = new Celebrity();
      $celebrity->fromArray($values['Celebrity']['new_celebrity']);
      $this->getObject()->Celebrity = $celebrity;
    }
 
    unset(
      $values['Celebrity'],
      $this['Celebrity']
    );
 
    parent::doUpdateObject($values);
  }

That’s it ! Everything should be alright now. But just to make sure, here’s a few tests.

Test your forms

We are reusing some code from the last day, so I just will output the new part here.

// test/function/backend/interviewActions.class.php
 
<?php
 
include(dirname(__FILE__).'/../../bootstrap/functional.php');
 
$browser = new sfBackendTestFunctional(new sfBrowser());
$browser->loadData();
$browser->setTester('doctrine', 'sfTesterDoctrine');
 
$interview = Doctrine::getTable('Interview')->
  createQuery('i')->
  leftJoin('i.Translation t')->
  andWhere('t.lang = ?', 'fr')->
  orderBy('i.id', 'asc')->
  fetchOne();
 
$celebrity = new Celebrity();
$celebrity->setFirstname('toto');
$celebrity->setLastname('tutu');
$celebrity->save();
 
$editUrl = sprintf('/interview/%s/edit', $interview->getId());
 
$browser->
  get('/interview')->
  with('response')->begin()->
    isStatusCode(200)->
  end()->
 
  info('1 - Interview list')->
  with('response')->begin()->
    checkElement('h1', '/Interview List/')->
    checkElement('body', '/Thibault Jouannic/')->
  end()->
 
// …
 
   info('  2.8 - The current celebrity is already selected')->
  get($editUrl)->
  with('response')->begin()->
    checkElement('#interview_Celebrity_celebrity_id option[selected="selected"]', sprintf('/%s/', $interview->getCelebrity()))->
  end()->
 
  info('  2.9 - The current celebrity can be updated')->
  get($editUrl)->
  click('Save', array('interview' => array(
    'Celebrity' => array(
      'celebrity_id' => $celebrity->getId()
      )
    )), array('_with_csrf' => true)
  )->
  with('form')->begin()->
    hasErrors(0)->
  end()->
 
  with('doctrine')->begin()->
    check('Interview', array(
      'id' => $interview->getId(),
      'celebrity_id' => $celebrity->getId()
    ))->
    info('  2.10 - Updating the celebrity does not create empty records')->
    check('Celebrity', array(), 4)->
  end()->
 
  info('  2.11 - A new celebrity can be created')->
  get($editUrl)->
  click('Save', array('interview' => array(
    'Celebrity' => array(
      'celebrity_id' => '',
      'NewCelebrity' => array(
        'firstname' => 'oncle',
        'lastname' => 'picsou'
      )
    )
  )), array('_with_csrf' => true)
  )->
  with('doctrine')->begin()->
    check('Celebrity', array(), 5)->
    check('Interview', array(
      'id' => $interview->getId(),
      'celebrity_id' => $celebrity->getId() + 1
    ))->
  end()
;

That’s all folks ! See you soon.

L’automobiliste du vendredi soir

Il m’arrive d’être stupide, parfois. Si si, ça m’arrive. Tenez, aujourd’hui par exemple, j’ai été assez bête pour aller faire des courses en centre ville, un vendredi, à 17h30. Moi qui suis freelance, et travaille à domicile, avouez, faut quand même pas être futé.

Parce qu’à 17h30, à Montpellier, dans le cente-ville, il y a beaucoup de voitures immobiles. À la limite, dans la mesure ou je suis cycliste, ces voitures ne me dérangeraient pas plus que ça, si chacune d’entre elle ne contenait un petit automobiliste aigri et hargneux.

Car l’automobiliste du vendredi soir, exténué par sa semaine de travail, et pressé de rentrer chez lui s’affaler devant sa télé, a tout de la version bipède du bichon. Mais si, ce petit monstre au poil blanc, version animale de la brosse à chiottes, à l’aboiement strident, et dont il est impossible de distinguer l’avant du derrière.

C’est amusant l’évolution. Prenons le cas des chats, par exemple. À l’instar d’un pigeon, un chat peut objectivement être considéré comme un animal à faible valeur ajoutée. En gros, un chat, ça bouffe, ça dort, ça casse deux ou trois trucs de temps en temps, mais à part ça, ça ne sert à rien

Seulement voilà, l’évolution a conféré au chat, notamment dans sa version juvénile, un minois si adorable qu’il est tout bonnement impossible pour un être humain normalement constitué de ne pas s’y attacher. Et c’est ainsi que l’espèce du chat, siècle après siècle, continue de se perpétuer, comptant sur sa miraculeuses faculté à se faire offrir à bouffer.

Quand aux bichons, honnêtement, je ne comprends pas comment ils ont réussi à ne pas tous se faire transformer en saucisses. Allons, je ne désespère pas.

pequeñita
Creative Commons License photo credit : Difusa

Mais je digresse, je divague, et je m’éloigne de mon sujet[1]. Sujet qui est, je le rappelle pour les moins attentifs d’entres vous, l’aggressivité hargneuses de l’automobiliste du vendredi soir. Car si le bichon aboie, l’automobiliste lui klaxonne rageusement, convaincu que grâce à un mystérieux phénomène physique, plus il fera de bruit, plus vite il sera chez lui.

Les geeks le savent, le vendredi est le jour du troll. Le jour ou il est toléré, dans les limites imposées par le bon sens, de prendre quelques libertés avec la netiquette. Sans doute inspirés par cette charmante coutume, les automobilistes, eux aussi, décident parfois de prendre quelques libertés avec la «  netiquette de la route  ».

Seulement voilà, contrairement à la netiquette du web, qu’il est recommandé de suivre pour le confort de tous, la nétiquette de l’automobiliste, plus connue sous le nom de «  code de la route  », n’est pas facultative. En fait, le code de la route est même un texte législatif, un truc quand même un peu sérieux, du genre qu’on n’est pas censé trop faire le guignol avec.

Alors, puisque j’ai la chance de disposer ici d’un espace d’expression libre, et que peut-être, il reste un ou deux automobilistes à lire ces lignes, voici quelques règles que je souhaite rappeler à leur attention :

  1. En agglomération, l’usage de l’avertisseur sonore n’est autorisé qu’en cas de danger immédiat. (art. R416-1 du code de la route). Je tiens à préciser que l’hypothétique possibilité de pouvoir espérer faire avancer sa voiture de quelques centimètres ne constitue pas un danger immédiat  ;
  2. L’arrêt ou le stationnement d’un véhicule empiétant sur un passage prévu à l’intention des piétons est interdit. (art. Article R417-5 du code de la route)  ;

Bref ! Tout ce que je viens de tartiner pourrait en fait être résumé de manière assez concise : Si vous êtes un automobiliste, que vous klaxonnez en agglomération juste parce que vous êtes fatigué, et/ou stationnez sur les passages piétons (même quelques secondes), ne vous cherchez pas d’excuses : vous êtes un con et vous me faites chier.

C’était vendredi, merci de votre attention, et bon week-end à vous.

Notes :

  1. Mais bon, pour une fois que je peux illustrer mon blog avec des photos de chats. [retour]

Optional translation form for I18n objects with Symfony and Doctrine

Scaffolding: Not just for construction workers anymore
Creative Commons License photo credit : kevindooley

There are people around here claiming that the Symfony form framework is a gift from the gods. One might say that this statement is a bit exaggerated, but it’s true that the release of the 1.3/1.4 version of the Symfony brought many improvements, and yes, the form framework can save you a lot of time.

In one of my current project, I had to developp some basic CMS-like features. In a back-office, an admin should be able to writes different kind of articles (news, interviews…). Hey ! That’s a job for inheritance ! But wait, there’s more. Articles can be translated in several languages. Easy, with the Doctrine I18n behavior.

One last requirement : article translations are optional. One might publish an article in english, an other in french, and a third in both languages. This is a little more tricky, so let’s see how to do this.

Some references

Before we start, here are the wonderful ressources I read to get my way out :

http://prendreuncafe.com/blog/post/2009/11/29/Embedding-Relations-in-Forms-with-Symfony-1.3-and-Doctrine
http://symfonyguide.wordpress.com/2009/09/28/symfony-forms-saving-process/
http://vousavezchoisi.com/img/formsdiagram.png
http://www.symfony-project.org/more-with-symfony/1_4/en/06-Advanced-Forms#chapter_06_ignoring_embedded_forms
http://www.ze-technology.com/2009/06/05/tests-fonctionnels-avec-symfony-gerer-lidentification/

Let’s start with the schema

Article:
  actAs:
    Timestampable: ~
    I18n:
      fields: [ title, body ]
      actAs:
        Sluggable: { fields: [ title ], uniqueBy: [ lang, title ] }
 
  columns:
    title: { type: string(255), notnull: true }
    body: { type: clob, notnull: true }
    author: { type: string(255), notnull: false }
 
News:
  inheritance:
    extends: Article
    type: concrete

As it’s friday, I will also give you some fixtures :

News:
  n1:
    author: 'Fantomas'
    Translation:
      fr:
        title: 'OK, l''Ipad est sorti. Vous allez me foutre la paix maintentant ?'
        body: |
          Vous commencez serieusement à me gonfler avec vos articles et vos tweets sur
          une techno inutile et bardée de DRM.
 
  n2:
    author: 'Garcin Fony'
    Translation:
      fr:
        title: 'Sinon, à part ça, ça va ?'
        body: |
          Allez, pour me calmer, je vais me refaire un p'tit café

Let’s build the whole thing, and generate the admin in the same time.

php symfony doctrine:build --all --and-load
php symfony generate:app backend
php symfony doctrine:generate-admin backend News

Check the module admin we’ve just build. Click on the first «  edit  » button. Wait a minute ? The I18n fields are just missing, how are we gonna edit our articles ? If you’re not familiar with the I18n behavior, you must know that the content is split into two tables. The new table, «  article_translation  », holds all the I18n fields.

mysql> SELECT * FROM news;
+----+-------------+---------------------+---------------------+
| id | author      | created_at          | updated_at          |
+----+-------------+---------------------+---------------------+
|  1 | Fantomas    | 2010-01-29 12:14:46 | 2010-01-29 12:14:46 | 
|  2 | Garcin Fony | 2010-01-29 12:14:46 | 2010-01-29 12:14:46 | 
+----+-------------+---------------------+---------------------+
 
mysql> SELECT id, lang, title FROM news_translation;
+----+------+------------------------------------------------------------------+
| id | lang | title                                                            |
+----+------+------------------------------------------------------------------+
|  1 | fr   | OK, l'Ipad est sorti. Vous allez me foutre la paix maintentant ? | 
|  2 | fr   | Sinon, à part ça, ça va ?                                     | 
+----+------+------------------------------------------------------------------+

So, to be able to edit our translations, you have to embed the I18n forms, using the embedI18 function. Let’s do that.

// lib/form/doctrine/NewsForm.class.php
class NewsForm extends BaseNewsForm
{
  /**
   * @see ArticleForm
   */
  public function configure()
  {
    parent::configure();
    $this->embedI18n(array('fr', 'en'));
  }
}

Reload the edit page. Now you can edit your news in french and english. Pretty cool, isn’t it ?

Let’s refactor this mess

Since now, it’s been pretty easy. However, the code we’ve written is bad. It’s bad, for the following reasons :

  1. Each time we will add a new article type (e.g. interview, etc.), we will have to update it’s configure method  ;
  2. Each time we will change the available languages (to add a new one, or disable an existing one), we will have to edit every forms  ;

Fortunately, since Symfony 1.3, the form inheritance follow the model structure. Notice that NewsForm extends BaseNewsForm, which extends ArticleForm.

Let’s refactor our code :

# config/app.yml
all:
  cultures:
    enabled:
      fr: Français
      en: English
// lib/form/doctrine/NewsForm.class.php
 
// Revert the changes we added there
class NewsForm extends BaseNewsForm
{
  /**
   * @see ArticleForm
   */
  public function configure()
  {
    parent::configure();
  }
}
 
// lib/form/doctrine/ArticleForm.class.php
class ArticleForm extends BaseArticleForm
{
  /**
   * Available languages
   *
   * @var array $languages
   **/
  protected $langages;
 
  public function configure()
  {
    $this->languages = sfConfig::get('app_cultures_enabled');
 
    $langs = array_keys($this->languages);
 
    $this->embedI18n($langs);
    foreach($this->languages as $lang => $label)
    {
      $this->widgetSchema[$lang]->setLabel($label);
    }
  }
}

Reload the edit form. Now, no matter how many articles sub-types you add, you still can configure available languages from one place.

Translation edition

Let’s try our brand new admin module by editing a news. If you used the fixtures I provided, you should have a french article, with no english translation. Try to update the french version, and click on the ‘Save’ button.

This is a miserable failure. We can’t save the form, because the english fields are required. We’re stuck, we have to fill every translation for an article before we can save it. Wouldn’t it be nice if we could make a translation form optional ? Let’s add a requirement : if every fields in a translation form are empty, it just should be ignored.

To achieve this goal, we will update the doBind method. Here’s the new ArticleForm class.

// lib/form/doctrine/ArticleForm.class.php
class ArticleForm extends BaseArticleForm
{
  /**
   * Available languages
   *
   * @var array $languages
   **/
  protected $langages;
 
  public function configure()
  {
    $this->languages = sfConfig::get('app_cultures_enabled');
 
    $langs = array_keys($this->languages);
 
    $this->embedI18n($langs);
    foreach($this->languages as $lang => $label)
    {
      $this->widgetSchema[$lang]->setLabel($label);
    }
  }
 
  /**
   * Cleans and binds values to the current form
   *
   * Ignore i18n forms when all their fields are empty
   *
   * @see sfForm::doBind
   **/
  protected function doBind(array $values)
  {
    foreach($this->languages as $lang => $label)
    {
      if($this->embeddedI18nFormIsEmpty($values[$lang]))
      {
        unset(
          $values[$lang],
          $this[$lang]
        );
      }
    }
 
    parent::doBind($values);
  }
 
  /**
   * Check if every fields, except for id and lang, are empty
   **/
  protected function embeddedI18nFormIsEmpty(array $values)
  {
    foreach($values as $key => $value)
    {
      if(in_array($key, array('id', 'lang')))
        continue;
 
      if('' !== trim($value))
      {
        return false;
      }
    }
    return true;
  }
}

In the overidden doBind method, we check, for every I18n form, if some values has been submited. If not, we just unset the corresponding fields.

Reload the edit form, update the french translation, leave the english field empty, and click on ‘Save’. Hurray, the forms is saved correctly.

However, you should keep reading, because you’re gonna have a surprise. Let’s look into the database.

mysql> SELECT id, lang, slug FROM news_translation;
+----+------+--------------------------------------------------------------+
| id | lang | slug                                                         |
+----+------+--------------------------------------------------------------+
|  1 | en   |                                                              | 
|  1 | fr   | ok-l-ipad-est-sorti-vous-allez-me-foutre-la-paix-maintentant | 
|  2 | fr   | sinon-a-part-ca-ca-va                                        | 
+----+------+--------------------------------------------------------------

Something went wrong ! Somewhere in the process, Symfony created an empty translation object for our article. Notice the void «  slug  » field ? If you try to edit another news, you will receive a duplicate content error. WTF ?

I must admit that I don’t fully understand why this happens (sometimes I think I do, and a few minutes later, I realize I don’t). However, here’s the solution to overcome this problem.

// lib/form/doctrine/ArticleForm.class.php
 
  // Add this at the beginnig of the class:
  /**
   * I18n ignored forms
   **/
  protected $I18nFormsIgnored = array();
 
  // update the doBind method:
  /**
   * Unset i18n forms values when every field is empty
   **/
  protected function doBind(array $values)
  {
    foreach($this->languages as $lang => $label)
    {
      if($this->embeddedI18nFormEmpty($values[$lang]))
      {
        $this->I18nFormsIgnored[] = $lang;
        unset(
          $values[$lang],
          $this[$lang]
        );
      }
    }
 
    parent::doBind($values);
  }
 
  // And override the doUpdateObject method:
  /**
   * Updates the values of the object with the cleaned up values.
   *
   * @param  array $values An array of values
   *
   * @see sfFormDoctrine::doUpdateObject()
   */
  protected function doUpdateObject($values)
  {
    parent::doUpdateObject($values);
 
    foreach($this->I18nFormsIgnored as $lang)
    {
      unset($this->object->Translation[$lang]);
      unset($values[$lang]);
    }
  }

Delete the useless line in the mysql database, so we can start on a clean base. Once more, reload the edit form, leave the english translation empty, save the form, et voilà ! The form is saved, the translation is updated, and no empty line is added in the table.

Adding and deleting a translation

Just to be sure, add some data in the english translation title, and try to save the form. You should get a «  body is required  » error, which is the expected behavior. Try to fill correctly the english translation, and save again. Check that the english translation is correctly added in the database. Erase all french fields, save one more time, and check that the french translation is definitly removed from the DB (no dummy entry).

Well, it seems that our form is pretty functional. One last thing : there is still a «  slug  » field in each translation form, that you might want to unset. Easy, just edit the ArticleTranslationForm.class.php file :

// lib/form/doctrine/ArticleTranslationForm.class.php
 
class ArticleTranslationForm extends BaseArticleTranslationForm
{
  public function configure()
  {
    unset($this['slug']);
  }
}

Edit a news. If you’re using Symfony 1.{3,4}.1, you’ll see that the damn slug field is still there. It’s a Symfony bug. Indeed, the translations forms don’t follow the inheritance schema. Notice that BaseNewsTranslationForm extends BaseFormDoctrine instead of ArticleNewsTranslation.

You’ll have to manually change that, however, this will be overidden each time you rebuild your forms.

// lib/form/doctrine/base/BaseNewsTranslationForm.class.php
// Replace
abstract class BaseNewsTranslationForm extends BaseFormDoctrine
 
// with
abstract class BaseNewsTranslationForm extends ArticleTranslationForm
 
// lib/form/doctrine/NewsTranslationForm.class.php
// Edit the configure method
class NewsTranslationForm extends BaseNewsTranslationForm
{
  public function configure()
  {
    parent::configure();
  }
}

Now, the slug field should disapear. At the present moment, the bug is still open.

Let’s test everything

As it’s friday, I feel generous, so as a bonus, I will give you the functional tests to cover our magical form. Enjoy.

// lib/sfBackendTestFunctional.class.php
/**
 * This class is used to run functional tests in a secured backend
 *
 * Takes care of the login action, and the fixtures loading
 */
class sfBackendTestFunctional extends sfTestFunctional
{
  public function __construct($browser, $lime = null, $testers = array())
  {
    parent::__construct($browser, $lime, $testers);
  }
 
  /**
   * Perform user authentication
   *
   * @param   array of String         $user_data
   * @return  sfGuardTestFunctional   $this
   */
  public function signin($user_data)
  {
   return $this->info(sprintf('Login as "%s"', $user_data['username']))->
     get('/login')->
     click('sign in', array('signin' => $user_data), array('_with_csrf' => true))->
 
     with('form')->begin()->
       hasErrors(false)->
     end()->
 
     with('user')->begin()->
       isAuthenticated(true)->
     end()->
 
     with('request')->begin()->
       isParameter('module', 'sfGuardAuth')->
       isParameter('action', 'signin')->
     end()->
 
     with('response')->begin()->
       isRedirected()->
       followRedirect()->
       end()
    ;
  }
  /**
   * Load project fixtures
   **/
  public function loadData()
  {
    Doctrine::loadData(sfConfig::get('sf_data_dir').'/fixtures');
    return $this;
  }
}
// test/functional/backend/newsActionsTest.php
 
include(dirname(__FILE__).'/../../bootstrap/functional.php');
 
$browser = new sfBackendTestFunctional(new sfBrowser());
$browser->loadData();
$browser->setTester('doctrine', 'sfTesterDoctrine');
 
$languages = sfConfig::get('app_cultures_enabled');
 
$news = Doctrine::getTable('News')->
  createQuery('n')->
  leftJoin('n.Translation t')->
  andWhere('t.lang = ?', 'fr')->
  orderBy('n.id', 'asc')->
  fetchOne();
 
$editUrl = sprintf('/news/%s/edit', $news->getId());
 
$browser->
  // Uncomment this if your backend is protected by a login
  //signin(array(
  //  'username' => 'admin',
  //  'password' => 'admin'
  //)))->
  get('/news')->
  with('response')->begin()->
    isStatusCode(200)->
  end()->
 
  info('1 - News list')->
  with('response')->begin()->
    checkElement('h1', '/News List/')->
    checkElement('body', '/Fantomas/')->
  end()->
 
  info('2 - News edit')->
  info('  2.1 - I18n forms for all languages are embedded')->
  get($editUrl)->
  with('response')->begin()->
    isStatusCode(200)->
    checkElement('input[name$="[title]"]', count($languages))->
  end()->
 
  info('  2.2 - I18n forms are validated')->
  get($editUrl)->
  click('Save', array('news' => array(
    'en' => array(
      'title' => 'toto',
      'body' => ''
    ))
    ), array('_with_csrf' => true)
  )->
  with('form')->begin()->
    hasErrors(1)->
    isError('en[body]', 'required')->
  end()->
 
  info('  2.3 - Empty forms are validated')->
  get($editUrl)->
  click('Save', array('news' => array(
    'en' => array(
      'title' => '',
      'body' => ''
    ))
    ), array('_with_csrf' => true)
  )->
  with('form')->begin()->
    hasErrors(0)->
  end()->
 
  info('  2.4 - Translations are updated')->
  get($editUrl)->
  click('Save', array('news' => array(
    'fr' => array(
      'title' => 'toto tutu tata',
      'body' => 'riri fifi loulou'
    ))
    ), array('_with_csrf' => true)
  )->
  with('form')->begin()->
    hasErrors(0)->
  end()->
 
  with('doctrine')->begin()->
    check('NewsTranslation', array(
      'id' => $news->getId(),
      'lang' => 'fr',
      'title' => 'toto tutu tata'
    ))->
    info('  2.5 - No empty translation is created')->
    check('NewsTranslation', array(
      'id' => $news->getId(),
      'lang' => 'en',
    ), false)->
  end()->
 
  info('  2.6 - New translations can be added')->
  get($editUrl)->
  click('Save', array('news' => array(
    'en' => array(
      'title' => 'toto tutu tata',
      'body' => 'riri fifi loulou'
    ))
    ), array('_with_csrf' => true)
  )->
 
  with('form')->begin()->
    hasErrors(0)->
  end()->
 
  with('doctrine')->begin()->
    check('NewsTranslation', array(
      'id' => $news->getId(),
      'lang' => 'en',
      'title' => 'toto tutu tata'
    ))->
  end()->
 
  info('  2.7 - Existing translations can be deleted')->
  get($editUrl)->
  click('Save', array('news' => array(
    'fr' => array(
      'title' => '',
      'body' => ''
    ))
    ), array('_with_csrf' => true)
  )->
  with('form')->begin()->
    hasErrors(0)->
  end()->
 
  with('doctrine')->begin()->
    check('NewsTranslation', array(
      'id' => $news->getId(),
      'lang' => 'fr',
    ), false)->
  end()
;

Here we are. Hope you find this helpful. I thought some kind of requirement would be pretty common, however, I didn’t find any directly related howto’s. If you can think of any other way to achieve this, please, let me know.