homeASCIIcasts

240: Ordenación, paginación y búsqueda con AJAX 

(view original Railscast)

Other translations: En

Other formats:

Written by Juan Lupión

En el episodio 228 [verlo, leerlo] creamos una tabla de productos que se podía ordenar haciendo clic en un enlace en la cabecera de cada columna. En este episodio llevaremos esta idea aún más lejos y añadiremos funcionalidades de búsqueda y paginación. Además añadiremos JavaScript no intrusivo para que dicha funcionalidad esté disponible con AJAX de forma que la tabla se pueda buscar, ordenar y paginar sin necesidad de recargar toda la página.

Todas estas técnicas ya han sido vistas individualmente en anteriores episodios pero puede resultar difícil combinar todas estas funcionalidades especialmente si queremos que la aplicación ofrezca la funcionalidad mediante AJAX y que sea compatible con Rails 3. Dado que la parte más complicada de esto es crear una tabla con columnas ordenables y esto ya lo tenemos empezaremos con el código del episodio 228. Se puede obtener el código del episodio de las páginas de Ryan Bates y cuando la aplicación se ejecute tendrá el siguiente aspecto. Nótese que no hay ni paginación ni búsqueda y que la ordenación se hace recargando la página, enviando los parámetros de búsqueda en la URL.

Tabla de productos.

Paginación y Búsqueda

El primer cambio que haremos será añadir paginación y búsqueda. Para empezar, lo haremos sin AJAX y una vez que tengamos las nuevas funcionalidades añadiremos el JavaScript necesario para que funcione sin recargar la página.

Empezaremos por la paginación. La gema will_paginate hace que sea fácil, aunque la única versión que funciona con Rails 3 es la de pre-lanzamiento (actualmente la 3.0.pre2) por lo que tendremos que especificar este número de versión cuando añadamos will_paginate al Gemfile de la aplicación.

/Gemfile

gem 'will_paginate', '3.0.pre2'

Como es habitual tendremos que ejecutar bundle install después de haber modificado el Gemfile para asegurarnos de que la gema queda instalada correctamente. Cuando Bundler haya terminado modificaremos la acción index de nuestro ProductsController para añadir la paginación en el listado de productos añadiendo el método paginate al final. Este método recibe dos parámetros: el primero es el número de ítems que queremos mostrar en cada página (en nuestro caso lo dejaremos a 5) y el segundo es el número de página que podemos recuperar del parámetro page de la URL.

/app/controllers/products_controller.rb

def index
  @products = Product.order(sort_column + ' ' + sort_direction).paginate(:per_page => 5, :page => params[:page])
end

En la vista index añadiremos la siguiente línea debajo de la tabla de productos para que en la página aparezcan los enlaces de paginación.

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

<%= will_paginate @products %>

Si ahora recargamos la página veremos que la página muestra los enlaces de paginación y podemos recorrer el listado de productos de cinco en cinco.

La tabla ya tiene paginación.

La paginación ya está en su sitio, si bien no funciona con AJAX. Pasaremos a la búsqueda. Ha habido varios episodios de Railscasts dedicados a las búsquedas, el primero de ellos “Simple Search Form” tiene ya más de 3 años pero sigue siendo aplicable por lo que podemos copiar y pegar en nuestra aplicación el código de ejemplo con cambios mínimos. Primero añadiremos el formulario de búsqueda a nuestro listado de productos.

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

<%= form_tag products_path, :method => 'get' do %>
  <p>
    <%= text_field_tag :search, params[:search] %>
    <%= submit_tag "Search", :name => nil %>
  </p>
<% end %>

Para que funcione el código de ejemplo del episodio 37 hemos tenido que cambiar projects_path por products_path en el form_tag, y también hemos tenido que añadir el símbolo de igualdad en la apertura de la etiqueta para hacer que sea compatible con Rails 3.

En la acción index del controlador tenemos que invocar a search sobre el modelo.

/app/controllers/products_controller.rb

def index
  @products = Product.search(params[:search]).order(sort_column + ' ' + sort_direction).paginate(:per_page => 5, :page => params[:page])
end

Nótese que estamos invocando a search antes que a order por lo que tenemos que asegurarnos de que search devuelve un ámbito en lugar de un array de registros. Añadiremos el método de clase search al modelo Product, haciendo algunos cambios al código del episodio 37 para hacer que funcione con Rails 3. Éste era el código original:

def self.search(search)
  if search
    find(:all, :conditions => ['name LIKE ?', "%#{search}%"])
  else
    find(:all)
  end
end

El código anterior utiliza find(:all), que devuelve un array de registros en lugar de un ámbito y que además está obsoleto en Rails 3.0 por lo que en su lugar utilizaremos where. En la condición else donde el código devuelve todos los registros podríamos utilizar all, pero esto también devolvería un listado de registros en lugar del ámbito que queremos, por lo que utilizaremos scope, que devolverá un ámbito vacío sobre los productos y permitirá que añadamos otras consultas más adelante. Con estos cambios el modelo Product quedaría así:

/app/models/product.rb

class Product < ActiveRecord::Base
  attr_accessible :name, :price, :released_at

  def self.search(search)
    if search
      where('name LIKE ?', "%#{search}%")
    else
      scoped
    end
  end
end

Obsérvese que estamos lanzando una búsqueda muy simple contra el campo name. En una aplicación más compleja tendríamos que utilizar un motor de búsqueda de texto completo como Sphinx. Si este es el caso merecerá la pena leer el episodio dedicado a Thinking Sphinx.

Si ahora recargamos la página de productos otra vez veremos que aparece el campo de búsqueda y si buscamos por ejemplo “video” veremos un listado filtrado de productos.

La tabla tiene la búsqueda implementada.

Aún quedan un par de cosas por corregir. Se pierde el término de búsqueda si se hace clic en una de las columnas para ordenar los resultados filtrados, y otro problema es que si por ejemplo se ordena por precio y luego se lanza una búsqueda la ordenación vuelve a efectuarse por nombre. Tenemos que corregir la búsqueda y la ordenación para que tengan en cuenta sus configuraciones entre sí.

Para que la ordenación permanezca cuando se realice una búsqueda tenemos que pasar los parámetros de ordenación como campos ocultos en el formulario de búsqueda. Esto puede hacerse simplemente añadiendo un par de campos ocultos al formulario que guarden los campos de orden y sentido obtenidos a partir de la URL.

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

<%= form_tag products_path, :method => 'get' do %>
  <%= hidden_field_tag :direction, params[:direction] %>
  <%= hidden_field_tag :sort, params[:sort] %>
  <p>
    <%= text_field_tag :search, params[:search] %>
    <%= submit_tag "Search", :name => nil %>
  </p>
<% end %>

A continuación tenemos que hacer que la búsqueda persista cuando cambie el campo de ordenación. Para esto tenemos que cambiar el código que genera los enlaces de ordenación. Si repasamos el episodio 228 veremos que esto se encuentra en el método sortable en un helper de la aplicación.

/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

El enlace se devuelve en la última línea del método y está claro que para que el término de búsqueda persista tenemos que añadirlo como parámetro en el enlace. Esto implica, sin embargo, que tendremos que cambiar este método cada vez que añadamos parámetros nuevos a la funcionalidad de búsqueda. En Rails 2 hubiéramos podido usar overwrite_params, que ha sido eliminado en Rails 3. La solución que podemos usar es utilizar params.merge.

/app/helpers/application_helper.rb

link_to title, params.merge(:sort => column, :direction => ↵
  direction), {:class => css_class}

Haciendo esto conseguiremos propagar todos los parámetros que no sean los usados para la ordenación. Dicho lo cual, no queremos que el número de página esté incluido porque siempre querremos que la paginación comience por la primera página cuando se cambie el campo de ordenación, así que asignaremos el valor nil al parámetro page.

/app/helpers/application_helper.rb

link_to title, params.merge(:sort => column, :direction => direction, :page => nil), {:class => css_class}

Ya podemos probarlo. Si ordenamos la lista por precio y luego buscamos “video” veremos que se mantiene el sentido de la ordenación.

La ordenación se mantiene al realizar una búsqueda.

Si ahora cambiamos la ordenación veremos que también el término de búsqueda también permanece.

También se persite el término de búsqueda al cambiar la ordenación

Incorporando AJAX

Una vez que funcionan la búsqueda, ordenación y paginación, vamos a añadir algo de AJAX para que todo esto ocurra sin tener que recargar la página. Antes de hacer esto en nuestras propias aplicaciones deberíamos preguntarnos si el uso de AJAX realmente va a mejorar la experiencia de usuario. A menudo es mejor dejarlo en este punto porque el uso de AJAX hace que sea difícil que funcionen como es debido los marcadores del navegador y el botón de volver atrás. Sigamos leyendo si, por el contrario, tenemos claro que la interfaz de usuario de nuestra aplicación se beneficiaría de utilizar AJAX.

Para hacer esto vamos a utilizar jQuery. La manera más fácil de añadir jQuery a una aplicación Rails es utilizando una gema llamada jquery-rails, por lo que la añadiremos a la aplicación y ejecutaremos bundle install una vez más.

/Gemfile

gem 'jquery-rails'

Para la instalación de los archivos de jQuery en la aplicación ejecutaremos la orden jquery:install.

$ rails g jquery:install

Tal vez aparezca un error cuando ejecutemos esta orden. Este error ya se ha corregido en la última versión de la gema por lo que si lo vemos tendremos que solicitar la instalación de un número de versión superior a la 0.2.5.

/Gemfile

gem 'jquery-rails', '>=0.2.5'

Con la última versión de la gema instalada mediante bundle todo debería funcionar sin problemas. Nos avisará que hay un conflicto con el fichero rails.js, pero podemos sobreescribirlo sin problema.

Ahora que tenemos jQuery instalado podemos añadir un poco de AJAX a la página. Lo primero que tenemos que hacer es identificar la parte de la página que queremos actualizar. En este caso se trata de la tabla que muestra el listado de productos por lo que la moveremos de la vista index a su propio parcial.

/app/views/products/_products.html.erb

<table class="pretty">
  <tr>
    <th><%= sortable "name" %></th>
    <th><%= sortable "price" %></th>
    <th><%= sortable "released_at", "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>

Rodearemos el parcial de un div que tendrá un id que podamos usar para identificarlo desde JavaScript.

app/views/products/index.html.erb

<% title "Products" %>

<%= form_tag products_path, :method => 'get' do %>
  <%= hidden_field_tag :direction, params[:direction] %>
  <%= hidden_field_tag :sort, params[:sort] %>
  <p>
    <%= text_field_tag :search, params[:search] %>
    <%= submit_tag "Search", :name => nil %>
  </p>
<% end %>

<div id="products"><%= render 'products' %></div>

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

Ya podemos añadir el código JavaScript que añadirá la funcionalidad AJAX.

/public/javascripts/application.js

$(function () {
  $('#products th a').live('click', function () {
    $.getScript(this.href);
    return false;
  });
})

El código empieza con la función $ de jQuery. Si a esta función se le pasa como argumento otra función, ésta será ejecutada cuando el DOM de la página haya terminado de cargarse. El código dentro de esta función utiliza un selector de jQuery para encontrar todas las etiquetas ancla dentro de las celdas de la cabecera y les añade un evento click. Utilizamos la función live en lugar de click para adjuntar los eventos de forma que cuando se recargue la página no tengamos que volver a asociar el evento.

Cuando se haga clic en uno de los enlaces se invocará a la función $.getScript de jQuery, que cargará y ejecutará JavaScript desde el servidor. El fichero que queremos cargar tiene la misma URL que el enlace por lo que podemos pasar el atributo href del enlace como argumento. Por último la función devuelve false para que el navegador no abra el propio enlace.

Si ahora recargamos la página veremos que no pasará nada si intentamos hacer clic en alguno de los enlaces en la parte superior de la tabla. Esto ocurre porque aún no hemos escrito una plantilla JavaScript para la acción index a la que llaman los enlaces. Y eso es lo que haremos ahora.

Queremos que el código de esta plantilla actualice el div de productos con la salida del parcial _products, lo que no nos costará demasiado.

/app/views/products/index.js.erb

$('#products').html('<%= escape_javascript(render("products")) %>');

Si ahora recargamos la página veremos que los enlaces de ordenación ya funcionan y la tabla se ordena sin recargar la página. Obviamente esto es difícil de capturar con pantallazos, pero si abrimos la página en Firefox y utilizamos Firefox para ver las peticiones XMLHTTP podremos verlas, así como la respuesta devuelta.

Ya se hace la ordenación por AJAX.

Con todo esto ya es muy fácil hacer que funcionen también los enlaces de paginación. Lo único que hay que hacer es añadir esos enlaces a la lista de elementos que invocan la llamada AJAX.

/public/javascripts/application.js

$(function () {
  $('#products th a, #products .pagination a').live('click', ↵ 
    function () {
      $.getScript(this.href);
      return false;
    }
  );
});

Y eso es todo. Ahora podemos ver las distintas páginas del listado sin tener que recargar la página.

El formulario de búsqueda

La última sección de la página que nos queda por hacer mediante AJAX es el formulario de búsqueda. Lo primero que tenemos que hacer es darle al elemento del formulario un id para que sea seleccionable mediante jQuery.

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

<%= form_tag products_path, :method => 'get', :id => ↵
  "products_search" do %>
  <%= hidden_field_tag :direction, params[:direction] %>
  <%= hidden_field_tag :sort, params[:sort] %>
  <p>
    <%= text_field_tag :search, params[:search] %>
    <%= submit_tag "Search", :name => nil %>
  </p>
<% end %>

Ahora sólo tenemos que añadir un poco más de JavaScript en el fichero application.js.

/public/application.js

$(function () {
  // Sorting and pagination links.
  $('#products th a, #products .pagination a').live('click', 
    function () {
      $.getScript(this.href);
      return false;
    }
  );
  
  // Search form.
  $('#products_search').submit(function () {
    $.get(this.action, $(this).serialize(), null, 'script');
    return false;
  });
});

El nuevo código selecciona el formulario de búsqueda y se queda esperando a su evento submit. Cuando esto ocurre se usa la función $.get para hacer una petición AJAX a la acción del formulario, pasando los datos del formulario con $(this).serialize, null porque no queremos que se ejecute ninguna función a la vuelta, y 'script' para que la respuesta se ejecute como JavaScript. Tras esto, devolvemos false para que no se envíe el formulario.

Ya podemos enviar la búsqueda y la tabla filtrará los resultados sin cargar la página entera. Una vez más usaremos Firefox y Firebug para ver la petición AJAX y su respuesta.

Las búsquedas ya van por AJAX también.

También podemos cambiar fácilmente el JavaScript para que la búsqueda también se haga en vivo y los resultados se actualicen automáticamente con cada pulsación (téngase en cuenta que esto es una demostración y no es la mejor forma de hacerlo, hay varias extensiones de jQuery que permiten hacer esto en nuestras aplicaciones de producción):

/public/application.js

$('#products_search input').keyup(function () {
  $.get($('#products_search').attr('action'), ↵ 
    $('#products_search').serialize(), null, 'script');
  return false;
});

Ahora cada vez que introducimos un carácter en la caja de texto se realiza la petición AJAX y se actualiza la tabla.

La búsqueda se hace 'en vivo'

Pero ahora hemos introducido un bug al hacer que caja de búsqueda funcione mediante AJAX. Cuando se realiza una búsqueda la ordenación vuelve a su comportamiento por defecto de ordenar por nombres. Esto es porque los campos ocultos que almacen en campo de ordenación y el sentido de ordenación no se actualizan automáticamente cuando se lleva a cabo la petición AJAX. Para corregir esto tenemos que mover estos campos también al parcial de productos.

/app/views/products/_products.html.erb

<%= hidden_field_tag :direction, params[:direction] %>
<%= hidden_field_tag :sort, params[:sort] %>
<table class="pretty">
  <tr>
    <th><%= sortable "name" %></th>
    <th><%= sortable "price" %></th>
    <th><%= sortable "released_at", "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>
<%= will_paginate @products %>

Tambén moveremos el parcial al formulario.

/app/view/products/index.html.erb

<% title "Products" %>

<%= form_tag products_path, :method => 'get', :id => "products_search" do %>
  <p>
    <%= text_field_tag :search, params[:search] %>
    <%= submit_tag "Search", :name => nil %>
  </p>
 <div id="products"><%= render 'products' %></div>
<% end %>

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

Ahora cuando realicemos una búsqueda se mantendrá el orden.

Ya funciona todo como queremos.

Con esto es todo, ya hemos terminado y tenemos una interfaz atractiva con paginación, ordenación y búsqueda con AJAX.