Casete de selectare dependente în mod elegant în Nette și JS pur

acum 2 ani De la David Grudl  

Cum să creați casete de selectare înlănțuite, unde după selectarea unei valori în una dintre ele, opțiunile sunt actualizate dinamic în cealaltă? Aceasta este o sarcină ușoară în Nette și JavaScript pur. Vom arăta o soluție care este curată, reutilizabilă și sigură.

Model de date

Ca exemplu, să creăm un formular care conține căsuțe de selectare pentru selectarea țării și a orașului.

În primul rând, vom pregăti un model de date care va returna intrări pentru ambele casete de selectare. Acesta le va prelua probabil din baza de date. Implementarea exactă nu este esențială, așa că vom face doar o aluzie la modul în care 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 prelua folosind AJAX. În acest scop, vom crea un EndpointPresenter, un API care va returna orașele din fiecare țară sub formă de 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ă există puține orașe (de exemplu, pe o altă planetă 😉 ) sau dacă modelul reprezintă date care pur și simplu nu sunt multe, am putea să le trecem pe toate ca matrice în JavaScript și să economisim cererile AJAX. În acest caz, nu ar mai fi nevoie de EndpointPresenter.

Formular

Și să trecem la formularul propriu-zis. Vom crea două casete de selectare și le vom lega, adică vom seta elementele copilului (city) în funcție de valoarea selectată a părintelui (country). Important este că vom face acest lucru în gestionarul de evenimente 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', 'Country:', $this->world->getCountries())
			->setPrompt('----');

		$city = $form->addSelect('city', 'City:');
		// <-- vom mai adăuga ceva aici

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

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

Formularul creat în acest mod va funcționa fără JavaScript. Acest lucru se realizează prin faptul că utilizatorul va selecta mai întâi o țară, va trimite formularul, apoi va apărea un meniu de orașe, va selecta unul dintre ele și va trimite din nou formularul.

Cu toate acestea, suntem interesați de încărcarea dinamică a orașelor folosind JavaScript. Cea mai curată modalitate de abordare este utilizarea atributelor data-, prin care trimitem informații către HTML (și, prin urmare, către JS) despre ce casete de selectare sunt legate și de unde să recuperăm datele.

Pentru fiecare selectbox copil, transmitem un atribut data-depends cu numele elementului părinte și apoi fie un atribut data-url cu URL-ul de la care să recuperăm elementele folosind AJAX, fie un atribut data-items în care enumerăm direct toate opțiunile.

Să începem cu varianta AJAX. Transmitem numele elementului părinte country și o referință la Endpoint:cities. Folosim caracterul # ca un spațiu rezervat, iar JavaScript va pune în loc cheia selectată de utilizator.

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

Și varianta fără AJAX? Pregătim o matrice cu toate țările și toate orașele sale, pe care o trecem la atributul 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);

Manipulator JavaScript

Următorul cod este universal, nu este legat de căsuțele de selectare specifice country și city din exemplu, dar va lega orice căsuță de selectare de pe pagină, doar dacă setați atributele data- menționate.

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

// găsește toate căsuțele de selectare copil de pe pagină
document.querySelectorAll('select[data-depends]').forEach((childSelect) => {
	let parentSelect = childSelect.form[childSelect.dataset.depends]; // părinte <select>
	let url = childSelect.dataset.url; // atributul data-url
	let items = JSON.parse(childSelect.dataset.items || 'null'); // attribute data-items

	// atunci când utilizatorul modifică elementul selectat în selecția părinte...
	parentSelect.addEventListener('change', () => {
		// dacă atributul data-items există...
		if (items) {
			// încarcă noile elemente direct în caseta de selectare copil
			updateSelectbox(childSelect, items[parentSelect.value]);
		}

		// dacă există atributul data-url...
		if (url) {
			// se face o cerere AJAX către punctul final cu elementul selectat în loc de placeholder.
			fetch(url.replace(encodeURIComponent('#'), encodeURIComponent(parentSelect.value)))
				.then((response) => response.json())
				// și încărcăm noi elemente în caseta de selectare copil
				.then((data) => updateSelectbox(childSelect, data));
		}
	});
});

// înlocuiește <options> în <select>
function updateSelectbox(select, items)
{
	select.innerHTML = ''; // remove all
	for (let id in items) { // introduceți un nou element
		let el = document.createElement('option');
		el.setAttribute('value', id);
		el.innerText = items[id];
		select.appendChild(el);
	}
}

Mai multe elemente și posibilitatea de reutilizare

Soluția nu se limitează la două casete de selectare, ci puteți crea o cascadă de trei sau mai multe elemente dependente. De exemplu, adăugăm o selecție de străzi care depinde de orașul selectat:

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

De asemenea, mai multe casete de selectare pot depinde de una singură comună. Trebuie doar să setați atributele data- prin analogie și să populați elementele cu setItems().

Nu este absolut deloc necesar să faceți nicio modificare a codului JavaScript, care funcționează universal.

Securitate

Chiar și în aceste exemple, toate mecanismele de securitate pe care le au formularele Nette sunt păstrate. În special, fiecare căsuță de selectare verifică dacă opțiunea selectată este una dintre cele oferite și, astfel, un atacator nu poate falsifica o valoare diferită.


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