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

před 11 měsíci od David Grudl  

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é.

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

Komentáře (RSS)

  1. Ty jo super řešení. Škoda, že nepřišlo tak před třemi roky, kdy jsem to potřeboval a řešil proti tomuto, strašně složitě.

    před 11 měsíci
  2. Ahoj,

    je to paráda. Jen u těch podřízených prvků netuším jak číst jejich value.

    Pokud čtu $values->city, tak to vrací null.

    Máte na to nějaký fígl? :) Díky

    před 10 měsíci · replied [4] vladimir.biro
  3. Skvělé, tohle se občas použije, strávil bych na tom hromadu času a neudělal bych to takhle elegantně :)

    před 10 měsíci
  4. #2 fikusir Mam stejnej problem

    před 9 měsíci
  5. Je to potřeba číst v $form->onAnchor po nastavení setItems()

    před 9 měsíci
  6. uplne najjednoduchsi sposob je https://github.com/…entSelectBox

    před 8 měsíci
  7. a jak jste vyresily $this[‚form‘]->setDefaults(…).
    Hlasi to chybu jelikoz city je prazdny tudis mu nemuzu predat value

    před 5 měsíci
  8. Došlo k nějakému breaku nebo se vždy tímto JS přepsal efekt ->setPrompt(„----“)?
    Když to zkouším (mam AJAXem, ne přednačteným polem), změnou Country se mi přepíšou položky City, ale neexistuje optiona "" ⇒ „----“, tak se předvybere první město (a to není žádoucí).

    před 5 měsíci
  9. Tohle řešení se setPrompt() nepočítá, asi by se to dalo vyřešit úpravou updateSelectbox(), která by ponechávala první prvek a přepisovala jen ty následující.

    před 4 měsíci
  10. Co se týče toho speciálního endpointu: Nebylo by lepší to tahat přes snippety? Řešilo by to i problém s upravenou podobou optionů (různé attributy).

    Na načítání velkého počtu záznamů v selectboxu používám techniku, kdy ten selectbox má vlastní implementaci signalReceived(), a tak je zodpovědný za poskytnutí dat. Přijde mi to takové čistější.

    před 4 měsíci
  11. Jako menší problém vidím, že JSON.parse() ignoruje pořadí, v jakém jsou položky připravené pro select, a seřadí je dle klíčů, tj. value pro option.

    před měsícem

Chcete-li odeslat komentář, přihlaste se