#228 Sortable Table Columns
- Download:
- source codeProject Files in Zip (107 KB)
- mp4Full Size H.264 Video (17 MB)
- m4vSmaller H.264 Video (11.5 MB)
- webmFull Size VP8 Video (30.4 MB)
- ogvFull Size Theora Video (25.2 MB)
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.
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.
<% 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 => "£") %></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
.
<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.
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.
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.
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í:
<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
.
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.
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
.
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.
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.
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:
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.
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
.
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.
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.
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.
.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.
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.