Créer un module Drupal : AHAH et formulaires dynamiques

Les formulaires dynamiques sont vieux comme le client-serveur. Cela peut correspondre par exemple à une liste principale dont le choix d'un élément déclenche la population d'une liste secondaire. Rien de bien sorcier donc, mais comme pour pas mal d'autres choses, ce qui était relativement simple à coder avec un

RAD
comme Delphi, ou même Visual Basic, est devenu un véritable enfer avec la mode des applications WEB. Voyons donc comment faire ce type de chose avec la dernière Form API de Drupal 6.

Sources

Les sources du module d'exemple courses sont disponibles ici.

Mise à jour du schéma

Comme depuis un moment déjà nous allons continuer à torpiller notre liste de courses à qui nous avions récemment ajouté les nouveaux schémas de Drupal 6.

Pour les besoins de l'expérience, nous allons ajouter deux nouveaux champs à notre table node_produit : categorie_produit et type_produit. Le but est simple, lors de l'ajout ou de l'édition d'un produit, nous aurons une liste permettant de choisir la catégorie (féculents, jus de fruits, légumes, etc.). Et lorsque l'utilisateur sélectionnera un élément de cette liste, cela déclenchera la mise à jour d'une seconde liste de types de produit (pâtes, choux, jus d'orange, etc.). Un peu neu-neu, je sais, mais au moins on peut se concentrer sur la technique.

Pour commencer nous allons rapidement modifier le schéma de notre module (courses.install) en ajoutant nos deux champs :
courses.install - courses_schema()

  1.           'type_produit' => array(
  2.             'description'   => 'Type du produit (pâtes, courgettes, etc...)',
  3.             'type'      => 'int',
  4.             'unsigned'    => TRUE,
  5.             'not null'    => TRUE),
  6.           'categorie_produit' => array(
  7.             'description'   => 'Catégorie de produit',
  8.             'type'      => 'int',
  9.             'unsigned'    => TRUE,
  10.             'not null'    => TRUE),  

Ensuite, il nous faut implémenter un nouveau hook_update pour permettre la mise à jour des anciens schémas :
courses.install - courses_update_2()

  1. function courses_update_2() {
  2.   $ret = array();
  3.   db_add_field($ret, 'node_produit', 'categorie_produit', array(
  4.     'description'   => 'Catégorie de produit',
  5.     'type'      => 'int',
  6.     'unsigned'    => TRUE,
  7.     'not null'    => TRUE)
  8.   );
  9.   db_add_field($ret, 'node_produit', 'type_produit', array(
  10.     'description'   => 'Type du produit (pâtes, courgettes, etc...)',
  11.     'type'      => 'int',
  12.     'unsigned'    => TRUE,
  13.     'not null'    => TRUE)
  14.   );
  15.   return $ret;
  16. }  

Ceci fait, lancez la procédure de mise à jour de Drupal (update.php) au terme de laquelle, note table devrait être modifiée.

Source de données

Pour une véritable application, nos données catégories et types seraient proprement stockées en base de donnée. Ici, nous allons faire simple avec deux fonctions en dur :
courses.module

  1. function courses_categories() {
  2.   return array(
  3.     0 => t("Catégorie du produit"),
  4.     1 => t("Légumes"),
  5.     2 => t("Féculents"),
  6.     3 => t("Jus de fruit"),
  7.   );
  8. }
  9.  
  10. function courses_types($category) {
  11.   switch ($category) {
  12.     case 1: {
  13.       return array (
  14.       1=>t('Choux'),
  15.       2=>t('Courgettes'),
  16.       3=>t('Carottes'),
  17.       );
  18.     }
  19.     case 2: {
  20.       return array (
  21.       1=>t('Spaghetties'),
  22.       2=>t('Penne Rigate'),
  23.       3=>t('Vermicelles'),
  24.       );
  25.     }
  26.     case 3: {
  27.       return array (
  28.       1=>t("Jus d'orange"),
  29.       2=>t("Jus de pamplemousse"),
  30.       );
  31.     }
  32.   }
  33. }    

Liste maître-esclave

A l'ancienne mode, cela aurait consisté à utiliser le très vilain attribut de formulaire DANGEROUS_SKIP_CHECK et ajouter un bouton qui provoque une validation intermédiaire. Aujourd'hui trois arguments s'y opposent. Tout d'abord DANGEROUS_SKIP_CHECK a été supprimé. Ensuite les formulaires sont tous mis en cache et donc difficile à modifier dynamiquement. C'est ceci dit faisable en utilisant l'attribut de champ #process et de formulaire #REBUILD mais le fait de ne pas pouvoir by-passer les contrôles implique qu'à chaque mise à jour de la liste s'affiche des erreurs de validation, c'est moche. Enfin dernier argument, c'est pas AJAX donc c'est pas bien, on m'a dit...

Le framework AHAH qui était un module pour Drupal 5, fait aujourd'hui parti du coeur de Drupal 6. Cette librairie utilise jQuery pour ajouter à Drupal cette giclée d'AJAX qui lui manquait temps. La différence entre AJAX et AHAH (Asynchronous HTML over HTTP, me demandez pas pourquoi) est que le résultat de la réponse est du XHTML qui est directement collée dans le document en cours avec de petits effets genre glissement, fondus, etc...

Il faut donc voir AHAH comme un sous-ensemble fonctionnel d'AJAX et cela va nous suffire car c'est exactement ce dont nous avons besoin.

L'intégration dans un formulaire d'AHAH est relativement directe. Pour nous deux listes, cela donne ceci (à placer à la tête de la fonction courses_form :

courses.module - courses_form()

  1. $form['categorie_produit'] = array(
  2.   '#type' => 'select',
  3.   '#title' => t('Catégorie'),
  4.   '#options' => courses_categories(),
  5.   '#default_value'=>$node->categorie_produit,
  6.   '#description' => t('Sélectionnez une catégorie'),
  7.   '#required' => TRUE,
  8.   '#ahah' => array(
  9.     'path' => 'courses/js/types',
  10.     'wrapper' => 'wrapper-types',
  11.     'method' => 'replace',
  12.     'effect' => 'fade'),  
  13. );
  14.  
  15. $form['wrapper-types'] = array(
  16.   '#prefix' => '<div id="wrapper-types">',
  17.   '#suffix' => '</div>',
  18.   'type_produit' => courses_types_field($node->categorie_produit, $node->type_produit),
  19. );

Simple mais nécessitant un peu d'explication. Le début de l'élément de formulaire categorie_produit ne change pas par rapport à ce que nous connaissions. Elle est alimentée par la fonction courses_categories() que nous avons défini plus haut et utilise $node->categorie_produit comme valeur par défaut.

Là où cela change, c'est justement avec l'attribut #ahah qui définit un comportement AJAX, pardon AHAH, que la liste doit adopter. Le bloc AHAH n'ayant pas d'attribut event, va aller se connecter à l'événement par défaut, à savoir la sélection d'un élément de la liste. Nous aurions pu rajouter un 'event'=>'mousedown' mais cela n'aurait pas grand intérêt. wrapper indique l'ID d'un DIV qui va recevoir les données, method dit que cette réception doit donner lieu à un remplacement de ce que contenait le DIV (cela pourrait être before ou after), effect, c'est pour faire joli, mettez none si vous n'aimez pas, et enfin path est l'URL vers laquelle le module AHAH doit émettre une requête pour recevoir ce fameux contenu à coller dans le DIV.

Ce fameux DIV est défini par l'élément de formulaire suivant avec comme ID, celui qui a été donné plus haut, et comme contenu notre fameux champ dynamique. Et comme il est dynamique, sa construction est placée dans une fonction que nous allons maintenant définir :
courses.module - courses_types_field()

  1.   function courses_types_field($categorie=null,$type=null) {
  2.   if (!empty($categorie)) {
  3.     $types=courses_types($categorie);
  4.   } else {
  5.     $types=array();
  6.   }
  7.   array_unshift($types, t("Type de produit"));
  8.   return array(
  9.       '#type' => 'select',
  10.       '#title' => t('Type'),
  11.       '#options' => $types,
  12.       '#description' => t('Sélectionnez un type'),
  13.       '#default_value'=>$type,
  14.       '#required' => TRUE,
  15.       '#disabled'=>count($types)==1,
  16.     );
  17. }

Rien de compliqué là dedans, il s'agit juste de la récupération de la bonne liste de types en fonction de la catégorie et éventuellement de la définition d'une position par défaut si le paramètre $type est renseigné (cas de l'édition d'un produit).

Voilà, le décor est en place, passons à la partie rock'n'roll, la réponse à la requête AHAH.

Requête AHAH

Comme nous l'avons vu, le module AHAH est censé lorsque l'utilisateur sélectionne un élément de la liste categories, émettre une requête vers courses/js/types de sorte à recevoir le nouvel élément de formulaire qui va aller remplacer l'ancien. Pour que Drupal sache répondre à cette requête, il faut donc déjà rajouter un nouveau menu (à placer avant le return $items :
courses.module - courses_menu()

  1.   $items['courses/js/types'] = array (
  2.   'page callback' => 'courses_js_types',
  3.   'type' => MENU_CALLBACK,
  4.   'access callback' => 'node_access',
  5.   'access arguments' => array ('view',1)
  6. );

Rien de nouveau ici, cela reprend la technique plus laborieuse que j'avais utilisée pour faire causer jQuery avec Drupal. Il nous reste donc à ajouter notre callback :

  1. function courses_js_types() {
  2.   // Récupération de la catégorie
  3.   $categorie=$_POST['categorie_produit'];
  4.  
  5.   // Fabrication de notre élément avec la bonen catégorie
  6.   $element=courses_types_field($categorie);
  7.  
  8.   // Récupération de l'ID unique du formulaire
  9.  $form_build_id = $_POST['form_build_id'];
  10.  
  11.  // On fabrique un faux form_state
  12.  $form_state = array('submitted' => FALSE);
  13.  
  14.  // Récupération du formulaire à partir du cache
  15.  $form = form_get_cache($form_build_id, $form_state);
  16.  
  17.  // On ajoute notre élément dynamique dans le formulaire (en fait, on remplace l'ancien...)
  18.   $form['wrapper-types']['type_produit']=$element;
  19.  
  20.   // Sauvegarde du formulaire dans le cache
  21.   form_set_cache($form_build_id, $form, $form_state);
  22.  
  23.   // Reconstruction du formulaire
  24.   $form = form_builder($_POST['form_id'], $form, $form_state);
  25.  
  26.   // Récupération de notre élément reconstruit
  27.   $element = $form['wrapper-types']['type_produit'];
  28.  
  29.   // Transformation de l'élément en HTML
  30.  $output = drupal_render($element)
  31.  
  32.  // On renvoie au client le formulaire sous sa forme HTML, convertie en JSON
  33.  print drupal_to_js(array('data' => $output, 'status' => true));
  34.  exit();
  35. }  

Alors oui, j'en conviens, c'est un peu "sportif". L'idée est que AHAH ne fait pas un GET mais un POST du formulaire dans son état courant. Du coup, nous avons toutes les valeurs que l'utilisateur a déjà saisies, dont la catégorie, dans la variable $_POST. Cela nous permet déjà de construire notre élément dynamique.

Une valeur un peu étonnante envoyée par POST est form_build_id. Il s'agit de l'ID unique de l'instance du formulaire pour cet utilisateur. Et nous allons utiliser cet ID pour aller faucher dans le cache le formulaire complet tel que Drupal l'a sauvegardé avant de l'envoyer. Ensuite nous allons remplacer dans ce formulaire l'ancien élément type_produit par le nouveau et sauver le tout dans le cache. Alors pourquoi se compliquer la vie ainsi ? Simplement pour tromper Drupal et lui faire croire que le formulaire que nous sommes en train de modifier dans son dos est le même que celui qu'il a originellement envoyé à l'utilisateur.

Pour terminer, nous allons utiliser la fonction form_builder qui va régénérer le formulaire dans le même état que si Drupal était sur le point de l'envoyer. La seule différence est que nous allons extraire notre élément 'type_produit' de ce formulaire regénéré pour le passer à la fonction drupal_render qui va le transformer en code XHTML.

Dernière étape, l'utilisation de drupal_to_js qui va transformer ce code XHTML en un fragment au format

JSON
que le module AHAH du client est capable de comprendre. Une fois cette dernière transformation faite, le tout est simplement envoyé au client.

Notez la fonction exit() qui arrête le traitement ici, interdisant à Drupal tout autre opération.

Conclusion

Voilà, c'est tout et ça marche très bien. Ne vous laissez pas trop effrayer par l'apparente complexité de l'approche car je l'ai développée au maximum. Il est possible de créer une ou deux fonctions génériques qui permettraient de faire la même chose en quelques lignes. Si cela continue dans cette voie, on va finir par pouvoir faire des choses aussi basiques que celles-ci avec la même simplicité que les Delphi & co d'il y a 10 ans... Enfin, je rêve un peu, avec les client Riches, il a fort à parier que tout cet acquis soit à nouveau remis en jeu...