Zależne selectboxy elegancko w Nette i czystym JavaScript

3 lata temu przez David Grudl  

Jak stworzyć powiązane selectboxy, gdzie po wybraniu wartości w jednym dynamicznie ładowane są opcje do drugiego? W Nette i czystym JavaScript jest to łatwe zadanie. Pokażemy rozwiązanie, które jest czyste, wielokrotnego użytku i bezpieczne.

Model danych

Jako przykład stworzymy formularz zawierający selectboxy dla wyboru kraju i miasta.

Najpierw przygotujemy model danych, który będzie zwracał pozycje dla obu selectboxów. Prawdopodobnie będzie je pobierał z bazy danych. Dokładna implementacja nie jest istotna, dlatego tylko zasygnalizujemy, jak będzie wyglądał interfejs:

class World
{
	public function getCountries(): array
	{
		return ...
	}

	public function getCities($country): array
	{
		return ...
	}
}

Ponieważ całkowita liczba miast jest naprawdę duża, będziemy je pobierać za pomocą AJAXu. W tym celu stworzymy EndpointPresenter, czyli API, które będzie nam zwracać miasta w poszczególnych krajach jako 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);
	}
}

Gdyby miast było mało (np. na innej planecie 😉), lub gdyby model reprezentował dane, których po prostu nie jest wiele, moglibyśmy przekazać je od razu wszystkie jako tablicę do JavaScriptu i zaoszczędzić żądania AJAX. W takim przypadku EndpointPresenter nie byłby potrzebny.

Formularz

I przejdźmy do samego formularza. Stworzymy dwa pola wyboru i połączymy je, czyli ustawimy pozycje dziecka (city) w zależności od wybranej wartości rodzica (country). Ważne jest to, że zrobimy to w event handlerze onAnchor, czyli w momencie, gdy formularz zna już wartości przekazane przez użytkownika.

class DemoPresenter extends Nette\Application\UI\Presenter
{
	public function __construct(
		private World $world,
	) {}

	protected function createComponentForm(): Form
	{
		$form = new Form;
		$country = $form->addSelect('country', 'Kraj:', $this->world->getCountries())
			->setPrompt('----');

		$city = $form->addSelect('city', 'Miasto:');
		// <-- tutaj coś jeszcze dodamy

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

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

Tak stworzony formularz będzie działał również bez JavaScriptu. A to tak, że użytkownik najpierw wybierze kraj, wyśle formularz, następnie pojawi się oferta miast, wybierze jedno z nich i wyśle formularz ponownie.

Nas jednak interesuje dynamiczne ładowanie miast za pomocą JavaScriptu. Najczystszym sposobem podejścia do tego jest wykorzystanie atrybutów data-, w których prześlemy do HTML (a tym samym JS) informację o tym, które selectboxy są powiązane i skąd mają czerpać dane.

Każdemu podrzędnemu selectboxowi przekażemy atrybut data-depends z nazwą elementu nadrzędnego oraz dalej albo data-url z URL, skąd ma pobierać pozycje za pomocą AJAXu, albo data-items, gdzie wszystkie warianty od razu podamy.

Zacznijmy od wariantu AJAXowego. Przekażemy nazwę elementu nadrzędnego country i link do Endpoint:cities. Znak # używamy jako placeholder, a JavaScript będzie zamiast niego wstawiał wybrany przez użytkownika klucz.

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

A wariant bez AJAXu? Przygotujemy tablicę wszystkich krajów i wszystkich ich miast, którą przekażemy do atrybutu data-items:

$items = [];
foreach ($this->world->getCountries() as $id => $name) {
	$items[$id] = $this->world->getCities($id);
}

$city = $form->addSelect('city', 'Miasto:')
	->setHtmlAttribute('data-depends', $country->getHtmlName())
	->setHtmlAttribute('data-items', $items);

I pozostaje napisać obsługę JavaScript.

Obsługa JavaScript

Poniższy kod jest uniwersalny, nie jest powiązany z konkretnymi selectboxami country i city z przykładu, ale powiąże dowolne selectboxy na stronie, wystarczy im tylko ustawić wspomniane atrybuty data-.

Kod jest napisany w czystym vanilla JS, nie wymaga więc jQuery ani innej biblioteki.

// znajdujemy na stronie wszystkie podrzędne selectboxy
document.querySelectorAll('select[data-depends]').forEach((childSelect) => {
	let parentSelect = childSelect.form[childSelect.dataset.depends]; // nadrzędny <select>
	let url = childSelect.dataset.url; // atrybut data-url
	let items = JSON.parse(childSelect.dataset.items || 'null'); // atrybut data-items

	// gdy użytkownik zmieni wybraną pozycję w nadrzędnym selectboxie...
	parentSelect.addEventListener('change', () => {
		// jeśli istnieje atrybut data-items...
		if (items) {
			// ładujemy nowe pozycje bezpośrednio do podrzędnego selectboxa
			updateSelectbox(childSelect, items[parentSelect.value]);
		}

		// jeśli istnieje atrybut data-url...
		if (url) {
			// wykonujemy żądanie AJAX do endpointu z wybraną pozycją zamiast placeholdera
			fetch(url.replace(encodeURIComponent('#'), encodeURIComponent(parentSelect.value)))
				.then((response) => response.json())
				// i ładujemy nowe pozycje do podrzędnego selectboxa
				.then((data) => updateSelectbox(childSelect, data));
		}
	});
});

// nadpisuje <options> w <select>
function updateSelectbox(select, items)
{
	select.innerHTML = ''; // usuwamy wszystko
	for (let id in items) { // wstawiamy nowe
		let el = document.createElement('option');
		el.setAttribute('value', id);
		el.innerText = items[id];
		select.appendChild(el);
	}
}

Więcej elementów i możliwość ponownego użycia

Rozwiązanie nie jest ograniczone do dwóch selectboxów, można stworzyć kaskadę trzech lub więcej zależnych od siebie elementów. Na przykład dodamy wybór ulicy, który będzie zależny od wybranego miasta:

$street = $form->addSelect('street', 'Ulica:')
	->setHtmlAttribute('data-depends', $city->getHtmlName())
	->setHtmlAttribute('data-url', $this->link('Endpoint:streets', '#'));

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

Również może więcej selectboxów zależeć od jednego wspólnego. Wystarczy tylko analogicznie ustawić atrybuty data- i wypełnienie pozycji za pomocą setItems().

Przy czym nie trzeba robić żadnej ingerencji w kod JavaScript, który działa uniwersalnie.

Bezpieczeństwo

Nawet w tych przykładach nadal zachowane są wszystkie mechanizmy bezpieczeństwa, którymi dysponują formularze w Nette. W szczególności, że każdy selectbox kontroluje, czy wybrany wariant jest jednym z oferowanych, a więc atakujący nie może podstawić innej wartości.


Rozwiązanie działa w Nette 2.4 i nowszych, próbki kodu są napisane dla PHP 8. Aby działały w starszych wersjach, należy zastąpić property promotion oraz fn() z function () use (...) { ... }.