Requête natives avec Symfony 2 et doctrine.

Quand on est développeur, à chaque fois qu’on résout un problème, c’est pour en trouver un autre derrière.

Je sortais à peine de ma dernière galère que voilà que j’ai replongé dans une nouvelle : les requête avec Doctrine.

Oui, parce que Doctrine, c’est beau, ça brille, ça donne envie. Mais… Non. Le nouveau constructeur de requête ne tiens pas ses promesses et on atteint très très vite ses limites. Contrairement à son ancienne version qui montrait une grande robustesse et une grande simplicité, vous aurez probablement très vite besoin d’utiliser avec la version 2 ce qu’on appelle des « requêtes natives » (native query dans la langue de Shakespeare).

mais bon, comme vous vous en foutez un peu de ma vie, ne prolongeons pas plus cette introduction.

Le constructeur de requêtes

Doctrine nous offre comme outil principal le constructeur de requête (query builder). Il est très simple et fonctionne très bien pour des requête basique :

$query = $this->createQueryBuilder('u')
->addSelect("SUM(CASE WHEN kComp.slug is not null THEN 1 ELSE 0 END)")
->leftJoin('u.keywords', 'kComp', \Doctrine\ORM\Query\Expr\Join::WITH, '1 = 1')
->where('u.id <> :userId')
->groupBy('u.id')
->orderBy('u.lastLogin', 'ASC')
->setParameter('userId', $_search->getUser()->getId())
;

Donc comme on peut le voir, ça fourni quand même toutes les fonctionnalités de bases :

  • ajout de champs personnalisés,
  • jointures strictes via innerJoin ou jointures flottantes via leftJoin, avec possibilités d’ajouter des conditions supplémentaire via le mot clef « WITH »
  • les conditions where
  • les classiques « order by »
  • et enfin l’utilisation de paramètres personnalisés avec la méthode « setParameter » qui remplacera automatiquement les variables par leurs valeurs.

Seulement, de nombreuse fonction ne sont pas reconnues, comme par exemple le « IF » mysql. De plus, Doctrine ne permet pas (plus) d’écraser les conditions ON ce qui s’avère beaucoup plus bloquant.

C’était pourtant ce qui rendaient Doctrine 1 vraiment plaisant à utiliser, on pouvait simplement écraser les jointures ON avec « ->innerJoin(‘u.Relation ON u.condition = « new_condition »‘« . Ce n’est malheureusement plus possible.

Alors comment faire quand on veut exécuter une requête un poil plus intéressante ? Et bien c’est le chapitre suivant…

Les requêtes natives

Doctrine nous fournie pour se faire la possibilité de lancer directement des requêtes natives. Malheureusement, elles sont beaucoup moins « user friendly », surtout si vous utilisez Symfony 2.3 ou inférieur.

La première chose à faire dans ce cas, c’est de mettre à jour l’ORM vers la version 2.4. En effet, Symfony utilise une version inférieur de l’ORM par défaut, et elle est bourrée de bugs au niveau des requêtes natives, ce qui les rend inutilisables.

Ouvrez donc le fichier composer.json à la racine de votre projet et modifiez-le pour redéfinir les dépendances tel que :

"require": {
...,
"doctrine/orm": "~2.4",
...,

Ensuite, ouvrez un terminal et lancez la commande ./composer.phar update pour effectuer la mise à jour de la nouvelle version de l’ORM Doctrine.

Maintenant, nous pouvons enfin créer notre requête native :

// init vars
$where = '';
$parameters = array();

// tout d'abords, nous créons le builder pour mapper les résultats en entités doctrine compréhensible :
$rsm = new ResultSetMappingBuilder($this->_em);
$rsm->addRootEntityFromClassMetadata('My\UserBundle\Entity\User', 'u'); // on définie l'entité de base qui apparaîtra dans le FROM
$rsm->addScalarResult('compatibility', 'compatibility'); // on déclare notre champ personnalisé
$rsm->addJoinedEntityFromClassMetadata('My\UserBundle\Entity\Keyword', 'kComp', 'u', 'keywords', array(
'id' => 'kCompid',
'title' => 'kComptitle',
)); // et enfin, on ajoute les relations que l'on désire récupérer. Notez qu'il faut obligatoirement renommer manuellement les champs qui ont le même nom que ceux que l'on pourrait trouver dans l'entité principale, ici "id" et "title".

// maintenant, nous allons définir des conditions manuellement
if ($_search->getTitle() !== null) {
$where .= ' AND u.title = :title';
$parameters['title'] = $_search->getTitle();
}

// On rédige la requête en sql. On insère l'objet de mapping des résultat en tant que select et les conditions WHERE. Remarquez qu'on peut maintenant tout à fait écraser les jointures et en créer plusieurs.
$sql = 'SELECT ' . $rsm . ', SUM(CASE WHEN kComp.slug = kSearch.slug THEN 1 ELSE 0 END) as compatibility
FROM `fos_user` u
LEFT JOIN keyword kSearch ON kSearch.user_id = :userId
LEFT JOIN keyword kComp ON kComp.user_id = u.id AND kComp.slug = kSearch.slug
WHERE u.id <> :userId ' . $where . '
GROUP BY u.id
ORDER BY u.last_activity ASC'
;

// Il ne reste plus qu'à créer l'objet de requête native et à lui passer les paramètres qu'on a défini plus haut.
$query = $this->_em->createNativeQuery($sql, $rsm);
$query->setParameters($parameters);
$query->setParameter('userId', $_search->getUser()->getId());

Voilà, maintenant vous pouvez récupérer le résultat de votre requête correctement hydraté en objet Doctrine !


Remerciement à FrozenFire du channel irc #doctrine pour son aide 😉

 

Accéder au repository depuis votre entity dans Symfony 2

… C’est impossible. Et oui, moi aussi ça m’a fait un choc.

Pourtant, mon problème initial me semblait particulièrement simple et logique : j’ai une entité User qui possède plusieurs entités Pictures, et je voulais créer une propriété personnalisée User->getAvatar() récupérant une seule entité Picture qui aurait été définie comme « isMain ».

Seulement non. on ne peut pas faire :

<?php // Entity\User
public function getAvatar() {
	return $this->getRepository('UserBundle:Picture')->getUserAvatar($this->id)->getQuery()->getSingleResult();
}

et donc on ne peut pas non plus faire dans twig :

{{ user.avatar }}

… Alors comment allons nous faire ?

Et bien j’aimerai vous dire que c’est simple. Mais ça ne l’ai pas.

Le tutoriel

Il faut créer un service UserManager et une extension twig, en plus bien sûr de notre fonction dans le repository.

Commençons par notre fonction dans le répository PictureRepository :

<?php // Entity\PictureRepository :
public function getUserAvatar($_id) {
	$query = $this->createQueryBuilder('p')
		->where('p.user = :userId')
		->setParameter(':userId', $_id)
		->orderBy('p.isMain', 'DESC') // j'utilise OrderBy dans le cas ou il y aurais effectivement des pictures associée, mais aucune définie comme main
	;

	return $query;
}

Voilà pour la fonction qui va nous permettre de récupérer l’avatar. Remarquez que l’on renvoie seulement la requête, et non son résultat. Cela permet de pouvoir réutiliser la même requête ailleurs si on a besoin d’y ajouter de nouvelles conditions par exemple.

Nous allons maintenant créer le service UserManager qui va se charger d’appeler les fonctions des répositories :

<?php // Entity\UserManager

namespace MyBundle\Entity;

use Doctrine\ORM\EntityManager;
use MyBundle\Entity\User;

class UserManager {

	protected $em;

	public function __construct(EntityManager $em) {
		$this->em = $em;
	}

	/**
	* return Picture is main for user
	* @param MyBundle\Entity\User $user
	* @return MyBundle\Entity\Picture
	*/
	public function getAvatar(User $user) {
		$avatar = $this->em->getRepository('MyBundle:Picture')->getUserAvatar($user->getId())->setMaxResults(1)->getQuery()->getOneOrNullResult();
		return !$avatar ? new Picture() : $avatar;
	}

}

Dans notre service, nous avons donc bien la fonction getAvatar() qui va aller chercher notre méthode getUserAvatar créée plus tôt. Remarquez également que notre constructeur prend en paramètre le manager d’entité de notre application.

Pour cela, il faut informer symfony que notre nouvelle classe UserManager est bien un service :

# MyBundle\Resources\config\services.yml
services
  my_user.user_manager:
    class: MyBundle\Entity\UserManager
    arguments: ["@doctrine.orm.entity_manager"]

Notre classe UserManager sera maintenant accessible dans les autres service et dans vos contrôleurs.

Il ne nous reste plus qu’à créer notre extension twig pour pouvoir accéder à notre objet Avatar dans nos templates :

<?php // MyBundle\Twig

namespace MyBundle\Twig;

use MyBundle\Entity\UserManager;

class UserExtension extends \Twig_Extension {

	protected $manager;

	public function __construct(UserManager $manager) {
		$this->manager = $manager;
	}

	public function getFunctions() {
		return array(
			'avatar' => new \Twig_Function_Method($this, 'avatarFunction'),
		);
	}

	/**
	* Function rendering the avatar Picture object of user
	* @param User $user
	* @return Picture
	*/
	public function avatarFunction($user) {
		return $this->manager->getAvatar($user);
	}

	public function getName() {
		return 'user_extension';
	}

}

Et finallement, on en fait également un service pour que Twig soit informé de cette nouvelle extention :

my_user.user_extension:
  class: MyBundle\Twig\UserExtension
  arguments: ["@my_user.user_manager"]
  tags:
    - { name: twig.extension }

Enfin, notre objet « Avatar » est complètement accessible, y compris dans Twig, et nous pouvons accéder à ses propriétés, comme le chemin vers le fichier image de notre Picture :

<img alt="" src="{{ avatar(user).webpath }}" />

Mes conclusions

Tout d’abord, force est de constater que le code est considérablement alourdi. On choisis d’utiliser un framework comme Symfony pour gagner du temps sur le développement, mais quand on voit ça, on ne peut que ce demander si on y arrive vraiment… On vient d’écrire plus de 30 lignes de code pour une fonction qui en prend normalement 3, et ce dans 4 fichiers différents…

De plus, ça rend également plus complexe la maintenabilité du code… Je n’ose imaginer ce qu’il se passerait si jamais on devait avoir des mises à jours à ce niveau.

Enfin je reviens sur le cœur du problème. Symfony nous impose ce système en prétendant que les entités ne doivent être utilisées uniquement pour représenter strictement le schéma de la base de donnée. Hors sur ce point, on voit qu’ils se contredisent eux-même dans leurs tutoriels en y créant de méthodes personnalisées : http://symfony.com/doc/current/cookbook/doctrine/file_uploads.html.

Quelle est donc la différence entre créer une méthode « getWebPath » et une méthode « getAvatar » qui fait appel à un repository ?

Vous me répondrez peut-être qu’un repository fait appel à la base de donnée via une requête.

Il me semble que c’est pourtant la fonction première d’une entité. Toutes ses propriétés résultent d’un appel à la BDD.

C’est le genre de choses qui étaient tellement simple avec Symfony 1 et qui font de Symfony 2 un véritable enfer.

Note complémentaire

Si ce code est fonctionnel, notez qu’il reste encore un hic : à chaque fois que vous appellerez votre nouvelle fonction avatar(), Doctrine va à nouveau exécuter la même requête, ce qui est inutile. Pour éviter cela, vous pouvez soit utiliser le cache de résultat fourni par Doctrine, soit créer quand même une propriété publique avatar à votre objet user et la remplir lors du premier appel de votre service UserManager->getAvatar().


Mes plus chaleureux remerciement à amenophis du chan #symfony sur IRC qui m’a expliqué le truc 😉

Jitsi

Depuis mon passage au CRI de l’UVSQ, je suis devenu un fervent défenseur des logiciels libres.

Je vais aujourd’hui vous parler de Jitsi, l’équivalent libre de Skype.

Pourquoi devrait-on abandonner Skype ?

Skype rempli effectivement ses fonctions. En effet, vous pouvez chatter, démarrer des conversations audios avec un ou plusieurs interlocuteurs, partager votre écran ou bien effectuer des visioconférences.

Mais skype, ce n’est pas que ça. C’est une vraie boite noire. Personne ne sais exactement ce qu’il fait, mais… On sait à quoi il accède. Notamment à tous vos historiques web, ce qui lui permet de connaître votre vrai nom, vos adresses mails, etc. Bref, tout ce dont a besoin un bon spammeur. D’ailleurs, Skype ne s’en cache pas vraiment, puisque vous acceptez qu’il récolte toutes ces données quand vous acceptez les CGU de skype.

Si ce côté technique ne vous pré-occupe pas des masses, l’autre point sensible est l’éthique plus que douteuse de Skype. Skype est une boite noire. Ce ne sont pas les conversations qui sont codées, mais le logiciel lui-même. Tout ce que vous dites ou écrivez reste parfaitement lisible par les employés de Skype qui possèdent la clé de décryptage. Cela entraîne quelques problèmes pour les utilisateurs ayant la malchance de vivre dans des pays comme la Chine ou la Russie

Les solutions

C’est pour cela que des petits gars bien sympathiques et très débrouillards ont créé leurs propres logiciels avec un code source ouvert !
L’intérêt d’un code source ouvert, c’est qu’on sait parfaitement ce que fait un logiciel.
Vous trouverez une liste des logiciels libres équivalent à Skype sur l’excellent site de framasoft.

Jitsi

Parmi l’ensemble des logiciels de VOIP, mon choix se porte sur Jitsi. Offrant toutes les fonctionnalités de Skype si on utilise un compte gmail ou SIP (via iptel ou ekiga par exemple), il est également un agrégateur de comptes de communication.

En effet, vous pouvez gérer en simultané à la fois votre compte gmail, msn, sip, facebook et j’en passe, la liste est longue !

Ce logiciel développé en Java est disponible sur toutes les plateformes, que vous utilisiez un système Windows, Mac, Linux ou Unix et permet une communication transparente entre chacun.

Léger et facile d’utilisation, ce logiciel ne pourra que vous satisfaire 😉

Tableaux et positions absolues

Les graphistes…

Qui a déjà eu à travailler avec un graphiste très « enthousiaste » le sait : l’intégration, ça peut vite devenir galère.

« ha bah tiens, je vais mettre une image très jolie qui sort d’une cellule de mon tableau »… sauf que c’est typiquement le genre de chose qui n’est pas prévu.

En effet, un élément de tableau ne peut pas être affiché en position:relative. De même, si comme moi vous êtes un adepte de la propriété display:table-cell pour remplir automatiquement l’espace disponible, vous serait confronté à ce problème.

Pas de panique, il n’est pas insurmontable, voici la marche à suivre.

Commençons par créer notre tableau (ou élément table-cell) :

<table>
  <tr>
    <td>
      <img src='monimage.png' alt='ma super image'style="position:absolute; top:20px; left:50px"/>
      un text à côté
    </td>
  </tr>
</table>

Donc un élément table cell ne pouvant être en position relative, inutile d’espérer placer correctement notre image. La solution est en fait très simple (bien qu’un peu gênante pour les puristes) : rajouter une div qui elle aura une position relative :

<table>
  <tr>
    <td>
      <div style="position:relative">
        <img src='monimage.png' alt='ma super image'style="position:absolute; top:20px; left:50px"/>
        un text à côté
      </div>
    </td>
  </tr>
</table>

Vous voilà paré pour affronter les graphistes les plus inventifs !

Symfony2, c’est plus du tout magique.

Symfony 1.X, c’était magique !

À la base, j’aimais pas trop le PHP. Outre le fait que c’est un langage que je n’ai jamais apprécié,  que se soit à cause de sa lourdeur générale que de sa syntaxe permissive (yes, tu fais n’importes quoi, tu as n’importes quoi !), créer un site web était pour moi une vraie plaie. Il fallait tout le temps vérifier les données, rajouter des filtres pour empêcher les injections de codes, toujours réécrire les mêmes bout de code à chaque fois…

Et puis j’ai découvert… Symfony !

Et là, le plaisir : on crée son schéma de BDD, symfony doctrine:build-all… Et c’est fini.

Symfony créait tout seul les modules CRUD, l’admin, les forms, les filters, la connexion à la base est sécurisée, symfony et doctrine s’occupaient de tout.

Certes, tout n’était pas parfait. Il restait notamment deux lacunes très contraignantes pour moi : l’impossibilité de surcharger un plugin avec un autre plugin, et le fait que Symfony1.X ne gérait pas l’imbrication de formulaires multiples.

  Symfony2 : la chute

Avec une base de travail aussi excellente que Symfony1.X, on aurait pu s’attendre à un framework inégalable. Symfony2 aurait pu être le paradis des développeurs web !

Hélas, Sensio a fait un autre choix : repartir de 0.

Adieu le fameux « schema.yml ». Adieu la génération des modèles, des formulaires, des filtres. Adieu l’admin-générator.

Bref, avec l’intention, certes louable, de vouloir apporter des pratiques de développement encore meilleures que chez son grand frère, on a vu disparaitre tout ce qui faisait la puissance de ce dernier.

Alors oui, c’est plus performant. Oui, le code est plus propre… mais le plaisir n’est plus là.

La première chose qui frappe, c’est la documentation. Alors que Symfony1 présentait une documentation exemplaire, traduite dans les langues majeures, toujours à jour, avec un tutoriel retraçant la création d’une application en conditions réelles de A à Z en 24 jours, Symfony2 ne propose plus qu’une documentation succincte, uniquement en anglais, et pas toujours à jours puisque le framework évolue en permanence.

La seconde chose qui frappe est donc la disparition du fameux fichier schema.yml. La nouvelle politique est qu’il faut créer soit-même les entités (qui correspondent à des tables de la base de donnée, par exemple l’entité « user » correspond à la table « user »). Chaque entité est un fichier php et Symfony les gère via un système d’annotation:

/**
 * @ORM\Column(type="string", length=255)
 *
 * @Assert\NotBlank(message="Please enter your firstname.", groups={"Registration", "Profile"})
 * @Assert\MinLength(limit="3", message="The name is too short.", groups={"Registration", "Profile"})
 * @Assert\MaxLength(limit="255", message="The name is too long.", groups={"Registration", "Profile"})
 */
protected $firstname;

Je suis peut-être vieux-jeu, mais pour moi, les commentaires sont fait pour « commenter », ils sont destinés à la documentation. D’ailleurs vous remarquerez très vite que votre IDE est inutile puisqu’il ne reconnait pas les annotations…

Bref, tout est sur ce modèle du « do it yourself ». Tu veux le formulaire de ton entité ? fais le toi même. Tu veux le contrôleur, idem.

Les commandes proposées par Symfony2 se contentent du minimum syndicale.

Toutefois, tout n’est pas noir, ça reste un bon framework sur le papier, notamment grâce aux nombreux bundles disponibles, comme le fameux FOSUserBundle qui propose un système très complet et complètement paramétrable pour gérer les utilisateurs. De plus, il est maintenant très facile de surcharger un bundle avec un autre bundle. Enfin il semble que les formulaires imbriqués soit théoriquement bien gérés (quoi que chez moi, sur la version 2.1 en pre-release, c’est loin d’être le cas :p).

Conclusion

Symfony2 a deux problèmes majeurs à mes yeux : premièrement il perd son côté « magique ». Et du coup tout son intérêt. Certes, on gagne en performance, on gagne en propreté, mais on continue d’utiliser PHP. Hors, PHP reste (et restera probablement encore longtemps) un langage médiocre. En perdant sa magie, Symfony a du coup perdu tout son intérêt en face des frameworks réellement performants comme Play! (Java/Scala), Django (Python) ou Ruby On Rail (dois-je préciser ?) qui, avec une logique similaire, resteront toujours incomparablement plus performants.

Deuxièmement, Symfony2 a été relâché beaucoup trop tôt. Encore aujourd’hui, les formulaires, qui sont sûrement le composant majeur de Symfony sont en constante évolution. Certes, j’utilise la pre-release, mais cela me permet de constater l’évolution constante de ce framework qui est loin d’être abouti.

Script JQuery pour gérer les embbed forms sous Symfony2

Bonjour,

Dans cet article je vais simplement partager avec vous mon petit script JS pour pouvoir rapidement utiliser les embbed forms de Symfony2. En effet, Symfony2 n’est plus du tout magique et choisi de nous laisser nous dépatouiller avec les embbed forms côté client (et côté serveur, mais c’est un autre sujet que j’aborderais peut-être dans un prochain article).

Pour l’utiliser :
gestion d’ajout/suppression :

  • définissez la classe with_default pour créer un objet vide si aucun n’existe.
  • définissez la classe with_empty pour créer un objet vide quelque soit le cas
  • définissez data-number-to-add pour définir le nombre d’objet vide à ajouter lors du clique sur le bouton d’ajout.
'class' => 'with_default',
'data-number-to-add' => 1

option uniqify (utile par exemple pour cocher une propriété « image principale » sur une gallerie) : définissez la classe uniqify ainsi qu’un data-uniqify-id pour n’autoriser qu’un seul élément à être coché à travers plusieurs formulaire embarqués :

'class' => 'uniqify',
'data-uniqify-id' => 'galleryFormIsMain',

Bref, ne vous laissons pas attendre plus longtemps, voici le script :

// Init vars
addingIndexes = new Array();

$(document).ready(function() {
	
	// Handle uniqifying
	$('input.uniqify').on('click', function(event) {
		console.log('uniqify : ' + $(this).data('uniqify-id'));
		$("input[data-uniqify-id='" + $(this).data('uniqify-id') + "']").prop('checked', false);
		$(this).prop('checked', true);
	});

	///////////////////////////////////////////////////////////////////////////////////////////////
	// Embed forms fonctions
	
	// Initialisation
	/**
	 * This will always add empty object if the embedded form type has class "with_empty"
	 * This will add empty object only if no object exists if the embedded form type has class "with_default"
	 * Number of added object is defined in data-number-to-add attribute of the embedded form type
	 */
	$('div').find('*[data-prototype]').each(function(_id) {
		// init vars
		existingObjects = false;
		// adding remove button for existing embedded forms items
		$(this).children().each(function(_id) {
			$(this).append(createRemoveButton());
			existingObjects = true;
		});
		// creating default empty object for each embedded forms
		if ($(this).hasClass('with_empty') || $(this).hasClass('with_default') && !existingObjects) {
			addObject($(this).attr('id'));
		}
		// creating add button
		$(this).parent().append(createAddButton($(this).attr('id')));
	});


	/**
	 * Function creating embedded forms items
	 * @param {type} _id
	 * @returns {undefined}
	 */
	function addObject(_id) {
		console.log("Embedded form : add object " + _id);
		// handle items indexes in an array
		if(!addingIndexes[_id]) {
			console.log('creating indexes for ' + _id);
			addingIndexes[_id] = $('#' + _id).children().length;
		} else {
			console.log('increment indexes for ' + _id);
			addingIndexes[_id]++;
		}

		// add objects
		numberToAdd = $('#' + _id).data('number-to-add');
		for (i = 0; i < numberToAdd; i++) {
			$('#' + _id).append(
					$($('#' + _id).attr('data-prototype').replace(/__name__label__/g, '').replace(/__name__/g, addingIndexes[_id])).append(createRemoveButton)
			);
		}
	}

	/**
	 * Function creating add button. You can create your own btnAdd js var in your template.
	 * @param {type} _toAddId
	 * @returns {String}
	 */
	function createAddButton(_toAddId) {
		thisBtnAdd = '<a class="add_object" data-objectid="' + _toAddId + '">Add</a>';
		if (typeof btnAdd !== 'undefined') {
			btnAdd = $.parseHTML(btnAdd);
			$(btnAdd).attr('data-objectid', _toAddId);
			thisBtnAdd = btnAdd;
		}
		return thisBtnAdd;
	}

	/**
	 * function creating remove button. You can create your own btnRemove js var in your template.
	 * @returns {String}
	 */
	function createRemoveButton() {
		thisBtnRemove = '<a class="remove_object">Remove</a>';
		if (typeof btnRemove !== 'undefined') {
			thisBtnRemove = btnRemove;
		}
		return thisBtnRemove;
	}

	// Handle add buttons' click event
	$('.add_object').click(function() {
		addObject($(this).attr('data-objectid'));
	});

	// Handle remove buttons' click event
	$('form').on('click', '.remove_object', function() {
		$(this).parent().remove();
	});
	///////////////////////////////////////////////////////////////////////////////////////////////

});

Et voilà, plus qu’à importer votre script dans votre template Twig ! 😉

Si je trouve le courage de faire un système pour laisser la possibilité de personnaliser facilement (et d’internationaliser) les boutons, je mettrais à jour cet article.

Enjoy !

Mise à jour du 10 novembre 2013 :
Le comptage du nombre d’enfant à été corrigé en cas de suppression et de rajout.
L’ajout de configuration par un jeu de classes et de data a été ajouté !
Mise à jour du 10 novembre 2013 :
L’utilisation des fonctions .live() a été remplacé par .on(), merci Thibault pour cette judicieuse remarque 😉
Mise à jour du 24 août 2014 :
l’utilisation de .on() à été mis à jour pour fonctionner avec les dernière version de jQuery et je gère à présent la création des boutons add et remove dans le template twig (donc avec internationalisation)