Selectbox-uri dependente elegant în Nette și JavaScript pur

acum 3 ani de David Grudl  

Cum să creăm selectbox-uri interconectate, unde după selectarea unei valori într-unul, opțiunile se încarcă dinamic în celălalt? În Nette și JavaScript pur, este o sarcină ușoară. Vom arăta o soluție curată, reutilizabilă și sigură.

Model de date

Ca exemplu, vom crea un formular care conține selectbox-uri pentru alegerea țării și a orașului.

Mai întâi, vom pregăti modelul de date care va returna elementele pentru ambele selectbox-uri. Probabil le va obține dintr-o bază de date. Implementarea exactă nu este esențială, așa că vom indica doar cum va arăta interfața:

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

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

Deoarece numărul total de orașe este foarte mare, le vom obține folosind AJAX. În acest scop, vom crea un EndpointPresenter, adică un API care ne va returna orașele din fiecare țară în format 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);
	}
}

Dacă ar fi puține orașe (poate pe altă planetă 😉), sau dacă modelul ar reprezenta date care pur și simplu nu sunt multe, am putea să le transmitem pe toate direct ca un array în JavaScript și să economisim cererile AJAX. În acest caz, EndpointPresenter nu ar fi necesar.

Formular

Și să trecem la formularul în sine. Vom crea două selectbox-uri și le vom interconecta, adică vom seta elementele pentru cel subordonat (city) în funcție de valoarea selectată în cel superior (country). Important este că facem acest lucru în handler-ul evenimentului onAnchor, adică în momentul în care formularul cunoaște deja valorile trimise de utilizator.

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

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

		$city = $form->addSelect('city', 'Oraș:');
		// <-- aici vom mai completa ceva

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

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

Formularul creat astfel va funcționa și fără JavaScript. Și anume, utilizatorul selectează mai întâi țara, trimite formularul, apoi apare oferta de orașe, selectează unul dintre ele și trimite formularul din nou.

Dar pe noi ne interesează încărcarea dinamică a orașelor folosind JavaScript. Cel mai curat mod de a aborda acest lucru este să utilizăm atribute data-, în care vom trimite informații către HTML (și, prin urmare, JS) despre care selectbox-uri sunt interconectate și de unde trebuie să se obțină datele.

Fiecărui selectbox subordonat îi vom transmite atributul data-depends cu numele elementului superior și, în plus, fie data-url cu URL-ul de unde trebuie să obțină elementele folosind AJAX, fie data-items, unde vom lista direct toate variantele.

Să începem cu varianta AJAX. Vom transmite numele elementului superior country și linkul către Endpoint:cities. Folosim caracterul # ca substituent (placeholder), iar JavaScript va insera în locul lui cheia selectată de utilizator.

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

Și varianta fără AJAX? Vom pregăti un array cu toate țările și toate orașele lor, pe care îl vom transmite atributului data-items:

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

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

Și rămâne să scriem handler-ul JavaScript.

Handler JavaScript

Următorul cod este universal, nu este legat de selectbox-urile specifice country și city din exemplu, ci va interconecta orice selectbox-uri de pe pagină, este suficient doar să le setați atributele data- menționate.

Codul este scris în JavaScript pur (vanilla JS), deci nu necesită jQuery sau altă bibliotecă.

// găsim pe pagină toate selectbox-urile subordonate
document.querySelectorAll('select[data-depends]').forEach((childSelect) => {
	let parentSelect = childSelect.form[childSelect.dataset.depends]; // <select> superior
	let url = childSelect.dataset.url; // atributul data-url
	let items = JSON.parse(childSelect.dataset.items || 'null'); // atributul data-items

	// când utilizatorul schimbă elementul selectat în selectul superior...
	parentSelect.addEventListener('change', () => {
		// dacă există atributul data-items...
		if (items) {
			// încărcăm direct în selectbox-ul subordonat elementele noi
			updateSelectbox(childSelect, items[parentSelect.value]);
		}

		// dacă există atributul data-url...
		if (url) {
			// facem o cerere AJAX către endpoint cu elementul selectat în locul substituentului
			fetch(url.replace(encodeURIComponent('#'), encodeURIComponent(parentSelect.value)))
				.then((response) => response.json())
				// și încărcăm în selectbox-ul subordonat elementele noi
				.then((data) => updateSelectbox(childSelect, data));
		}
	});
});

// suprascrie <options> în <select>
function updateSelectbox(select, items)
{
	select.innerHTML = ''; // eliminăm tot
	for (let id in items) { // inserăm elementele noi
		let el = document.createElement('option');
		el.setAttribute('value', id);
		el.innerText = items[id];
		select.appendChild(el);
	}
}

Mai multe elemente și reutilizabilitate

Soluția nu este limitată la două selectbox-uri, se poate crea chiar și o cascadă de trei sau mai multe elemente dependente între ele. De exemplu, vom adăuga alegerea străzii, care va depinde de orașul selectat:

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

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

De asemenea, mai multe selectbox-uri pot depinde de unul comun. Este suficient doar să setați analogic atributele data- și să completați elementele folosind setItems().

În același timp, nu este necesară nicio intervenție în codul JavaScript, care funcționează universal.

Securitate

Chiar și în aceste exemple, se păstrează în continuare toate mecanismele de securitate de care dispun formularele în Nette. În special, fiecare selectbox verifică dacă varianta selectată este una dintre cele oferite și, prin urmare, un atacator nu poate introduce o altă valoare.


Soluția funcționează în Nette 2.4 și versiuni mai noi, exemplele de cod sunt scrise pentru Nette pentru PHP 8. Pentru a funcționa în versiuni mai vechi, înlocuiți property promotion și fn() cu function () use (...) { ... }.