Selects dependientes de forma elegante en Nette y JavaScript puro

hace 3 años por David Grudl  

¿Cómo crear selects dependientes, donde al elegir un valor en uno, las opciones del otro se cargan dinámicamente? En Nette y JavaScript puro es una tarea fácil. Mostraremos una solución limpia, reutilizable y segura.

Modelo de datos

Como ejemplo, crearemos un formulario que contenga selects para elegir país y ciudad.

Primero, prepararemos un modelo de datos que devolverá los elementos para ambos selects. Probablemente los obtendrá de una base de datos. La implementación exacta no es importante, por lo que solo indicaremos cómo será la interfaz:

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

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

Dado que el número total de ciudades es realmente grande, las obtendremos mediante AJAX. Para este propósito, crearemos un EndpointPresenter, es decir, una API que nos devolverá las ciudades de cada país como 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);
	}
}

Si hubiera pocas ciudades (quizás en otro planeta 😉), o si el modelo representara datos que simplemente no son muchos, podríamos pasarlos todos directamente como un array a JavaScript y ahorrar peticiones AJAX. En tal caso, no sería necesario el EndpointPresenter.

Formulario

Y pasemos al formulario propiamente dicho. Crearemos dos cajas de selección y las enlazaremos, es decir, estableceremos los elementos hijos (city) en función del valor seleccionado del padre (country). Lo importante es que lo hagamos en el manejador del evento onAnchor, es decir, en el momento en que el formulario ya conoce los valores enviados por el usuario.

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

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

		$city = $form->addSelect('city', 'Ciudad:');
		// <-- aquí añadiremos algo más tarde

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

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

Un formulario creado de esta manera funcionará incluso sin JavaScript. Y lo hará de forma que el usuario primero seleccione un país, envíe el formulario, luego aparecerá la oferta de ciudades, seleccionará una de ellas y enviará el formulario de nuevo.

Pero a nosotros nos interesa la carga dinámica de ciudades mediante JavaScript. La forma más limpia de abordar esto es utilizar atributos data-, en los que enviaremos a HTML (y por extensión a JS) información sobre qué selects están vinculados y de dónde deben obtener los datos.

A cada select secundario le pasaremos el atributo data-depends con el nombre del elemento principal y luego, o bien data-url con la URL desde donde debe obtener los elementos mediante AJAX, o data-items, donde indicaremos directamente todas las variantes.

Comencemos con la variante AJAX. Pasaremos el nombre del elemento principal country y el enlace a Endpoint:cities. Usamos el carácter # como placeholder y JavaScript insertará en su lugar la clave elegida por el usuario.

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

¿Y la variante sin AJAX? Prepararemos un array de todos los países y todas sus ciudades, que pasaremos al atributo data-items:

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

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

Y solo queda escribir el manejador de JavaScript.

Manejador de JavaScript

El siguiente código es universal, no está vinculado a los selects específicos country y city del ejemplo, sino que vinculará cualquier select en la página, solo es necesario establecerles los atributos data- mencionados.

El código está escrito en vanilla JS puro, por lo que no requiere jQuery ni ninguna otra librería.

// encontramos todos los selects secundarios en la página
document.querySelectorAll('select[data-depends]').forEach((childSelect) => {
	let parentSelect = childSelect.form[childSelect.dataset.depends]; // el <select> padre
	let url = childSelect.dataset.url; // atributo data-url
	let items = JSON.parse(childSelect.dataset.items || 'null'); // atributo data-items

	// cuando el usuario cambia la opción seleccionada en el select padre...
	parentSelect.addEventListener('change', () => {
		// si existe el atributo data-items...
		if (items) {
			// cargamos directamente nuevas opciones en el select secundario
			updateSelectbox(childSelect, items[parentSelect.value]);
		}

		// si existe el atributo data-url...
		if (url) {
			// hacemos una petición AJAX al endpoint con la opción seleccionada en lugar del placeholder
			fetch(url.replace(encodeURIComponent('#'), encodeURIComponent(parentSelect.value)))
				.then((response) => response.json())
				// y cargamos nuevas opciones en el select secundario
				.then((data) => updateSelectbox(childSelect, data));
		}
	});
});

// reescribe los <options> en el <select>
function updateSelectbox(select, items)
{
	select.innerHTML = ''; // eliminamos todo
	for (let id in items) { // insertamos los nuevos
		let el = document.createElement('option');
		el.setAttribute('value', id);
		el.innerText = items[id];
		select.appendChild(el);
	}
}

Múltiples elementos y reutilización

La solución no está limitada a dos selects, se puede crear tranquilamente una cascada de tres o más elementos dependientes entre sí. Por ejemplo, añadimos la elección de la calle, que dependerá de la ciudad elegida:

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

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

También pueden depender varios selects de uno común. Basta con establecer análogamente los atributos data- y rellenar las opciones mediante setItems().

Sin necesidad de hacer ninguna intervención en el código JavaScript, que funciona universalmente.

Seguridad

Incluso en estos ejemplos, se mantienen todos los mecanismos de seguridad de los que disponen los formularios en Nette. En particular, que cada select controla que la variante seleccionada sea una de las ofrecidas y, por lo tanto, un atacante no puede colar otro valor.


La solución funciona en Nette 2.4 y posteriores, los ejemplos de código están escritos para PHP 8. Para que funcionen en versiones anteriores, sustituya property promotion y fn() por function () use (...) { ... }.