#262 Trees with Ancestry
- Download:
- source codeProject Files in Zip (206 KB)
- mp4Full Size H.264 Video (16.7 MB)
- m4vSmaller H.264 Video (10.5 MB)
- webmFull Size VP8 Video (23.6 MB)
- ogvFull Size Theora Video (21.8 MB)
Supongamos que tenemos una aplicación que nos permite enviar mensajes. La página de mensajes muestra un listado de todos los mensajes con un área de texto en la cual se puede añadir un nuevo mensaje.
Los mensajes nuevos aparecen en la parte de abajo de la lista. Para mejorar esta aplicación queremos añadir la posibilidad de anidar los mensajes. Añadiremos un enlace ‘reply’ a cada mensaje que nos permitirá responder a un mensaje específico. El nuevo mensaje aparecerá inmediatamente debajo de su padre en la lista.
En el episodio 162 [verlo, leerlo] creamos una asociación en árbol utilizando el plugin acts_as_tree. Este enfoque funcionaría aquí pero su rendimiento no sería el mejor porque necesitaría hacer una consulta SQL separada para determinar los hijos de cada mensaje. Sería mucho mejor si pudiésemos recuperar todos los descendientes de un mensaje dado a partir de una única consulta.
Hay disponibles varios plugins que implementan conjuntos anidados, el que vamos a usar se llama Ancestry. Se distingue de los otros porque almacena toda la información de la jerarquía en una única columna, en lugar de manejar un único campo de entero para alojar el padre de un registro. Esto hace que cada registro guarde toda la información que necesita para recuperar los registros relacionados y Ancestry proporciona varios métodos, incluyendo parent
, siblings
y children
.
Para incorporar Ancestry en nuestra aplicación añadiremos una referencia a la gema en el Gemfile
de la aplicación y luego ejecutaremos bundle
para instalarlo.
source 'http://rubygems.org' gem 'rails', '3.0.6' gem 'sqlite3' gem 'nifty-generators' gem 'ancestry'
A continuación ejecutaremos una migración para añadir la funcionalidad de Ancestry a la tabla de messages
.
$ rails g migration add_ancestry_to_message ancestry:string
El README de Ancestry sugiere añadir un índice al campo ancestry
así que se lo añadiremos también a la migración antes de ejecutarla.
class AddAncestryToMessage < ActiveRecord::Migration def self.up add_column :messages, :ancestry, :string add_index :messages, :ancestry end def self.down remove_index :messages, :ancestry remove_column :messages, :ancestry end end
Ya podemos ejecutar la migración como es habitual con rake db:migrate
.
El paso final es modificar el modelo Message
y añadir una llamada a has_ancestry
.
class Message < ActiveRecord::Base has_ancestry end
Con esto ya hemos terminado de configurar Ancestry.
Cómo añadir hilos en los mensajes
Con Ancestry instalado ya podemos empezar a hacer cambios en nuestra aplicación . Lo primero es modificar el código de la vista que muestra la lista de mensajes y añadir un enlace “Reply” a cada uno. Con esto enlazaremos a la página de nuevo mensaje pero tenemos que saber a qué mensaje está respondiendo el que vamos a crear, así que pasaremos el id
del mensaje correspondiente como el parámetro parent_id
.
<% title "Messages" %> <% for message in @messages %> <div class="message"> <div class="created_at"><%= message.created_at.strftime("%B %d, %Y") %></div> <div class="content"> <%= message.content %> </div> <div class="actions"> <%= link_to "Reply", new_message_path(:parent_id => message) %> | <%= link_to "Destroy", message, :confirm => "Are you sure?", :method => :delete %> </div> </div> <% end %> <%= render "form" %>
En la acción new
de MessageController
tenemos que pasar el parámetro parent_id
al nuevo Message
. Ancestry utilizará este parent_id
para establecer el padre de este mensaje.
def new @message = Message.new(:parent_id => params[:parent_id]) end
Por último en el formulario de nuevo mensaje añadiremos este parent_id
como un campo oculto para que el nuevo mensaje se marque como hijo del mensaje al que responde.
<%= form_for @message do |f| %> <%= f.error_messages %> <%= f.hidden_field :parent_id %> <p> <%= f.label :content, "New Message" %><br /> <%= f.text_area :content, :rows => 8 %> </p> <p><%= f.submit "Post Message" %></p> <% end %>
Ya podemos probarlo todo. Si recargamos la página de mensajes veremos el enlace para responder en cada mensaje. Haciendo clic en uno de los enlaces nos llevará a la página de nuevo mensaje con el id
del mensaje al que estamos respondiendo como parámetro.
Nos gustaría poder ver en el formulario el mensaje al que estamos respondiendo. Idealmente, tendríamos alguna funcionalidad basada en AJAX que mostraría dinámicamente el formulario cuando se hace clic en el enlace ‘reply’ pero por simplicidad lo mostraremos en la página de nuevo mensaje sobre el formulario. Para hacerlo todavía más fácil moveremos el código que muestra un mensaje a su propio parcial.
<div class="message"> <div class="created_at"><%= message.created_at.strftime("%B %d, %Y") %></div> <div class="content"> <%= message.content %> </div> <div class="actions"> <%= link_to "Reply", new_message_path(:parent_id => message) %> | <%= link_to "Destroy", message, :confirm => "Are you sure?", :method => :delete %> </div> </div>
Ya podemos actualizar el código de la vista index
para utilizar el nuevo parcial.
<% title "Messages" %> <%= render @messages %> <%= render "form" %>
Ahora en la nueva plantilla podemos usar este parcial para mostrar el mensaje padre, si es que lo hay
<% title "Reply" %> <%= render @message.parent if @message.parent %> <%= render "form" %> <p><%= link_to "Back to Messages", messages_path %></p>
Si ahora hacemos clic en ‘reply’ sobre un mensaje ya existente, dicho mensaje aparecerá en el formulario. Podemos introducir una respuesta y ver lo que ocurre.
Cuando ahora hagamos clic en el botón ‘Post Message’ seremos dirigidos a la acción index
y veremos nuestro nuevo mensaje. El padre de este mensaje ha sido almacenado por Ancestry pero como no hemos modificado la forma en la que mostramos la lista, el nuevo mensaje aparece al final de la misma, en lugar de aparecer debajo de su padre.
Reorganización de los mensajes
Ancestry nos da un método llamado arrange
que devuelve los registros como un conjunto anidado de hashes que en nuestro caso encaja perfectamente para organizar los mensajes en forma de hilos. Podemos pasar una cláusula :order
a este método para especificar cómo deben volver ordenados todos esos registros.
Usaremos arrange
en la plantilla index
para devolver los mensajes en el orden que queremos. Como arrange
devuelve un hash no podemos pasar su salida directamente a render
, tendremos que iterar sobre cada mensaje devuelto de forma separada. Para esto vamos a escribir un método help que llamaremos nested_messages
y que podemos usar en lugar de render
para visualizar la lista anidada de mensajes.
<% title "Messages" %> <%= nested_messages @messages.arrange(:order => :created_at) %> <%= render "form" %>
Escribiremos nested_messages
en MessagesHelper
y recibirá un conjunto anidado de hashes de mensajes. El método tendrá que recorrer cada hash. Para esto usaremos map
, y tendremos que unirlo todo a la vuelta. El bloque que recibe map
tendrá un mensaje como clave y sus respuestas como valor.
En el bloque mostraremos el mensaje actual y luego llamaremos recursivamente nested_messages
pasando los descendientes del mensaje actual. Queremos modificar la apariencia de las respuestas así que usaremos content_tag
para rodear estos mensajes por un div
al que pondremos la clase nested_message
. Una vez que hayamos sacado todos los mensajes los uniremos y llamaremos a html_safe
en la salida.
module MessagesHelper def nested_messages(messages) messages.map do |message, sub_messages| render(message) + content_tag(:div, nested_messages(sub_messages), :class => "nested_messages") end.join.html_safe end end
Si ahora recargamos la página de un mensaje se verá la respuesta del primero en el lugar correcto, debajo de su padre.
Como le hemos puesto una clase distinta a cada respuesta, podemos ajustar los estilos para indentarlos de forma que la relación entre un mensaje y sus respuestas sea más evidente.
.nested_messages { margin-left: 30px; }
Si un mensaje tiene un número elevado de respuestas los márgenes se irán añadiendo y algunas respuestas aparecerán demasiado lejos en la pantalla. Podemos hacer que las respuestas que tengan, por ejemplo, más de tres niveles de anidamiento no sean indentadas añadiendo la siguiente regla de estilo:
.nested_messages .nested_messages .nested_messages { margin-left: 0; }
Si ahora recargamos la página de mensajes veremos que los mensajes anidados se indentan adecuadamente. Si respondemos a nuestra propia respuesta anterior veremos que se indenta también correctamente.
Mostrar un único hilo
Para concluir este episodio añadiremos una nueva funcionalidad que consistirá en modificar la aplicación para que podamos hacer clic en un mensaje para ver sólo ese mensaje y sus respuestas.
Lo primero que haremos será cambiar el texto de cada mensaje para que se muestre un enlace que llevará a la acción show
de dicho mensaje.
<div class="message"> <div class="created_at"><%= message.created_at.strftime("%B %d, %Y") %></div> <div class="content"> <%= link_to message.content, message %> </div> <div class="actions"> <%= link_to "Reply", new_message_path(:parent_id => message) %> | <%= link_to "Destroy", message, :confirm => "Are you sure?", :method => :delete %> </div> </div>
Ya podemos modificar la plantilla de show
para mostrar los mensajes. Invocaremos a subtree
en el mensaje, que recuperará al propio mensaje y a sus hijos y llamaremos a arrange
sobre el resultado para organizarlo todo por su fecha de creación.
<% title "Message" %> <%= nested_messages @message.subtree.arrange(:order => :created_at) %> <p><%= link_to "Back to All Messages", messages_path %></p>
Si ahora recargamos la página de mensaje y hacemos clic en uno de los mensajes veremos ese mensaje y sus hijos.
Todas las repuestas serán recuperadas en una única consulta SQL independientemente de cuántas respuestas tenga un mensaje dado, por lo que no tendremos que preocuparnos si esto está afectando al rendimiento de nuestra aplicación.
Con esto concluimos este episodio dedicado a Ancestry. Es una solución interesante si tenemos que organizar nuestros modelos de Rails en una estructura de árbol.