Závislé selectboxy elegantně v Nette a čistém JavaScriptu

Publikováno: 25.1.2022

Jak vytvořit provázané selectboxy, kdy po volbě hodnoty v jednom se dynamicky načtou volby do druhého? V Nette a čistém JavaScriptu jde o snadnou úlohu. Ukážeme si řešení, které je čisté, znovupoužitelné a bezpečné.

Celý článek

Text vyšel původně na webu autora.

Datový model

Jako příklad si vytvoříme formulář obsahující selectboxy pro volbu státu a města.

Nejprve si připravíme datový model, který bude vracet položky pro oba selectboxy. Pravděpodobně je bude získávat z databáze. Přesná implementace není podstatná, proto jen naznačíme jak bude vypadat rozhraní:

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

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

Protože je celkový počet měst opravdu velký, budeme je získávat pomocí AJAXu. Pro tento účel si vytvoříme EndpointPresenter, tedy API, které nám bude vracet města v jednotlivých státech jako 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);
	}
}

Pokud by měst bylo málo (třeba na jiné planetě ????), nebo by model reprezentoval data, kterých prostě není mnoho, mohli bychom je předat rovnou všechna jako pole do JavaScriptu a ušetřit AJAXové požadavky. V takém případě by nebyl EndpointPresenter potřeba.

Formulář

A pojďme na samotný formulář. Vytvoříme dva selectboxy a ty provážeme, tj. podřízenému (city) nastavíme položky v závislosti na zvolené hodnotě nadřízeného (country). Důležité je, že tak činíme v obsluze události onAnchor, tedy ve chvíli, kdy formulář už zná hodnoty odeslané uživatelem.

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

	protected function createComponentForm(): Form
	{
		$form = new Form;
		$country = $form->addSelect('country', 'Stát:', $this->world->getCountries())
			->setPrompt('----');

		$city = $form->addSelect('city', 'Město:');
		// <-- sem pak ještě něco doplníme

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

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

Takto vytvořený formulář bude fungovat i bez JavaScriptu. A to tak, že uživatel nejprve vybere stát, odešle formulář, poté se objeví nabídka měst, jedno z nich vybere a formulář odešle znovu.

Nás ale zajímá dynamické načítání měst pomocí JavaScriptu. Nejčistějším způsobem, jak k tomu přistoupit, je využít data- atributy, ve kterých si pošleme do HTML (a potažmo JS) informaci o tom, které selectboxy jsou provázané a odkud se mají čerpat data.

Každému podřízenému selectboxu předáme atribut data-depends s názvem nadřízeného prvku a dále buď data-url s URL, odkud má získávat položky pomocí AJAXu, nebo data-items, kde všechny varianty rovnou uvedeme.

Začněme s AJAXovou variantou. Předáme jméno nadřazeného prvku country a odkaz na Endpoint:cities. Znak # používáme jako placeholder a JavaScript bude místo něj vkládat uživatelem zvolený klíč.

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

A varianta bez AJAXu? Připravíme si pole všech států a všech jejich měst, které předáme do atributu data-items:

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

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

A zbývá napsat obslužný JavaScript.

JavaScriptová obsluha

Následující kód je univerzální, není vázaný na konkrétní selectboxy country a city z příkladu, ale prováže jakékoliv selectboxy na stránce, stačí jim jen nastavit zmíněné data- atributy.

Kód je napsaný v čistém vanilla JS, nevyžaduje tedy jQuery nebo jinou knihovnu.

// najdeme na stránce všechny podřízené selectboxy
document.querySelectorAll('select[data-depends]').forEach((childSelect) => {
	let parentSelect = childSelect.form[childSelect.dataset.depends]; // nadřízený <select>
	let url = childSelect.dataset.url; // atribut data-url
	let items = JSON.parse(childSelect.dataset.items || 'null'); // atribut data-items

	// když uživatel změní vybranou položku v nadřízeném selectu...
	parentSelect.addEventListener('change', () => {
		// pokud existuje atribut data-items...
		if (items) {
			// nahrajeme rovnou do podřízeného selectboxu nové položky
			updateSelectbox(childSelect, items[parentSelect.value]);
		}

		// pokud existuje atribut data-url...
		if (url) {
			// uděláme AJAXový požadavek na endpoint s vybranou položkou místo placeholderu
			fetch(url.replace(encodeURIComponent('#'), encodeURIComponent(parentSelect.value)))
				.then((response) => response.json())
				// a nahrajeme do podřízeného selectboxu nové položky
				.then((data) => updateSelectbox(childSelect, data));
		}
	});
});

// přepíše <options> v <select>
function updateSelectbox(select, items)
{
	select.innerHTML = ''; // odstraníme vše
	for (let id in items) { // vložíme nové
		let el = document.createElement('option');
		el.setAttribute('value', id);
		el.innerText = items[id];
		select.appendChild(el);
	}
}

Více prvků a znovupoužitelnost

Řešení není limitované dvěma selectboxy, lze vytvořit klidně kaskádu tří nebo více na sobě závisejících prvků. Například doplníme volbu ulice, která bude závislá na zvoleném městě:

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

Také může více selectboxů záviset na jednom společném. Stačí jen analogicky nastavit data- atributy a naplnění položek pomocí setItems().

Přičemž není potřeba dělat žádný zásah do JavaScriptového kódu, který funguje univerzálně.

Bezpečnost

I v těchto ukázkách se stále zachovávají všechny bezpečnostní mechanismy, kterými disponují formuláře v Nette. Zejména že každý selectbox kontroluje, zda vybraná varianta je jednou z nabízených a tedy útočník nemůže podstrčit jinou hodnotu.


Řešení funguje v Nette 2.4 a novějším, ukázky kódu jsou psané pro Nette pro PHP 8. Aby fungovaly ve starších verzích, nahraďte property promotion a fn() za function () use (...) { ... }.

Nahoru
Tento web používá k poskytování služeb a analýze návštěvnosti soubory cookie. Používáním tohoto webu s tímto souhlasíte. Další informace