Lors de la saisie d’adresses dans des formulaire, une source fréquente de problèmes est la saisie des villes et codes postaux: gestion des accents, minuscules ou majuscules, code postal ne correspondant pas à la ville, etc.
Nous allons voir l’implémentation rudimentaire d’un autocomplete sur les noms et codes postaux des villes qui tient compte de ces soucis.
Comme point de départ, nous allons partir d’une entité “City” qui possède les colonnes “name” et “zipcode”.
La table correspondante est déjà alimentée avec les informations sur les communes françaises. L’importation de ces données n’étant pas le but-même de l’article, elle se sera pas détaillée ici.
De nombreuses bases sont facilement disponibles sur le web, entre autre:
Un autocomplete standard est assez trivial à gérer, mais peut devenir rapidement problématique pour des noms de villes lorsque l’on considère les accents, les tirets, ou les éventuels articles.
Pour nous aider nous allons utiliser Elasticsearch et y indexer les informations de nos villes.
Après avoir installé et lancé Elasticsearch (http://www.elasticsearch.org/download/), installons FOSElasticaBundle.
Dans composer.json :
{
"require": {
"friendsofsymfony/elastica-bundle": "3.0.*@dev"
}
}
Une fois le bundle installé, déclarons notre index dans config.yml
:
fos_elastica:
clients:
# à renseigner dans votre parameter.yml, généralement "localhost" et "9200"
default: { host: %elasticsearch_host%, port: %elasticsearch_port% }
indexes:
lexik:
client: default
settings:
index:
analysis:
analyzer:
custom_search_analyzer:
type: custom
tokenizer: standard
filter : [standard, lowercase, asciifolding]
custom_index_analyzer:
type: custom
tokenizer: standard
filter : [standard, lowercase, asciifolding, custom_filter]
filter:
custom_filter:
type: edgeNGram
side: front
min_gram: 1
max_gram: 20
types:
city:
mappings:
name: { search_analyzer: custom_search_analyzer, index_analyzer: custom_index_analyzer, type: string }
zipcode: { type: string }
persistence:
driver: orm
# spécifiez votre propre entité
model: Lexik\Bundle\CitiesBundle\Entity\City
provider: ~
finder: ~
Beaucoup de choses intéressantes se passent dans cette configuration.
Tout d’abord nous décrivons les colonnes qui vont être indéxées dans ES.
mappings:
name: { search_analyzer: custom_search_analyzer, index_analyzer: custom_index_analyzer, type: string }
zipcode: { type: string }
Dans le cas d’un code postal, une autocomplétion n’est pas intéressante (inutile de suggérer toutes les villes d’un département lorsque l’utilisateur est en train de saisir son code postal). Nous n’attribuons donc aucun analyzer et stockons juste la donnée brute. La recherche sur un code postal ne sera jamais partielle.
En revanche pour les noms de villes, nous attribuons des analyzers spécifiques pour l’indexation et pour la recherche. Voyons-les en détail:
analysis:
analyzer:
custom_search_analyzer:
type: custom
tokenizer: standard
filter : [standard, lowercase, asciifolding]
custom_index_analyzer:
type: custom
tokenizer: standard
filter : [standard, lowercase, asciifolding, custom_filter]
filter:
custom_filter:
type: edgeNGram
side: front
min_gram: 1
max_gram: 20
Le filtre standard
gère la séparation automatique des mots pour les langages de type européen.
lowercase
assure que les tokens sont générés uniquement en minuscule pour avoir une recherche insensible à la casse.
asciifolding
retire tous les caractères spéciaux (accents, cédilles, …) et les remplace par leur équivalent ascii, ce qui nous permet par exemple de retrouver “Béziers” à partir de la recherche “beziers”.
Enfin nous ajoutons un filtre custom de type edgeNGram
qui nous permet de créer des tokens pour tous les sous-ensembles d’un mot, mais seulement à partir d’un bord (“edge”). Nous précisons que le bord souhaité est le début du mot avec front
. Ainsi, la recherche “seil” va correspondre à “Seillac” mais pas à “Marseille”.
Avec ces settings, nous avons configuré un index qui peut retrouver des villes à partir soit d’un code postal exact, ou d’une chaîne correspondant au début d’un mot dans un nom de ville.
Assurons-nous que les villes sont bien indexées à l’aide d’un app/console fos:elastica:populate
.
Maintenant on peut se pencher sur l’action qui va effectuer la recherche:
public function citySuggestAction(Request $request)
{
$query = $request->get('search', null);
// notre index est directement disponible sous forme de service
$index = $this->container->get('fos_elastica.index.lexik.city');
$searchQuery = new \Elastica\Query\QueryString();
$searchQuery->setParam('query', $query);
// nous forçons l'opérateur de recherche à AND, car on veut les résultats qui
// correspondent à tous les mots de la recherche, plutôt qu'à au moins un
// d'entre eux (opérateur OR)
$searchQuery->setDefaultOperator('AND');
// on exécute une requête de type "fields", qui portera sur les colonnes "name"
// et "zipcode" de l'index
$searchQuery->setParam('fields', array(
'name',
'zipcode',
));
// exécution de la requête, limitée aux 10 premiers résultats
$results = $index->search($searchQuery, 10)->getResults();
$data = array();
// on arrange les données des résultats...
foreach ($results as $result) {
$source = $result->getSource();
$data[] = array(
'suggest' => $source['zipcode'].' '.$source['name'],
'zipcode' => $source['zipcode'],
'city' => $source['name'],
);
}
// ...avant de les retourner en json
return new JsonResponse($data, 200, array(
'Cache-Control' => 'no-cache',
));
}
Notons que tout le code est dans l’action à titre d’exemple, mais la requête serait bien mieux installée dans son propre repository/service.
Pour finir, côté client, voici un formulaire simplifié dans le but de l’article:
Nous laissons le champ “zipcode” en readonly
car le code postal sera renseigné automatiquement lors de la séléction de la ville.
Côté javascript, l’autocomplete peut être facilement réalisé à l’aide du module typeahead
présent dans Bootstrap 2:
var suggestUrl = $('#city').attr('data-suggest');
$('#city').typeahead({
// suggestions pour une saisie d'au minimum 3 caractères
minLength: 3,
// nous configurons ici la source distante de données
source: function(query, process) {
// "query" est la chaîne de recherche
// "process" est une closure qui doit recevoir la liste des suggestions à afficher
var $this = this;
$.ajax({
url: suggestUrl,
type: 'GET',
data: {
search: query,
},
success: function(data) {
// les données que nous recevons sont de 3 types:
// "suggest" est la chaîne de caractères à afficher dans les suggestions
// "city" et "zipcode" sont les données que nous voulons utiliser pour
// remplir nos champs lors de la sélection d'une suggestion
// ce tableau "reversed" conserve temporairement une relation entre chaque
// suggestion, et ses données associées
var reversed = {};
// ici nous générons simplement la liste des suggestions à afficher
var suggests = [];
$.each(data, function(id, elem) {
reversed[elem.suggest] = elem;
suggests.push(elem.suggest);
});
$this.reversed = reversed;
// affichage des suggestions
process(suggests);
}
})
},
// cette méthode est appelée lorsque qu'une suggestion est sélectionnée depuis la liste
updater: function(item) {
// nous retrouvons alors les données associées
var elem = this.reversed[item];
// puis nous remplissons les champs "zipcode"...
$('#zipcode').val(elem.zipcode);
// ...et "city" du formulaire
return elem.city;
},
// cette méthode permet de déterminer lesquelles des suggestions sont valides par rapport
// à la recherche. Nous effectuons déjà tout cela côté serveur, donc ici il suffit de
// retourner "true"
matcher: function() {
return true;
}
});
Et voilà notre autocomplete opérationnel. On peut y chercher directement des code postaux:
Ou des noms de villes:
Et la sélection remplit automatiquement les deux champs: