homeASCIIcasts

196: Formularios de modelos anidados (I) 

(view original Railscast)

Other translations: En It Fr Cn

Written by Juan Lupión

Allá por 2007 cubrimos durante una serie de episodios la creación de formularios complejos que podían gestionar múltiples modelos en un único formulario, pero esa serie ha quedado desfasada ya. En este episodio vamos a empezar a ver técnicas más modernas para gestionar este tipo de formularios.

La mayor novedad a la hora de enfocar este problema viene dada por el método accepts_nested_attributes_for que se añadió en Rails 2.3. Vamos a usarlo a lo largo de toda esta serie, de modo que necesitaremos ejecutar la última versión de Rails para poder utilizar esta técnica en nuestras aplicaciones.

Merece la pena repasar la documentación de accepts_nested_attributes_for donde se muestra cómo utilizar esta función con atributos anidados en una única llamada. Pero esta documentación es menos clara a la hora de explicar cómo utilizarlo en la propia vista, así que nos centraremos en eso.

Nuestra aplicación de encuestas

La aplicación que queremos construir durante esta serie de episodios es una aplicación para realizar encuestas. Esta aplicación tendrá un formulario compuesto para crear y editar encuestas que nos dejará introducir el nombre de la encuesta así como un número de preguntas con un número de respuestas múltiples. El formulario también tendrá enlaces para permitirnos añadir y eliminar preguntas y respuestas dinámicamente de la encuesta.

El formulario compuesto para la creación y edición de encuestas.

El formulario compuesto para la creación y edición de encuestas.

Se trata de una asociación profundamente anidada en la que una encuesta tiene muchas preguntas y una pregunta tiene muchas repuestas. En las series anteriores sobre formularios compuestos no era posible crear este tipo de formularios con anidación pero con Rails 2.3 sí se puede.

Empezamos

Vamos a crear nuestra aplicación de encuestas desde cero, así que iremos creando una nueva aplicación Rails denominada surveysays.

rails surveysays

Vamos a usar dos de los Nifty Generators de Ryan Bates: en primer lugar, el generador de layouts para crear un layout general para la aplicación.

script/generate nifty_layout

Nuestra aplicación tendrá tres modelos: Survey, Question y Answer. Empezaremos con el modelo Survey, usando el generado nifty_scaffold para crear un scaffold a juego. En Survey tan sólo tendremos un atributo llamado name.

script/generate nifty_scaffold survey name:string

A continuación ejecutaremos las migraciones para crear la tabla de encuestas en la base de datos.

rake db:migrate

Si examinamos nuestra aplicación veremos que tenemos archivos generados automáticamente con el código necesario para poder listar, crear y editar encuestas, y un formulario básico para las encuestas sobre el que podemos empezar a trabajar.

El formulario básico generado para la encuesta.

Lo que queremos poner en el formulario son los campos que nos permitirán añadir y eliminar preguntas y respuestas cuando creemos una encuesta. El primer paso será generar el modelo Question que tendrá un campo survey_id para relacionarlo con la encuesta y un campo de contenido para albergar el texto de la pregunta.

script/generate model question survey_id:integer content:text

Una vez hecho esto migraremos otra vez la base de datos para crear a tabla de preguntas.

rake db:migrate

A continuación establecemos la relación entre Survey y Question en sus modelos.

/app/models/question.rb
class Question < ActiveRecord::Base
  belongs_to :survey
end
/app/models/survey.rb
class Survey < ActiveRecord::Base
  has_many :questions, :dependent => :destroy
end

Obsérvese que en Survey hemos usado :dependent => :destroy para que cuando eliminemos una encuesta se eliminen también todas sus preguntas.

En el modelo Survey vamos a utilizar accepts_nested_attributes_for para poder gestionar las preguntas a través de Survey. Con esto podremos crear, actualizar y destruir preguntas cuando actualicemos los atributos de una encuesta.

/app/models/survey.rb
  class Survey < ActiveRecord::Base
    has_many :questions, :dependent => :destroy
    accepts_nested_attributes_for :questions
  end

Creación del formulario

Una vez que los modelos Survey Question ya están listos, pasaremos al formulario de encuesta. Lo que queremos es añadir al formulario los campos para cada una de las preguntas del fomulario. Podemos usar el método fields_for para gestionar los ficheros asociados en un formulario, pasándole el nombre del modelo asociado y luego iterando por todas las preguntas y creando un constructor de formularios para cada una de ellas. El constructor mostrará una etiqueta y un área de texto para cada pregunta.

/app/views/survey/_form.html.erb
<% form_for @survey do |f| %>
  <%= f.error_messages %>
  <p>
    <%= f.label :name %><br />
    <%= f.text_field :name %>
  </p>
  <% f.fields_for :questions do |builder| %>
  <p>
    <%= builder.label :content, "Question" %><br />
    <%= builder.text_area :content, :rows => 3 %>
  </p>
  <% end %>
  <p><%= f.submit "Submit" %></p>
<% end %>

Si recargamos el formulario veremos que tiene el mismo aspecto que antes. Esto es porque una nueva encuesta no tendrá ninguna pregunta asociada que mostrar. En última instancia tendremos que añadir un enlace para añadir preguntas en el formulario pero por ahora solamente crearemos algunas preguntas en la acción new de SurveyController.

/app/controllers/surveys_controller.rb
def new
  @survey = Survey.new
  3.times { @survey.questions.build }
end

Con esto añadiremos tres preguntas a una nueva encuesta que veremos en el formulario al recargar la página. Ahora podemos rellenar el nombre y las primeras dos preguntas y enviar un nuevo formulario.

Rellenando el formulario de nueva encuesta.

Cuando rellenemos el formulario se creará un nuevo registro Survey, si bien no veremos sus preguntas porque aún no las estamos mostrando en la página. Para corregir esto modificaremos la vista show de Survey para ver las preguntas de una encuesta.

No vemos las preguntas en la página de una encuesta.
/app/views/survey/show.html.erb
<% title "Survey" %>

<p>
  <strong>Name:</strong>
  <%=h @survey.name %>
</p>

<ol>
  <% for question in @survey.questions %>
  <li><%= h question.content %></li>
  <% end %>
</ol>

<p>
  <%= link_to "Edit", edit_survey_path(@survey) %> |
  <%= link_to "Destroy", @survey, :confirm => 'Are you sure?', :method => :delete %> |
  <%= link_to "View All", surveys_path %>
</p>

Si recargamos la página de la encuesta veremos las preguntas, con lo que se comprueba que cuando se añadió la encuesta también se almacenaron sus preguntas.

Ahora se muestran las preguntas.

Podemos también editar una encuesta y si cambiamos cualquiera de las preguntas éstas se actualizarán cuando enviemos el formulario.

En la página de arriba vemos que aparecen tres preguntas aunque sólo hemos introducido las primeras dos. Sería mejor que las que se dejasen en blanco fueran eliminadas automáticamente. Para esto podemos usar la opción reject_if del método accepts_nested_attributes_for. Este método acepta un lambda al que se le pasa un hash de atributos que podemos usar para rechazar una pregunta si su atributo content viene vacío.

/app/models/survey.rb
class Survey < ActiveRecord::Base
  has_many :questions, :dependent => :destroy
  accepts_nested_attributes_for :questions, :reject_if => lambda { |a| a[:content].blank? }
end

Si ahora creamos una nueva encuesta rellenando sólo dos campos de pregunta, sólo se crearán dichas preguntas y no veremos una pregunta vacía al final de la lista.

Ya no se muestran las preguntas vacías.

¿Y si queremos eliminar alguna pregunta cuando estamos editando una encuesta? En la aplicación final queremos tener un enlace para eliminar preguntas pero por ahora vamos a tirar por el camino rápido y utilizaremos una caja de selección que añadiremos en el parcial del formulario de la encuesta, junto con una etiqueta.

/app/views/survey/_form.html.erb
<% form_for @survey do |f| %>
  <%= f.error_messages %>
  <p>
    <%= f.label :name %><br />
    <%= f.text_field :name %>
  </p>
  <% f.fields_for :questions do |builder| %>
  <p>
    <%= builder.label :content, "Question" %><br />
    <%= builder.text_area :content, :rows => 3 %>
    <%= builder.check_box :_destroy %>
    <%= builder.label :_destroy, "Remove Question" %>
  </p>
  <% end %>
  <p><%= f.submit "Submit" %></p>
<% end %>

El truco está en el nombre de atributo para la caja de selección: _destroy. Cuando tenga un valor true (cuando haya sido marcada), el registro será eliminado al enviar el formulario.

Paa que esto funcione tenemos que habilitarlo en el modelo Survey añadiendo en accepts_nested_attributes_for la opción :allow_destroy => true.

/apps/models/survey.rb
class Survey < ActiveRecord::Base
  has_many :questions, :dependent => :destroy
  accepts_nested_attributes_for :questions, :reject_if => lambda { |a| a[:content].blank? }, :allow_destroy => true
end

Si recargamos la página, veremos que ahora tenemos el checkbox de borrado junto a cada pregunta.

Ya aparecen los checkboxes para poder borrar preguntas.

...y si marcamos la caja de una de las preguntas, ésta será borrada cuando enviemos el formulario.

Se borran correctamente las preguntas

Ahora las respuestas

Ya tenemos las preguntas como queremos pero aún nos faltan las respuestas. Empezaremos, al igual que antes, añadiendo el modelo Answer y configurando su anidamiento. Primero generaremos el modelo.

script/generate model answer question_id:integer content:string

Y luego migraremos la base de datos otra vez.

rake db:migrate

A continuación, configuraremos la relación entre Answer y Question.

/app/models/answer.rb
class Answer < ActiveRecord::Base
  belongs_to :question
end

Para Question tendremos que utilizar accepts_nested_attributes_for igual que hicimos en el modelo Survey model.

/app/models/question.rb
class Question < ActiveRecord::Base
  belongs_to :survey
  has_many :answers, :dependent => :destroy
  accepts_nested_attributes_for :answers, :reject_if => lambda { |a| a[:content].blank? }, :allow_destroy => true
end

En este formulario podríamos añadir los campos para las respuestas pero el código de la vista del formulario quedará bastante recargado si añadiésemos otro modelo anidado. Por otro lado queremos que la adición de preguntas se haga, más adelante, mediante Javascript. Así que por ambas razones moveremos el código del formulario que muestra cada pregunta a su propio parcial llamado question_fields.

Después de hacerlo la vista del formulario tendrá este aspecto.

/app/views/surveys/_form.html.erb
<% 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><%= f.submit "Submit" %></p>
<% end %>

Obsérvese que tan sólo pasamos el nombre del parcial como una cadena a mostrar, haciendo uso de la nueva notación breve que se introdujo en Rails 2.3. También pasamos el builder al parcial con el nombre f. En el nuevo parcial question_fields podemos usar la variable f para pintar los elementos de formulario de una pregunta.

/app/views/surveys/_question_fields.html.erb
<p>
  <%= f.label :content, "Question" %><br />
  <%= f.text_area :content, :rows => 3 %><br />
  <%= f.check_box :_destroy %>
  <%= f.label :_destroy, "Remove Question" %>
</p>

Podemos gestionar los campos de las respuestas de una forma similar y ponerlos en su propio parcial. En el parcial _question_fields recorremos todas las respuestas de una pregunta y llamaremos a un nuevo parcial llamado _answer_fields.

/app/views/surveys/_question_fields.html.erb
<p>
  <%= f.label :content, "Question" %><br />
  <%= f.text_area :content, :rows => 3 %><br />
  <%= f.check_box :_destroy %>
  <%= f.label :_destroy, "Remove Question" %>
</p>
<% f.fields_for :answers do |builder| %>
  <%= render 'answer_fields', :f => builder %>
<% end %>

En nuestro nuevo parcial llamado _answer_fields pondremos el código para mostrar una respuesta.

/app/views/survey/_answer_fields.html.erb
<p>
  <%= f.label :content, "Answer" %>
  <%= f.text_field :content %>
  <%= f.check_box :_destroy %>
  <%= f.label :_destroy, "Remove" %>
</p>

Igual que antes para poder ver los campos de respuestas en el formulario vamos a modificar la acción new de SurveyController para que se creen cuatro respuestas para cada una de las tres nuevas preguntas.

/app/controllers/survey_controller.rb
def new
  @survey = Survey.new
  3.times do
    question = @survey.questions.build
    4.times { question.answers.build }
  end
end

A partir de ahora cuando creemos una encuesta tendremos tres preguntas con cuatro posibles respuestas cada una.

En el formulario de la encuesta ya se muestran los campos de las respuestas.

Cuando se envíen los campos del formulario aún no veremos las respuestas pero esto lo resolveremos fácilmente. En la vista show de una encuesta, añadiremos código para mostrar las respuestas en la sección que recorre cada pregunta .

/app/views/survey/show.html.erb
<ol>
  <% for question in @survey.questions %>
  <li><%= h question.content %></li>
  <ul>
    <% for answer in question.answers %>
      <li><%= h answer.content %></li>
    <% end %>
  </ul>
  <% end %>
</ol>

Si ahora recargamos la página de la encuesta veremos las preguntas y sus repuestas.

Las respuestas aparecen ahora debajo de las preguntas

Todavía no hemos terminado puesto que queremos poder añadir o eliminar dinámicamente preguntas y respuestas del formulario usando enlaces. Esto lo veremos en el próximo episodio.