Függő selectboxok elegánsan Nette-ben és tiszta JavaScriptben

3 éve írta David Grudl  

Hogyan hozzunk létre összekapcsolt selectboxokat, ahol az egyikben lévő érték kiválasztása után dinamikusan betöltődnek a választási lehetőségek a másikba? Nette-ben és tiszta JavaScriptben ez egy egyszerű feladat. Bemutatunk egy tiszta, újrafelhasználható és biztonságos megoldást.

Adatmodell

Példaként létrehozunk egy űrlapot, amely selectboxokat tartalmaz az ország és a város kiválasztásához.

Először elkészítjük az adatmodellt, amely visszaadja az elemeket mindkét selectboxhoz. Valószínűleg adatbázisból fogja őket lekérni. A pontos implementáció nem lényeges, ezért csak felvázoljuk, hogyan fog kinézni az interfész:

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

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

Mivel a városok teljes száma valóban nagy, AJAX segítségével fogjuk őket lekérni. Erre a célra létrehozunk egy EndpointPresenter-t, azaz egy API-t, amely JSON formátumban adja vissza nekünk az egyes országok városait:

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

Ha kevés város lenne (például egy másik bolygón 😉), vagy ha a modell olyan adatokat reprezentálna, amelyekből egyszerűen nincs sok, akkor mindet átadhatnánk közvetlenül tömbként a JavaScriptnek, és megspórolhatnánk az AJAX kéréseket. Ebben az esetben nem lenne szükség az EndpointPresenter-re.

Űrlap

És térjünk rá magára az űrlapra. Létrehozunk két selectboxot, és összekapcsoljuk őket, azaz a gyermek (city) elemeit a szülő (country) kiválasztott értékétől függően állítjuk be. Fontos, hogy ezt az onAnchor eseménykezelőjében tesszük, tehát abban a pillanatban, amikor az űrlap már ismeri a felhasználó által elküldött értékeket.

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

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

		$city = $form->addSelect('city', 'Város:');
		// <-- ide majd még kiegészítünk valamit

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

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

Az így létrehozott űrlap JavaScript nélkül is működni fog. Mégpedig úgy, hogy a felhasználó először kiválasztja az országot, elküldi az űrlapot, majd megjelenik a városok kínálata, kiválaszt egyet közülük, és újra elküldi az űrlapot.

De minket a városok dinamikus betöltése érdekel JavaScript segítségével. Ennek legtisztább módja a data- attribútumok használata, amelyekben információt küldünk a HTML-nek (és ezáltal a JS-nek) arról, hogy mely selectboxok vannak összekapcsolva, és honnan kell adatokat szerezniük.

Minden gyermek selectboxnak átadjuk a data-depends attribútumot a szülő elem nevével, és továbbá vagy a data-url-t az URL-lel, ahonnan AJAX segítségével kell lekérnie az elemeket, vagy a data-items-t, ahol az összes változatot rögtön megadjuk.

Kezdjük az AJAX-os változattal. Átadjuk a szülő elem country nevét és a linket az Endpoint:cities-re. A # jelet placeholderként használjuk, és a JavaScript helyette a felhasználó által kiválasztott kulcsot fogja beilleszteni.

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

És a változat AJAX nélkül? Elkészítünk egy tömböt az összes országról és azok összes városáról, amelyet átadunk a data-items attribútumba:

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

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

És már csak a kiszolgáló JavaScript megírása van hátra.

JavaScript kiszolgáló

A következő kód univerzális, nem kötődik a példában szereplő konkrét country és city selectboxokhoz, hanem bármilyen selectboxot összekapcsol az oldalon, csak be kell állítani nekik az említett data- attribútumokat.

A kód tiszta vanilla JS-ben íródott, tehát nem igényel jQuery-t vagy más könyvtárat.

// megtaláljuk az oldalon az összes gyermek selectboxot
document.querySelectorAll('select[data-depends]').forEach((childSelect) => {
	let parentSelect = childSelect.form[childSelect.dataset.depends]; // szülő <select>
	let url = childSelect.dataset.url; // data-url attribútum
	let items = JSON.parse(childSelect.dataset.items || 'null'); // data-items attribútum

	// amikor a felhasználó megváltoztatja a kiválasztott elemet a szülő selectben...
	parentSelect.addEventListener('change', () => {
		// ha létezik a data-items attribútum...
		if (items) {
			// rögtön betöltjük a gyermek selectboxba az új elemeket
			updateSelectbox(childSelect, items[parentSelect.value]);
		}

		// ha létezik a data-url attribútum...
		if (url) {
			// AJAX kérést intézünk az endpoint felé a kiválasztott elemmel a placeholder helyett
			fetch(url.replace(encodeURIComponent('#'), encodeURIComponent(parentSelect.value)))
				.then((response) => response.json())
				// és betöltjük a gyermek selectboxba az új elemeket
				.then((data) => updateSelectbox(childSelect, data));
		}
	});
});

// felülírja a <options>-t a <select>-ben
function updateSelectbox(select, items)
{
	select.innerHTML = ''; // mindent eltávolítunk
	for (let id in items) { // újakat illesztünk be
		let el = document.createElement('option');
		el.setAttribute('value', id);
		el.innerText = items[id];
		select.appendChild(el);
	}
}

Több elem és újrafelhasználhatóság

A megoldás nem korlátozódik két selectboxra, létrehozhatunk akár három vagy több egymástól függő elemből álló kaszkádot is. Például kiegészítjük az utca választásával, amely a kiválasztott várostól függ:

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

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

Több selectbox is függhet egy közös elemtől. Csak analóg módon be kell állítani a data- attribútumokat és az elemek feltöltését a setItems() segítségével.

Mindeközben nincs szükség semmilyen beavatkozásra a JavaScript kódban, amely univerzálisan működik.

Biztonság

Ezekben a példákban is megmaradnak a Nette űrlapjaiban rejlő összes biztonsági mechanizmus. Különösen az, hogy minden selectbox ellenőrzi, hogy a kiválasztott változat az egyik felkínált-e, és így a támadó nem tud más értéket becsempészni.


A megoldás Nette 2.4-ben és újabb verziókban működik, a kódpéldák Nette for PHP 8-hoz íródtak. Ahhoz, hogy régebbi verziókban működjenek, cserélje le a property promotion és a fn()-t function () use (...) { ... }-re.