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