Selectbox dipendenti elegantemente in Nette e JavaScript puro

3 anni fa Da David Grudl  

Come creare selectbox collegati, in cui dopo aver scelto un valore in uno, le opzioni vengono caricate dinamicamente nel secondo? In Nette e JavaScript puro è un compito facile. Mostreremo una soluzione pulita, riutilizzabile e sicura.

Modello di dati

Come esempio, creeremo un form contenente selectbox per la scelta dello stato e della città.

Innanzitutto, prepareremo un modello di dati che restituirà gli elementi per entrambi i selectbox. Probabilmente li otterrà da un database. L'implementazione esatta non è importante, quindi indicheremo solo come apparirà l'interfaccia:

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

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

Poiché il numero totale di città è davvero grande, le otterremo tramite AJAX. A tale scopo, creeremo un EndpointPresenter, ovvero un'API che ci restituirà le città nei singoli stati come 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);
	}
}

Se le città fossero poche (magari su un altro pianeta 😉), o se il modello rappresentasse dati che semplicemente non sono molti, potremmo passarli tutti direttamente come array a JavaScript e risparmiare richieste AJAX. In tal caso, EndpointPresenter non sarebbe necessario.

Form

E passiamo al form stesso. Creeremo due selectbox e li collegheremo, cioè imposteremo gli elementi del subordinato (city) in base al valore selezionato del superiore (country). È importante farlo nel gestore dell'evento onAnchor, cioè nel momento in cui il form conosce già i valori inviati dall'utente.

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

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

		$city = $form->addSelect('city', 'Città:');
		// <-- qui aggiungeremo qualcos'altro

		$form->onAnchor[] = fn() =>
			$city->setItems($country->getValue()
				? $this->world->getCities($country->getValue())
				// Se nessun paese è selezionato, restituisce un array vuoto
				: []);

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

Un form creato in questo modo funzionerà anche senza JavaScript. E cioè, l'utente seleziona prima lo stato, invia il form, quindi appare l'offerta di città, ne seleziona una e invia nuovamente il form.

Ma a noi interessa il caricamento dinamico delle città tramite JavaScript. Il modo più pulito per farlo è utilizzare attributi data-, nei quali invieremo a HTML (e quindi a JS) informazioni su quali selectbox sono collegati e da dove devono essere recuperati i dati.

A ogni selectbox subordinato passeremo l'attributo data-depends con il nome dell'elemento superiore e poi o data-url con l'URL da cui recuperare gli elementi tramite AJAX, oppure data-items, dove elencheremo direttamente tutte le varianti.

Iniziamo con la variante AJAX. Passiamo il nome dell'elemento superiore country e il link a Endpoint:cities. Usiamo il carattere # come placeholder e JavaScript inserirà al suo posto la chiave selezionata dall'utente.

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

E la variante senza AJAX? Prepariamo un array di tutti gli stati e di tutte le loro città, che passeremo all'attributo data-items:

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

$city = $form->addSelect('city', 'Città:')
	->setHtmlAttribute('data-depends', $country->getHtmlName())
	->setHtmlAttribute('data-items', json_encode($items)); // Codifica in JSON

E resta da scrivere il gestore JavaScript.

Gestore JavaScript

Il codice seguente è universale, non è legato ai selectbox specifici country e city dell'esempio, ma collegherà qualsiasi selectbox sulla pagina, basta impostare loro i suddetti attributi data-.

Il codice è scritto in puro vanilla JS, quindi non richiede jQuery o altre librerie.

// troviamo sulla pagina tutti i selectbox subordinati
document.querySelectorAll('select[data-depends]').forEach((childSelect) => {
	let parentSelect = childSelect.form[childSelect.dataset.depends]; // il <select> superiore
	let url = childSelect.dataset.url; // attributo data-url
	let items = JSON.parse(childSelect.dataset.items || 'null'); // attributo data-items

	// quando l'utente cambia l'elemento selezionato nel select superiore...
	parentSelect.addEventListener('change', () => {
		// se esiste l'attributo data-items...
		if (items) {
			// carichiamo direttamente nel selectbox subordinato i nuovi elementi
			updateSelectbox(childSelect, items[parentSelect.value] || {}); // Usa un oggetto vuoto se la chiave non esiste
		}

		// se esiste l'attributo data-url...
		if (url) {
			// facciamo una richiesta AJAX all'endpoint con l'elemento selezionato al posto del placeholder
			fetch(url.replace(encodeURIComponent('#'), encodeURIComponent(parentSelect.value)))
				.then((response) => response.json())
				// e carichiamo nel selectbox subordinato i nuovi elementi
				.then((data) => updateSelectbox(childSelect, data));
		}
	});

	// Attiva l'evento change all'inizio per caricare i dati iniziali se un valore è già selezionato
	parentSelect.dispatchEvent(new Event('change'));
});

// sovrascrive le <options> in <select>
function updateSelectbox(select, items)
{
	select.innerHTML = ''; // rimuove tutto
	for (let id in items) { // inserire nuovi elementi
		let el = document.createElement('option');
		el.setAttribute('value', id);
		el.innerText = items[id];
		select.appendChild(el);
	}
}

Più elementi e riutilizzabilità

La soluzione non è limitata a due selectbox, è possibile creare tranquillamente una cascata di tre o più elementi dipendenti l'uno dall'altro. Ad esempio, aggiungiamo la scelta della via, che dipenderà dalla città selezionata:

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

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

Inoltre, più selectbox possono dipendere da uno comune. Basta impostare analogamente gli attributi data- e popolare gli elementi tramite setItems().

Senza dover apportare alcuna modifica al codice JavaScript, che funziona universalmente.

Sicurezza

Anche in questi esempi vengono mantenuti tutti i meccanismi di sicurezza di cui dispongono i form in Nette. In particolare, ogni selectbox controlla che la variante selezionata sia una di quelle offerte e quindi un attaccante non può sostituire un valore diverso.


La soluzione funziona in Nette 2.4 e versioni successive, gli esempi di codice sono scritti per Nette per PHP 8. Per farli funzionare nelle versioni precedenti, sostituisci la property promotion e fn() con function () use (...) { ... }.