Rails & nested_attributes : un formulaire dynamique ?

Dans le cas d’une application ayant un objet parent qui possède un ou plusieurs objets enfants, il est possible de créer au sein du formulaire parent, les enfants qui lui sont associés.

Nous allons vous présenter notre façon d’utiliser les « nested_attributes », celle-ci n’est pas forcément la meilleure et/ou la plus adaptée. Mais face au manque d’articles et de communication autour des nested, nous avons décidé de partager avec vous cette astuce. N’hésitez pas à nous communiquer vos retours via les commentaires.

Prenons un exemple concret : nous avons un objet « voiture » (model) avec une liaison « has_many » sur l’objet enfant (model) « equipement ».

# app/models/voiture.rb
class Voiture < ActiveRecord::Base
  has_many :equipements, dependent: :destroy
end
 
# app/models/equipement.rb
class Equipement < ActiveRecord::Base
  belongs_to :voiture
end

Jusque ici rien de plus classique que deux objets reliés entre eux. Cependant ce que nous souhaitons est un peu particulier : pouvoir ajouter plusieurs équipements à notre voiture lors de la création d’un nouvel objet « voiture » , le tout via un et un seul formulaire :

Résultat attendu : ajouter une voiture + ajouter un ou plusieurs équipements à cette voiture

Pour parvenir à ce résultat, voici les étapes à suivre :

1 – Ajout de « accepts_nested_attributes_for » dans le modèle parent

# app/models/voiture.rb
class Voiture < ActiveRecord::Base
  has_many :equipements, dependent: :destroy
  accepts_nested_attributes_for :equipements, :reject_if => lambda { |a| a[:intitule].blank? }, :allow_destroy => true
end

On ajoute ici l’option « accepts_nested_attributes_for » qui permet au modèle « voiture » de créer une instance du modèle « equipement » et par la même occasion de le lier au modèle « voiture » qui est appelé. Le « reject_if » agit comme un before_save, ce qui signifie que si la condition n’est pas respectée, l’instance « equipement » ne sera pas sauvegardée (cependant cela ne déclenche pas de message d’erreur). Le « allow_destroy » permet quant à lui au modèle « voiture » de pouvoir supprimer une instance du modèle « equipement ».

2 – Le mass-assignment

# app/controllers/voitures_controller.rb
private
    # Never trust parameters from the scary internet, only allow the white list through.
    def voiture_params
      params.require(:voiture).permit(:marque, :genre, equipements_attributes: Equipement.attribute_names + ["_destroy"])
    end

Plaçons-nous dans le controller parent et ajoutons « equipements_attributes » pour autoriser le mass-assignement (pouvoir setter plusieurs attributs à la fois) et récupérer tous les attributs du modèle « equipement ». A cela nous ajoutons un attribut virtuel supplémentaire « _destroy » qui va nous permettre de supprimer un équipement si sa valeur vaut « true » dans le formulaire.

3 – Création d’une instance vide « equipement » depuis notre « voiture »

# app/controllers/voitures_controller.rb
def new
    @voiture = Voiture.new
    @voiture.equipements.build
 end

Nous partons du principe que notre voiture possède au moins un équipement. Nous voulons donc afficher dès le chargement du formulaire (new) les champs nécessaires pour l’ajout d’un équipement. Dans l’action « new » nous ajoutons donc « @voiture.equipements.build » afin de construire une première instance vide d’un équipement (qui sera liée à notre voiture).

4 – Vue : un partial pour les champs enfants

Ensuite nous allons ajouter au sein du formulaire « voiture » les champs de l’objet enfant « equipement ». On crée dans un premier temps un « partial » pour y afficher uniquement les champs enfants :

# app/views/voitures/_form_equipements.html.erb
<div class="equipements">
  <div class="equipement">
    <span class="btn btn-mini remove_equipement">Supprimer</span>
    <%= q.hidden_field :_destroy %>
    <div class="field">
      <%= q.label :intitule, "Equipement <span class='numero-equipement'>#{js ? "valeur_cherchee2" : (q.options[:child_index] + 1)}</span>".html_safe %>
      <%= q.text_field :intitule %>
    </div>
    <div class="field">
      <%= q.label :prix, "Prix" %>
      <%= q.text_field :prix %>
    </div>
  </div>
</div>

Il existe deux façons d’exécuter ce partial : premièrement lorsque l’on charge les objets « equipements » au sein du formulaire (ex : l’instance buildée pour le new, et les instances présentes en base de données pour l’edit). Le deuxième cas concerne la partie « dynamique » du formulaire qui nous permettra en javascript d’ajouter autant d’equipements souhaités (à ce moment là nous ne connaissons pas le nom et l’id exact des champs ajoutés au DOM dynamiquement).

Décortiquons un peu ce partial. Sur le bouton « supprimer » la classe « remove_equipement » est importante car elle sera réutilisée par la suite dans le code JS. Le champ « <%= q.hidden_field :_destroy %> » permet, lorsque sa valeur est à « true » de supprimer l’équipement correspondant (cf code JS plus bas).

« Equipement […] #{js ? « valeur_cherchee2 » : (q.options[:child_index] + 1)} » nous permet d’afficher un numéro pour chaque équipement (ex : equipement 1, equipement 2 etc.), si nous sommes dans le cas d’une exécution ruby simple (variable « js » qui vaut false), nous irons chercher l’index de l’équipement fourni par Rails (« q.options[:child_index] » ceci vient de la méthode f.fields_for que nous verrons par la suite). Dans le cas d’une exécution qui nous permettra l’ajout en JS (variable « js » vaut true) nous écrivons une valeur facilement remplaçable (« valeur_cherchee2 ») par le numéro de l’équipement qui sera décidé dynamiquement dans le JS.

Le reste des lignes est un appel standard aux méthodes de rails permettant de créer les inputs du formulaire.

5 – Vue : modifions le formulaire parent « voitures »

# app/views/voitures/_form.html.erb
<%= form_for(@voiture) do |f| %>
  ...
  # champs concernant l'objet voiture
  ...
  <div id="bloc-equipements">
    <%= f.fields_for :equipements do |q| %>
      <%= render partial: "form_equipements", locals: { q: q, js: false } %>
    <% end %>
  </div>
  <span class="btn btn-mini add_equipement">Ajouter</span>
  <div class="actions">
    <%= f.submit "Valider", :class => "btn btn-primary" %>
  </div>
  ...
<% end %>

Dans notre formulaire voiture (avec un builder nommé f), nous ajoutons une partie qui appelle notre partial pour les objets « equipement » liés à notre objet « voiture » grâce à la méthode Rails « f.fields_for :equipements do |q| » . « q » représente ici le builder pour la partie du formulaire qui concerne les objets « equipement ».

On appelle donc le partial précédemment créé et on lui passe les variables (à réutiliser dans le partial) « q » et « js » à false (car ici nous utilisons le partial dans le premier cas pour charger les objets « equipement » liés à notre voiture). Nous ajoutons également des classes CSS (ex : bloc-equipements, add_equipement, equipements et equipement dans le partial) pour les manipuler dans le code JS plus bas.

6 – Un peu de javascript (jQuery) …

Dans le code qui va suivre nous allons aborder les notions « Rails » suivantes :
escape_javascript : échappe les retours chariots, les guillemets simples et doubles
child_index : est une option de fields_for permettant de contrôler le nommage des inputs

Le script est à insérer avant la fermeture <% end %> du formulaire (form_for) afin de pouvoir utiliser le builder « f » au sein du code JS ci-dessous. Attention toutefois, en fonction de votre intégration HTML des modifications peuvent être à apporter.

<%= form_for(@voiture) do |f| %>
# ....
<script>
    $(function(){
 
      // On clique sur le bouton ajouter (equipement) 
      $(".add_equipement").click(function(){
 
        // On récupère le nombre d'équipements créés dans le DOM
        index = $("#bloc-equipements .equipement").length;
        // On récupère le nombre d'équipements affichés à l'utilisateur
        index2 = $("#bloc-equipements .equipement").not(':hidden').length;
 
        // On créé en ruby une nouvelle instance vide d'équipement liée à notre objet voiture (f.object retourne notre @voiture)
        <% new_option = f.object.equipements.build %>
 
        // on fait appel au fields_for en ruby en lui passant notre nouvelle instance d'équipement et en lui précisant le child_index
        // On appelle ensuite notre partial en lui fournissant le "q" et le "js" à true (cf : cas d'utilisation dynamique du partial)  
        // On stocke le résultat du partial dans la var html
        html = "<%= escape_javascript (f.fields_for(:equipements, new_option, child_index: 'valeur_cherchee') { |q| render(partial: 'form_equipements', locals: { q: q, js: true }) }) %>"
 
        // On remplace valeur_cherchee2 par le numéro de l'équipement 
        var regexp2 = new RegExp("valeur_cherchee2", "g");
        html = html.replace(regexp2, (index2 + 1));
 
        // On remplace valeur_cherchee par le nouvel index ruby des inputs
        var regexp = new RegExp("valeur_cherchee", "g");
        html = html.replace(regexp, index);
 
        // On ajoute au DOM les champs de l'équipement ajouté
        $("#bloc-equipements").append(html);
        return false;
      });
 
      // On clique sur le bouton supprimer (equipement)
      $("#bloc-equipements").on("click", ".remove_equipement", function(){
 
        // On passe le champ caché _destroy à true 
        $(this).next().val("true");
 
        // On cache l'affichage des champs de l'équipement à supprimer
        $(this).parent().parent(".equipements").hide();
 
        // On récupère tous les équipements situés à la suite de l'équipement cliqué
        liste_div_equipements = $(this).parent().parent().nextAll(".equipements");
 
        // Pour chaque equipement on réattribue un numéro pour les labels (ex : 1,2,3,4, on supprime 2, on renomme 3,4 en 2,3)
        $.each(liste_div_equipements, function(index, div_equipements){
          span_numero = $(div_equipements).children(".equipement").children(".field").children("label").children("span.numero-equipement");
          span_numero.text(span_numero.text()-1);
        });
        return false;
      });
    });
  </script>
# ....
<% end %>

Et voilà lorsque l’on ajoute (new) ou modifie (edit) une voiture, on peut par la même occasion ajouter/modifer/supprimer les équipements qui lui sont associés. Si vous aussi vous utilisez les nested_attributes, n’hésitez pas à commenter cet article pour partager votre expérience !

2 réponses sur “Rails & nested_attributes : un formulaire dynamique ?”

Laisser un commentaire

Votre adresse de messagerie ne sera pas publiée.

Ce site utilise Akismet pour réduire les indésirables. En savoir plus sur comment les données de vos commentaires sont utilisées.