Зависими selectbox-ове елегантно в Nette и чист JavaScript

преди 3 години от David Grudl  

Как да създадем свързани 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 (...) { ... }.

David Grudl Founder of Uměligence and creator of Nette Framework, the popular PHP framework. Since 2021, he's been fully immersed in artificial intelligence, teaching practical AI applications. He discusses weekly tech developments on Tech Guys with his co-hosts and writes for phpFashion and La Trine. He believes AI isn't science fiction—it's a practical tool for improving life today.

Последни публикации