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 (...) { ... }.

David Grudl A web developer since 1999 who now specializes in artificial intelligence. He's the creator of Nette Framework and libraries including Texy!, Tracy, and Latte. He hosts the Tech Guys podcast and covers AI developments on Uměligence. His blog La Trine earned a Magnesia Litera award nomination. He's dedicated to AI education and approaches technology with pragmatic optimism.