Tutoriel Vidéo JavaScript Lier plusieurs select en Ajax

Télécharger la vidéo Télécharger les sources

Lors de la création de formulaire complexe nous avons souvent besoin de lier plusieurs select entre eux. Par exemple dans le cas d'un select permettant de choisir la ville de résidence d'un utilisateur, il n'est pas envisageable d'afficher 30 000 options. Une solution est alors de commencer par demander à l'utilisateur sa région, puis son département, pour enfin lui proposer une liste limitée de villes.

Côté serveur (PHP)

Afin de préparer le terrain, nous allons avoir besoin de créer côté serveur une page capable de nous renvoyer la liste des départements ou des villes en fonction de ce qui a été sélectionné par l'utilisateur. Nous allons donc créer une page PHP qui récupérera les informations depuis la base de données et qui les renverra au format JSON.

<?php
// Code spécifique à mon application
require '../vendor/autoload.php';
$pdo = \App\Config::getPDO();

// On récupère le type demandé
$type = empty($_GET['type']) ? 'department' : $_GET['type'];
if ($type === 'department') {
    $table = 'departments';
    $foreign = 'region_id';
} else if ($type === 'city') {
    $table = 'cities';
    $foreign =  'department_id';
} else {
    throw new Exception('Unknown type ' . $type);
}

// On récupère les élément depuis la base de données
$query = $pdo->prepare("SELECT id, name FROM $table WHERE $foreign = ?");
$query->execute([$_GET['filter']]);
$items = $query->fetchAll();
// On renvoie les données au format JSON en choisissant des clefs spécifiques
header('Content-Type: application/json');
echo json_encode(array_map(function ($item) {
    return [
        'label' => $item['name'],
        'value' => $item['id']
    ];
}, $items));

Côté HTML

Pour notre système nous allons chercher à créer quelque chose de relativement générique afin de ne pas avoir à répéter le code pour chaque nouveau select. Nous utiliserons donc des attributs HTML pour spécifier les paramètres :

  • L'attribut data-target permettra de spécifier l'élément qui recevra les résultats lors d'un changement de valeur du select
  • L'attribut data-source permettra de spécifier l'URL à appeler pour obtenir les résultats qui seront injectés dans le Select suivant.

Ainsi, l'élément qui va nous permettre de sélectionner notre villeressemblera à ça :

<select id="region" class="form-control linked-select" data-target="#department" data-source="/list.php?type=department&filter=$id">
  <option value="0">Sélectionnez votre région</option>
    <?php
    $regions = $pdo->query('SELECT id, name FROM regions');
    foreach($regions as $region) {
      ?>
      <option value="<?= $region['id'] ?>"><?= $region['name']; ?></option>
      <?php
    }
    ?>
</select>
<select id="department" class="form-control linked-select" data-target="#city" data-source="/list.php?type=city&filter=$id" style="display: none;">
  <option value="0">Sélectionnez votre département</option>
</select>
<select id="city" name="city" class="form-control" style="display: none;">
  <option value="0">Sélectionnez votre ville</option>
</select>

Côté JavaScript

Nous devons donc maintenant créer la logique qui va permettre d'ajouter le comportement souhaité à nos différents Select. L'objectif est d'avoir un code qu'il sera facile de réutiliser :

let $selects = document.querySelectorAll('.linked-select')
$selects.forEach(function ($select) {
  new LinkedSelect($select)
})

Pour fonctionner, notre classe aura donc besoin de récupérer l'élément (le <select>) sur lequel on écoutera les changements.

Dès le début, on va en profiter pour initialiser différentes variables nous permettant d'accéder plus facilement à certains éléments (comme le select cible, ou l'option par défaut par exemple). Ceci nous évitera de faire ses récupération plus tard et ainsi d'optimiser les performances. Nous créerons aussi, une propriété cache qui permettra de garder en mémoire les résultats et qui évitera d’appeler plusieurs fois le serveur pour récupérer un résultat déjà obtenu. Il est aussi optimiser les performances et de limiter les appels serveur inutil en utilisant la méthode debounce lorsque l'on écoute l'évènement change.

class LinkedSelect {

  /**
   * @param {HTMLSelectElement} $select
   */
  constructor ($select) {
    this.$select = $select
    this.$target = document.querySelector(this.$select.dataset.target)
    this.$placeholder = this.$target.firstElementChild
    this.onChange = debounce(this.onChange.bind(this), 500)
    this.$loader = null
    this.cache = {}
    this.$select.addEventListener('change', this.onChange)
  }
}

Dans la méthode onChange() nous allons vérifier si une valeur à correctement été sélectionnée et lancer le chargement des données si nécessaire. Pour la récupération, nous allons directement utiliser l'objet XMLHttpRequest et appeler l'URL qui se situe dans l'attribut data-source. Puisque les données sont récupérées de manière asynchrone, il faudra passer un callback (fonction anonyme) dans notre méthode loadOptions. Ce callback sera appelé lorsque l'on récupère les résultats et contiendra le code HTML à injecter dans le select suivant.



  /**
   * Se déclenche au changement de valeur d'un select
   * @param {Event} e
   */
  onChange (e) {
    if (e.target.value === '0') {
      return
    }
    this.loadOptions(e.target.value, (options) => {
      this.$target.innerHTML = options
      this.$target.insertBefore(this.$placeholder, this.$target.firstChild)
      this.$target.selectedIndex = 0
      this.$target.style.display = null
    })
  }

  /**
   * Charge les options à partir du serveur (ou du cache)
   * @param {string} id
   * @param callback
   */
  loadOptions (id, callback) {
    if (this.cache[id]) {
      callback(this.cache[id])
      return
    }
    this.showLoader()
    let request = new XMLHttpRequest()
    request.open('GET', this.$select.dataset.source.replace('$id', id), true)
    request.onload = () => {
      if (request.status >= 200 && request.status < 400) {
        let data = JSON.parse(request.responseText)
        let options = data.reduce(function (acc, option) {
          return acc + '<option value="' + option.value + '">' + option.label + '</option>'
        }, '')
        this.cache[id] = options
        this.hideLoader()
        callback(options)
      } else {
        alert('Impossible de charger la liste')
      }
    }
    request.onerror = function () {
      alert('Impossible de charger la liste')
    }
    request.send()
  }

Afin de signifier à l'utilisateur que les données sont en train d'être chargées, nous allons rajouter le message Chargement... après notre select :

  showLoader () {
    this.$loader = document.createTextNode('Chargement...')
    this.$target.parentNode.appendChild(this.$loader)
  }

  hideLoader () {
    if (this.$loader !== null) {
      this.$loader.parentNode.removeChild(this.$loader)
      this.$loader = null
    }
  }

Vous pouvez évidemment personnaliser ses méthodes là pour rajouter un indicateur de chargement plus sympa ;).