#197 Nested Model Form Part 2
- Download:
- source codeProject Files in Zip (121 KB)
- mp4Full Size H.264 Video (17.1 MB)
- m4vSmaller H.264 Video (12.4 MB)
- webmFull Size VP8 Video (34.2 MB)
- ogvFull Size Theora Video (23.6 MB)
Dans l'épisode précédent, nous avions abordé comment créer un formulaire pouvant gérer plusieurs modèles imbriqués. Dans cette application, on avait créé pour cela un modèle Enquête Survey
, pour lequel chaque Survey
possède plusieurs Questions
et chaque Question
ayant elle-même plusieurs réponses Answers
.
Dans les modèles Survey
et Question
, on a utilisé accepts_nested_attributes_for
pour pouvoir créer, éditer et supprimer des enregistrements imbriqués à travers un seul modèle.
Actuellement dans notre application, si on veut supprimer une question ou une réponse, on doit utiliser un checkbox. Parallèlement, on n'a aucun autre moyen d'ajouter de nouvelles questions ou réponses au moyen du même formulaire. Dans cet épisode on se propose de régler ces problèmes par l'utilisation de JavaScript afin de modifier le formulaire existant au moyen de liens, pour créer et supprimer des modèles dynamiquement.
Le JavaScript que l'on va écrire implique quelques manipulations du DOM, aussi allons-nous utiliser Prototype pour nous simplifier la tâche. Pour ajouter Prototype à notre application, on va rajouter la ligne suivante dans la partie <head> de la layout de l'application.
<%= javascript_include_tag :defaults, :cache => true %>
Si vous préférez utiliser jQuery, c'est tout à fait possible et vous trouverez l'équivalent jQuery à la fin de cet épisode.
Ajout de liens pour supprimer des réponses
Commençons par la partie la plus facile: remplacer les checkbox, utilisés pour supprimer les questions et les réponses, par des liens. Tout d'abord, voyons le cas des réponses.
Le code responsable de l'affichage de chaque réponse se trouve dans le partial appelé answer_fields
et ressemble à ceci:
<p> <%= f.label :content, "Answer" %> <%= f.text_field :content %> <%= f.check_box :_destroy %> <%= f.label :_destroy, "Remove" %> </p>
Dans le code ci-dessus, se trouve le checkbox _destroy
, que nous voulons cocher pour supprimer la réponse. On va le remplacer par un champ caché, dont la valeur sera définie quand le lien “remove” sera cliqué. De cette façon, on pourra toujours indiquer quelle sera la réponse à supprimer.
On va remplacer le label du code ci-dessus par un lien et utiliser link_to_function
pour créer un lien qui déclenchera un appel Javascript lorsqu'il sera cliqué. Avec le champ caché et le lien, le code du partial devrait ressembler à ceci:
<p class="fields"> <%= f.label :content, "Answer" %> <%= f.text_field :content %> <%= f.hidden_field :_destroy %> <%= link_to_function "remove", "remove_fields(this)" %> </p>
Quand le lien “remove” à côté d'une réponse est cliqué, cela va déclencher un appel à une fonction JavaScript, appelée remove_fields
, qui sera passée comme argument dans le lien, nous permettant ainsi d'utiliser comme élément de reference pour retrouver les autres éléments relatifs à cette réponse. Il n'y a pas de moyen direct d'avoir accès à tous ces champs, si bien que l'on va ajouter un nom de class au paragraphe englobant tous ces éléments, de telle sorte qu'on puisse les retrouver plus facilement.
Ensuite il nous faut écrire cette fonction remove_fields
. On va le faire dans le fichier application.js
, car c'est un fichier qui est automatiquement chargé dans nos pages, parce qu'on a spécifié la propriété JavaScript :defaults
.
function remove_fields(link) { $(link).previous("input[type=hidden]").value = "1"; $(link).up(".fields").hide(); }
Cette fonction effectue deux actions. La première utilise la fonction Prototype previous
pour trouver le premier champ caché relatif au lien qui l'a appelé, le champ _destroy, et lui assigne sa valeur à 1
, de telle sorte que la réponse sera marquée comme étant à supprimer. Elle utilise ensuite up
pour remonter l'arbre du DOM à partir du lien, jusqu'à ce qu'elle retrouve un élément de la classe fields
, qui est le nom de la classe que nous avons donné au paragraphe, englobant les champs réponses, et qui le masque aussi lorsque la réponse est cachée.
Si maintenant on recharge la page enquête, on verra apparaître un lien en face de chaque réponse.
Si on clique sur quelques liens, la valeur du champ caché _destroy
sera mise à 1
pour toutes ces réponses comme étant à supprimer et leurs champs dans le formulaire seront cachés.
Notez bien que l'on n'utilise pas AJAX pour renvoyer les valeurs mises à jour du formulaire, lorsque l'on clique sur le lien et bien que l'on masque immédiatement les réponses supprimées, rien ne sera mis à jour dans la base de données tant que le formulaire n'aura pas été envoyé. Lorsqu'on envoie le formulaire, les réponses seront supprimées et on verra le résultat apparaître sur la show
de l'enquête.
Suppression des Questions
A présent que l'on peut supprimer des réponses via des liens, on va s'intéresser au cas des questions. La manière de supprimer une question est fondamentalement la même que celle pour une réponse, on va pouvoir réutiliser une partie du code que l'on avait écrit un peu plus tôt.
Comme on l'a fait pour les réponses, on va remplacer le checkbox et le label par un champ caché et on va reprendre cette partie du code du partial answer_fields
et on va le placer dans une nouvelle helper méthode appelée link_to_remove_fields
, en lui passant dans le lien, le texte qu'on veut lui voire apparaître et la variable form f
.
<p class="fields"> <%= f.label :content, "Answer" %> <%= f.text_field :content %> <%= link_to_remove_fields "remove", f %> </p>
On va maintenant écrire cette méthode dans le fichier application_helper
.
# Methods added to this helper will be available to all templates in the application. module ApplicationHelper def link_to_remove_fields(name, f) f.hidden_field(:_destroy) + link_to_function(name, "remove_fields(this)") end end
Les méthodes qui créent des champs de formulaire retournent des chaînes de caractères (strings), si bien que l'on peut concatenater le HTML généré par les méthodes f.hidden_field
et link_to_function
et les retourner dans le partial.
On pourra aussi utiliser notre méthode dans le partial question_fields
.
<div class="fields"> <p> <%= f.label :content, "Question" %> <%= link_to_remove_fields "remove", f%><br /> <%= f.text_area :content, :rows => 3 %><br /> </p> <% f.fields_for :answers do |builder| %> <%= render 'answer_fields', :f => builder %> <% end %> </div>
Comme notre fonction JavaScript remove_fields
recherche un élément de la classe fields
lorsque l'on masque une question ou une réponse, on a englobé tout le partial dans un élément div
avec ce même nom de classe, de telle sorte que lorsqu'on clique sur un lien “remove” pour une question, cette question sera masquée en même temps que ses réponses.
Si maintenant, on regarde la page edit
d'une enquête et que l'on clique sur le lien “remove” d'une question, celle-ci va être supprimée en même temps que ses réponses qui l'accompagnent, et quand on envoie le formulaire, la question et toutes ses réponses seront supprimées de l'enquête.
Ajout de Questions et Réponses
Et maintenant, la partie la plus délicate: ajouter de nouvelles questions and réponses. On veut des liens sur le formulaire qui créeront les nouveaux champs dynamiquement lorsqu'on cliquera dessus. Ce qui rend ceci difficile est que le JavaScript aura besoin d'avoir accès à un ensemble de champs vides pour que l'on puisse créer une nouvelle question ou réponse, lorsque le lien est cliqué.
Pour ce faire, on va écrire une nouvelle méthode dans l'application helper, appelée link_to_add_fields
. On pourra utiliser cette méthode dès lors que l'on a besoin d'afficher un lien pour ajouter les champs d'une nouvelle question, ou réponse, sur le formulaire. Le code de cette méthode ressemblera à ceci:
def link_to_add_fields(name, f, association) new_object = f.object.class.reflect_on_association(association).klass.new fields = f.fields_for(association, new_object, :child_index => "new_#{association}") do |builder| render(association.to_s.singularize + "_fields", :f => builder) end link_to_function(name, h("add_fields(this, \"#{association}\", \"#{escape_javascript(fields)}\")")) end
Cette méthode prend trois arguments: name
, qui sera le texte du lien, f
, l'objet form builder et l'association
, qui dans ce cas sera soit “questions” soit “answers”.
La première ligne de cette méthode crée une nouvelle instance de la classe de cette nouvelle association, c-à-d une nouvelle Question
ou Answer
. Cela signifie que l'on a un objet template, qui va nous servir à créer les nouveaux champs du formulaire.
La seconde partie du code construit une chaîne avec les champs du formulaire de cet objet, de telle sorte que l'on peut les insérer dans la fonction javascript qui les ajoutera au formulaire quand le lien sera cliqué. Ceci est possible en appelant le partial approprié, passé par le form builder f. La seule chose vraiment nouvelle ici est :child_index
. On a fait cela pour avoir une référence de la nouvelle question ou réponse lors de la création des champs. Dans le code JavaScript, on va remplacer ce nom par un identifiant unique basé sur l'heure courante. De cette façon, chaque fois que l'on va créer une question ou réponse, on aura un index unique, qui nous permettra de l'identifier lorsqu'on enverra le formulaire.
On va de nouveau utiliser la méthode link_to_function
, lui passant le nom du lien etun appel à la fonction JavaScript appelée add_fields
, à laquelle on lui passe le lien, le nom de l'association et une chaîne contenant le texte échappé des champs du formulaire.
Maintenant, revenons au JavaScript et écrivons la fonction add_fields
.
function add_fields(link, association, content) { var new_id = new Date().getTime(); var regexp = new RegExp("new_" + association, "g"); $(link).up().insert({ before: content.replace(regexp, new_id) }); }
Cette fonction prend les 3 arguments, dont on a parlé un peu plus tôt: le lien qui a été cliqué, le nom de l'association et une chaîne contenant le HTML des champs du formulaire. La première chose que cette fonction fait est de créer un nouvel id pour les éléments du formulaire. Si on crée plusieurs nouvelles questions ou réponses, on ne veut pas qu'ils aient tous le même index, sinon ils seraient considérés comme appartenant au même modèle lors de l'insertion. On va utiliser l'heure courante pour rendre l'id unique dans une expression régulière pour remplacer la chaîne “new_question” ou “new_answer” des champs du formulaire par ce nouvel identifiant unique. Ceci étant fait, on va insérer la chaîne des champs du formulaire à la bonne place dans le DOM.
Ceci marque la fin de la partie la plus délicate. Tout ce que l'on a à faire maintenant, c'est de rajouter les liens eux-mêmes. Dans le partial question_fields
, on va rajouter un lien link_to_add_fields
, qui permet d'ajouter une nouvelle réponse, en lui passant comme paramètre :answers comme de l'association ,car une question possède plusieurs réponses.
<div class="fields"> <p> <%= f.label :content, "Question" %> <%= link_to_remove_fields "remove", f %><br /> <%= f.text_area :content, :rows => 3 %><br /> </p> <% f.fields_for :answers do |builder| %> <%= render 'answer_fields', :f => builder %> <% end %> <p><%= link_to_add_fields "Add Answer", f, :answers %></p> </div>
On peut faire la même chose dans le formulaire enquête pour ajouter un lien “Add Question”.
<% form_for @survey do |f| %> <%= f.error_messages %> <p> <%= f.label :name %><br /> <%= f.text_field :name %> </p> <% f.fields_for :questions do |builder| %> <%= render 'question_fields', :f => builder %> <% end %> <p><%= link_to_add_fields "Add Question", f, :questions %> <p><%= f.submit "Submit" %></p> <% end %>
Si on recharge la page enquête maintenant, on devrait voir les liens pour ajouter une nouvelle question ou réponse et lorsqu'on clique sur l'un d'eux, un nouveau champ va apparaître sur le formulaire.
Un champ réponse apparaît sur le formulaire, lorqu'on clique sur le lien “Add Answer”.
Si on remplit le nouveau champ réponse ci-dessus avec “jQuery” et que l'on soumet le formulaire, la nouvelle réponse sera ajoutée.
Nous avons atteint notre but et maintenant nous avons un formulaire, sur lesquel on peut dynamiquement ajouter ou supprimer des champs et avoir la base de données mise à jour de façon appropriée, lorsque le formulaire est envoyé.
Alternative utilisant jQuery
Le code JavaScript, que l'on a écrit dans cet épisode fonctionne avec Prototype. Si vous préférez utilier jQuery, le code devrait ressembler à ceci:
function remove_fields(link) { $(link).prev("input[type=hidden]").val("1"); $(link).closest(".fields").hide(); } function add_fields(link, association, content) { var new_id = new Date().getTime(); var regexp = new RegExp("new_" + association, "g"); $(link).parent().before(content.replace(regexp, new_id)); }
Ce code est très semblable au code pour Prototype, que l'on a pu écrire.
Certains d'entre vous seront peut-être choqués que le JavaScript que nous avons écrit manque un peu de discrétion. Bien qu'une solution discrète soit toujours préférable, il n'y en a pas vraiment pour notre problème, qui soit suffisamment simple à présenter dans cet épisode. Ryan Bates travaille actuellement sur un plugin appelé nested_form, utilisant jQuery et qui permettra de gérer des formulaires imbriqués de façon discrète. Ce plugin en est encore à un stade primitif, et s'il peut vous être utile, pensez à venir le visiter de temps à autre et à suivre ses évolutions.