Odvisna izbirna polja elegantno v Nette in čistem JS

pred 2 leti od David Grudl  

Kako ustvariti verižna izbirna polja, kjer se po izbiri vrednosti v enem dinamično posodobijo možnosti v drugem? To je enostavno opravilo v programu Nette in čistem javascriptu. Prikazali bomo rešitev, ki je čista, ponovno uporabna in varna.

Podatkovni model

Kot primer ustvarimo obrazec z izbirnimi polji za izbiro države in mesta.

Najprej bomo pripravili podatkovni model, ki bo vrnil vnose za obe izbirni polji. Verjetno jih bo pridobil iz podatkovne zbirke. Natančna implementacija ni bistvena, zato samo namignimo, kako bo videti vmesnik:

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

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

Ker je skupno število mest zelo veliko, jih bomo pridobili z uporabo AJAX-a. V ta namen bomo ustvarili EndpointPresenter, API, ki bo vrnil mesta v vsaki državi v obliki 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 je mest malo (na primer na drugem planetu 😉) ali če model predstavlja podatke, ki jih preprosto ni veliko, jih lahko vsa posredujemo kot polje v JavaScript in prihranimo zahteve AJAX. V tem primeru ne bi bilo potrebe po EndpointPresenter.

Obrazec

Preidimo na sam obrazec. Ustvarili bomo dve izbirni polji in ju povezali, kar pomeni, da bomo otroške (city) elemente nastavili glede na izbrano vrednost starševskega (country). Pomembno je, da to storimo v izvajalcu dogodka onAnchor, tj. v trenutku, ko obrazec že pozna vrednosti, ki jih je posredoval uporabnik.

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:');
		// <-- tukaj bomo dodali še kaj drugega

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

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

Tako ustvarjen obrazec bo deloval brez uporabe JavaScripta. To naredimo tako, da uporabnik najprej izbere državo, odda obrazec, nato se mu prikaže meni z mesti, izbere eno od njih in znova odda obrazec.

Vendar nas zanima dinamično nalaganje mest z uporabo JavaScripta. Najčistejši način za to je uporaba atributov data-, s katerimi pošljemo HTML (in s tem JS) informacije o tem, katera izbirna polja so povezana in od kod je treba pridobiti podatke.

Za vsako podrejeno izbirno polje posredujemo atribut data-depends z imenom nadrejenega elementa, nato pa bodisi atribut data-url z naslovom URL, od koder se elementi pridobijo z uporabo AJAX-a, bodisi atribut data-items, v katerem neposredno navedemo vse možnosti.

Začnimo z različico AJAX. Predamo ime nadrejenega elementa country in sklic na Endpoint:cities. Znak # uporabimo kot nadomestek, JavaScript pa bo namesto njega vstavil ključ, ki ga je izbral uporabnik.

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

In varianta brez AJAX-a? Pripravimo polje vseh držav in vseh njenih mest, ki ga posredujemo atributu data-items:

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

Obvladovalnik JavaScript

Naslednja koda je univerzalna, ni vezana na posebna izbirna polja country in city iz primera, temveč bo povezala vsa izbirna polja na strani, samo nastavite omenjene atribute data-.

Koda je napisana v čistem vanilla JS, zato ne potrebuje jQueryja ali katere koli druge knjižnice.

// poiščite vsa otroška izbirna polja na strani.
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 izbrani element v nadrejenem izbirnem polju...
	parentSelect.addEventListener('change', () => {
		// če atribut data-items obstaja...
		if (items) {
			// naloži nove elemente neposredno v podrejeno izbirno polje
			updateSelectbox(childSelect, items[parentSelect.value]);
		}

		// če obstaja atribut data-url...
		if (url) {
			// naredimo zahtevo AJAX do končne točke z izbranim elementom namesto nadomestnega elementa
			fetch(url.replace(encodeURIComponent('#'), encodeURIComponent(parentSelect.value)))
				.then((response) => response.json())
				// in naložimo nove elemente v otroško izbirno polje
				.then((data) => updateSelectbox(childSelect, data));
		}
	});
});

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

Več elementov in ponovna uporabnost

Rešitev ni omejena na dve izbirni polji, temveč lahko ustvarite kaskado treh ali več odvisnih elementov. Na primer, dodamo izbiro ulice, ki je odvisna od izbranega mesta:

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

Tudi več izbirnih polj je lahko odvisnih od enega skupnega. Preprosto analogno nastavite atribute data- in napolnite elemente z setItems().

Pri tem nikakor ni treba spreminjati kode JavaScript, ki deluje univerzalno.

Varnost

Tudi v teh primerih so ohranjeni vsi varnostni mehanizmi, ki jih imajo obrazci Nette. Zlasti vsako polje za izbiro preveri, ali je izbrana možnost ena od ponujenih, zato napadalec ne more ponarediti drugačne vrednosti.


Rešitev deluje v sistemu Nette 2.4 in novejših, primeri kode so napisani za PHP 8. Če želite, da delujejo v starejših različicah, zamenjajte property promotion in fn() s function () use (...) { ... }.