Formularios anidados
En ocasiones, tenemos modelos asociados que necesitamos manipular en un único
formulario en lugar de tener un formulario por cada uno de ellos. La gema
nested_form
nos permite crear formularios complejos cuando trabajamos con
modelos anidados, su uso es realmente sencillo y su
documentación cubre los aspectos más
comunes. Sin embargo, existen algunos escenarios en los que debemos de tener
algunas consideraciones adicionales para que todo funcione correctamente,
especialmente con Rails 4.
En este tutorial, veremos cómo utilizar esta gema cuando incluimos relaciones uno a muchos y muchos a muchos en nuestros modelos.
Creación del proyecto
Para este tutorial, trabajaremos con un proyecto en Rails 4.1. Al momento de escribir este tutorial, es una versión que todavía está en RC, por lo que se se instala de la siguiente manera:
gem install rails --pre
Para empezar, agregamos la gema nested_form
a nuestro proyecto.
echo "gem 'nested_form'" >> Gemfile
Y ejecutamos
bundle install
Ahora debemos agregar el javascript de la gema al asset pipeline. En el
archivo application.js
, ingresamos la siguiente línea:
//= require jquery_nested_form
Nota: La versión de nested_form
utilizada para este post es 0.3.2
.
Creación de modelos
Trabajaremos con tres modelos relacionados entre sí. El modelo Proyecto
tiene
muchas Tareas
(relación uno a muchos), y las Tareas
están asociadas a muchos
Empleados
(relación muchos a muchos).
Por comodidad, usaremos scaffold
para generar los recursos de Proyecto
y
Empleado
, mientras que Tarea
no tendrá ni vista ni controlador propio, sino
que se creará a través del formulario de Proyecto
, que es donde utilizaremos
la gema nested_form
que acabamos de instalar.
rails generate scaffold proyecto nombre fecha_entrega:date
rails generate scaffold empleado nombre_completo
rails generate model tarea nombre prioridad:integer proyecto:references
Para la relación muchos a muchos entre tareas
y empleados
necesitamos
una migración adicional para crear la tabla intermedia entre estos dos
modelos. En Rails 4, podemos hacerlo de la siguiente manera:
rails generate migration create_join_table_empleados_tareas empleado tarea
Este comando nos genera una migración que crea la tabla intermedia para nuestra relación muchos a muchos:
class CreateJoinTableEmpleadosTareas < ActiveRecord::Migration
def change
create_join_table :empleados, :tareas do |t|
# t.index [:empleado_id, :tarea_id]
# t.index [:tarea_id, :empleado_id]
end
end
end
Ahora creamos la base de datos y generamos las tablas por medio de las
migraciones. En este ejemplo, utilizaremos SQLite como manejador de base de
datos, por lo que no es necesario configurar nada más. Si prefieres utilizar
MySql, Postgres o algún otro manejador, asegúrate de incluir su gema
correspondiente en el archivo Gemfile
y adaptar el archivo
config/database.yml
.
rake db:create
rake db:migrate
Modificamos los modelos correspondientes para que incluyan las relaciones que hemos creado en la base de datos:
# app/models/proyecto.rb
class Proyecto < ActiveRecord::Base
has_many :tareas
end
# app/models/tarea.rb
class Tarea < ActiveRecord::Base
belongs_to :proyecto
has_and_belongs_to_many :empleados
end
# app/models/empleado.rb
class Empleado < ActiveRecord::Base
has_and_belongs_to_many :tareas
end
Datos de inicio
Agreamos empleados a la base de datos por medio del archivo db/seeds.rb
.
# db/seeds.rb
Empleado.create! [
{nombre_completo: 'Juan Pérez'},
{nombre_completo: 'Pedro López'},
{nombre_completo: 'María Hernández'},
{nombre_completo: 'Carlos Sánchez'},
]
Para almacenar esa información en la base de datos:
rake db:seed
Por supuesto, también podemos agregar empleados manualmente desde nuestra
aplicación, ejecutando rails server
, y accediendo a la ruta empleados/new
.
Formulario de proyectos
Ya con nuestros modelos creados, debemos trabajar con el formulario de proyectos, donde podemos crear un proyecto que contenga muchas tareas que a su vez estén asociadas con muchos empleados.
El formulario que nos creó el scaffold es el siguiente:
app/views/proyectos/_form.html.erb
<%= form_for(@proyecto) do |f| %>
<% if @proyecto.errors.any? %>
<div id="error_explanation">
<h2><%= pluralize(@proyecto.errors.count, "error") %> prohibited this proyecto from being saved:</h2>
<ul>
<% @proyecto.errors.full_messages.each do |msg| %>
<li><%= msg %></li>
<% end %>
</ul>
</div>
<% end %>
<div class="field">
<%= f.label :nombre %><br>
<%= f.text_field :nombre %>
</div>
<div class="field">
<%= f.label :fecha_entrega %><br>
<%= f.date_select :fecha_entrega %>
</div>
<div class="actions">
<%= f.submit %>
</div>
<% end %>
Para utilizar la gema, debemos utilizar el helper nested_form_for
en lugar de
form_for
y agregar los campos de las tareas. Rails nos provee de un helper
para agregar campos a objetos asociados: fields_for
. El formulario quedaría de
la siguiente manera:
app/views/proyectos/_form.html.erb
<%= nested_form_for(@proyecto) do |f| %>
<% if @proyecto.errors.any? %>
<div id="error_explanation">
<h2><%= pluralize(@proyecto.errors.count, "error") %> prohibited this proyecto from being saved:</h2>
<ul>
<% @proyecto.errors.full_messages.each do |msg| %>
<li><%= msg %></li>
<% end %>
</ul>
</div>
<% end %>
<div class="field">
<%= f.label :nombre, 'Nombre del proyecto' %><br>
<%= f.text_field :nombre %>
</div>
<div class="field">
<%= f.label :fecha_entrega %><br>
<%= f.date_select :fecha_entrega %>
</div>
<fieldset id="tareas">
<%= f.fields_for :tareas do |tareas_form| %>
<div class="field">
<%= tareas_form.label :nombre, 'Nombre de la tarea' %><br>
<%= tareas_form.text_field :nombre %>
</div>
<div class="field">
<%= tareas_form.label :prioridad %><br>
<%= tareas_form.text_field :prioridad %>
</div>
<%= tareas_form.link_to_remove "Eliminar esta tarea" %>
<% end %>
<p><%= f.link_to_add "Agregar una tarea", :tareas %></p>
</fieldset>
<div class="actions">
<%= f.submit %>
</div>
<% end %>
Uso de accepts_nested_attributes_for
Si en estos momentos entramos a nuestra aplicación e intentamos crear un proyecto, obtendremos el siguiente error:
Invalid association. Make sure that accepts_nested_attributes_for is used for :tareas association.
Esto es porque nuestro formulario de Proyecto
incluye ya los atributos
anidados de Tareas
, pero no hemos configurado nuestros modelos para que
permita recibir estos campos.
En nuestro modelo Proyecto
agregamos el accepts_nested_attributes_for
:
# app/models/proyecto.rb
class Proyecto < ActiveRecord::Base
has_many :tareas
accepts_nested_attributes_for :tareas, allow_destroy: true
end
De esta manera, Proyecto
puede recibir y procesar los atributos de tareas
mediante la llave tareas_attributes
. Ejemplo:
Proyecto.create nombre: 'Mi proyecto',
fecha_entrega: 1.month.from_now,
tareas_attributes: [
{nombre: 'Tarea 1', prioridad: 5},
{nombre: 'Tarea 2', prioridad: 3}
]
Con esto, le indicamos a ActiveRecord que cree un proyecto con dos tareas.
La opción allow_destroy
nos permitirá eliminar alguna tarea de la lista de
tareas agregando la bandera :_destroy
. Ejemplo:
Proyecto.create nombre: 'Mi proyecto',
fecha_entrega: 1.month.from_now,
tareas_attributes: [
{nombre: 'Tarea 1', prioridad: 5},
{nombre: 'Tarea 2', prioridad: 3, _destroy: true }
]
Con esto, le indicamos a ActiveRecord que cree un proyecto con una sola tarea
(Tarea 1
), ya que Tarea 2
está marcada para ser eliminada y por lo tanto no
se crea. Si la tarea ya existe previamente, al incluir la opción _destroy:
true
se borrará también de la base de datos, pero para eso habrá que
especificar el id
, como veremos más adelante.
Si tratamos de entrar a nuestro formulario de proyectos (/proyectos/new) veremos que el formulario se muestra correctamente y podemos agregar o eliminar tareas visualmente de manera dinámica.
Agregar tareas por omisión
Si queremos que nuestro proyecto se muestre inicialmente con algunas tareas asociadas, podemos agregar algunas desde el controlador. Ejemplo:
#app/controllers/proyectos_controller.rb
def new
@proyecto = Proyecto.new
2.times { @proyecto.tareas.build }
end
Parciales
En nuestro ejemplo, el modelo Proyecto
tiene dos campos, y el modelo Tarea
otros dos, pero en un caso real, ambos tendrían muchos más campos y mostrarlos
todos dejaría un tanto sucio el código de nuestro formulario. En esos casos, es
mejor utilizar parciales.
nested_form
permite el uso de parciales de una manera elegante. Necesitamos
crear una parcial con el nombre en singular de nuestro modelo más el sufijo
_fields
. En la nueva parcial, la variable del formulario anidado
(tareas_form
en nuestro caso), se pasa simplemente como f
. Los archivos
quedarían de la siguiente manera:
El formulario principal de proyectos de proyectos.
app/views/proyectos/_form.html.erb
<%= nested_form_for(@proyecto) do |f| %>
<% if @proyecto.errors.any? %>
<div id="error_explanation">
<h2><%= pluralize(@proyecto.errors.count, "error") %> prohibited this proyecto from being saved:</h2>
<ul>
<% @proyecto.errors.full_messages.each do |msg| %>
<li><%= msg %></li>
<% end %>
</ul>
</div>
<% end %>
<div class="field">
<%= f.label :nombre, 'Nombre del proyecto' %><br>
<%= f.text_field :nombre %>
</div>
<div class="field">
<%= f.label :fecha_entrega %><br>
<%= f.date_select :fecha_entrega %>
</div>
<fieldset id="tareas">
<%= f.fields_for :tareas %>
<p><%= f.link_to_add "Agregar una tarea", :tareas %></p>
</fieldset>
<div class="actions">
<%= f.submit %>
</div>
<% end %>
La nueva parcial:
app/views/proyectos/_tarea_fields.html.erb
<div class="field">
<%= f.label :nombre, 'Nombre de la tarea' %><br>
<%= f.text_field :nombre %>
</div>
<div class="field">
<%= f.label :prioridad %><br>
<%= f.text_field :prioridad %>
</div>
<%= f.link_to_remove "Eliminar esta tarea" %>
Como vemos, queda más limpio y mejor organizado.
Show
Antes de crear un proyecto por medio del formulario, modificaremos el show
para mostrar no sólo los datos del proyecto sino también los de las tareas
asociadas. Así podremos saber si las tareas se crearon correctamente.
app/views/proyectos/show.html.erb
<p id="notice"><%= notice %></p>
<p>
<strong>Nombre:</strong>
<%= @proyecto.nombre %>
</p>
<p>
<strong>Fecha entrega:</strong>
<%= @proyecto.fecha_entrega %>
</p>
<p>
<strong>Tareas:</strong>
<p>
<% @proyecto.tareas.each do |tarea| %>
Nombre: <%= tarea.nombre %><br/>
Prioridad: <%= tarea.prioridad %>
<hr/>
<% end %>
</p>
</p>
<%= link_to 'Edit', edit_proyecto_path(@proyecto) %> |
<%= link_to 'Back', proyectos_path %>
Strong parameters
Ahora sí, es momento de crear nuestro primer proyecto por medio del formulario. Ingresamos los datos tanto del proyecto como de las tareas, y damos clic en el botón de submit.
La página de show
nos muestra el nombre del proyecto y la fecha de entrega
pero la lista de tareas está vacía, lo que significa que algo falló en el
proceso.
El problema se encuentra en el controlador. Rails 4 incluye strong_parameters
,
que valida que se reciban sólo atributos autorizados para un formulario. Por
omisión, al ejecutar el scaffold, sólo se autorizan los campos que se incluyen
en el modelo Proyecto
, por lo que tenemos que agregar a mano los campos del
modelo Tarea
, incluyendo el atributo _destroy
, para que una tarea pueda ser
eliminada de la lista. Estos atributos se agregan a la lista que recibe el
método permit
como un hash, donde la llave es tareas_attributes
y el valor
es un arreglo con los nombres de los campos.
# app/controllers/proyectos_controller.rb
# [...]
# Never trust parameters from the scary internet, only allow the white list through.
def proyecto_params
params.require(:proyecto).permit(
:nombre, :fecha_entrega, tareas_attributes: [
:nombre, :prioridad, :_destroy
]
)
end
Y ahora sí, al crear un proyecto con tareas, éstas se crearán correctamente.
Update
Ahora bien, si queremos modificar un proyecto que ya hayamos ingresado, podemos hacerlo en el path correspondiente, por ejemplo, /proyectos/1/edit.
Sin embargo, aquí notamos un comportamiento extraño. En lugar de actualizar las tareas, crea siempre tareas nuevas. Es decir, mantiene las tareas creadas anteriormente y trata las tareas que estamos modificando como si las estuvieras agregando. Vemos también otro error: si queremos eliminar una tarea, ésta no se elimina, sino que sigue ahí.
Este comportamiento sucede porque para realizar la modificación, ActiveRecord espera recibir como parámetro, el id del modelo a modificar. Si el id corresponde a un registro de la base de datos, entonces lo modifica. Ejemplo:
Imaginemos un proyecto con dos tareas, las tareas tienen los ids 1 y 2 respectivamente.
proyecto = Proyecto.first
proyecto.update nombre: 'Mi nuevo proyecto',
fecha_entrega: 1.week.from_now,
tareas_attributes: [
{nombre: 'Tarea nueva', prioridad: 1 },
{id: 1, nombre: 'Tarea modificada', prioridad: 4 },
{id: 2, nombre: 'Tarea 2', prioridad: 2, _destroy: true }
]
Este fragmento de código actualiza los datos del proyecto, incluyendo sus
tareas. La primera tarea no lleva id, por lo que simplemente se crea y
se asocia al proyecto. Las siguientes dos tareas sí llevan id, por lo que se
entiende que ya existen previamente y están asociadas a ese proyecto; esas
tareas se actualizan. En este caso, la tarea con id 1 sólo actualiza sus datos,
mientras que la tarea con id 2 se elimina porque incluye la bandera _destroy
como true
.
Strong parameters
Conociendo la teoría, la solución es sencilla. Basta con agregar a la lista de parámetros aceptados, el id de las tareas. Para esto, modificamos de nuevo la lista de nuestros strong parameters.
# app/controllers/proyectos_controller.rb
# [...]
# Never trust parameters from the scary internet, only allow the white list through.
def proyecto_params
params.require(:proyecto).permit(
:nombre, :fecha_entrega, tareas_attributes: [
:id, :nombre, :prioridad, :_destroy
]
)
end
Y listo. Con eso podemos modificar y eliminar tareas correctamente desde nuestro formulario de proyectos.
Muchos a muchos
Ya hemos asociado correctamente proyectos y tareas desde un solo formulario, pero aún nos falta asociar tareas y empleados, que es una relación muchos a muchos.
Lo primero que modificaremos será nuestro show
para incluir información sobre
los empleados.
app/views/proyectos/show.html.erb
<p id="notice"><%= notice %></p>
<p>
<strong>Nombre:</strong>
<%= @proyecto.nombre %>
</p>
<p>
<strong>Fecha entrega:</strong>
<%= @proyecto.fecha_entrega %>
</p>
<p>
<strong>Tareas:</strong>
<p>
<% @proyecto.tareas.each do |tarea| %>
Nombre: <%= tarea.nombre %><br/>
Prioridad: <%= tarea.prioridad %><br/>
Empleados: <%= tarea.empleados.map(&:nombre_completo).to_sentence %>
<hr/>
<% end %>
</p>
</p>
<%= link_to 'Edit', edit_proyecto_path(@proyecto) %> |
<%= link_to 'Back', proyectos_path %>
Ahora procedemos a modifcar nuestro formulario para incluir empleados.
Como primer recurso, recurro a este
railscast, donde se
muestra una forma de hacerlo utilizando chec_kbox_tag
.
En un formulario normal, la asociación podría hacerce de la siguiente manera:
<% Empleado.all.each do |empleado| %>
<%= check_box_tag 'tarea[empleado_ids][]',
empleado.id,
@tarea.empleado_ids.include?(empleado.id) %>
<%= empleado.nombre_completo %><br/>
<% end %>
Sin embargo, eso da por sentado que la tarea es el formulario del primer nivel,
lo que es falso para nuestro caso. Nosotros necesitaríamos de una estructura más
compleja que incluyera el proyecto y la tarea (mediante tareas_attributes
).
Si analizamos el request que se hace cuando envía nuestro formulario, veremos que los parámetros llevan la siguiente estructura:
Started POST "/proyectos" for 127.0.0.1 at 2013-08-12 12:41:01 -0500
Processing by ProyectosController#create as HTML
Parameters: {"utf8"=>"✓",
"authenticity_token"=>"RtyGum+m6wIvaoOAvxcQnIlvMEPGStBmFvaTL+t+paQ=",
"proyecto"=>{
"nombre"=>"Mi proyecto",
"fecha_entrega(1i)"=>"2013",
"fecha_entrega(2i)"=>"9",
"fecha_entrega(3i)"=>"22",
"tareas_attributes"=>{
"0"=>{
"nombre"=>"Tarea 1",
"prioridad"=>"1",
"_destroy"=>"false"},
"1"=>{
"nombre"=>"Tarea 2",
"prioridad"=>"",
"_destroy"=>"false"}
}
},
"commit"=>"Create Proyecto"}
Como vemos, el formulario envía tareas_attributes
no como un arreglo sino como
un hash, donde la llave es un índice para cada tarea. Si quisiéramos incluir los
checkboxes de empleados, tendríamos que indicar el número de cada tarea. Otro
aspecto a tomar en cuenta es que no tenemos @tarea
, sino @proyecto
, por lo
que tendríamos que tomarlo del objeto del formulario. Algo como esto:
<%= check_box_tag "proyecto[tareas_attributes][#{id_tarea}][empleado_ids][]",
empleado.id,
f.object.empleado_ids.include?(empleado.id) %>
Para el caso de nuestro ejemplo, es posible llevar el control del id de la tarea
por medio de alguna variable generada en _form
y pasarla a la parcial
_tareas_fields
, pero pensando en varios niveles de formularios anidados, sería
muy complicado llevar el registro de cada uno de los ids de los modelos e irlos
pasando entre parciales. Por ejemplo, si la tarea no estuviera relacionada
directamente con empleados, sino con asignaciones, y las asignaciones con
empleados, tendríamos un tag parecido a esto:
<%= check_box_tag(
"proyecto[tareas_attributes][#{tarea_index}][asignaciones_attributes][#{asignacion_index}][empleado_ids][]",
empleado.id,
f.object.empleado_ids.include?(empleado.id)) %>
Lo que poco a poco se vuelve insostenible.
Afortunadamente, Rails 4 incluye un helper para asociaciones muchos a muchos a
través de checkboxes, y eso nos facilita bastante las cosas. El helper es
collection_check_boxes
. Nuestra parcial de tareas quedaría de la siguiente
manera:
app/views/proyectos/_tarea_fields.html.erb
<div class="field">
<%= f.label :nombre, 'Nombre de la tarea' %><br>
<%= f.text_field :nombre %>
</div>
<div class="field">
<%= f.label :prioridad %><br>
<%= f.text_field :prioridad %>
</div>
<div class="field">
<%= f.collection_check_boxes :empleado_ids, Empleado.all, :id, :nombre_completo %>
</div>
<%= f.link_to_remove "Eliminar esta tarea" %>
Haciendo uso de este helper, no tenemos que preocuparnos por nada más, Rails se encarga de llevar los índices por nosotros.
Strong parameters
Si probamos nuestro formulario ahora, funciona correctamente, los parámetros que genera son los correctos, pero aún no guarda las asociaciones con empleados. De nuevo, esto tiene que ver con strong parameters. Debemos indicar que acepte los campos correspondientes a empleado_ids.
Aquí, hay que observar un pequeño truco para hacer que strong parameters
acepte la lista de ids que le mandamos desde nuestro formulario. Si agregamos
simplemente el campo :empleado_ids
, strong parameters lo filtrará como un
campo cuando en realidad necesitamos que lo considere un arreglo. En este caso
debemos indicarlo como un hash, donde la llave sea :empleado_ids
y el valor
un arreglo vacío.
# app/controllers/proyectos_controller.rb
# [...]
# Never trust parameters from the scary internet, only allow the white list through.
def proyecto_params
params.require(:proyecto).permit(
:nombre, :fecha_entrega, tareas_attributes: [
:id, :nombre, :prioridad, :_destroy, empleado_ids: []
]
)
end
Y ahora sí, nuestro formulario funciona correctamente asociando proyectos, tareas y empleados.
Conclusiones
Rails permite trabajar con formularios anidados de manera relativamente fácil.
La gema nested_form
hace que este trabajo sea todavía más sencillo, sin
embargo, hay que conocer el funcionamiento de otros módulos, como strong
parameters y acceptsnestedattributes_for para que todo funcione
correctamente.
Este tutorial cubrió los aspectos básicos para el trabajo con formularios anidados, pero todavía quedan algunos detalles que cubrir. En el siguiente post explicaré cómo utilizar la gema cuando tenemos una estructura de modelos más compleja. En particular, cuando tenemos modelos dentro de un namespace y recursos anidados ( nested resources ) en nuestras rutas.
Recursos
Para quien esté interesado en conocer el funcionamiento interno de la gema, puede revisar este post. Además, estos railscasts son de mucha utilidad: Nested Model Form Part 1 y Nested Model Form Part 2. También hay una versión actualizada (de paga): Nested Model Form (revised).
Un post
muy completo sobre Strong Parameters
.
El railscast de habtm con checkboxes y su versión de pago.
La documentación de collection check boxes.
También creé un proyecto de ejemplo en github para que sea fácil revisar el código.