Зависимые селектбоксы элегантно в Nette и чистом JavaScript

3 года назад от 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', 'Страна:', $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) информацию о том, какие селектбоксы связаны и откуда следует брать данные.

Каждому подчиненному селектбоксу передадим атрибут data-depends с именем родительского элемента и далее либо data-url с URL, откуда он должен получать элементы с помощью AJAX, либо data-items, где все варианты сразу укажем.

Начнем с AJAX-варианта. Передадим имя родительского элемента country и ссылку на Endpoint:cities. Знак # используем как плейсхолдер, и 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

Следующий код универсален, он не привязан к конкретным селектбоксам 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-запрос на endpoint с выбранным элементом вместо плейсхолдера
			fetch(url.replace(encodeURIComponent('#'), encodeURIComponent(parentSelect.value)))
				.then((response) => response.json())
				// и загружаем в подчиненный селектбокс новые элементы
				.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);
	}
}

Больше элементов и повторное использование

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

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

Также несколько селектбоксов могут зависеть от одного общего. Достаточно лишь аналогично установить data- атрибуты и наполнение элементов с помощью setItems().

При этом нет необходимости вносить какие-либо изменения в JavaScript-код, который работает универсально.

Безопасность

Даже в этих примерах сохраняются все механизмы безопасности, которыми обладают формы в Nette. В частности, каждый селектбокс проверяет, является ли выбранный вариант одним из предложенных, и, следовательно, злоумышленник не может подставить другое значение.


Решение работает в Nette 2.4 и новее, примеры кода написаны для Nette для PHP 8. Чтобы они работали в старых версиях, замените продвижение свойств и fn() на function () use (...) { ... }.

David Grudl An artificial intelligence and web technology specialist, creator of the Nette Framework and other popular open-source projects. He writes for Uměligence, phpFashion, and La Trine blogs. He conducts AI training workshops and hosts the Tech Guys show. He's passionate about making artificial intelligence accessible through clear, practical explanations. Creative and pragmatic, he has a keen eye for real-world technology applications.