Abhängige Selectboxen elegant in Nette und purem JS

vor 3 Jahren von David Grudl  

Wie kann man verkettete Auswahlboxen erstellen, bei denen nach der Auswahl eines Wertes in der einen die Optionen in der anderen dynamisch aktualisiert werden? Das ist eine einfache Aufgabe in Nette und purem JavaScript. Wir zeigen eine Lösung, die sauber, wiederverwendbar und sicher ist.

Datenmodell

Lassen Sie uns als Beispiel ein Formular erstellen, das Auswahlfelder für die Auswahl des Landes und der Stadt enthält.

Zunächst bereiten wir ein Datenmodell vor, das Einträge für beide Auswahlfelder zurückgibt. Wahrscheinlich wird es diese aus der Datenbank abrufen. Die genaue Implementierung ist nicht unbedingt erforderlich, also lassen Sie uns nur andeuten, 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 über AJAX abrufen. Zu diesem Zweck werden wir eine EndpointPresenter erstellen, eine API, die die Städte in jedem Land 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 gibt (z. B. auf einem anderen Planeten 😉 ) oder wenn das Modell Daten darstellt, die einfach nicht viele sind, könnten wir sie alle als Array an JavaScript übergeben und AJAX-Anfragen sparen. In diesem Fall besteht keine Notwendigkeit für EndpointPresenter.

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', 'Country:', $this->world->getCountries())
			->setPrompt('----');

		$city = $form->addSelect('city', 'City:');
		// <-- Wir fügen hier noch etwas hinzu

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

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

Das auf diese Weise erstellte Formular funktioniert ohne JavaScript. Dies geschieht, indem der Benutzer zunächst ein Land auswählt, das Formular abschickt, dann ein Menü mit Städten erscheint, eine davon auswählt und das Formular erneut abschickt.

Wir sind jedoch daran interessiert, die Städte mit Hilfe von JavaScript dynamisch zu laden. Der sauberste Weg, dies zu tun, ist die Verwendung von data- -Attributen, mit denen wir dem HTML-Code (und damit JS) Informationen darüber übermitteln, welche Auswahlfelder verknüpft sind und woher die Daten abgerufen werden sollen.

Für jedes untergeordnete Auswahlfeld übergeben wir ein data-depends -Attribut mit dem Namen des übergeordneten Elements und dann entweder ein data-url -Attribut mit der URL, von der aus die Elemente über AJAX abgerufen werden, oder ein data-items -Attribut, in dem wir alle Optionen direkt auflisten.

Beginnen wir mit der AJAX-Variante. Wir übergeben den Namen des übergeordneten Elements country und einen Verweis auf Endpoint:cities. Wir verwenden das Zeichen “#” als Platzhalter und JavaScript setzt stattdessen den vom Benutzer ausgewählten Schlüssel ein.

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

Und die Variante ohne AJAX? Wir bereiten ein Array mit allen Ländern und allen Städten 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', 'City:')
	->setHtmlAttribute('data-depends', $country->getHtmlName())
	->setHtmlAttribute('data-items', $items);

JavaScript-Handler

Der folgende Code ist universell, er ist nicht an die spezifischen Auswahlfelder country und city aus dem Beispiel gebunden, sondern verknüpft alle Auswahlfelder auf der Seite, indem er einfach die erwähnten data- Attribute setzt.

Der Code ist in reinem Vanilla JS geschrieben, benötigt also weder jQuery noch 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 in der übergeordneten Auswahl ändert...
	parentSelect.addEventListener('change', () => {
		// wenn das data-items-Attribut existiert...
		if (items) {
			// neue Elemente direkt in die untergeordnete Auswahlbox laden
			updateSelectbox(childSelect, items[parentSelect.value]);
		}

		// wenn das data-url-Attribut existiert...
		if (url) {
			// stellen 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 neue Elemente in die Child-Selectbox
				.then((data) => updateSelectbox(childSelect, data));
		}
	});
});

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

Mehr Elemente und Wiederverwendbarkeit

Die Lösung ist nicht auf zwei Auswahlfelder beschränkt, Sie können eine Kaskade von drei oder mehr abhängigen Elementen erstellen. Zum Beispiel fügen wir eine Straßenauswahl hinzu, die von der ausgewählten Stadt abhängt:

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

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

Auch mehrere Auswahlfelder können von einem einzigen gemeinsamen abhängen. Setzen Sie einfach die data- Attribute analog und füllen Sie die Elemente mit setItems().

Es ist absolut nicht notwendig, den JavaScript-Code zu ändern, der universell funktioniert.

Sicherheit

Auch in diesen Beispielen bleiben alle Sicherheitsmechanismen, die Nette-Formulare haben, erhalten. Insbesondere wird bei jedem Auswahlfeld geprüft, ob die gewählte Option eine der angebotenen ist, so dass ein Angreifer keinen anderen Wert fälschen 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 (...) { ... }.