Зависими selectbox-ове елегантно в Nette и чист JavaScript
Как да създадем свързани selectbox-ове, при които след избор на стойност в единия, динамично се зареждат опциите в другия? В Nette и чист JavaScript това е лесна задача. Ще покажем решение, което е чисто, преизползваемо и безопасно.

Модел на данни
Като пример ще създадем формуляр, съдържащ selectbox-ове за избор на държава и град.
Първо ще подготвим модел на данни, който ще връща елементите за двата selectbox-а. Вероятно ще ги получава от база данни. Точната имплементация не е съществена, затова само ще очертаем как ще изглежда интерфейсът:
class World
{
public function getCountries(): array
{
return ...
}
public function getCities($country): array
{
return ...
}
}
Тъй като общият брой градове е наистина
голям, ще ги получаваме чрез AJAX. За тази цел
ще създадем EndpointPresenter
, т.е. API, което
ще ни връща градовете в отделните държави
като 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);
}
}
Ако градовете бяха малко (например на
друга планета 😉), или ако моделът
представляваше данни, които просто не са
много, можехме да ги предадем направо
всички като масив в JavaScript и да спестим AJAX
заявките. В такъв случай EndpointPresenter
нямаше да е необходим.
Формуляр
И нека преминем към самия формуляр. Ще
създадем две полета за избор и ще ги
свържем, т.е. ще зададем елементите на
детето (city
) в зависимост от избраната
стойност на родителя (country
). Важното
е, че правим това в обработчика на събитието
onAnchor,
т.е. в момента, в който формата вече знае
стойностите, подадени от потребителя.
class DemoPresenter extends Nette\Application\UI\Presenter
{
public function __construct(
private World $world,
) {}
protected function createComponentForm(): Form
{
$form = new Form;
$country = $form->addSelect('country', 'Държава:', $this->world->getCountries())
->setPrompt('----');
$city = $form->addSelect('city', 'Град:');
// <-- тук после ще допълним още нещо
$form->onAnchor[] = fn() =>
$city->setItems($country->getValue()
? $this->world->getCities($country->getValue())
: []);
// $form->onSuccess[] = ...
return $form;
}
}
Така създаденият формуляр ще работи и без JavaScript. И то така, че потребителят първо избира държава, изпраща формуляра, след това се появява списък с градове, избира един от тях и изпраща формуляра отново.
Нас обаче ни интересува динамичното
зареждане на градове с помощта на JavaScript.
Най-чистият начин да подходим към това е да
използваме data-
атрибути, в които ще
изпратим към HTML (и съответно JS) информация
за това кои selectbox-ове са свързани и откъде
трябва да се черпят данни.
На всеки подчинен selectbox ще предадем
атрибут data-depends
с името на надредения
елемент и след това или data-url
с URL,
откъдето трябва да получава елементи чрез
AJAX, или data-items
, където всички варианти
директно ще посочим.
Да започнем с AJAX варианта. Ще предадем
името на надредения елемент country
и
връзка към Endpoint:cities
. Знакът #
използваме като placeholder и JavaScript ще вмъква
вместо него избрания от
потребителя ключ.
$city = $form->addSelect('city', 'Град:')
->setHtmlAttribute('data-depends', $country->getHtmlName())
->setHtmlAttribute('data-url', $this->link('Endpoint:cities', '#'));
А вариантът без AJAX? Ще подготвим масив от
всички държави и всички техни градове,
който ще предадем в атрибута
data-items
:
$items = [];
foreach ($this->world->getCountries() as $id => $name) {
$items[$id] = $this->world->getCities($id);
}
$city = $form->addSelect('city', 'Град:')
->setHtmlAttribute('data-depends', $country->getHtmlName())
->setHtmlAttribute('data-items', $items);
И остава да напишем обслужващия JavaScript.
JavaScript обработка
Следващият код е универсален, не е
обвързан с конкретните selectbox-ове country
и city
от примера, а ще свърже всякакви
selectbox-ове на страницата, достатъчно е само
да им се зададат споменатите data-
атрибути.
Кодът е написан на чист vanilla JS, не изисква jQuery или друга библиотека.
// намираме на страницата всички подчинени selectbox-ове
document.querySelectorAll('select[data-depends]').forEach((childSelect) => {
let parentSelect = childSelect.form[childSelect.dataset.depends]; // надреден <select>
let url = childSelect.dataset.url; // атрибут data-url
let items = JSON.parse(childSelect.dataset.items || 'null'); // атрибут data-items
// когато потребителят промени избрания елемент в надредения select...
parentSelect.addEventListener('change', () => {
// ако съществува атрибут data-items...
if (items) {
// зареждаме направо в подчинения selectbox нови елементи
updateSelectbox(childSelect, items[parentSelect.value]);
}
// ако съществува атрибут data-url...
if (url) {
// правим AJAX заявка към endpoint с избрания елемент вместо placeholder-а
fetch(url.replace(encodeURIComponent('#'), encodeURIComponent(parentSelect.value)))
.then((response) => response.json())
// и зареждаме в подчинения selectbox нови елементи
.then((data) => updateSelectbox(childSelect, data));
}
});
});
// презаписва <options> в <select>
function updateSelectbox(select, items)
{
select.innerHTML = ''; // премахваме всичко
for (let id in items) { // вмъкваме нови
let el = document.createElement('option');
el.setAttribute('value', id);
el.innerText = items[id];
select.appendChild(el);
}
}
Повече елементи и преизползваемост
Решението не е ограничено до два selectbox-а, може да се създаде каскада от три или повече зависими един от друг елементи. Например, ще допълним избор на улица, който ще зависи от избрания град:
$street = $form->addSelect('street', 'Улица:')
->setHtmlAttribute('data-depends', $city->getHtmlName())
->setHtmlAttribute('data-url', $this->link('Endpoint:streets', '#'));
$form->onAnchor[] = fn() =>
$street->setItems($city->getValue() ? $this->world->getStreets($city->getValue()) : []);
Също така може повече selectbox-ове да зависят
от един общ. Достатъчно е само аналогично да
се зададат data-
атрибути и да се
попълнят елементите с помощта на
setItems()
.
При което не е необходимо да се прави никаква намеса в JavaScript кода, който работи универсално.
Сигурност
Дори в тези примери все още се запазват всички механизми за сигурност, с които разполагат формулярите в Nette. По-специално, че всеки selectbox проверява дали избраният вариант е един от предлаганите и следователно нападателят не може да подмени с друга стойност.
Решението работи в Nette 2.4 и по-нови
версии, а примерните кодове са написани за
PHP 8. За да работят в по-стари версии,
заменете property
promotion и fn()
с
function () use (...) { ... }
.
За да изпратите коментар, моля, влезте в системата