Caselle di selezione dipendenti in modo elegante in Nette e puro JS

2 anni fa Da David Grudl  

Come creare selectbox concatenate, in cui dopo aver selezionato un valore in una, le opzioni vengono aggiornate dinamicamente nell'altra? Questo è un compito facile in Nette e puro JavaScript. Mostreremo una soluzione pulita, riutilizzabile e sicura.

Modello di dati

A titolo di esempio, creiamo un modulo contenente caselle di selezione per la scelta del Paese e della città.

Per prima cosa, prepareremo un modello di dati che restituirà le voci per entrambe le caselle di selezione. Probabilmente li recupererà dal database. L'implementazione esatta non è essenziale, quindi accenniamo solo all'aspetto dell'interfaccia:

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

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

Poiché il numero totale di città è molto grande, le recupereremo utilizzando AJAX. A tale scopo, creeremo una EndpointPresenter, un'API che restituirà le città di ogni Paese in formato 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à sono poche (ad esempio su un altro pianeta 😉 ), o se il modello rappresenta dati che semplicemente non sono molti, potremmo passarli tutti come array a JavaScript e risparmiare richieste AJAX. In questo caso, non ci sarebbe bisogno di EndpointPresenter.

Modulo

Passiamo al modulo stesso. Creeremo due caselle di selezione e le collegheremo, cioè imposteremo gli elementi figli (city) a seconda del valore selezionato del genitore (country). L'importante è farlo nel gestore dell'evento onAnchor, cioè nel momento in cui il modulo 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', 'Country:', $this->world->getCountries())
			->setPrompt('----');

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

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

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

Il modulo così creato funzionerà senza JavaScript. Per fare ciò, l'utente deve prima selezionare un paese, inviare il modulo, poi apparirà un menu di città, selezionarne una e inviare nuovamente il modulo.

Tuttavia, a noi interessa caricare dinamicamente le città utilizzando JavaScript. Il modo più pulito per farlo è usare gli attributi data-, con i quali si inviano informazioni all'HTML (e quindi a JS) su quali caselle di selezione sono collegate e da dove recuperare i dati.

Per ogni casella di selezione figlia, passiamo un attributo data-depends con il nome dell'elemento padre e poi un attributo data-url con l'URL da cui recuperare gli elementi tramite AJAX, oppure un attributo data-items in cui elenchiamo direttamente tutte le opzioni.

Cominciamo con la variante AJAX. Passiamo il nome dell'elemento padre country e un riferimento a Endpoint:cities. Usiamo il carattere # come segnaposto e JavaScript inserirà invece la chiave selezionata dall'utente.

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

E la variante senza AJAX? Prepariamo un array di tutti i paesi e di tutte le loro città, che passiamo all'attributo 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);

Gestore JavaScript

Il codice seguente è universale, non è legato alle caselle di selezione specifiche country e city dell'esempio, ma collega qualsiasi casella di selezione della pagina, basta impostare gli attributi data- citati.

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

// trova tutte le selectbox figlio della pagina
document.querySelectorAll('select[data-depends]').forEach((childSelect) => {
	let parentSelect = childSelect.form[childSelect.dataset.depends]; // genitore <select>
	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 nella selezione padre...
	parentSelect.addEventListener('change', () => {
		// se l'attributo data-items esiste...
		if (items) {
			// carica i nuovi elementi direttamente nella casella di selezione figlio
			updateSelectbox(childSelect, items[parentSelect.value]);
		}

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

// sostituisce <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 si limita a due caselle di selezione, ma è possibile creare una cascata di tre o più elementi dipendenti. Ad esempio, aggiungiamo una selezione di strade che dipende dalla città selezionata:

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

Inoltre, più caselle di selezione possono dipendere da una singola casella comune. Basta impostare gli attributi data- per analogia e popolare gli elementi con setItems().

Non è assolutamente necessario modificare il codice JavaScript, che funziona universalmente.

Sicurezza

Anche in questi esempi, tutti i meccanismi di sicurezza dei moduli Nette sono stati mantenuti. In particolare, ogni casella di selezione controlla che l'opzione selezionata sia una di quelle proposte e quindi un aggressore non può falsificare un valore diverso.


La soluzione funziona con Nette 2.4 e successive, mentre gli esempi di codice sono stati scritti per PHP 8. Per farli funzionare nelle versioni precedenti, è necessario che il codice sia stato scritto per PHP 8. Per farli funzionare nelle versioni precedenti, sostituire property promotion e fn() con function () use (...) { ... }.