Dependent selectboxes elegantly in Nette and pure JS

3 years ago by David Grudl  

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

  1. Thank you for the beautiful and elegant example! It's very applicable.

    3 years ago
  2. 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 years ago · replied [3] mikeb
  3. #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?

    2 years ago
  4. 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)

    8 months ago · replied [5] David Grudl
  5. 8 months ago

Sign in to submit a comment