Scriptaculous Ajax.Autompleter : situation réelle : suggestions dynamiques pour la saisie d'une ville
4 novembre 2007 —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 :
- Le Framework prototype.js,
- les fichiers
effects.js
etcontrol.js
, de la bibliothèque graphique Script.aculo.us, - et le fichier
client-1.js
, où nous placerons nos scripts.
<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 !
- Attention, il s'agit bien de l'élément
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 :
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 :
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 :
Et une fois le choix effectué au sein de la liste de suggestions :
Une fois le formulaire entier validé (bouton "Hop"), nous obtenons, côté serveur, les données suivantes :
(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 :
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} => {$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} => {$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 :
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 ^^