Зависими селектиращи полета елегантно в Nette и чист JS

преди 2 години От David Grudl  

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

Модел на данните

Като пример, нека създадем формуляр, съдържащ полета за избор на държава и град.

Първо ще подготвим модел на данни, който ще връща записи за двете полета за избор. Той вероятно ще ги извлича от базата данни. Точната имплементация не е от съществено значение, затова нека само подскажем как ще изглежда интерфейсът:

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', 'Country:', $this->world->getCountries())
			->setPrompt('----');

		$city = $form->addSelect('city', 'City:');
		// <-- ще добавим още нещо тук

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

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

Създадената по този начин форма ще работи без JavaScript. Това става, като потребителят първо избере държава, изпрати формуляра, след което ще се появи меню с градове, избере един от тях и отново изпрати формуляра.

Ние обаче се интересуваме от динамично зареждане на градове с помощта на JavaScript. Най-чистият начин да подходим към това е да използваме атрибути data-, при които изпращаме информация на HTML (и следователно на JS) за това кои полета за избор са свързани и откъде да се извличат данни.

За всяко дъщерно поле за избор предаваме атрибут data-depends с името на родителския елемент, а след това или атрибут data-url с URL адреса, откъдето да изтеглим елементите с помощта на AJAX, или атрибут data-items, в който директно изброяваме всички опции.

Нека да започнем с варианта AJAX. Предаваме името на родителския елемент country и препратка към Endpoint:cities. Използваме символа # като заместител и JavaScript ще постави вместо него избрания от потребителя ключ.

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

А вариантът без AJAX? Подготвяме масив от всички държави и всички техни градове, който предаваме на атрибута 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);

Обработваща програма на JavaScript

Следващият код е универсален, той не е обвързан с конкретните полета за избор country и city от примера, а ще свърже всички полета за избор на страницата, като просто зададете споменатите атрибути data-.

Кодът е написан на чист vanilla JS, така че не изисква jQuery или друга библиотека.

// намиране на всички детски полета за избор в страницата
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

	// когато потребителят промени избрания елемент в родителската селекция...
	parentSelect.addEventListener('change', () => {
		// ако атрибутът data-items съществува...
		if (items) {
			// зареждане на нови елементи директно в детската селекция
			updateSelectbox(childSelect, items[parentSelect.value]);
		}

		// ако атрибутът data-url съществува...
		if (url) {
			// правим AJAX заявка към крайната точка с избрания елемент вместо със заместител
			fetch(url.replace(encodeURIComponent('#'), encodeURIComponent(parentSelect.value)))
				.then((response) => response.json())
				// и зареждаме нови елементи в допълнителния селектор
				.then((data) => updateSelectbox(childSelect, data));
		}
	});
});

// замества <options> в <select>
function updateSelectbox(select, items)
{
	select.innerHTML = ''; // remove all
	for (let id in items) { // вмъкване на нови
		let el = document.createElement('option');
		el.setAttribute('value', id);
		el.innerText = items[id];
		select.appendChild(el);
	}
}

Повече елементи и възможност за повторна употреба

Решението не е ограничено до две полета за избор, можете да създадете каскада от три или повече зависими елемента. Например, добавяме избор на улица, който зависи от избрания град:

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

Също така, няколко полета за избор могат да зависят от едно общо такова. Просто задайте атрибутите data- по аналогия и попълнете елементите с setItems().

Няма абсолютно никаква нужда да правите модификация на кода на JavaScript, който работи универсално.

Сигурност

Дори и в тези примери всички механизми за сигурност, с които разполагат формите на Nette, са запазени. По-специално, всяко поле за избор проверява дали избраната опция е една от предложените и по този начин нападателят не може да подмени различна стойност.


Решението работи в Nette 2.4 и по-нови версии, а примерните кодове са написани за PHP 8. За да работят в по-стари версии, заменете property promotion и fn() с function () use (...) { ... }.

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