Pierre-Henri Cumenge
Pierre-Henri est architecte chez Theodo.
Sur Twitter : @cpierrehenri
Aujourd'hui nous allons nous intéresser aux formulaires Symfony2, côté frontend et rendu, et montrer comment ils peuvent s'intégrer à des librairies javascript pour améliorer l'expérience utilisateur en évitant le spaghetti.Js.
Prenons un exemple : Kevin arrive sur perenoelcorp.com pour y commander la dernière tablette de sa marque préférée* :
Evidemment, en ajoutant plus de modèles, le menu déroulant devient rapidement moins pratique ! Ne pourrait-on pas demander à Kévin de choisir la marque puis le modèle ?
Le data-binding et les outils existants vont nous permettre de mettre en pratique cette dernière idée de manière très simple.
Le contexte est donc similaire à celui évoqué dans l'article du jour 16 de Thibault et Maxime : nous allons voir comment adapter notre Symfony pour utiliser certaines fonctionnalités de frameworks javascript.
Le data-binding va consister à associer des éléments de la vue -ici nos deux menus déroulants- à un modèle, de sorte que toute modification de l'un soit répercutée automatiquement sur l'autre.
Il s'agit donc de data-binding à double sens ("two way bindings"), par opposition au schema classique dans lequel les modifications du modèles seraient répercutées sur la vue, mais pas le contraire. Les frameworks gérant le data-binding à double sens implémenteront en général le design pattern "Observateur", les changements de certains éléments du DOM étant scrutés par l'application. (Mais comme nous le verrons en conclusion, ce n'est pas 100% le cas pour certains).
Diverses librairies javascript spécifiques et la plupart des frameworks JS permettent de faire du data-binding : angularjs, knockoutjs, emberjs, backbonejs avec éventuellement en complément epoxyjs, rivetjs, et pas mal d'autres !
Je ne les ai pas tous testés intensivement et le but de cet article n'est pas de faire une fastidieuse démo de chacun de ces outils. Nous allons nous intéresser à la manière dont on peut personnaliser ses formulaires Symfony2 pour les faire interagir très simplement avec l'une ou l'autre de ces librairies.
La documentation sur le sujet est assez abondante, notamment dans le cookbook, voici quelques articles utiles sur le sujet :
Je ne vais pas recopier la documentation ici, mais donner quelques éléments essentiels pour la suite.
Le templating des formulaires se trouve dans le fichier https://github.com/symfony/symfony/blob/master/src/Symfony/Bridge/Twig/Resources/views/Form/form_div_layout.html.twig. Pour modifier par exemple le rendu des menus déroulants que nous avons définis plus haut, il faut surcharger le block "choice_widget" défini dans le fichier.
Nous pouvons enfin rentrer dans le vif du sujet et modifier nos formulaires pour lier le second menu déroulant de notre exemple au premier. Nous allons pour les exemples de code travailler ici essentiellement avec Knockout.js, mais les principes restent les mêmes pour les autres framework JS - sauf pour la syntaxe des exemples qui est bien-sûr à adapter !
On retrouve le pattern MVVM (Model-View-ViewModel) évoqué au jour 16 : en plus des éléments classique Modèle et Vue d'une application web statique, la partie "VueModèle" permet de gérer la logique d'affichage de la vue via un simple paramétrage du data-binding dans la vue.
Partie Vue :
{% block body %}
Bienvenue sur le site de commande au Père Noël
{{ form_start(form) }}
<div class="row">
{{ form_label(form.fanboy) }}
<select data-bind="options: BrandList, optionsText: 'name', value: brand">
</select>
</div>
<div class="row">
{{ form_label(form.tablet) }}
<div data-bind="with: brand">
<select data-bind="options: devices, optionsText: 'name', optionsValue: "id">
</select>
</div>
</div>
{{ form_end(form) }}
{% endblock body %}
Partie VueModèle
var BrandList = [
{ "name": "Apple","devices": [
{ "value": 1, "name": 'iPhone 4'},
{ "value": 2, "name" : 'iPhone 5'},
{ "value": 3, "name": 'iPad 2'},
{ "value": 4, "name": 'iPad Air'}
]
},
{ "name": "Google","devices": [
{ "value": 1, "name": 'Nexus 4'},
{ "value": 2, "name" : 'Nexus 5'},
{ "value": 3, "name": 'Nexus 7'},
{ "value": 4, "name": 'Nexus 10'}
]
}
];
var Device = function() {
this.brand = ko.observable();
}
ko.applyBindings(new Device());
A présent, la liste de choix du second menu déroulant est devenue dynamique et s'adapte au choix du premier : à chaque fois que l'on sélectionne une marque, l'observable brand est modifiée dans la VueModèle et la Vue mise à jour en conséquence :
Nous avons donc :
Cependant, pour la première étape, nous avons écrit le formulaire à la main. On ne profite du coup plus du formulaire Symfony2 que l'on avait pu définir avant d'ajouter le data-binding !
C'est ici que nous allons devoir créer une template de formulaire personnalisée.
Regardons le widget correspondant aux menus déroulants dans le form_div_layout (j'ai volontairement enlevé certains éléments non utilisés dans notre cas pour plus de lisibilité) :
{% block choice_widget_collapsed %}
{% spaceless %}
<select {{ block('widget_attributes') }}{% if multiple %} multiple="multiple"{% endif %}>
{% if empty_value is not none %}
<option value=""{% if required and value is empty %} selected="selected"{% endif %}>{{ empty_value|trans({}, translation_domain) }}</option>
{% endif %}
{% set options = choices %}
{{ block('choice_widget_options') }}
</select>
{% endspaceless %}
{% endblock choice_widget_collapsed %}
{% block choice_widget_options %}
{% spaceless %}
{% for group_label, choice in options %}
<option value="{{ choice.value }}"{% if choice is selectedchoice(value) %} selected="selected"{% endif %}>{{ choice.label|trans({}, translation_domain) }}</option>
{% endfor %}
{% endspaceless %}
{% endblock choice_widget_options %}
Il suffit donc de modifier le cas échéant le contenu du block choice_widget_collapsed en ajoutant les éléments dont knockout a besoin :
{% form_theme form _self %}
{% block choice_widget_collapsed %}
{% spaceless %}
{% if data_bind %}
<select {{ block('widget_attributes') }}{% if multiple %} multiple="multiple"{% endif %} data-bind="{{ data_bind }}">
</select>
{% else %}
<select {{ block('widget_attributes') }}{% if multiple %} multiple="multiple"{% endif %}>
{% if empty_value is not none %}
<option value=""{% if required and value is empty %} selected="selected"{% endif %}>{{ empty_value|trans({}, translation_domain) }}</option>
{% endif %}
{% endif %}
{% set options = choices %}
{{ block('choice_widget_options') }}
</select>
{% endspaceless %}
{% endblock choice_widget_collapsed %}
....
{{ form_widget(form.tablet, {'data_bind': 'options: devices, optionsText: "name", optionsValue: "id"' }) }}
...
Nous avons légèrement avancé : on profite du système de templating des formulaires Symfony, par exemple l'id est de nouveau générée automatiquement, on récupère les options du formulaire type "required", etc. De plus, cela permet d'utiliser le même formulaire dans plusieurs contextes, selon que l'on veut ou non utiliser le data-binding. Par contre, les données affichées dans le formulaire sont toujours écrites en dur dans le fichier JS...
Nous allons donc les passer depuis Symfony :
//ChristmasController.php
/**
* @Route("/chose-device", name="choseDevice")
* @Template()
*/
public function choseTablet2Action()
{
$form = $this->createForm(new DeviceType());
$$brands = array(
array(
"name" => "Apple",
"devices" => array(
array("id" => 1, "name" => "iPhone 4"),
...
)
),
array(
"name" => "Google",
"devices" => array(
array("id" => 1, "name" => "Nexus 4"),
...
)
),
);
return array(
'form' => $form->createView(),
'devices' => json_encode($brands),
);
}
On récupère alors les données dans notre code js, et le tour est joué. Evidemment l'intérêt ici est très limité, on avait des données en dur en javascript et l'on passe à des données en dur en php. Cela prend par contre tout son sens lorsque les données deviennent dynamiques, typiquement lorsqu'on récupère des entités du modèle Symfony2.
Par exemple, créons des entités Brand et Device et utilisons des formulaires de type "entity".
//DeviceType.php
$builder->add('device', 'entity', array(
'label' => 'Quel appareil souhaites-tu te faire offrir par le Père Noël ?',
"class" => 'AcmeDemoBundle:Device',
"property" => 'name',
));
$builder->add('fanboy', 'entity', array(
'label' => 'Quelle est ta marque préférée ?',
'class' => 'AcmeDemoBundle:Brand',
"property" => 'name',
));
Pas besoin de changer quoi que ce soit pour l'instant dans la template puisque le type "entity" hérite de "type". Par contre, les données envoyées en json sont maintenant celles du modèle.
Pour transmettre la même structure, il nous suffit d'implémenter \JSONSerializable :
//Brand.php
...
class Brand implements \JsonSerializable
{
...
public function jsonSerialize()
{
return [
'id' => $this->id,
'name' => $this->name,
'devices' => $this->devices->toArray(),
];
}
//Device.php
...
class Device implements \JsonSerializable
{
...
public function jsonSerialize()
{
return [
'id' => $this->id,
'name' => $this->name,
];
}
...
et de même dans la classe Device. Il ne reste plus qu'à remplacer le tableau en dur $brands du controlleur par la liste des objets Brands trouvés en table :
//ChristmasController
$brands = $this->getDoctrine()->getRepository('AcmeDemoBundle:Brand')->findAll();
Quelques éléments pour aller plus loin :
Quelques remarques et liens pour conclure :
*note : les objets évoqués ne sont là qu'à titre d'exemple, l'auteur n'a aucun lien avec l'une ou l'autre des marques présentées ou aucune autre et gardera pour lui le modèle et la marque de son propre téléphone.