Abhängige Selectboxen elegant in Nette und reinem JavaScript

vor 3 Jahren von David Grudl  

Wie erstellt man verknüpfte Selectboxen, bei denen nach Auswahl eines Wertes in einer Box dynamisch die Optionen in einer anderen geladen werden? In Nette und reinem JavaScript ist dies eine einfache Aufgabe. Wir zeigen eine Lösung, die sauber, wiederverwendbar und sicher ist.

Datenmodell

Als Beispiel erstellen wir ein Formular mit Selectboxen zur Auswahl von Land und Stadt.

Zuerst bereiten wir das Datenmodell vor, das die Einträge für beide Selectboxen zurückgibt. Wahrscheinlich werden sie aus einer Datenbank bezogen. Die genaue Implementierung ist nicht wesentlich, daher skizzieren wir nur, wie die Schnittstelle aussehen wird:

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

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

Da die Gesamtzahl der Städte sehr groß ist, werden wir sie mittels AJAX abrufen. Zu diesem Zweck erstellen wir einen EndpointPresenter, also eine API, die uns die Städte in den einzelnen Ländern als JSON zurückgibt:

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);
	}
}

Wenn es nur wenige Städte gäbe (vielleicht auf einem anderen Planeten 😉), oder wenn das Modell Daten repräsentieren würde, von denen es einfach nicht viele gibt, könnten wir sie alle direkt als Array an JavaScript übergeben und AJAX-Anfragen sparen. In diesem Fall wäre der EndpointPresenter nicht notwendig.

Formular

Kommen wir nun zum Formular selbst. Wir werden zwei Auswahlfelder erstellen und sie miteinander verknüpfen, d. h. wir werden die untergeordneten Elemente (city) in Abhängigkeit vom ausgewählten Wert des übergeordneten Elements (country) setzen. Wichtig ist, dass wir dies im onAnchor-Ereignishandler tun, d. h. in dem Moment, in dem das Formular bereits die vom Benutzer eingegebenen Werte kennt.

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

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

		$city = $form->addSelect('city', 'Stadt:');
		// <-- hier fügen wir später noch etwas hinzu

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

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

Ein so erstelltes Formular funktioniert auch ohne JavaScript. Und zwar so, dass der Benutzer zuerst das Land auswählt, das Formular abschickt, dann das Angebot der Städte erscheint, eine davon auswählt und das Formular erneut abschickt.

Uns interessiert aber das dynamische Laden der Städte mittels JavaScript. Der sauberste Weg, dies anzugehen, ist die Verwendung von data-Attributen, in denen wir Informationen an HTML (und damit an JS) senden, welche Selectboxen verknüpft sind und woher die Daten bezogen werden sollen.

Jeder untergeordneten Selectbox übergeben wir das Attribut data-depends mit dem Namen des übergeordneten Elements und entweder data-url mit der URL, von der die Einträge mittels AJAX bezogen werden sollen, oder data-items, wo wir alle Varianten direkt angeben.

Beginnen wir mit der AJAX-Variante. Wir übergeben den Namen des übergeordneten Elements country und den Link zu Endpoint:cities. Das Zeichen # verwenden wir als Platzhalter, und JavaScript wird an seiner Stelle den vom Benutzer gewählten Schlüssel einfügen.

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

Und die Variante ohne AJAX? Wir bereiten ein Array aller Länder und all ihrer Städte vor, das wir an das Attribut data-items übergeben:

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

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

Und es bleibt, den JavaScript-Handler zu schreiben.

JavaScript-Handler

Der folgende Code ist universell, er ist nicht an die konkreten Selectboxen country und city aus dem Beispiel gebunden, sondern verknüpft beliebige Selectboxen auf der Seite, es genügt, ihnen die genannten data-Attribute zu setzen.

Der Code ist in reinem Vanilla JS geschrieben, er erfordert also kein jQuery oder eine andere Bibliothek.

// alle untergeordneten Selectboxen auf der Seite finden
document.querySelectorAll('select[data-depends]').forEach((childSelect) => {
	let parentSelect = childSelect.form[childSelect.dataset.depends]; // übergeordnetes <select>
	let url = childSelect.dataset.url; // Attribut data-url
	let items = JSON.parse(childSelect.dataset.items || 'null'); // Attribut data-items

	// wenn der Benutzer das ausgewählte Element im übergeordneten Select ändert...
	parentSelect.addEventListener('change', () => {
		// wenn das Attribut data-items existiert...
		if (items) {
			// laden wir die neuen Elemente direkt in die untergeordnete Selectbox
			updateSelectbox(childSelect, items[parentSelect.value]);
		}

		// wenn das Attribut data-url existiert...
		if (url) {
			// machen wir eine AJAX-Anfrage an den Endpunkt mit dem ausgewählten Element anstelle des Platzhalters
			fetch(url.replace(encodeURIComponent('#'), encodeURIComponent(parentSelect.value)))
				.then((response) => response.json())
				// und laden die neuen Elemente in die untergeordnete Selectbox
				.then((data) => updateSelectbox(childSelect, data));
		}
	});
});

// überschreibt <options> in <select>
function updateSelectbox(select, items)
{
	select.innerHTML = ''; // alles entfernen
	for (let id in items) { // neue einfügen
		let el = document.createElement('option');
		el.setAttribute('value', id);
		el.innerText = items[id];
		select.appendChild(el);
	}
}

Mehrere Elemente und Wiederverwendbarkeit

Die Lösung ist nicht auf zwei Selectboxen beschränkt, es kann auch eine Kaskade von drei oder mehr voneinander abhängigen Elementen erstellt werden. Zum Beispiel ergänzen wir die Auswahl der Straße, die von der gewählten Stadt abhängt:

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

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

Es können auch mehrere Selectboxen von einer gemeinsamen abhängen. Es genügt, analog die data-Attribute zu setzen und die Einträge mittels setItems() zu füllen.

Dabei ist kein Eingriff in den JavaScript-Code erforderlich, der universell funktioniert.

Sicherheit

Auch in diesen Beispielen bleiben alle Sicherheitsmechanismen erhalten, über die Formulare in Nette verfügen. Insbesondere prüft jede Selectbox, ob die ausgewählte Variante eine der angebotenen ist, sodass ein Angreifer keinen anderen Wert unterschieben kann.


Die Lösung funktioniert in Nette 2.4 und höher, die Codebeispiele sind für PHP 8 geschrieben. Damit sie in älteren Versionen funktionieren, ersetzen Sie property promotion und fn() durch function () use (...) { ... }.