Зависимые селектбоксы элегантно в 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 (...) { ... }.