Odvisni selectboxi elegantno v Nette in čistem JavaScriptu

pred 3 leti od David Grudl  

Kako ustvariti povezane selectboxe, kjer se po izbiri vrednosti v enem dinamično naložijo izbire v drugega? V Nette in čistem JavaScriptu gre za enostavno nalogo. Pokazali si bomo rešitev, ki je čista, ponovno uporabljiva in varna.

Podatkovni model

Kot primer si bomo ustvarili obrazec, ki vsebuje selectboxe za izbiro države in mesta.

Najprej si pripravimo podatkovni model, ki bo vračal elemente za oba selectboxa. Verjetno jih bo pridobival iz podatkovne baze. Natančna implementacija ni bistvena, zato le nakažemo kako bo izgledal vmesnik:

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

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

Ker je celotno število mest res veliko, jih bomo pridobivali s pomočjo AJAXa. Za ta namen si bomo ustvarili EndpointPresenter, torej API, ki nam bo vračal mesta v posameznih državah kot 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);
	}
}

Če bi mest bilo malo (na primer na drugem planetu 😉), ali bi model predstavljal podatke, katerih preprosto ni veliko, bi jih lahko predali kar vse kot polje v JavaScript in prihranili AJAX zahteve. V takem primeru EndpointPresenter ne bi bil potreben.

Obrazec

In pojdimo na sam obrazec. Ustvarili bomo dva selectboxa in ju povezali, tj. podrejenemu (city) nastavili elemente v odvisnosti od izbrane vrednosti nadrejenega (country). Pomembno je, da tako storimo v obdelavi dogodka onAnchor, torej v trenutku, ko obrazec že pozna vrednosti poslane s strani uporabnika.

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

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

		$city = $form->addSelect('city', 'Mesto:');
		// <-- sem potem še nekaj dopolnimo

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

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

Tako ustvarjen obrazec bo deloval tudi brez JavaScripta. In to tako, da uporabnik najprej izbere državo, pošlje obrazec, nato se pojavi ponudba mest, eno izmed njih izbere in obrazec pošlje ponovno.

Nas pa zanima dinamično nalaganje mest s pomočjo JavaScripta. Najčistejši način, kako k temu pristopiti, je uporaba data- atributov, v katerih si pošljemo v HTML (in posledično JS) informacijo o tem, kateri selectboxi so povezani in od kod se naj črpajo podatki.

Vsakemu podrejenemu selectboxu predamo atribut data-depends z imenom nadrejenega elementa in dalje bodisi data-url z URL, od kod naj pridobiva elemente s pomočjo AJAXa, bodisi data-items, kjer vse variante kar navedemo.

Začnimo z AJAX varianto. Predamo ime nadrejenega elementa country in povezavo na Endpoint:cities. Znak # uporabljamo kot placeholder in JavaScript bo namesto njega vstavljal uporabnikom izbrani ključ.

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

In varianta brez AJAXa? Pripravimo si polje vseh držav in vseh njihovih mest, ki ga predamo v atribut data-items:

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

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

In preostane napisati obdelovalni JavaScript.

JavaScriptova obdelava

Naslednja koda je univerzalna, ni vezana na konkretne selectboxe country in city iz primera, ampak poveže katerekoli selectboxe na strani, zadostuje jim le nastaviti omenjene data- atribute.

Koda je napisana v čistem vanilla JS, ne zahteva torej jQuery ali druge knjižnice.

// najdemo na strani vse podrejene selectboxe
document.querySelectorAll('select[data-depends]').forEach((childSelect) => {
	let parentSelect = childSelect.form[childSelect.dataset.depends]; // nadrejeni <select>
	let url = childSelect.dataset.url; // atribut data-url
	let items = JSON.parse(childSelect.dataset.items || 'null'); // atribut data-items

	// ko uporabnik spremeni izbrano postavko v nadrejenem selectu...
	parentSelect.addEventListener('change', () => {
		// če obstaja atribut data-items...
		if (items) {
			// naložimo kar v podrejeni selectbox nove postavke
			updateSelectbox(childSelect, items[parentSelect.value]);
		}

		// če obstaja atribut data-url...
		if (url) {
			// naredimo AJAX zahtevo na endpoint z izbrano postavko namesto placeholderja
			fetch(url.replace(encodeURIComponent('#'), encodeURIComponent(parentSelect.value)))
				.then((response) => response.json())
				// in naložimo v podrejeni selectbox nove postavke
				.then((data) => updateSelectbox(childSelect, data));
		}
	});
});

// prepiše <options> v <select>
function updateSelectbox(select, items)
{
	select.innerHTML = ''; // odstranimo vse
	for (let id in items) { // vstavimo nove
		let el = document.createElement('option');
		el.setAttribute('value', id);
		el.innerText = items[id];
		select.appendChild(el);
	}
}

Več elementov in ponovna uporabljivost

Rešitev ni omejena na dva selectboxa, lahko ustvarimo mirno kaskado treh ali več med seboj odvisnih elementov. Na primer dopolnimo izbiro ulice, ki bo odvisna od izbranega mesta:

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

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

Tudi lahko več selectboxov odvisnih od enega skupnega. Zadostuje le analogno nastaviti data- atribute in napolnjenje elementov s pomočjo setItems().

Pri čemer ni potrebno delati nobenega posega v JavaScript kodo, ki deluje univerzalno.

Varnost

Tudi v teh primerih se še vedno ohranjajo vsi varnostni mehanizmi, ki jih imajo obrazci v Nette. Predvsem da vsak selectbox preverja, ali je izbrana varianta ena izmed ponujenih in torej napadalec ne more podtakniti druge vrednosti.


Rešitev deluje v Nette 2.4 in novejšem, primeri kode so napisani za Nette za PHP 8. Da bi delovale v starejših različicah, nadomestite property promotion in fn() z function () use (...) { ... }.