Warning : This website is not updated anymore. Its content surely is outdated.

Comment paginer, trier et filtrer un tableau avec Ajax et Rails

Introduction et avertissements

Avertissement important : comme je n'ai malheureusement plus trop l'occasion de travailler avec Rails ces temps-ci, il est possible que certains points de ce documents soient rendus obsolètes pour les versions les plus récentes de Rails.

L'objectif de ce tutoriel est d'utiliser Ajax et Ruby on Rails pour afficher un tableau de manière dynamique avec les fonctionnalités suivantes :

Une version de démonstration de cette application est visible à l'adresse suivante :

http://dev.nozav.org/ajaxtable/

Il s'agit de quelque chose de très courant lorsqu'on développe une application Web. L'intérêt d'utiliser Ajax est de permettre une interface dynamique qui ne recharge pas l'intégralité de la page à chaque changement. L'intérêt d'utiliser Rails... et bien si vous lisez ceci vous devez déjà être convaincus, mais Ajax est étroitement intégré à Rails, et son utilisation est ainsi grandement facilitée.

Quoi qu'il en soit, le code dans ce tutoriel a été conçu pour fonctionner aussi de manière "classique", c'est à dire en rechargeant l'intégralité de la page, lorsque javascript est désactivé ou non supporté par le navigateur du client. Ceci est très important pour des raisons d'accessibilité.

Le code qui suit est largement issu de plusieurs pages du wiki de rails, et en particulier de How to make a real-time search box with the Ajax helpers, et How to paginate with Ajax. J'ai essentiellement réunis ensemble ces différents éléments en modifiant légèrement le code par endroit.

Et maintenant, les avertissements : je suis loin d'être un expert de Rails, et très loin d'être un spécialiste Ajax. Ce document doit donc être abordé comme une introduction rédigée par un débutant et pour des débutants : le code pourrait sans doute être plus propre et les explications plus détaillées.

Dans tous les cas, n'hésitez pas à me faire parvenir vos remarques, critiques, etc. à l'adresse suivante :

julien (at) nozav (dot) org

Installation et configuration de l'application

La première chose à faire est d'installer et configurer la base de notre application. Si vous avez déjà effectué ce genre de choses auparavant, vous pouvez sans doute sauter cette section.

Prérequis

Ce document s'applique à une version récente de Rails (au minimum la 2.0) et nécessite l'utilisation d'un SGBD. Dans ce qui suit nous utiliserons Sqlite, mais vous pouvez évidemment le remplacer par ce que vous voulez.

Fichiers

D'abord, nous devons créer le "squelette" de notre application dans le répertoire de notre choix1 :

$ rails ajaxtable
$ cd ajaxtable

Depuis la version 2.0 de Rails, les fonctionnalités de pagination ont été intégrées sous la forme de plugins. Le plugin le plus utilisé actuellement se nomme will_paginate, mais je ne suis pas parvenu à faire fonctionner ce tutoriel avec. Nous allons donc installer le plugin "historique" de pagination de Rails, qui se nomme classic pagination :

$ ruby script/plugin install svn://errtheblog.com/svn/plugins/classic_pagination

Comme il s'agit d'une application très simple, nous n'utiliserons qu'un seul modèle, qui représentera les éléments du tableau, et un seul contrôleur pour ces éléments. Les fichiers correspondants sont générés par les scripts Rails :

$ ruby script/generate model Item
$ ruby script/generate controller Item

Base de données

Nous devons désormais configurer la source des données de notre tableau. Par souci de simplicité nous utiliserons Sqlite associé aux schémas Rails. Ceci signifie que nous allons décrire notre base de données à l'intérieur de notre application Rails et laisser ensuite ce dernier gérer tout ça.

D'abord, il faut configurer la base de données de développement dans config/database.yml :

development:
  adapter: sqlite3
  database: db/development.db

Ensuite, nous allons créer la base à parti des outils de migration de Rails :

$ ruby script/generate migration database_creation

Vous devriez maintenant avoir un fichier db/migrate/001_database_creation.rb dans lequel nous allons définir notre première table de la manière suivante :

class DatabaseCreation < ActiveRecord::Migration

  def self.up
    create_table :items do |t|
      t.column :name, :string, :limit => 30
      t.column :quantity, :integer, :null => false, :default => 0
      t.column :price,  :integer, :null => false, :default => 0
    end
  end

  def self.down
    drop_table :items
  end

end

Ceci définit une unique table items avec trois colonnes2 : une colonne texte nommée name, et deux colonnes numériques appelées quantity et price (oui, je sais, j'aurais pu trouver un peu plus original...).

Dès lors, il suffit d'un petit :

$ rake db:migrate

Et vous devriez avoir un fichier db/development.db qui n'est autre que la base créée par Rails et contenant votre table items.

Vous pouvez alors insérer quelques éléments dans votre table histoire d'avoir quelque chose à afficher pendant le développement de l'application. Ceci peut être fait à la main ou en sauvegardant les instructions SQL suivantes3 dans db/dump.sql :

BEGIN TRANSACTION;
INSERT INTO "items" VALUES(1, 'binette', 3, 10);
INSERT INTO "items" VALUES(2, 'brouette volante', 2, 60);
INSERT INTO "items" VALUES(3, 'salsifis surgelés', 15, 3);
INSERT INTO "items" VALUES(4, 'batman', 1, 3000);
INSERT INTO "items" VALUES(5, 'saucisse de poisson', 2, 8);
INSERT INTO "items" VALUES(6, 'glace à la viande', 9, 9);
INSERT INTO "items" VALUES(7, 'arrosoir', 4, 13);
INSERT INTO "items" VALUES(8, 'pissenlits confits', 78, 1);
INSERT INTO "items" VALUES(9, 'frigo dégivré', 12, 250);
INSERT INTO "items" VALUES(10, 'allumettes mouillées', 8, 145);
INSERT INTO "items" VALUES(11, 'accordéon déchaîné', 1, 18);
INSERT INTO "items" VALUES(12, 'chuchotement sauvage', 5, 7);
INSERT INTO "items" VALUES(13, 'escargot hystérique', 8, 13);
COMMIT;

Et en faisant un petit :

$ sqlite3 db/development.db < db/dump.sql

Création du modèle

Comme vous le savez sans doute, une application Rails est composée de trois grands types d'éléments : les modèles, les vues et les contrôleurs. Nous allons donc les créer tour à tour.

Le modèle de notre application sera ici très simple. En fait, comme nous n'avons aucune requête compliquée à transmettre à notre base, nous allons le laisser tel que généré par Rails lors de l'installation, c'est-à-dire complètement vide. Nous ne toucherons donc pas au fichier app/models/item.rb.

Création de la vue

La vue de notre application sera séparée en trois parties : un layout, une vue et un partiel.

Layout

Le layout est un modèle de page qui sera utilisé pour le rendu de plusieurs vues. Il contient les éléments qui ne varient pas d'une page à l'autre : en-tête et pied de page HTML, menus, éléments de design, etc. L'intérêt d'utiliser un layout ici est plus que limité puisque nous n'avons qu'une page à afficher, mais ce pourrait être utile si nous décidions par la suite d'en ajouter d'autres...

Le layout sera situé dans app/views/layouts/item.rhtml, avec le contenu suivant :

<html>
<head>
  <title>Essai de tableau avec Ajax</title>
  <%= stylesheet_link_tag "style" %>
  <%= javascript_include_tag :defaults %>
</head>
<body>

<div id="content">
<%= @content_for_layout %>
</div>

</body>
</html>

La chose à noter ici est la balise javascript_include_tag qui sera remplacée par des appels aux librairies javascript utilisées par Rails pour les fonctionnalités Ajax.

À noter également l'instruction @content_for_layout, qui sera remplacée par le contenu généré.

Vue

La vue sera utilisée pour rendre une action particulière de notre contrôleur, lequel sera décrit tout de suite après. Comme il s'agit d'une action nommée list de notre contrôleur item, la vue sera située dans app/views/item/list.rhtml.

Le contenu du fichier est le suivant :

<h1>Bienvenue dans ce magnifique tableau</h1>

<p>Cette liste est mise à jour en temps de réel depuis la plus large
source de données actuellement accessible en utilisant les
technologies les plus innovantes associées aux effets graphiques
époustouflants du Web 2.0.</p>

<p>Mais faites gaffe, y'a plein de bugs.</p>

<h2>Et voilà la liste...</h2>

<p>
<form name="sform" action="" style="display:inline;">
<label for="item_name">Rechercher sur le nom  : </label>
<%= text_field_tag("query", params['query'], :size => 10 ) %>
</form>

<%= image_tag("spinner.gif",
              :align => 'absmiddle',
              :border => 0,
              :id => "spinner",
              :style => "display: none;" ) %>
</p>

<%= observe_field 'query',  :frequency => 2,
         :update => 'table',
         :before => "Element.show('spinner')",
         :success => "Element.hide('spinner')",
         :url => {:action => 'list'},
         :with => 'query' %>

<div id="table">
<%= render :partial => "items_list" %>
</div>

Le début du fichier n'a rien de très compliqué. Après une courte et stupide introduction, un formulaire de recherche est affiché pour permettre de filter la liste des éléments du tableau selon leur nom.

Ensuite vient une image dont l'identifiant est spinner et qui n'est pas affichée par défaut. Cette image sera brièvement affichée lorsqu'une requête Ajax sera effectuée sur la page, puis masquée à nouveau lorsque cette requête s'affiche. Vous pouvez trouver un certain nombre d'images du domaine public à l'adresse suivante :

http://mentalized.net/activity-indicators/

Vous devez ensuite mettre l'image choisie dans le répertoire public/images.

L'instruction observe_field qui suit est un peu moins habituelle. Son objet ici est de placer un observateur Ajax sur le champ query. Cet observateur va vérifier le contenu de ce champ à intervalle régulier (ici, toutes les 2 secondes comme l'indique le paramètre frequency) et réagir si ce contenu a changé depuis sa dernière inspection.

L'action qui doit être exécutée dans ce cas est définie par les paramètres restants :

Le résultat de tous ces paramètres est relativement simple. Quand l'utilisateur saisit quelque chose dans le champ query, l'observateur va inspecter cette nouvelle valeur du champ, et générer une requête Ajax envoyée au serveur selon les paramètres url et with. Dès que la requête est envoyée, l'action before bascule la visibilité de l'élément XHTML spinner, et l'utilisateur voit s'afficher l'image correspondante. Lorsque la requête a été traitée, l'élément XHTML table est mis à jour avec le contenu de la réponse, et l'action success cache à nouveau l'image.

Pour information, voici ce que l'instruction observe_field décrite ici génère au final :

<script type="text/javascript">
//<![CDATA[
new Form.Element.Observer('query', 2, function(element, value) {Element.show('spinner'); new Ajax.Updater('table', '/item/list', {asynchronous:true, evalScripts:true, onSuccess:function(request){Element.hide('spinner')}, parameters:'query=' + value})})
//]]>
</script>

Nous avons délibérément détaillé les différentes options car nous allons les retrouver dans toutes les autres méthode ayant trait à Ajax dans la suite de ce document.

Enfin, pour terminer la description de cette vue, nous avons un appel à un élément partiel appelé items_list. Nous décrirons ce concept et son contenu en détail après avoir configuré notre contrôleur.

Création du contrôleur

Le contrôleur a pour rôle de prendre en charge les différents types de requêtes qui arrivent à notre application pour mettre à jour la vue en appelant le modèle en fonction du type de requête et de ses paramètres.

Notre contrôleur Item sera ici très simple, et ne contiendra qu'une action nommée list. Nous n'implémenterons aucune autre action de type CRUD (Create, read, update, delete) dans ce tutoriel.

Le contenu de app/controllers/item_controller.rb est le suivant :

class ItemController < ApplicationController

  def list

    items_per_page = 10

    sort = case params['sort']
           when "name"  then "name"
           when "qty"   then "quantity"
           when "price" then "price"
           when "name_reverse"  then "name DESC"
           when "qty_reverse"   then "quantity DESC"
           when "price_reverse" then "price DESC"
           end

    conditions = ["name LIKE ?", "%#{params[:query]}%"] unless params[:query].nil?

    @total = Item.count(:conditions => conditions)
    @items_pages, @items = paginate :items, :order => sort, :conditions => conditions, :per_page => items_per_page

    if request.xml_http_request?
      render :partial => "items_list", :layout => false
    end

  end

end

Ceci mérite quelques explications.

Notre contrôleur ne définit qu'une action, nommée list. Cette action traitera l'ensemble des requêtes reçue par notre application.

D'abord, nous définissons une variable items_per_page qui représente le nombre de lignes du tableau à afficher sur chaque page.

Ensuite, nous définissons une deuxième variable nommée sort, en fonction du contenu du paramètre de requête du même nom. Le case ici peut être utilisé pour masquer à l'utilisateur le véritable nom des champs de notre base de données, ce qui est préférable pour des raisons de sécurité. La chaîne reverse dans le paramètre sort est utilisée pour indiquer que le tri doit être effectué en ordre inversé.

Une variable conditions est alors construite si le paramètre query est présent dans notre requête. Il s'agit d'une instruction SQL qui sera utilisée pour filtrer notre requête à la base de données sur le contenu du champ name.

Puis nous assignons à la variable @total le nombre total d'items dans notre base correspondant aux conditions décrites par conditions.

Enfin, nous trouvons un appel à l'instruction Rails paginate. Nous passons à cette instruction le nom du modèle associé (:items), un champ de tri, les conditions devant être appliquées à la requête, et le nombre d'objets à afficher par page. Et elle nous renvoit automagiquement un objet de type paginator appelé @items_pages ainsi que le tableau des items de la page actuellement sélectionnée (le numéro de cette page est indiqué de manière transparente par le paramètre de requête page). L'objet paginator sera utilisé un peu plus tard pour afficher les liens de pagination.

Tout ce que nous avons vu jusqu'à présent pour notre contrôleur s'applique à l'ensemble des requêtes reçues, quel que soit leur type. Les types de requêtes HTTP les plus utilisées sont sans doute les traditionnels GET et POST, mais Rails et Ajax utilise également un troisième type nommé XmlHttpRequest4.

Ce type de requête est lancé par javascript, qui l'envoie au serveur via HTTP en arrière-plan, sans que ceci soit visible pour l'utilisateur. L'un des usages de ce type de requête est de récupérer un fragment de page XHTML et de l'utiliser pour mettre à jour, toujours via javascript, une partie de la page affichée par le navigateur, sans avoir à en recharger l'intégralité. Ceci peut donner à l'utilisateur la sensation d'une interface plus dynamique et réactive.

C'est ce que la dernière partie de notre contrôleur traite : il teste si la requête reçue est de type xml_http_request5. Si c'est le cas, il ne génère pas le rendu de l'ensemble de la vue associée à l'action list, mais seulement d'un fragment de celle-ci, le désormais célèbre partiel nommé items_list.

Ce test sur xml_http_request est donc le seul élément relatif à Ajax de notre contrôleur. Ceci est logique car Ajax est avant tout relié à l'interface de notre application, donc à la vue, et plus particulièrement au fragment de celle-ci qui sera réellement géré par Ajax, c'est-à-dire le partiel.

Et ça tombe bien, car c'est justement ce qu'il nous reste à voir.

Création du partiel

Un partiel peut être défini comme un bout de vue utilisé pour générer seulement un fragment de page. Ceci est très utile pour isoler des éléments de vue "répétés" et les rendre réutilisables, et suivre ainsi le principe de Rails "ne pas se répéter" (Don't Repeat Yourself ou DRY). Mais c'est également très utile pour utiliser Ajax.

L'effet des différentes actions Ajax de ce tutoriel sera toujours de rafraîchir l'affichage de notre tableau sans recharger l'intégralité de la page. C'est pourquoi nous allons séparer tous les éléments devant être mis à jour dans un partiel6.

Le nom de fichier d'un partiel commençant toujours par un tiret bas, il sera donc situé ici dans app/views/item/_items_list.rhtml.

Son contenu sera le suivant :


<% if @total == 0 %>

<p>Aucun objet trouvé...</p>

<% else %>

<p>Nombre d'objets trouvés : <b><%= @total %></b></p>

<p>
<% if @items_pages.page_count > 1 %>
Page&nbsp;:
<%= pagination_links_remote @items_pages %>
<% end %>
</p>


<table>
  <thead>
    <tr>
      <td <%= sort_td_class_helper "name" %>>
        <%= sort_link_helper "Nom", "name" %>
      </td>
      <td <%= sort_td_class_helper "qty" %>>
        <%= sort_link_helper "Quantité", "qty" %>
      </td>
      <td <%= sort_td_class_helper "price" %>>
        <%= sort_link_helper "Prix", "price" %>
      </td>
    </tr>
  </thead>
  <tbody>
    <% @items.each do |i| %>
    <tr class="<%= cycle("even","odd") %>">
      <td><%= i.name %></td>
      <td><%= i.quantity %></td>
      <td><%= i.price %></td>
    </tr>
    <% end %>
  </tbody>
</table>

<% end %>

Quelques explications sont nécessaires. Le partiel inclut la gestion de la pagination et du tri de notre tableau, que nous allons regarder chacun plus en détail.

Assistant de pagination

Nous avons tout d'abord un test déterminant si le nombre total d'objets trouvés est supérieur à zéro. Si c'est le cas, on affiche ce nombre ainsi qu'un paragraphe qui sera vide si la pagination de notre tableau ne comporte qu'une page.

S'il y a plus d'une page de résultats, nous devons afficher les liens de pagination permettant de naviguer d'une page à l'autre. Rails fournit des méthodes très utiles pour générer ces liens, mais nous allons devoir les personnaliser un petit peu. Pour cela, nous allons créer un helper.

Un helper (ou assistant) est une fonction Ruby aidant à générer la vue. L'objectif de créer un helper est de séparer le code de ces fonctions de la vue elle-même, tout en rendant ce code réutilisable par différentes vues.

Nos assistants seront tous situés dans le fichier app/helpers/item_helper.rb. Chaque méthode de ce fichier sera accessible depuis notre vue. Si nous avions voulu rendre ces méthodes accessibles parl'ensemble des vues de notre application, il aurait fallu les placer dans application_helper.rb.

Bref, assez de bla bla, voici le code de notre assistant pagination_links_remote :

def pagination_links_remote(paginator)
  page_options = {:window_size => 1}
  pagination_links_each(paginator, page_options) do |n|
    options = {
      :url => {:action => 'list', :params => params.merge({:page => n})},
      :update => 'table',
      :before => "Element.show('spinner')",
      :success => "Element.hide('spinner')"
    }
    html_options = {:href => url_for(:action => 'list', :params => params.merge({:page => n}))}
    link_to_remote(n.to_s, options, html_options)
  end
end

Cette méthode prend comme argument un objet de type paginator. Il s'agit d'un objet Rails qui contient toutes les informations relatives à l'état de notre pagination (nombre de pages, page courante, etc.).

Nous définissons ensuite un hash nommé page_options qui contient un seul élément window_size. Ce paramètre indique à Rails le nombre de pages à afficher autour de la page courante dans les liens de pagination. Ainsi, si window_size est égal à un, on aura quelque chose comme ça :

1 ... 5 6 7 ... 13

Et si window_size égale deux :

1 ... 4 5 6 7 8 ... 13

Nous pourrions dès lors faire un appel à la fonction pagination_links, qui génèrerait directement le code XHTML affichant nos liens. Le problème est que cette fonction crée des liens "classiques", pas des liens "Ajax". Nous allons donc devoir redéfinir ces liens nous-mêmes, ce qui est accompli par la méthode pagination_links_each.

Cette méthode traverse l'ensemble des pages qui doivent être afichées sous forme de lien et leur applique le bloc qui lui a été passé en argument. Notre bloc définit ici deux types d'options :

Puis, un simple appel à la fonction link_to_remote génèrera le XHTML complet correspondant au lien de pagination du numéro de page traité par le bloc, incluant à la fois la partie javascript et la partie XHTML href.

Par exemple, voici ce que l'assistant génère si nous avons deux pages, la première étant actuellement affichée :

1 <a href="/item/list?page=2" onclick="Element.show('spinner'); new Ajax.Updater('table', '/item/list?page=2', {asynchronous:true, evalScripts:true, onSuccess:function(request){Element.hide('spinner')}}); return false;">2</a>

Assistant de tri

Revenons à notre partiel. Après les liens de pagination, nous commençons (enfin) l'affichage de notre tableau. La définition de l'en-tête de la table est un peu compliquée, car c'est là que nous définissons les liens permettant de trier notre tableau selon une colonne ou une autre. Chaque cellule d'en-tête de tableau fait appel à deux helpers.

Le premier assistant sort_td_class_helper, n'est en rien obligatoire. Son nunique fonction est d'ajouter un class="sortup" si la colonne est celle actuellement utilisée pour trier le tableau, et un class="sortdown" si elle est utilisée pour trier par ordre inverse. La seule utilité de tout ceci est de permettre, via CSS, d'indiquer à l'utilisateur quelle est la colonne actuellement utilisée pour le tri.

Le code n'a vraiment rien de passionnant :

def sort_td_class_helper(param)
  result = 'class="sortup"' if params[:sort] == param
  result = 'class="sortdown"' if params[:sort] == param + "_reverse"
  return result
end

Nous avons ensuite un second assistant, nommé sort_link_helper.

def sort_link_helper(text, param)
  key = param
  key += "_reverse" if params[:sort] == param
  options = {
      :url => {:action => 'list', :params => params.merge({:sort => key, :page => nil})},
      :update => 'table',
      :before => "Element.show('spinner')",
      :success => "Element.hide('spinner')"
  }
  html_options = {
    :title => "Trier selon ce champ",
    :href => url_for(:action => 'list', :params => params.merge({:sort => key, :page => nil}))
  }
  link_to_remote(text, options, html_options)
end

Cet assistant est en définitive très similaire à pagination_links_remote, vu précédemment. Il prend deux arguments :

Les deux premières lignes définissent une nouvelle variable, key, qui reçoit le contenu de l'argument param, c'est à dire la clé de tri. La chaîne _reverse est ajoutée à cette clé si param est déjà la clé de tri. Ceci sert à implémenter le tri par ordre croissant et décroissant : si l'utilisateur sélectionne un lien de tri, le tableau sera trié selon la colonne correspondante, par ordre croissant ; si il sélectionne alors à nouveau le même lien, le tri se fera par ordre décroissant, etc. Si vous ne trouvez pas ces explications très claires, jetez un oeil au contrôleur.

Le reste de la fonction définit les options passées au link_to_remote final. Tout ceci est très similaire à ce que nous avons vu pour pagination_links_remote :

Voici ce que l'assistant renvoit s'il est appelé avec les arguments "Quantité" et "qty" :

<a href="/item/list?sort=qty" onclick="Element.show('spinner'); new Ajax.Updater('table', '/item/list?sort=qty', {asynchronous:true, evalScripts:true, onSuccess:function(request){Element.hide('spinner')}}); return false;" title="Sort by this field">Quantité</a>

Corps du tableau

La fin de notre partiel affiche le contenu proprement dit de notre tableau, un objet par ligne. La seule chose pouvant être notée est l'utilisation de la fonction Rails cycle, qui va automatiquement et alternativement ajouter un attribut "odd" ou "even" comme classe de style pour les lignes du tableau, ce qui peut être utile pour améliorer l'affichage en alternant les couleurs de ligne.

C'est fini !

Nous avons désormais passé en revue tous les éléments de notre application. En théorie vous devriez pouvoir voir le résultat en lançant le serveur WebRick intégré à Rails et en vous connectant sur :

http://localhost:3000/item/list

J'espère que ce document aura pu vous être utile. Dans tous les cas n'hésitez pas à me faire parvenir vos retours via l'adresse donnée dans l'introduction.

À propos de ce document

Ce document est publié sous licence Creative Commons Attribution.

Merci à Nicolas St-Laurent, Rachel McConnell et Michel Loiseleur pour leurs remarques judicieuses.

Merci à Juan Lupión pour la traduction espagnole de ce document.


1. Les instructions en ligne de commande sont présentées dans le cadre d'un environnement GNU/Linux.

2. En fait, notre table aura quatre colonnes, car un champ id est automatiquement ajouté par Rails.

3. Si vous utilisez MySQL comme SGBD, la syntaxe correcte est INSERT INTO items VALUES (1, 'hoe', 3, 10);

4. En fait, XmlHttpRequest n'est pas réellement un nouveau type de requête HTTP. Il s'agit d'une requête GET ou POST traditionnelle envoyée et traitée par javascript de manière non-synchrone.

5. L'instruction request.xml_http_request? peut être abrégée en request.xhr?

6. On aurait pu envisager d'autres manières de faire. En particulier on aurait pu créer une nouvelle action (par exemple ajax_list) pour traiter les requêtes de type xml_http_request, et une vue associée sans partiel. L'un des problèmes de cette méthode est qu'une grande part du code d'ajax_list aurait été identique à celui de list.