Select boxes dépendants élégamment en Nette et JavaScript pur

il y a 3 ans par David Grudl  

Comment créer des select boxes liés, où après avoir choisi une valeur dans l'un, les options de l'autre se chargent dynamiquement ? En Nette et en JavaScript pur, c'est une tâche facile. Nous allons montrer une solution propre, réutilisable et sécurisée.

Modèle de données

Comme exemple, nous allons créer un formulaire contenant des select boxes pour choisir un pays et une ville.

Préparons d'abord le modèle de données qui retournera les éléments pour les deux select boxes. Il les obtiendra probablement d'une base de données. L'implémentation exacte n'est pas importante, nous allons donc juste esquisser à quoi ressemblera l'interface :

class World
{
	public function getCountries(): array
	{
		return ...
	}

	public function getCities($country): array
	{
		return ...
	}
}

Comme le nombre total de villes est vraiment grand, nous les obtiendrons via AJAX. À cette fin, nous créerons un EndpointPresenter, c'est-à-dire une API qui nous retournera les villes de chaque pays au format JSON :

class EndpointPresenter extends Nette\Application\UI\Presenter
{
	public function __construct(
		private World $world,
	) {}

	public function actionCities($country): void
	{
		$cities = $this->world->getCities($country);
		$this->sendJson($cities);
	}
}

S'il y avait peu de villes (peut-être sur une autre planète 😉), ou si le modèle représentait des données qui ne sont tout simplement pas nombreuses, nous pourrions les transmettre toutes directement sous forme de tableau à JavaScript et économiser les requêtes AJAX. Dans ce cas, EndpointPresenter ne serait pas nécessaire.

Formulaire

Et passons au formulaire lui-même. Nous créerons deux select boxes et les lierons, c'est-à-dire que nous définirons les éléments du subordonné (city) en fonction de la valeur choisie du supérieur (country). Il est important de le faire dans le gestionnaire d'événements onAnchor, c'est-à-dire au moment où le formulaire connaît déjà les valeurs envoyées par l'utilisateur.

class DemoPresenter extends Nette\Application\UI\Presenter
{
	public function __construct(
		private World $world,
	) {}

	protected function createComponentForm(): Form
	{
		$form = new Form;
		$country = $form->addSelect('country', 'Pays:', $this->world->getCountries())
			->setPrompt('----');

		$city = $form->addSelect('city', 'Ville:');
		// <-- nous ajouterons quelque chose ici plus tard

		$form->onAnchor[] = fn() =>
			$city->setItems($country->getValue()
				? $this->world->getCities($country->getValue())
				: []);

		// $form->onSuccess[] = ...
		return $form;
	}
}

Le formulaire ainsi créé fonctionnera même sans JavaScript. Et ce, de telle sorte que l'utilisateur choisit d'abord le pays, envoie le formulaire, puis l'offre de villes apparaît, il en choisit une et renvoie le formulaire.

Mais ce qui nous intéresse, c'est le chargement dynamique des villes à l'aide de JavaScript. La manière la plus propre d'aborder cela est d'utiliser les attributs data-, dans lesquels nous envoyons à HTML (et donc à JS) l'information sur les select boxes qui sont liés et d'où les données doivent être extraites.

À chaque select box subordonné, nous transmettrons l'attribut data-depends avec le nom de l'élément parent et ensuite soit data-url avec l'URL d'où il doit obtenir les éléments via AJAX, soit data-items, où nous indiquerons directement toutes les variantes.

Commençons par la variante AJAX. Nous transmettons le nom de l'élément parent country et le lien vers Endpoint:cities. Nous utilisons le caractère # comme placeholder et JavaScript insérera à sa place la clé choisie par l'utilisateur.

$city = $form->addSelect('city', 'Ville:')
	->setHtmlAttribute('data-depends', $country->getHtmlName())
	->setHtmlAttribute('data-url', $this->link('Endpoint:cities', '#'));

Et la variante sans AJAX ? Nous préparons un tableau de tous les pays et de toutes leurs villes, que nous transmettons à l'attribut data-items :

$items = [];
foreach ($this->world->getCountries() as $id => $name) {
	$items[$id] = $this->world->getCities($id);
}

$city = $form->addSelect('city', 'Ville:')
	->setHtmlAttribute('data-depends', $country->getHtmlName())
	->setHtmlAttribute('data-items', $items);

Et il ne reste plus qu'à écrire le gestionnaire JavaScript.

Gestionnaire JavaScript

Le code suivant est universel, il n'est pas lié aux select boxes spécifiques country et city de l'exemple, mais liera n'importe quels select boxes sur la page, il suffit de leur définir les attributs data- mentionnés.

Le code est écrit en pur vanilla JS, il ne nécessite donc pas jQuery ou une autre bibliothèque.

// trouve tous les select boxes enfants sur la page
document.querySelectorAll('select[data-depends]').forEach((childSelect) => {
	let parentSelect = childSelect.form[childSelect.dataset.depends]; // le <select> parent
	let url = childSelect.dataset.url; // l'attribut data-url
	let items = JSON.parse(childSelect.dataset.items || 'null'); // l'attribut data-items

	// lorsque l'utilisateur change l'élément sélectionné dans le select parent...
	parentSelect.addEventListener('change', () => {
		// s'il existe un attribut data-items...
		if (items) {
			// nous chargeons directement les nouveaux éléments dans le select box enfant
			updateSelectbox(childSelect, items[parentSelect.value]);
		}

		// s'il existe un attribut data-url...
		if (url) {
			// nous faisons une requête AJAX vers l'endpoint avec l'élément sélectionné à la place du placeholder
			fetch(url.replace(encodeURIComponent('#'), encodeURIComponent(parentSelect.value)))
				.then((response) => response.json())
				// et nous chargeons les nouveaux éléments dans le select box enfant
				.then((data) => updateSelectbox(childSelect, data));
		}
	});
});

// réécrit les <options> dans le <select>
function updateSelectbox(select, items)
{
	select.innerHTML = ''; // nous supprimons tout
	for (let id in items) { // nous insérons les nouveaux
		let el = document.createElement('option');
		el.setAttribute('value', id);
		el.innerText = items[id];
		select.appendChild(el);
	}
}

Plusieurs éléments et réutilisabilité

La solution n'est pas limitée à deux select boxes, il est possible de créer une cascade de trois éléments ou plus dépendants les uns des autres. Par exemple, nous ajoutons le choix de la rue, qui dépendra de la ville choisie :

$street = $form->addSelect('street', 'Rue:')
	->setHtmlAttribute('data-depends', $city->getHtmlName())
	->setHtmlAttribute('data-url', $this->link('Endpoint:streets', '#'));

$form->onAnchor[] = fn() =>
	$street->setItems($city->getValue() ? $this->world->getStreets($city->getValue()) : []);

Plusieurs select boxes peuvent également dépendre d'un seul élément commun. Il suffit de définir de manière analogue les attributs data- et de remplir les éléments à l'aide de setItems().

Il n'est pas nécessaire d'apporter de modification au code JavaScript, qui fonctionne de manière universelle.

Sécurité

Même dans ces exemples, tous les mécanismes de sécurité dont disposent les formulaires Nette sont toujours préservés. Notamment, chaque select box vérifie que la variante sélectionnée est l'une des options proposées et donc qu'un attaquant ne peut pas soumettre une autre valeur.


La solution fonctionne avec Nette 2.4 et plus, les exemples de code sont écrits pour PHP 8. Pour les faire fonctionner dans des versions plus anciennes, remplacez property promotion et fn() par function () use (...) { ... }.