Créer un module Drupal : AHAH et formulaires dynamiques
Ecrit par Yoran, le mer, 24/09/2008 - 16:37

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 de 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.

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.

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 :

          'type_produit' => array(
            'description'   => 'Type du produit (pâtes, courgettes, etc...)',
            'type'      => 'int',
            'unsigned'    => TRUE,
            'not null'    => TRUE),
          'categorie_produit' => array(
            'description'   => 'Catégorie de produit',
            'type'      => 'int',
            'unsigned'    => TRUE,
            'not null'    => TRUE),  
courses.install - courses_schema()

Ensuite, il nous faut implémenter un nouveau hook_update pour permettre la mise à jour des anciens schémas :

function courses_update_2() {
  $ret = array();
  db_add_field($ret, 'node_produit', 'categorie_produit', array(
    'description'   => 'Catégorie de produit',
    'type'      => 'int',
    'unsigned'    => TRUE,
    'not null'    => TRUE)
  );
  db_add_field($ret, 'node_produit', 'type_produit', array(
    'description'   => 'Type du produit (pâtes, courgettes, etc...)',
    'type'      => 'int',
    'unsigned'    => TRUE,
    'not null'    => TRUE)
  );
  return $ret;
}  
courses.install - courses_update_2()

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 :

function courses_categories() {
  return array(
    0 => t("Catégorie du produit"),
    1 => t("Légumes"),
    2 => t("Féculents"),
    3 => t("Jus de fruit"),
  );
}

function courses_types($category) {
  switch ($category) {
    case 1: {
      return array (
      1=>t('Choux'),
      2=>t('Courgettes'),
      3=>t('Carottes'),
      );
    }
    case 2: {
      return array (
      1=>t('Spaghetties'),
      2=>t('Penne Rigate'),
      3=>t('Vermicelles'),
      );
    }
    case 3: {
      return array (
      1=>t("Jus d'orange"),
      2=>t("Jus de pamplemousse"),
      );
    }
  }
}    
courses.module

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 :

$form['categorie_produit'] = array(
  '#type' => 'select',
  '#title' => t('Catégorie'),
  '#options' => courses_categories(),
  '#default_value'=>$node->categorie_produit,
  '#description' => t('Sélectionnez une catégorie'),
  '#required' => TRUE,
  '#ahah' => array(
    'path' => 'courses/js/types',
    'wrapper' => 'wrapper-types',
    'method' => 'replace',
    'effect' => 'fade'),  
);

$form['wrapper-types'] = array(
  '#prefix' => '<div id="wrapper-types">',
  '#suffix' => '</div>',
  'type_produit' => courses_types_field($node->categorie_produit, $node->type_produit),
);
courses.module - courses_form()

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 :

  function courses_types_field($categorie=null,$type=null) {
  if (!empty($categorie)) {
    $types=courses_types($categorie);
  } else {
    $types=array();
  }
  array_unshift($types, t("Type de produit"));
  return array(
      '#type' => 'select',
      '#title' => t('Type'),
      '#options' => $types,
      '#description' => t('Sélectionnez un type'),
      '#default_value'=>$type,
      '#required' => TRUE,
      '#disabled'=>count($types)==1,
    );
}
courses.module - courses_types_field()

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 :

  $items['courses/js/types'] = array (
  'page callback' => 'courses_js_types',
  'type' => MENU_CALLBACK,
  'access callback' => 'node_access',
  'access arguments' => array ('view',1)
);
courses.module - courses_menu()

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 :

function courses_js_types() {
  // Récupération de la catégorie
  $categorie=$_POST['categorie_produit'];

  // Fabrication de notre élément avec la bonen catégorie
  $element=courses_types_field($categorie);

  // Récupération de l'ID unique du formulaire
  $form_build_id = $_POST['
form_build_id'];

  // On fabrique un faux form_state
  $form_state = array('
submitted' => FALSE);

  // Récupération du formulaire à partir du cache
  $form = form_get_cache($form_build_id, $form_state);

  // On ajoute notre élément dynamique dans le formulaire (en fait, on remplace l'
ancien...)
  $form['wrapper-types']['type_produit']=$element;

  // Sauvegarde du formulaire dans le cache
  form_set_cache($form_build_id, $form, $form_state);

  // Reconstruction du formulaire
  $form = form_builder($_POST['form_id'], $form, $form_state);

  // Récupération de notre élément reconstruit
  $element = $form['wrapper-types']['type_produit'];

  // Transformation de l'élément en HTML
  $output = drupal_render($element)

  // On renvoie au client le formulaire sous sa forme HTML, convertie en JSON
  print drupal_to_js(array('
data' => $output, 'status' => true));
  exit();
}  

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...

Daniel , le sam, 27/09/2008 - 13:21

Merci pour ce tuto très clair, comme d'hab, car la doc officielle n'est pas très prolixe sur AHAH.

Le GROS pb de AHAH, à mon avis, est qu'il envoie tout le form à chaque fois (body compris). Pour une liste de course c'est pas grave mais pour un form un peu plus élaboré (avec pleins de select ayant chacun pleins d'items, et un body un peu conséquent), ça me paraît assez délirant.

J'avais fait un essai pour un besoin de select dépendants (liste d'un select dépendante du choix fait dans un autre select), mais outre la BP consommée, ça donnait des temps de réponse catastrophiques.

Je n'ai d'ailleurs toujours pas compris que l'on ne puisse pas configurer AHAH pour ne poster en ajax que l'élement de form concerné, et pas tout le form (si c'est possible, je suis preneur).

Du coup, je me suis rabattu sur un fichier js ad hoc à la place de AHAH.
Les détails sur http://drupalfr.org/node/4492

Yoran, le sam, 27/09/2008 - 19:22

@Daniel ben là tu me vois surpris :

// Récupération de notre élément reconstruit
$element = $form['wrapper-types']['type_produit'];
// Transformation de l'élément en HTML
$output = drupal_render($element)
// On renvoie au client le formulaire sous sa forme HTML, convertie en JSON
print drupal_to_js(array('data' => $output, 'status' => true))

Comme tu le vois, c'est bien l'élément, la liste déroulante (SELECT), que j'envoie par ce canal, absolument pas le formulaire complet..

Ou alors j'ai pas compris où tu voulais en venir.

Yoran, le sam, 27/09/2008 - 19:34

@Daniel je viens de vérifier au cas où avec firebug et lors d'une sélection d'Item, j'ai juste le formulaire qui est posté au serveur, et en réponse, j'ai juste l'élément à utiliser pour remplacer l'ancienne liste.

Daniel , le sam, 27/09/2008 - 22:20

Oui, c'est ce que je voulais dire, lors de l'appel ajax client->serveur, TOUT le formulaire est transmis, la réponse c'est toi qui gère donc tu peux mettre ce que tu veux, mais pour l'appel, le formulaire complet peut être très gros, si tu l'envoies en entier à chaque clic dans chaque select, c'est vraiment trop bourrin, et j'ai pas trouvé comment configurer la bestiole autrement.

Yoran, le sam, 27/09/2008 - 22:57

@Daniel Pour avoir étudié les sources (misc/AHAH.js) il n'y a pas de moyen de configurer cela plus finement, effectivement, mais c'est aussi assez logique. Comme je le disais en intro AHA est une version simplifiée d'AJAX, d'où le côté bourrin mais aussi que tu peux mettre entre toutes les mains. La finesse dont tu parles implique de coder toi-même tes requêtes ce qui est facilement faisable avec jQuery.

Maintenant j'aurais tendance à dire aussi que si tu as un formulaire si long qu'un simple POST de celui-ci draine tes ressources, c'est qu'il est trop long et qu'il te faudrait un multi-part, ne serait-ce que pour des raisons de lisibilité et d'utilisabilité.

Daniel , le dim, 28/09/2008 - 00:22

La finesse dont tu parles implique de coder toi-même tes requêtes ce qui est facilement faisable avec jQuery

Oui, c'est ce que j'ai fait, et effectivement ça fait plutôt moins de lignes de code js que le php pour déclarer ahah dans le module, c'est juste qu'il faut se remettre à js, avec la syntaxe jQuery et la surcouche drupal (je me plains pas, le Drupal.behaviors est bien pratique).

si tu as un formulaire si long qu'un simple POST de celui-ci draine tes ressources, c'est qu'il est trop long

C'est pas ça qui pompe les ressources du serveur, mais ça rend la réaction plus lente (surtout si le client a un débit moyen), chaque POST peut dépasser 20ko là où qq centaines d'octets suffisent en js.

J'était juste étonné de ce gaspillage de BP, et qu'il n'y ait pas moyen de se limiter au select concerné avec ahah.

Je n'ai pas fait de mesures, mais à vue de nez on gagne largement un facteur 10 à la réactualisation des selects (à chaque clic dans un select concerné).

La différence est probablement nettement moins perceptible dans un cas comme ton exemple (le form complet ne doit pas dépasser 2-3ko).

Yoran, le dim, 28/09/2008 - 01:38

@Daniel Lorsque je parle de ressources, c'était bien de bande passante qu'il s'agissant Smile

La raison qui fait que AHAH gaspille ainsi la BP c'est qu'il n'y a pas de protocole remontant du client vers le serveur à proprement parler dans ce concept. Il POST le formulaire et ça s'arrête là. JSON c'est uniquement pour la descente.

Maintenant c'est pas non plus la méthode que j'utilise mais c'est de loin la plus simple pour qui n'est pas développeur. Perso je préfère la méthode JQery avec mon module AJAD Wink Si tu veux je peux la mettre en plus.

zmove , le mar, 21/10/2008 - 17:04

Est-il possible d'appliquer une variable au path appelé par ahah ? Je m'explique

J'ai 2 listes déroulantes, la premiere permet de séléctionner un node, et la deuxième affiches les attributs disponible pour ce node.

j'ai donc besoin d'avoir un path contenant le $nid afin de charger ensuite la bonne liste d'attributs. Voici ce que j'ai pour le moment.

J'aurais donc besoin de faire quelque chose dans le genre j'imagine :
<?php
//...
'#ahah' => array(
'path' => 'uc_discounts_product/js/$nid',
//...
?>

Le problème c'est que $nid doit être l'id du node que je séléctionne dans cette même liste, je ne peux donc le savoir via PHP car c'est une donnée qui varie dynamiquement (en fonction du produit que je séléctionne dans ma liste).

Comment je peux donc afficher une liste dépendante de la valeur séléctionnée dans la liste précédente ?