Zależne selectboxy elegancko w Nette i czysty JS

2 lata temu Ze strony David Grudl  

Jak stworzyć łańcuchowe selectboxy, gdzie po wybraniu wartości w jednym, opcje są dynamicznie aktualizowane w drugim? Jest to łatwe zadanie w Nette i czystym JavaScripcie. Pokażemy rozwiązanie, które jest czyste, wielokrotnego użytku end secure.

Model danych

Jako przykład, stwórzmy formularz zawierający pola wyboru do wyboru kraju i miasta.

Najpierw przygotujemy model danych, który będzie zwracał wpisy dla obu pól wyboru. Prawdopodobnie będzie on pobierał je z bazy danych. Dokładna implementacja nie jest niezbędna, więc podpowiedzmy tylko, jak będzie wyglądał interfejs:

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

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

Ponieważ łączna liczba miast jest naprawdę duża, będziemy je pobierać za pomocą AJAX-a. W tym celu stworzymy EndpointPresenter, API, które zwróci miasta w każdym kraju 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);
	}
}

Jeśli miast jest niewiele (na przykład na innej planecie 😉 ), lub jeśli model reprezentuje dane, których jest po prostu niewiele, moglibyśmy przekazać je wszystkie jako tablicę do JavaScript i zapisać żądania AJAX. W takim przypadku nie byłoby potrzeby korzystania z EndpointPresenter.

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

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

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

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

Tak utworzony formularz będzie działał bez użycia JavaScript. Odbywa się to w ten sposób, że użytkownik najpierw wybiera kraj, przesyła formularz, następnie pojawia się menu miast, wybiera jedno z nich i ponownie przesyła formularz.

Jednak my jesteśmy zainteresowani dynamicznym ładowaniem miast przy użyciu JavaScript. Najczystszym sposobem podejścia do tego jest użycie atrybutów data-, w których wysyłamy do HTML (a więc i JS) informacje o tym, które pola wyboru są powiązane i skąd pobierać dane.

Dla każdego pola wyboru dziecka przekazujemy atrybut data-depends z nazwą elementu nadrzędnego, a następnie albo data-url z adresem URL, z którego należy pobrać elementy za pomocą AJAX-a, albo atrybut data-items, w którym bezpośrednio wymieniamy wszystkie opcje.

Zacznijmy od wariantu AJAX-owego. Przekazujemy nazwę elementu nadrzędnego country oraz referencję do Endpoint:cities. Używamy znaku # jako placeholder, a JavaScript umieści zamiast niego wybrany przez użytkownika klucz.

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

A wariant bez AJAX-a? Przygotowujemy tablicę wszystkich krajów i wszystkich jego miast, którą przekazujemy do atrybutu 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);

Obsługa JavaScript

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

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

// znajdź wszystkie dzieci selectbox na stronie
document.querySelectorAll('select[data-depends]').forEach((childSelect) => {
	let parentSelect = childSelect.form[childSelect.dataset.depends]; // parent <select>
	let url = childSelect.dataset.url; // atrybut data-url
	let items = JSON.parse(childSelect.dataset.items || 'null'); // atrybut data-items

	// gdy użytkownik zmienia wybrany element w selekcji rodzica...
	parentSelect.addEventListener('change', () => {
		// jeśli atrybut data-items istnieje...
		if (items) {
			// załaduj nowe elementy bezpośrednio do pola wyboru dziecka
			updateSelectbox(childSelect, items[parentSelect.value]);
		}

		// jeśli atrybut data-url istnieje...
		if (url) {
			// wykonujemy żądanie AJAX do punktu końcowego z wybranym elementem zamiast placeholder
			fetch(url.replace(encodeURIComponent('#'), encodeURIComponent(parentSelect.value)))
				.then((response) => response.json())
				// i ładujemy nowe elementy do pola wyboru dziecka
				.then((data) => updateSelectbox(childSelect, data));
		}
	});
});

// zastępuje <options> w <select>
function updateSelectbox(select, items)
{
	select.innerHTML = ''; // usuń wszystko
	for (let id in items) { // wstawić nowy
		let el = document.createElement('option');
		el.setAttribute('value', id);
		el.innerText = items[id];
		select.appendChild(el);
	}
}

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

Rozwiązanie nie ogranicza się do dwóch pól wyboru, można stworzyć kaskadę trzech lub więcej elementów zależnych. Na przykład dodajemy wybór ulicy, który zależy od wybranego miasta:

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

Również wiele pól wyboru może zależeć od jednego wspólnego. Wystarczy analogicznie ustawić atrybuty data- i zaludnić elementy za pomocą setItems().

Nie ma absolutnie żadnej potrzeby wykonywania jakichkolwiek modyfikacji kodu JavaScript, który działa uniwersalnie.

Bezpieczeństwo

Nawet w tych przykładach zachowane są wszystkie mechanizmy bezpieczeństwa, które posiadają formularze Nette. W szczególności każde pole wyboru sprawdza, czy wybrana opcja jest jedną z oferowanych, a więc atakujący nie może podrobić 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 (...) { ... }.