Залежні селектбокси елегантно в 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-.

Код написаний на чистій ванільній мові JS, тому він не потребує jQuery або будь-якої іншої бібліотеки.

// знайти всі дочірні селектбокси на сторінці
document.querySelectorAll('select[data-depends]').forEach((childSelect) => {
	let parentSelect = childSelect.form[childSelect.dataset.depends]; // parent <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 = ''; // видаляє все
	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 (...) { ... }..