Dependent selectboxes elegantly in Nette and pure JS
How to create chained select boxes, where after selecting a value in one the options are dynamically updated in the other? This is an easy task in Nette and pure JavaScript. We will show a solution that is clean, reusable end secure.
Data model
As an example, let's create a form containing select boxes for selecting the country and city.
First, we will prepare a data model that will return entries for both select boxes. It will probably retrieve them from the database. The exact implementation is not essential, so let's just hint at what the interface will look like:
class World
{
public function getCountries(): array
{
return ...
}
public function getCities($country): array
{
return ...
}
}
Because the total number of cities is really big, we will retrieve them using
AJAX. For this purpose, we will create an EndpointPresenter
, an API
that will return the cities in each country as 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);
}
}
If there are few cities (for example on another planet 😉), or if the model
represents data that are simply not many, we could pass them all as array to
JavaScript and save AJAX requests. In that case, there would be no need for
EndpointPresenter
.
Form
And let's move on to the form itself. We will create two select boxes and
link them, i.e. we will set the child (city
) items depending on the
selected value of the parent (country
). The important thing is that
we do this in the onAnchor
event handler, i.e. at the moment when the form already knows the values
submitted by the user.
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:');
// <-- we'll add something else here
$form->onAnchor[] = fn() =>
$city->setItems($country->getValue()
? $this->world->getCities($country->getValue())
: []);
// $form->onSuccess[] = ...
return $form;
}
}
The form created in this way will work without JavaScript. This is done by having the user first select a country, submit the form, then a menu of cities will appear, select one of them, and submit the form again.
However, we are interested in dynamically loading cities using JavaScript.
The cleanest way to approach this is to use data-
attributes, in
which we send information to the HTML (and hence JS) about which select boxes
are linked and where to retrieve data from.
For each child selectbox, we pass a data-depends
attribute with
the name of the parent element, and then either a data-url
with the
URL from where to retrieve items using AJAX, or a data-items
attribute where we list all the options directly.
Let's start with the AJAX variant. We pass the name of the parent element
country
and a reference to Endpoint:cities
. We use the
#
character as a placeholder and JavaScript will put the
user-selected key instead.
$city = $form->addSelect('city', 'City:')
->setHtmlAttribute('data-depends', $country->getHtmlName())
->setHtmlAttribute('data-url', $this->link('Endpoint:cities', '#'));
And the variant without AJAX? We prepare an array of all the countries and
all its cities, which we pass to the data-items
attribute:
$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);
JavaScript handler
The following code is universal, it is not bound to the specific
country
and city
select boxes from the example, but it
will link any select boxes on the page, just set the mentioned
data-
attributes.
The code is written in pure vanilla JS, so it doesn't require jQuery or any other library.
// find all child selectboxes on the page
document.querySelectorAll('select[data-depends]').forEach((childSelect) => {
let parentSelect = childSelect.form[childSelect.dataset.depends]; // parent <select>
let url = childSelect.dataset.url; // attribute data-url
let items = JSON.parse(childSelect.dataset.items || 'null'); // attribute data-items
// when the user changes the selected item in the parent selection...
parentSelect.addEventListener('change', () => {
// if the data-items attribute exists...
if (items) {
// load new items directly into the child selectbox
updateSelectbox(childSelect, items[parentSelect.value]);
}
// if the data-url attribute exists...
if (url) {
// we make AJAX request to the endpoint with the selected item instead of placeholder
fetch(url.replace(encodeURIComponent('#'), encodeURIComponent(parentSelect.value)))
.then((response) => response.json())
// and load new items into the child selectbox
.then((data) => updateSelectbox(childSelect, data));
}
});
});
// replaces <options> in <select>
function updateSelectbox(select, items)
{
select.innerHTML = ''; // remove all
for (let id in items) { // insert new
let el = document.createElement('option');
el.setAttribute('value', id);
el.innerText = items[id];
select.appendChild(el);
}
}
More elements and reusability
The solution is not limited to two select boxes, you can create a cascade of three or more dependent elements. For example, we add a street selection that depends on the selected city:
$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()) : []);
Also, multiple select boxes can depend on a single common one. Just set the
data-
attributes by analogy and populate the items with
setItems()
.
There is absolutely no need to do any modification to the JavaScript code, which works universally.
Security
Even in these examples, all the security mechanisms that Nette forms have are still preserved. In particular, each select box checks that the selected option is one of the offered ones, and thus an attacker cannot spoof a different value.
The solution works in Nette 2.4 and later, code samples are written for
PHP 8. To make them work in older versions, replace property
promotion and fn()
with
function () use (...) { ... }
.
Comments
Thank you for the beautiful and elegant example! It's very applicable.
This is elegant. With the non-AJAX approach, not having the third parameter in addSelect prevents form validation problems when it is submitted with different data, but it also means when the form is first loaded there is no validation possible and are no options in the ‘city’ dropdown until the js does its magic. Solved by running that js function when the page first loads. Or is there a php way to load the options while not getting suck on the form validation? I see that issues raised re AJAX here https://github.com/…s/issues/111 but not quite resolved (possible using the $form->getHttpData(‘country’) apprach but not elegant!)
#2 mikeb also worth noting that when not setting the options array in addSelect (in php) then the submitted value is missing from $form->getValues() or from ->getControls( ), even tho the form validates.
or am i doing something wrong?
There is an error in the code, the line
->setHtmlAttribute(‘data-url’, $country->link(‘Endpoint:cities’, ‘#’));
should be:
->setHtmlAttribute(‘data-url’, $this->link(‘Endpoint:cities’, ‘#’));
(as is in the czech version of this article)
#4 harvalikjan fixed
Sign in to submit a comment