Зависими 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 (...) { ... }.

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