homeASCIIcasts

228: Ordenación de tablas por columnas 

(view original Railscast)

Other translations: En It

Other formats:

Written by Juan Lupión

En este episodio aprenderamos a hacer que una tabla se pueda ordenar haciendo clic en cualquiera de sus columnas. A continuación se muestra una página de una aplicación de tienda que muestra un listado de productos en su tabla. Queremos que se puedan ordenar los ítems de la tabla haciendo clic en la cabecera. Tal vez no nos parezca una idea muy interesante porque en nuestro ejemplo sólo tenemos un puñado de productos pero si nuestra tabla paginada tuviese cientos de elementos seguro que resultaría bastante más útil.

Tabla de productos.

Hay bastantes plugins que podemos usar, tales como Searchlogic, que vimos en el episodio 176 [verlo, leerlo], pero en esta ocasión empezaremos de cero y no vamos a usar ningún otro plugin.

Creación de los enlaces

Empezaremos en el código de la vista de la acción index que contiene la tabla que muestra los productos.

/app/views/products/index.html.erb

<% title "Products" %>

<table class="pretty">
  <tr>
    <th>Name</th>
    <th>Price</th>
    <th>Released</th>
  </tr>
  <% for product in @products %>
  <tr>
    <td><%= product.name %></td>
    <td class="price"><%= number_to_currency(product.price, :unit => "&pound;") %></td>
    <td><%= product.released_at.strftime("%B %e, %Y") %></td>
  </tr>
  <% end %>
</table>

<p><%= link_to "New Product", new_product_path %></p>

Tendremos que hacer la mayor parte de los cambios en las celdas de la cabecera de la tabla. Queremos que el texto de dichas celdas sean enlaces para poder ordenar la tabla cuando se haga clic en ellos. Para poder ver cómo cambia el proceso según avanzamos iremos dando pequeños pasos haciendo sólo lo mínimo imprescindible en cada uno de ellos.

Lo primero que haremos será convertir en enlaces los textos del encabezado de la tabla, para lo que añadiremos un link_to antes del texto de cada cabecera (en TextMate esto puede hacerse rápidamente pulsando la tecla "opción" y escogiendo las tres columnas al final de la etiqueta <th>, cualquier texto que escribamos aparecerá simultáneamente en las tres líneas) Queremos que los enlaces vayan a la misma página pero con diferentes parámetros en la petición. Esto lo podemos hacer especificando un hash como el segundo parámetro de link_to.

/app/views/products/index.html.erb

<th><%= link_to "Name", :sort => "name" %></th>
<th><%= link_to "Price", :sort => "price" %></th>
<th><%= link_to "Released", :sort => "released_at" %></th>

Cuando volvamos a cargar la página de productos, en la cabecera de la tabla ahora aparecerán los enlaces y si ponemos el cursor encima de cada uno veremos que en la URL aparece el parámetro adecuado en la URL.

El texto del encabezamiento de cada columna es un enlace.

Ordenación de los productos

Tendremos que modificar la acción index del controlador ProductController para que la ordenación se haga según el parámetro recibido en la cadena de la petición.

/app/controllers/products_controller.rb

def index
  @products = Product.order(params[:sort])
end

Para ordenar los productos vamos a usar el método order de Rails 3. Si estuviésemos con una aplicación Rails 2 podríamos usar find con un hash que especificase el orden. Nótese que estamos pasando directamente parámetros introducidos por el usuario hacia la cláusula de ordenación, esto es algo que no debe hacerse porque dicha entrada no ha sido saneada y por tanto hay peligro de inyección de SQL (vimos este tema en el episodio 25 [verlo, leerlo].) Por ahora lo dejaremos tal cual y más adelante volveremos para corregir esto.

Con este código la ordenación funcionará y la tabla aparecerá ordenada correctamente cuando hagamos clic en uno de los encabezados.

La tabla se ordena dependiendo de la columna en la que se haya hecho clic.

Cambio del sentido de la ordenación

Sin haber tenido que escribir mucho código ya hemos avanzado bastante pero aún nos quedan otras funcionalidades por añadir como por ejemplo ordenar las columnas al revés si se vuelve a hacer clic en el encabezamiento, y mostrar un icono con una flecha según la ordenación escogida.

Empezaremos con el cambio de sentido de la ordenación, cuando se haga clic en el enlace de la columna de la tabla por la que estemos ordenando deberíamos ordenar por el mismo campo pero en el orden inverso. Si hiciésemos esto en la vista añadiríamos bastante lógica y duplicación, por lo que vamos a hacer una función helper que genere cada enlace.

El nuevo helper se llamará sortable y recibirá dos argumentos (el segundo será opcional). El primer argumento será el nombre de la columna y el segundo será el texto de cabecera en caso de que sea diferente del nombre de la columna. Por tanto, la cabecera de la tabla quedará así:

/app/views/products/index.html.erb

<tr>
  <th><%= sortable "name" %></th>
  <th><%= sortable "price" %></th>
  <th><%= sortable "released_at", "Released" %></th>
</tr>

Escribamos ahora el método sortable en el módulo ApplicationHelper.

/app/helpers/application_helper.rb

module ApplicationHelper
  def sortable(column, title = nil)
    title ||= column.titleize
    direction = (column == params[:sort] && params[:direction] == "asc") ? "desc" : "asc"
    link_to title, :sort => column, :direction => direction
  end
end

El método tiene los dos argumentos de los que hablábamos antes, y el argumento title por defecto vale nil por lo que si no viene podemos darle valor basándonos en el argumento column. A continuación tenemos la lógica que determina cuál es la dirección de ordenación para el enlace. Si la columna para la que estamos generando el enlace es la columna por la que ya estamos ordenando y el orden es ascendente entonces hacemos que la dirección sea desc para que la próxima vez que hagamos clic se ordene al revés. En el resto de casos queremos que la dirección de ordenación sea ascendente. Con esto ya podemos añadir el criterio de ordenación como parámetro al enlace.

Si recargamos la página y hacemos clic en el enlace “Name” la tabla mostrará la ordenación por nombre en orden ascendente. Si volvemos a hacer clic otra vez la URL cambia el parámetro direction a desc pero la tabla no se muestra en orden descendente.

El sentido de la ordenación en la cadena de la petición.

Nuestro controlador está ignorando el parámetro que indica el sentido de ordenación. Para corregir esto sólo tenemos que añadir dicho parámetro al método order cuando recuperamos todos los productos en la acción index del controlador ProductController.

/app/controllers/products_controller.rb

def index
  @products = Product.order(params[:sort] + ' ' + params[:direction])
end

Nuevamente estamos pasando directamente a una consulta los parámetros introducidos por el usuario, lo que no es seguro, pero ya arreglaremos esto más adelante. Cuando recarguemos la página ya veremos que los ítems se ordenan correctamente y podemos hacer clic en cualquiera de las columnas para ordenarla en orden ascendente o descendente.

Ya podemos ordenar en ambas direcciones

Valores por defecto

Aunque la tabla ya parece funcionar, si tratamos de ir directamente a la página de productos sin pasar parámetros por la URL veremos que nos da un error porque el código del controlador trata de leer los valores de los parametros directamente de la cadena de la petición. El error aparece al intentar unir ambos parámetros en una única cadena porque ambos son nil .

Tenemos que establecer algunos valores por defecto en los parámetros sort y direction Podemos modificar directamente el hash de params y establecer ambos parámetros si no vienen en la petición, pero en vez de eso vamos a escribir dos métodos en el controlador que devolverán el parámetro o un valor por defecto y luego usaremos estos métodos para construir el argumento de ordenación.

/app/controllers/products_controller.rb

class ProductsController < ApplicationController
  helper_method :sort_column, :sort_direction
  def index
    @products = Product.order(sort_column + ' ' + sort_direction)
  end
  
  private
  def sort_column
    params[:sort] || "name"
  end
  
  def sort_direction
    params[:direction] || "asc"
  end
end

Estos métodos tienen que estar disponibles en ApplicationHelper para que puedan ser usados desde el método sortable que escribimos antes:

/app/helpers/application_helper.rb

module ApplicationHelper
  def sortable(column, title = nil)
    title ||= column.titleize
    direction = (column == sort_column && sort_direction == "asc") ? "desc" : "asc"
    link_to title, :sort => column, :direction => direction
  end
end

Si visitamos otra vez la página de productos sin especificar parámetros la página utilizará la ordenación por defecto: el nombre de producto en orden ascendente. Si hacemos clic en el enlace “Name” la lista se ordenará en sentido descendente, como sería de esperar.

A default sort order.

Asegurando la consulta

Ya vimos antes que no es recomendable pasar datos introducidos por el usuario directamente a una consulta de base de datos (como por ejemplo la cláusula de ordenación) debido al peligro que supone la inyección SQL. Para sanear la entrada podríamos hacer un saneado genérico de los parámetros pero en vez de eso adoptaremos un enfoque más estricto.

Como ya tenemos métodos de acceso a la columna y sentido de la ordenación podemos añadir código en estos métodos que garantice que los valores pasados sean seguros y válidos. El sentido de la ordenación sólo puede adoptar dos valores, por lo que podemos comprobar si el parámetro recibido se corresponde con uno de ellos y si no, establecer por defecto el valor asc.

/app/controllers/products_controller.rb

def sort_direction
  %w[asc desc].include?(params[:direction]) ?  params[:direction] : "asc"
end

Podemos hacer algo muy parecido en el método sort_column para asegurarnos de que el parámetro de ordenación se correspondente con alguno de los campos del modulo de producto, devolviendo “name” en caso contrario.

/app/controllers/products_controller.rb

def sort_column
  Product.column_names.include?(params[:sort]) ? params[:sort] : "name"
end

Podríamos incluso ser todavía más estrictos y restringir la ordenación sólo a ciertas columnas en la tabla, pero con esto nos bastará por ahora.

Mostrar el campo de ordenación escogido

Acabaremos este episodio añadiendo un icono delante del campo por el que estemos ordenando en un momento dado para que indique la dirección en la cual se está realizando la ordenación. Podemos hacer esto con CSS pero primero tenemos que hacer algunos ajustes en el método sortable para que añada una clase al enlace en la celda de la cabecera en el campo de ordenación actual.

/app/helpers/application_helper.rb

module ApplicationHelper
  def sortable(column, title = nil)
    title ||= column.titleize
    css_class = (column == sort_column) ? "current #{sort_direction}" : nil
    direction = (column == sort_column && sort_direction == "asc") ? "desc" : "asc"
    link_to title, {:sort => column, :direction => direction}, {:class => css_class}
  end
end

En el método hemos añadido la variable css_class. Si la columna actual es por la que estamos ordenando dicha variable valdrá o bien current asc o bien current desc dependiendo del sentido de la ordenación (en caso contrario su valor será nil).

Podemos añadir el atributo class en el código que genera el enlace, pero debemos recordar que tendremos que separar los parámetros en dos hashes diferentes para que el atributo de clase no sea pasado como parámetro en la URL de la etiqueta.

Ya tenemos listas dos imágenes que serán las que usaremos en el directorio /public/images de nuestra aplicación, por lo que sólo nos queda añadir algo de CSS en la hoja de estilos de la aplicación para mostrar la imagen correcta.

/public/stylesheets/application.css

.pretty th .current {
  padding-right: 12px;
  background-repeat: no-repeat;
  background-position: right center;
}
.pretty th .asc { 
  background-image: url(/images/up_arrow.gif); 
}

.pretty th .desc { 
  background-image: url(/images/down_arrow.gif); 
}

Si reordenamos la tabla, la columna por la que se estará ordenando dicha tabla tendrá una flecha para indicar que es la columna de ordenación.

La fecha muestra la columna y el sentido de la ordenación

Con esto terminamos este episodio. Ya tenemos lo que queríamos: una tabla que podemos ordenar por cualquiera de sus columnas y que muestra un indicador de cuál es la columna por la que estamos ordenando.