#197 Nested Model Form Part 2
- Download:
- source codeProject Files in Zip (121 KB)
- mp4Full Size H.264 Video (17.1 MB)
- m4vSmaller H.264 Video (12.4 MB)
- webmFull Size VP8 Video (34.2 MB)
- ogvFull Size Theora Video (23.6 MB)
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
.
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.
<%= 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:
<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í:
<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
).
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.
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.
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.
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
.
<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
:
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
.
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.
<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.
<% 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.
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.
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.