homeASCIIcasts

197: Formularios de modelos anidados (y II)  (view original Railscast)

Other translations: En It Fr Cn

Written by Juan Lupión

En el episodio anterior aprendimos a crear un formulario que pudiese gestionar múltiples modelos anidados. En la aplicación que creamos tenemos un mode Survey, que a su vez tiene muchas Questions, cada una de las cuales tiene muchas Answers.

La página de edición de una encuesta.

En los modelos Survey y Question hemos utilizado accepts_nested_attributes_for para poder crear, editar y destruir los registros anidados a través de un único modelo.

Tal y como está ahora nuestra aplicación si queremos eliminar una pregunta o una respuesta tenemos que usar una caja de selección, y tampoco tenemos forma de añadir nuevas preguntas o respuestas a través del formulario. En este episodio corregiremos estos problemas utilizando JavaScript para modificar el formulario de forma que podamos usar enlaces para crear y destruir estos modelos dinámicamente.

El JavaScript que vamos a escribir implica que tendremos que manipular el DOM por lo que para hacerlo más fácil utilizaremos la librería Prototypp. Para incluir el código de Prototype a nuestra aplicación añadiremos la siguiente línea a la sección <head> del fichero de layout de nuestra aplicación.

/app/views/layouts/application.html.erb

  <%= javascript_include_tag :defaults, :cache => true %>
  

(Los que prefieran usar jQuery encontrarán el código equivalente al final del episodio.)

Añadiendo enlaces para quitar respuestas

Vamos a acometer primero la parte sencilla: cambiar las cajas de selección por enlaces para quitar preguntas y respuestas. Veamos primero las respuestas. El código que muestra cada respuesta se encuentra en un parcial llamado answer_fields y tiene el siguiente aspecto:

/app/views/surveys/_answer_fields.html.erb

  <p>
    <%= f.label :content, "Answer" %>
    <%= f.text_field :content %>
    <%= f.check_box :_destroy %>
    <%= f.label :_destroy, "Remove" %>
  </p>
  

La caja de selección _destroy es la que marcamos cuando queremos destruir una respuesta. Vamos a poner en su lugar un campo oculto cuyo valor estableceremos cuando se haga clic en el enlace de borrado. De esta manera podremos saber qué respuestas se han marcado para borrar.

Cambiaremos la etiqueta del código anterior por un enlace y usaremos el helper link_to_function de Rails para crear un enlace que al hacer clic sobre él invoque una función JavaScript. Con el campo oculto y el enlace el código del parcial queda así:

/app/views/surveys/_answer_fields.html.erb

  <p class="fields">
    <%= f.label :content, "Answer" %>
    <%= f.text_field :content %>
    <%= f.hidden_field :_destroy %>
    <%= link_to_function "remove", "remove_fields(this)" %>
  </p>
  

Al hacer clic sobre el enlace “remove” que ahora aparece al lado de cada respuesta se lanzará una función llamada remove_fields que recibe dicha respuesta como argumento para que poder utilizarla como referencia para encontrar los otros elementos relacionados con la respuesta. No hay una forma directa de acceder a dichos campos, así que hemos añadido una clase al elemento párrafo que los incluye para poder encontrarlos más fácilmente.

A continuación tenemos que escribir la función remove_fields. Lo haremos en el fichero application.js dado que es uno de los ficheros que se incluye automáticamente en nuestras páginas al haber incluido los ficheros de javascript por defecto (:defaults).

/public/javascripts/application.js

  function remove_fields(link) {
  	$(link).previous("input[type=hidden]").value = "1";
  	$(link).up(".fields").hide();
  }
  

Esta función hace dos cosas. En primer lugar utiliza la función previous de Prototype para encontrar el campo oculto anterior relativo al enlace que llamó a la función, que es el campo _destroy y le pone su valor a 1 de forma que dicha respuesta quedará marcada para ser borrada. Después utiliza el método up para escalar el árbol DOM hasta que encuentra un elemento con la clase fields (que es el nombre de la clase que le dimos al elemento párrafo que contiene los campos de las respuestas) y lo oculta de forma que la respuesta deje de verse.

Si recargamos la página de la encuesta, ahora veremos un enlace junto a cada respuesta.

Las cajas de selección se han convertido en enlaces

Si hacemos clic en algunos de los enlaces de las respuestas se pondrá a 1 el valor del campo oculto _destroy para dichas respuestas y se ocultan los campos del formulario.

Las respuestas se ocultan cuando hacemos clic en en enlace

Nótese, sin embargo, que no estamos utilizando AJAX para actualizar los valores del formulario cuando se hace clic en el enlace de forma que, aunque estemos ocultando las respuestas al momento, la base de datos no será actualizada hasta que enviemos el formulario, sólo entonces se eliminarán y lo veremos en la página show de la encuesta.

The answers have now been removed.

Borrado de las preguntas

Ahora que somos capaces de eliminar respuestas usando enlaces pasaremos a las preguntas. La forma de borrarlas es básicamente la misma, así que podremos reutilizar parte del código que escribimos anteriormente.

Tal y como hicimos con las respuestas vamos a cambiar las etiquetas y cajas de selección por un campo oculto y un enlace, por lo que quitaremos esa parte del parcial answer_fields y la colocaremos en un nuevo método helper llamado link_to_remove_fields, pasando el texto que queremos que aparezca en el enlace, y la variable del formulario f.

/app/views/surveys/_answer_fields.html.erb

  <p class="fields">
    <%= f.label :content, "Answer" %>
    <%= f.text_field :content %>
    <%= link_to_remove_fields "remove", f %>
  </p>
  

Escribiremos el método en el archivo application_helper:

/app/helpers/application_helper.rb

Los métodos que crean campos de formulario devuelven cadenas para que se pueda concatenar el HTML generado por f.hidden_field y link_to_function y devolvérselo al parcial.

Ya podemos usar nuestro nuevo método en el parcial question_fields.

/app/views/surveys/_question_fields.html.erb

Dado que la función remove_fields busca un elemento que tenga la clase fields para ocultar una pregunta o respuesta hemos envuelto todo el parcial en un elemento div con ese nombre de clase para que cuando se haga clic en el enlace “remove” de una pregunta se oculten dicha pregunta y todas sus respuestas.

Si miramos la página de edicion de una encuesta y hacemos clic en el enlace de borrado la pregunta será borrada así como sus respuestas, y cuando enviemos el formulario tanto la pregunta como las respuestas serán efectivamente borradas de la encuesta.

La pregunta y sus respuestas han sido borradas.

Para añadir preguntas y respuestas

Ahora viene la parte más difícil: añadir nuevas preguntas y respuestas. Queremos tener enlaces en el formulario que creen dinámicamente nuevos campos de formulario cuando se haga clic en ellos. Lo que hace que esto sea difícil es que el JavaScript tendrá que acceder a un grupo de campos vacíos para poder crear una nueva pregunta o respuesta como respuesta a un clic.

Para esto tendremos que escribir un nuevo método llamado link_to_add_fields en application_helper.rb. Podremos utilizar este método cada vez que queramos mostrar un enlace para añdir los campos de una nueva pregunta o respuesta en el formulario. El código del método tendrá el siguiente aspecto:

/app/helpers/application_helper.rb

  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
  

Este método recibe tres argumentos: name, que será el texto del enlace; f, el constructor de formulario y association, que en nuestro caso será o bien “questions” o “answers”.

La primera línea del método crea una nueva instancia de esa nueva clase de asociación, es decir, una nueva Question o Answer. Esto quiere decir que tendremos un objeto plantilla que podemos usar para crear los campos del formulario.

La segunda parte del código construye una cadena de los campos del formulario de ese objeto para poder insertarlos en la función JavaScript que los añade al formulario cuando se hace clic en dicho enlace. Esto lo hace llamando al parcial apropiado pasando el constructor de formulario (f). Lo único nuevo aquí es el valor :child_index. Lo empleamos para poder tener algo a lo que hacer referencia para crear los campos para la nueva pregunta o respuesta. En el código JavaScript reemplazaremos el nombre de this con un valor único que estará basado en la hora actual. De esta manera cada vez que creemos una nueva pregunta o respuesta tendrá un índice único que la identifique cuando se envíe el formulario.

Por último utilizamos el método link_to_function de nuevo pasando el nombre del enlace y una llamada a una función JavaScript llamada add_fields a la que le pasaremos el enlace, el nombre de la asociación y una cadena que contiene los campos de formulario escapados.

Ahora podemos volver al código JavaScript y escribir la función add_fields.

/public/javascripts/application.js

  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)
    });
  }
  

Esta función recibe los tres argumentos que mencionábamos anteriormente: el enlace que ha sido pulsado, el nombre de la asociación y una cadena que contiene el HTML de los campos del formulario. Lo primero que hace esta función es crear un nuevo identificador para los campos del formulario. Si creamos varia preguntas nuevas no queremos que tengan todas el mismo campo de índice porque entonces serían consideradas como pertenecientes al mismo modelo al enviar el formulario. Utilizaremos la hora actual para que este identificador sea único y después reemplazaremos la cadena new_question o new_answer por ese identifciador utilizando una expresión regular. Una vez hecho esto insertamos la cadena de campos en su lugar correspondiente en el DOM.

Ya ha pasado lo más difícil. Ahora todo lo que nos queda por hacer es añadir los enlaces propiamente dichos. En el parcial question_fields añadiremos un enlace para añadir una nueva respuesta utilizando link_to_add_fields, pasándole :answers como el nombre de la asociación dado que una pregunta tiene muchas respuestas.

/app/views/surveys/_question_fields.html.erb

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

Podemos hacer algo parecido en el formulario de encuesta para añadir un enlace para añadir preguntas.

/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><%= link_to_add_fields "Add Question", f, :questions %>
    <p><%= f.submit "Submit" %></p>
  <% end %>
  

Si recargamos la página de encuestas ahora veremos los enlaces para añadir una nueva pregunta o respuesta y si hacemos clic en uno de ellos aparecerá un nuevo campo en el formulario.

Aparece un nuevo campo vacío cuando se hace clic en el enlace “Add Answer”.

Cuando hacemos clic en el enlace “Add Answer” aparece un nuevo campo vacío para la respuesta.

Si rellenamos el nuevo campo de respuesta con “jQuery” y enviamos el formulario se añadirá la nueva respuesta.

Se ha añadido la nueva pregunta

Hemos alcanzado nuestro objetivo y ya tenemos un formulario en el que podemos añadir o eliminar campos dinámicamente, y que actualizará adecuadamente la base de datos cuando se envíe.

Código alternativo para jQuery

El JavaScript que hemos utilizado en este episodio funciona con la librería Prototype. Si preferimos utilizar jQuery, el código sería el siguiente:

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));
  }
  

Este código es muy parecido al que hemos escrito para Prototype.

Algunos lectores no estarán satisfechos porque el JavaScript que hemos utilizado no es "no intrusivo". Aunque siempre es preferible adoptar una solución no intrusiva, para este problema en concreto no había una que fuese lo suficientemente sencilla como para presentarla en este episodio. Ryan Bates está trabajando en un plugin llamado nested_form que utiliza jQuery para controlar estos formularios no anidados de forma no intrusiva. Aún está en desarrollo, así que si piensan que necesitan algo como esto, lo mejor es pasarse por allí para ver cómo está de avanzado.