Scriptaculous Ajax.Autompleter : situation réelle : suggestions dynamiques pour la saisie d'une ville

4 novembre 2007Ajax, Autocompletion, Javascript, SQLite, Scriptaculous, prototype.js
 Cet article a été rédigé il y a plusieurs années et peut ne plus être tout à fait à jour…

Nous avons vu dans un article précédent comment créer une liste de suggestions à partir d'une saisie utilisateur.

Au cours de cet article, nous avons appris à utiliser le composant Ajax.Autocompleter du framework Javascript Script.aculo.us, lui-même basé sur la librairie prototype.js, pour répondre à des besoins basiques.

Nous allons maintenant voir comment l'utiliser dans une situation plus proche d'un cas "réel" :

  • saisie de plusieurs informations
  • utilisation d'une base de données côté serveur
  • grand nombre de données
  • affichage d'informations supplémentaires en cours de saisie
  • récupération et exploitation de la donnée saisie par l'utilisateur :
    • côté client, pour traitements Javascript pendant l'utilisation du formulaire,
    • et côté serveur, une fois le formulaire validé.


Côté client : HTML et CSS

Comme bien souvent, commençons par une page HTML, que viendront enrichir une CSS pour la présentation, puis du JavaScript pour le comportement :

Page HTML incluant un formulaire

Côté client, nous avons besoin d'une page HTML.

Celle-ci doit contenir un formulaire, lui-même incluant un champ input text, où l'utilisateur pourra saisir la donnée attendue.
Ici, ce champ a pour identifiant et nom villes.

Un élément HTML doit aussi être prévu pour permettra l'affichage des suggestions.
Ici, il s'agit d'un <div>, d'identifiant villes_propositions.

Nous avons aussi inclut un indicateur de chargement : l'élément d'id indicateur-chargement-ville, masqué au chargement de la page.
Il sera rendu visible en Javascript, lors du chargement de données depuis le serveur, pour indiquer à l'utilisateur qu'il se passe "quelque chose".

<form action="serveur-action.php" method="post">
    Saisissez des noms de villes (séparateur : ',' ou ';') :
    <br /><input type="text" id="villes" name="villes"/>
    <span id="indicateur-chargement-ville" style="display: none;">?</span>
    <br /><span id="code_insee" style="display: none;"></span>
    <div id="villes_propositions" class="autocomplete"></div>
    <div>
        <input type="submit" name="ok" value="Hop !" />
    </div>
</form>

Naturellement, il nous faut inclure les bibliothèques Javascript dont nous avons besoin :

<script type="text/javascript" src="lib/prototype.js"></script>
<script type="text/javascript" src="lib/effects.js"></script>
<script type="text/javascript" src="lib/controls.js"></script>
<script type="text/javascript" src="client-1.js"></script>
<script type="text/javascript">
    window.onload = init;
</script>

Les dernières lignes nous permettent de déclencher une fonction nommée init une fois la page chargée[1].


Et un peu de CSS pour plus de style

Pour éviter que le rendu ne soit trop spartiate, voici quelques lignes de CSS que vous pouvez mettre en place :

div.autocomplete {
  position: absolute;
  width: 500px;
  background-color: white;
  border: 1px solid #888;
  margin: 0px;
  padding: 0px;
}

div.autocomplete ul {
  list-style-type: none;
  margin: 0px;
  padding: 0px;
  max-height: 20em;
  overflow: auto;
}

div.autocomplete ul li.selected {
    background-color: #ffb;
}

div.autocomplete ul li {
  list-style-type:none;
  display: block;
  margin: 0;
  padding: 2px;
  cursor: pointer;
}

div.autocomplete ul li span.informal {
    color: grey;
}

La seule différence introduite par rapport à ce que je proposais dans mon premier article à propos de Ajax.Autocompleter est l'ajout de la dernière définition : chaque élément de classe informal situé dans la liste de propositions apparaîtra en gris, au lieu du noir de la suggestion en elle-même.

Vous comprendrez le pourquoi de cette règle un peu plus tard.


Côté client : Javascript et Autocompleter

La création de l'instance d'objet AJax.Autocompleter se fait, dans le principe, de la même manière que lors de notre précédent article.

La différence est que nous avons rajouté plusieurs options :

// Instanciation de la classe Autocompleter, pour le champ de saisie "villes"
new Ajax.Autocompleter(
    "villes",   // id du champ de formulaire
    "villes_propositions",  // id de l'élément utilisé pour les propositions
    "serveur-ville-3.php",  // URL du script côté serveur
    {
        paramName: 'ville',  // Nom du paramètre reçu par le script serveur
        minChars: 2,   // Nombre de caractères minimum avant que des appels serveur ne soient effectués
        tokens: [',', ';'],
        indicator: 'indicateur-chargement-ville',
        afterUpdateElement : function (input, li) {
                // Fonction appelée après choix de l'utilisateur
                $('code_insee').innerHTML = 'Code INSEE choisi : ' + li.id;
                $('code_insee').show();
            }
    });

Au programme des nouveautés :

  • Les appels Ajax vers le serveur ne sont plus effectués dès que l'utilisateur effectue une saisie, mais seulement à partir du moment où il a déjà saisi deux caractères : option minChars.
  • L'utilisateur peut effectuer plusieurs saisies au sein du même champ de formulaire ; ces saisies devront être séparées par des virgules ou des point-virgule : option tokens.
  • Un indicateur de chargement sera affiché pendant l'exécution des requêtes Ajax en arrière-plan : option indicator
    • Vous devez définir pour cette option l'identifiant de l'élément à afficher ou à masquer
    • L'affichage s'effectuera via un appel à la méthode Element.show de Prototype,
    • et l'élément sera ensuite masqué via un appel à la méthode Element.hide.
  • L'option afterUpdateElement permet de définir une fonction qui sera appelée après que l'utilisateur ait effectué un choix dans la liste de suggestions.

La fonction appelée lorsque l'option afterUpdateElement est définie attend deux paramètres :

  • Le champ de saisie,
  • et l'élément sélectionné dans la liste de sélections.
    • Attention, il s'agit bien de l'élément <li> entier, et non simplement de son contenu ou de son identifiant !

Ici, la fonction que nous avons défini affiche un message contenant le code INSEE de la ville sélectionnée.
(Ce du fait que nous avons renvoyé depuis le serveur, pour chaque élément de la liste de suggestions, le code INSEE en identifiant des-dits éléments)


Côté serveur : script appelé par l'Autocompleter

Le principe du champ de saisie et de la liste de suggestions que nous mettons en place est simple :

  • L'utilisateur commence à saisir du texte - un début de nom de ville, dans l'idéal,
  • et nous lui affichons la liste des villes dont le nom commence par sa saisie.

Cela implique que, côté serveur, nous ayons à disposition une liste de villes.
Dans un cas "réel", cette liste de villes serait stockée au sein d'une base de données, et les données seraient chargées via une requête SQL...

... Et bien, c'est exactement ce que nous ferons ici !


Base de données de villes

Pour cet article, j'ai choisi d'utiliser une base de données SQLite, pour la simplicité de mise en place.

Voici quelques lignes de code permettant de la créer.
Les noms et type des colonnes étant aisément lisible depuis le code PHP fourni, je ne détaillerai pas :

<?php
    $sqliteerror = null;
    if ($db = sqlite_open('villes.sqlite.db', 0666, $sqliteerror))
    {
        //$result_drop = sqlite_exec($db, 'drop table villes');
        $query_create_table = <<<QUERY_CREATE_TABLE
create table villes
(
    code_insee varchar(5) primary key,
    code_postal varchar(5),
    code_departement varchar(2) not null,
    libelle varchar(64) not null,
    latitude float,
    longitude float
)
QUERY_CREATE_TABLE;
        $result_create = sqlite_exec($db, $query_create_table);
    }
?>

Les colonnes code_departement, latitude, et longitude ne nous servirons pas pour cet article - il s'agit d'une base de données que j'ai mis en place pour une autre situation, et que je réutilise ici.
Si vous ne souhaitez pas les reproduire, il vous faudra peut-être modifier quelques portions de codes au sein des exemples fournis plus bas.

En terme de contenu, voici ce que cela peut donner :

sqlite> select * from villes limit 0, 10;
code_insee  code_postal  code_departement  libelle                 latitude    longitude
----------  -----------  ----------------  ----------------------  ----------  ----------
01001       01400        01                Abergement-Clémenciat   46.123722   5.011565
01002       01640        01                Abergement-de-Varey     46.029120   5.411734
01004       01500        01                Ambérieu-en-Bugey       45.979848   5.336887
01005       01330        01                Ambérieux-en-Dombes     45.990500   5.016713
01006       01300        01                Ambléon                 45.723066   5.663965
01007       01500        01                Ambronay                45.979848   5.336887
01008       01500        01                Ambutrix                45.979848   5.336887
01009       01300        01                Andert-et-Condon        45.723066   5.663965
01010       01350        01                Anglefort               45.852517   5.752835
01011       01100        01                Apremont                46.244422   5.652013

Je ne produirai pas le contenu entier de cette table ici... A raison de plus de 36600 lignes, ce serait difficile...
Néanmoins, pour les besoins des exemples qui suivent, je joins à cet article un fichier contenant les requêtes d'insertion nécessaires pour charger[2] :

  • La liste des villes commençant par 'l',
  • la liste des villes commençant par 'pa',
  • et la liste des villes commençant par 'beaure'

Pour information, la liste de toutes les communes de France peut se télécharger depuis le site de l'INSEE, ou depuis le site de l'IGN.
La liste des codes postaux est trouvable en fouillant un peu via les moteurs de recherche ; malheureusement, je n'ai pas réussi à trouver une liste "officielle"[3]...


Script PHP chargeant les données depuis celle-ci, et les renvoyant à l'Autocompleter

L'instance d'objet Ajax.Autocompleter, configurée comme plus haut, effectue une requête Ajax vers un script sur le serveur à partir du moment où l'utilisateur a saisi plus d'un certain nombre de caractères ; plus de 1 caractère, ici.

Ce script reçoit un paramètre dont le nom correspond à celui déclaré comme paramName plus haut dans notre code Javscript ; ici, ville.
Et ce paramètre a pour valeur la saisie actuelle de l'utilisateur - ici, typiquement, un début de nom de ville.

Globalement, notre script va charger depuis une base de donnée la liste des villes dont le libellé commence par la saisie, et va renvoyer cette liste.

Les données en sortie seront organisées sous forme d'une liste non-ordonnée, comme attendues par la classe Ajax.Autocompleter :

<?php
    // Pour faciliter les choses (dont le debug !), accepte des données soit en GET, soit en POST
    // => Eventuellement, à modifier une fois le debug terminé.
    $saisie = (isset($_POST['ville']) ? $_POST['ville'] : (isset($_GET['ville']) ? $_GET['ville'] : ''));

    if (strlen($saisie) > 1)
    { // En théorie, script jamais appelé avec une saisie de moins de 1 caractère...
      // Mais autant s'en assurer : sans ça, on sélectionne le contenu de
      // tout la table... Et boum !
        $sqliteerror = null;
        if ($db = sqlite_open('data/villes.sqlite.db', 0666, $sqliteerror))
        {
            $saisie_escaped = sqlite_escape_string($saisie . '%');

            $query = "select * from villes where libelle like '$saisie_escaped' order by libelle";
            $result = sqlite_query($db, $query);

            echo "<ul>\n";
            while ($obj = sqlite_fetch_object($result))
            {
                $informal = (!empty($obj->code_postal) ? '<span class="informal"> (' . $obj->code_postal . ')</span>' : '');
                echo "<li id=\"{$obj->code_insee}\">{$obj->libelle}$informal</li>\n";
            }
            echo '</ul>';

            sqlite_close($db);
        }
        else
        {
            // Pour débugguer
            //die($sqliteerror);

            // Pour indiquer à l'Autocompleter qu'il n'y a rien à afficher :
            echo "<ul>\n";
            echo '</ul>';
        }
    }
?>

Un point à noter : l'objet Ajax.Autocompleter permet de définir, pour chaque élément de la liste retournée, un sous-élément de classe "informal".
La donnée placée au sein de ce bloc sera affichée dans la liste de suggestions, mais ne sera pas reproduite dans le champ de saisie, une fois que l'utilisateur aura choisi l'une des suggestions.
Ici, j'utilise cette fonctionnalité pour indiquer à côté de chaque libellé de ville son code postal.

Ajax.Autocompleter admet aussi que nous définitions, pour chaque élément <li>, un identifiant : "id".
Si cet attribut est renseigné, la valeur placée là doit permettre d'identifier, de manière unique, quelle est la suggestion sélectionnée par l'utilisateur.
Dans notre cas, l'identifiant unique de chaque ville est son code INSEE - c'est d'ailleurs la clef primaire de notre table.


Captures d'écran du résultat

Pour vous donner une idée de ce que ça donne, voici maintenant quelques captures d'écran du résultat, une fois le tout assemblé :

Saisie de "ly", qui provoque l'affichage en suggestions de toutes les villes commençant par ces deux caractères :

Ajax.Autocompleter : saisie 'ly'

Ajout d'un caractère à la saisie ; la liste de suggestions est donc filtrée en tenant compte de cette nouvelle donnée, plus précise :

Ajax.Autocompleter : saisie 'lyo'

Et saisie de "lyon", sans effectuer au choix depuis la liste de suggestions :
(ça se voit à la non-présence de majuscule dans le champ de saisie, alors que la majuscule à "Lyon" dans la liste de suggestions était présent sur la capture précédente)

En même temps, début de saisie d'un second libellé de ville, après une virgule comme séparateur :

Ajax.Autocompleter : saisie 'lyon, par'

Et une fois le choix effectué au sein de la liste de suggestions :

Ajax.Autocompleter : saisie 'lyon, par' - choix effectué

Une fois le formulaire entier validé (bouton "Hop"), nous obtenons, côté serveur, les données suivantes :

Ajax.Autocompleter : saisie 'lyon, par' - choix effectué : côté serveur

(Il s'agit d'un var_dump de $_POST - avec l'extension Xdebug d'installée[4].)


Exemple de traitement à la soumission du formulaire

Maintenant que nous avons vu comment exploiter les possibilités de l'objet Ajax.Autocompleter côté client, il nous reste à récupérer les saisies effectuées par l'utilisateur, une fois le formulaire validé.

Pour terminer cet article, voyons comment vous pourriez récupérer ces données saisies... Et quelques problématiques qui peuvent se poser.

Cas de tests

Considérons le cas où l'utilisateur a effectué la saisie suivante :

Ajax.Autocompleter : saisies pour exemple de traitement côté serveur

Voici quelques cas auxquels il convient de penser :

  • "lyon" est une ville existante en base, mais l'utilisateur l'a saisi à la main, sans cliquer sur une suggestion de la liste, et sans respecter la casse (ça se voit à la minuscule : dans la liste de suggestions, "Lyon" était proposée avec une majuscule, alors que, finalement, la saisie finale est toute en minuscule),
  • "Paris-l'Hôpital" a sans aucun doute été choisi parmis des propositions d'une liste de suggestions,
  • " " est une saisie vide : on a deux séparateurs de saisie, un espace entre, mais pas de saisie réelle - c'est un cas qu'il faut prévoir de gérer,
  • "glop" est de toute évidence une saisie ne correspondant pas à une ville existante
  • et "beaurepaire" est un nom qui correspond à plusieurs villes.

Au cours de vos tests, vous en identifierez peut-être d'autres ; à vous de vous assurer qu'ils soient bien gérés.


Principe de fonctionnement côté serveur

Côté serveur, probablement, vous voulez :

  • Enregistrer dans un champ d'une table la saisie de l'utilisateur, en particulier dans le cas où il ne s'agit pas d'une ville connue.
  • Si la saisie correspond à une ville "connue", vous voudrez probablement enregistrer aussi le code INSEE de celle-ci ; peut-être aussi son code postal ?
  • Et enfin, si la saisie correspond à plusieurs villes... Et bien, à vous de voir comment faire !

C'est un peu l'idée suivie par le script suivant :

  • Si le formulaire a été validé,
  • Ouverture d'une connexion vers une Base de Données (la même que plus haut, en l'occurence)
  • Découpage du champ de saisie selon les séparateurs admis : ',' et ';'.
  • Parcours de la liste de saisie ainsi obtenue
    • Pour chaque saisie :
    • "Nettoyage" : suppression des éventuels espaces en début et fin de chaine
    • Conversion en minuscule pour test en DB un peu plus loin
    • Conservation uniquement des saisies non-vides - Gestion du cas de la saisie " "
    • Échappement de la saisie, pour éviter les injections SQL
    • Tentative de chargement des données de la ville depuis la DB :
      • Gestion des trois cas :
      • Ville non trouvée,
      • Ville trouvée une fois,
      • Ville trouvée plusieurs fois.
<?php
    if (isset($_POST['ok'], $_POST['villes']))
    {
        $sqliteerror = null;
        if (($db = sqlite_open('data/villes.sqlite.db', 0666, $sqliteerror)))
        {
            // On a permis la saisie de plusieurs villes, séparées par ',' ou ';'
            $villes = preg_split('/[,;]/', $_POST['villes']);

            foreach ($villes as $ville)
            {
                $ville = strtolower(trim($ville));
                if ($ville !== '')
                { // Suppression des éventuelles saisies vides
                    if (get_magic_quotes_gpc())
                    {
                        $ville = stripslashes($ville);
                    }
                    $ville_escaped = sqlite_escape_string($ville);

                    $query = "select * from villes where lower(libelle) = '$ville_escaped'";
                    $result = sqlite_query($db, $query);

                    $nbr_results = sqlite_num_rows($result);
                    if ($nbr_results == 0)
                    { // Aucune ville trouvée pour ce libellé => saisie libre
                        echo "<strong>Ville '$ville' non trouvée</strong><br />";
                    }
                    else if ($nbr_results == 1)
                    { // Une ville trouvée pour ce libellé => ok
                        echo "<strong>Ville '$ville' trouvée :</strong><br />";
                        $obj = sqlite_fetch_object($result);
                        echo "{$obj->code_insee} =&gt; {$obj->libelle}<br />";
                    }
                    else
                    { // Plusieurs villes trouvées pour ce libellé... Etrange
                        echo "<strong>Ville '$ville' trouvée plusieurs fois :</strong><br />";
                        while ($obj = sqlite_fetch_object($result))
                        { // Affichage de chacun des villes
                            echo "{$obj->code_insee} =&gt; {$obj->libelle}<br />";
                        }
                    }
                }
            } // Fin parcours des saisies
            sqlite_close($db);
        }
        else
        {
            die($sqliteerror);
        }
    }
    else
    {
        die("'ok' et 'villes' attendus.");
    }
?>

Quelques remarques :

  • J'ai préféré tester si les magic_quotes sont activées, et, si oui, dés-échapper la saisie, pour l'échapper ensuite quoi qu'il en soit, à l'aide de la fonction spécifique à SQLite.
  • Je suis passé par une double conversion en minuscules :
    • Conversion en minuscules de la saisie avant recherche en base,
    • Conversion en minuscule des libelles de villes en base au sein de la requête - ce qu'il ne faut absolument pas faire : avec ça, on se tappe un parcours de toutes les lignes de la table, dans tous les cas, plutôt que de profiter d'un éventuel index sur la colonne libelle !
    • Ce du fait que je ne connais, à l'heure où j'écris cet article, qu'assez mal SQLite : j'ai l'impression que la collation par défaut est sensible à la casse, et qu'on ne peut définir de collation qu'à la création de la table... Et je n'ai pas le courage de supprimer / re-créer la-dite table.
    • Dans un cas réel, considérant les besoins de cette application, la colonne libelle utiliserait une collation insensible à la casse, insensible aux accents[5], et serait indexée.

Quoi qu'il en soit, avec les saisies présentées plus haut, voici quelle serait la sortie du script :
(Présentation HTML non reproduite)

Ville 'lyon' trouvée :
69123 => Lyon
Ville 'paris-l'hôpital' trouvée :
71343 => Paris-l'Hôpital
Ville 'glop' non trouvée
Ville 'beaurepaire' trouvée plusieurs fois :
38034 => Beaurepaire
60056 => Beaurepaire
76064 => Beaurepaire
85017 => Beaurepaire


Cas d'un libellé correspondant à plusieurs villes

Nous avons eu un peu plus haut un cas qui nous pose problème : le libellé "Beaurepaire" correspond à plusieurs villes, toutes éloignées géographiquement les unes des autres :

sqlite> select * from villes where libelle = 'Beaurepaire';
code_insee  code_postal  code_departement  libelle      latitude    longitude
----------  -----------  ----------------  -----------  ----------  ----------
38034       38270        38                Beaurepaire  45.359931   5.030076
60056       60700        60                Beaurepaire  49.315466   2.601329
76064       76280        76                Beaurepaire  49.631195   0.239438
85017       85500        85                Beaurepaire  46.873793   -1.042068

Une fois ces villes reportées sur une carte, voila ce qu'on obtient :

Quatre villes nommées 'Beaurepaire', et toutes éloignées les unes des autres !

Effectivement, aucune de ces villes n'est qu'un "doublon" d'un autre : elles sont toutes bien distinctes !
(Et j'en ai déjà vu deux par moi-même ; je confirme, il faut du temps pour aller de l'une à l'autre ^^)

Dans ce genre de cas, avec le seul libellé, nous n'avons aucune manière d'identifier, une fois le formulaire validé, quelle ville avait été choisie par l'utilisateur...

Plus haut, nous avons affiché à l'écran, lors du choix de l'utilisateur, le code INSEE de la commune choisie.
Nous pourrions stocker celui-ci dans un champ caché du formulaire, afin de le récupérer côté serveur une fois le formulaire validé.

Bien entendu, il faudrait gérer le fait que nous avons permis à l'utilisateur, pour cet article, de saisir plusieurs libellés de ville au sein du même champ de saisie... Était-ce une bonne idée ?

Et dans le cas où l'utilisateur saisi entièrement à la main un libellé correspondant à plusieurs communes, sans le sélectionner dans la liste de suggestions, nous n'avons pas la possibilité d'identifier à quelle ville l'utilisateur voulait effectivement faire allusion... Faut-il lui afficher un écran intermédiaire, en le forçant à saisir, parmis les quatre "Beaurepaire" en France, la ville qui l'intéresse ?
(Et si c'était plus d'une ?[6] )

Et encore une autre question : comment pourriez-vous gérer le fait qu'un libellé corresponde, en France, à une ville... Mais que ce libellé corresponde aussi à un nom de ville dans un autre pays ?
(Et que, bien entendu, votre utilisateur veuille désigner cette ville là, et non celle en France ! Voila qui pose problème par rapport à l'enregistrement du code INSEE ou du code postal, non ? [7] )


Une liste de suggestions "à la volée" présente bien des avantages... Sinon, vous ne seriez pas en train de lire cet article...
Mais un Autocompleter ne répond pas nécessairement à tous les besoins. Pensez-y avant de commencer à développer !



Notes

[1] Oui, il y a des façons plus propres de brancher un appel de fonction sur le chargement de la page, mais ce n'est pas le but de cet article. Au besoin, vous pouvez consulter, par exemple, Closures and executing JavaScript on page load - ou lancer une recherche via votre moteur de recherche favoris...

[2] Notez que le fichier d'ordre SQL d'insertion joint est encodé en UTF-8 ; si vous n'y prenez pas garde, vous rencontrerez éventuellement des problèmes d'accents mal affichés

[3] Si vous connaissez un site sur lequel on peut télécharger gratuitement la liste "officielle" des codes postaux de toutes les communes de France, je suis intéressé !

[4] Extension Xdebug, que je conseille pour tout poste de développement PHP !

[5] Est-ce possible en SQLite ?

[6] Oui, je cherche les ennuis :p

[7] Si ^^