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 une 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 :

1 – Ajout de “accepts_nested_attributes_for” dans le modèle parent</h2>
# 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 :
Unordered List
- 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 %>
Originally published at Sois-net.