Selectboxes dependientes elegantemente en Nette y JS puro
¿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', $this->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 (...) { ... }
.
Para enviar un comentario, inicie sesión