Odvisni selectboxi elegantno v Nette in čistem JavaScriptu
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 (...) { ... }
.
Če želite oddati komentar, se prijavite