Selectboxes dependientes elegantemente en Nette y JS puro

hace 2 años por David Grudl  

¿Cómo crear cajas de selección encadenadas, donde después de seleccionar un valor en una las opciones se actualizan dinámicamente en la otra? Esta es una tarea fácil en Nette y JavaScript puro. Mostraremos una solución limpia, reutilizable y segura.

Modelo de datos

A modo de ejemplo, vamos a crear un formulario que contenga casillas de selección para elegir el país y la ciudad.

En primer lugar, prepararemos un modelo de datos que devuelva las entradas de ambas casillas de selección. Probablemente las recuperará de la base de datos. La implementación exacta no es esencial, así que vamos a insinuar cómo será la interfaz:

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

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

Como el número total de ciudades es realmente grande, las recuperaremos utilizando AJAX. Para ello, crearemos un EndpointPresenter, una API que nos devolverá las ciudades de cada país en 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);
	}
}

Si hay pocas ciudades (por ejemplo en otro planeta 😉 ), o si el modelo representa datos que simplemente no son muchos, podríamos pasarlos todos como array a JavaScript y ahorrarnos peticiones AJAX. En ese caso, no haría falta 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', 'Country:', $this->world->getCountries())
			->setPrompt('----');

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

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

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

El formulario creado de esta forma funcionará sin JavaScript. Esto se hace haciendo que el usuario primero seleccione un país, envíe el formulario, luego aparecerá un menú de ciudades, seleccione una de ellas, y envíe el formulario de nuevo.

Sin embargo, nos interesa cargar dinámicamente las ciudades utilizando JavaScript. La forma más limpia de enfocar esto es utilizar atributos data-, en los que enviamos información al HTML (y por tanto a JS) sobre qué casillas de selección están vinculadas y de dónde recuperar los datos.

Para cada caja de selección hija, pasamos un atributo data-depends con el nombre del elemento padre, y luego o bien un data-url con la URL desde donde recuperar los elementos usando AJAX, o bien un atributo data-items donde listamos todas las opciones directamente.

Empecemos con la variante AJAX. Pasamos el nombre del elemento padre country y una referencia a Endpoint:cities. Utilizamos el carácter # como marcador de posición y JavaScript pondrá la clave seleccionada por el usuario en su lugar.

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

¿Y la variante sin AJAX? Preparamos un array de todos los países y todas sus ciudades, que pasamos al atributo 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);

Manejador JavaScript

El siguiente código es universal, no está vinculado a las cajas de selección específicas country y city del ejemplo, sino que vinculará cualquier caja de selección de la página, simplemente estableciendo los atributos data- mencionados.

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

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

	// cuando el usuario cambia el elemento seleccionado en la selección padre...
	parentSelect.addEventListener('change', () => {
		// si el atributo data-items existe...
		if (items) {
			// cargar nuevos elementos directamente en el cuadro de selección hijo
			updateSelectbox(childSelect, items[parentSelect.value]);
		}

		// si el atributo data-url existe...
		if (url) {
			// hacemos una petición AJAX al endpoint con el elemento seleccionado en lugar del marcador de posición
			fetch(url.replace(encodeURIComponent('#'), encodeURIComponent(parentSelect.value)))
				.then((response) => response.json())
				// y cargamos los nuevos elementos en el cuadro de selección secundario
				.then((data) => updateSelectbox(childSelect, data));
		}
	});
});

// reemplaza <options> en <select>
function updateSelectbox(select, items)
{
	select.innerHTML = ''; // remove all
	for (let id in items) { // insertar nuevo
		let el = document.createElement('option');
		el.setAttribute('value', id);
		el.innerText = items[id];
		select.appendChild(el);
	}
}

Más elementos y reusabilidad

La solución no se limita a dos cajas de selección, puedes crear una cascada de tres o más elementos dependientes. Por ejemplo, añadimos una selección de calle que depende de la ciudad seleccionada:

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

Además, varios cuadros de selección pueden depender de uno común. Basta con establecer los atributos data- por analogía y rellenar los elementos con setItems().

No es necesario modificar en absoluto el código JavaScript, que funciona de forma universal.

Seguridad

Incluso en estos ejemplos, se conservan todos los mecanismos de seguridad que tienen los formularios Nette. En particular, cada casilla de selección comprueba que la opción seleccionada es una de las ofrecidas, por lo que un atacante no puede falsificar un valor diferente.


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