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

2 éve A címről David Grudl  

Hogyan hozhatok létre láncolt selectboxokat, ahol az egyikben egy érték kiválasztása után az opciók dinamikusan frissülnek a másikban? Ez egyszerű feladat a Nette-ben és a pure JavaScriptben. Olyan megoldást mutatunk, amely tiszta, újrafelhasználható és biztonságos.

Adatmodell

Példaként hozzunk létre egy űrlapot, amely az ország és a város kiválasztására szolgáló kiválasztó dobozokat tartalmaz.

Először is készítsünk egy adatmodellt, amely mindkét kiválasztási mezőre vonatkozó bejegyzéseket ad vissza. Valószínűleg az adatbázisból fogja azokat lekérni. A pontos megvalósítás nem lényeges, ezért csak utalunk arra, hogy hogyan fog kinézni a felület:

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

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

Mivel a városok száma nagyon nagy, AJAX segítségével fogjuk lekérni őket. Ehhez létrehozunk egy EndpointPresenter, egy API-t, amely JSON formájában adja vissza 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 van (például egy másik bolygón 😉 ), vagy ha a modell olyan adatokat képvisel, amelyek egyszerűen nem sokak, akkor az összeset tömbként átadhatjuk a JavaScriptnek, és megspórolhatjuk az AJAX-kéréseket. Ebben az esetben nem lenne szükség a EndpointPresenter.

Form

És térjünk rá magára az űrlapra. Létrehozunk két kiválasztó dobozt, és összekapcsoljuk őket, azaz a szülő (country) kiválasztott értékétől függően állítjuk be a gyermek (city) elemeket. A lényeg, hogy ezt az onAnchor eseménykezelőben tesszük, vagyis abban a pillanatban, amikor az űrlap már ismeri a felhasználó által megadott é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', 'Country:', $this->world->getCountries())
			->setPrompt('----');

		$city = $form->addSelect('city', 'City:');
		// <-- még valami mást is hozzáadunk itt

		$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. Ez úgy történik, hogy a felhasználó először kiválaszt egy országot, elküldi az űrlapot, majd megjelenik a városok menüje, kiválaszt egyet közülük, és ismét elküldi az űrlapot.

Minket azonban az érdekel, hogy a városokat dinamikusan, JavaScript segítségével töltsük be. Ezt a legtisztább módon a data- attribútumok használatával közelíthetjük meg, amelyben információt küldünk a HTML-nek (és így a JS-nek) arról, hogy mely kiválasztó dobozok vannak összekapcsolva, és honnan kell adatokat lekérni.

Minden egyes gyermek selectboxhoz átadunk egy data-depends attribútumot a szülőelem nevével, majd vagy egy data-url attribútumot az URL-címmel, ahonnan AJAX segítségével lekérdezzük az elemeket, vagy egy data-items attribútumot, ahol közvetlenül felsoroljuk az összes opciót.

Kezdjük az AJAX-változattal. Átadjuk a country szülőelem nevét és egy hivatkozást a Endpoint:cities címre. A # karaktert helyőrzőként használjuk, és a JavaScript a felhasználó által kiválasztott kulcsot helyezi el helyette.

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

És az AJAX nélküli változat? Előkészítünk egy tömböt az összes országból és az összes városból, amelyet átadunk a data-items attribútumnak:

$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 kezelő

Az alábbi kód univerzális, nem kötődik a példában szereplő country és city kiválasztó dobozokhoz, hanem az oldal bármelyik kiválasztó dobozát összekapcsolja, csak állítsa be 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álja az összes gyermek selectboxot az oldalon
document.querySelectorAll('select[data-depends]').forEach((childSelect) => {
	let parentSelect = childSelect.form[childSelect.dataset.depends]; // szülő <select>
	let url = childSelect.dataset.url; // attribútum data-url
	let items = JSON.parse(childSelect.dataset.items || 'null'); // attribútum data-items

	// amikor a felhasználó megváltoztatja a kiválasztott elemet a szülő kiválasztásban...
	parentSelect.addEventListener('change', () => {
		// ha a data-items attribútum létezik...
		if (items) {
			// új elemek betöltése közvetlenül a gyermek kiválasztó dobozba
			updateSelectbox(childSelect, items[parentSelect.value]);
		}

		// ha a data-url attribútum létezik...
		if (url) {
			// AJAX-kérést küldünk a végpontra a kiválasztott elemmel a helyőrző helyett.
			fetch(url.replace(encodeURIComponent('#'), encodeURIComponent(parentSelect.value)))
				.then((response) => response.json())
				// és új elemeket töltünk be a gyermek selectboxba
				.then((data) => updateSelectbox(childSelect, data));
		}
	});
});

// helyettesíti a <options> címet a <select>
function updateSelectbox(select, items)
{
	select.innerHTML = ''; // oldalon remove all
	for (let id in items) { // új beillesztése
		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 kiválasztó mezőre, létrehozhat három vagy több függő elemből álló kaszkádot. Például hozzáadunk egy utca-kiválasztást, amely a kiválasztott várostól függ:

$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()) : []);

Emellett több kijelölőmező is függhet egyetlen közös mezőtől. Csak állítsuk be a data- attribútumokat analóg módon, és töltsük fel az elemeket a setItems() címmel.

Egyáltalán nincs szükség semmilyen módosításra a JavaScript-kódon, amely univerzálisan működik.

Biztonság

Még ezekben a példákban is megmarad a Nette űrlapok összes biztonsági mechanizmusa. Különösen minden egyes kiválasztási mező ellenőrzi, hogy a kiválasztott opció a felkínált lehetőségek egyike-e, és így egy támadó nem tud más értéket meghamisítani.


A megoldás a Nette 2.4 és újabb verziókban működik, a kódminták PHP 8-ra íródtak. Ahhoz, hogy régebbi verziókban is működjenek, helyettesítse a property promotion és a fn() szót a function () use (...) { ... } címmel.