Závislé selectboxy elegantně v Nette a čistém JavaScriptu
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
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ě.
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
Skvělé, tohle se občas použije, strávil bych na tom hromadu času a neudělal bych to takhle elegantně :)
#2 fikusir Mam stejnej problem
Je to potřeba číst v
$form->onAnchor
po nastavení setItems()uplne najjednoduchsi sposob je https://github.com/…entSelectBox
a jak jste vyresily $this[‚form‘]->setDefaults(…).
Hlasi to chybu jelikoz city je prazdny tudis mu nemuzu predat value
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í).
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í.
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ší.
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.
Dobrý den, mohl by jste mi prosím poradit s touto chybou?
Cannot read an undeclared property Nette\Application\UI\Form::$onAnchor.
Pracuji na verzi Nette 2.3 a php 7.1 a Form knihovnu mám.
Můj celkový kód toho Anchoru je následující.
$form->onAnchor[] = function () use ($zakaznik, $pobocka) {
$zakaznikValue = $zakaznik->getValue();
if ($zakaznikValue) {
$customerBranch = $this->zakaznikMap->getCustomerBranch($zakaznikValue);
} else {
$customerBranch = [];
}
$pobocka->setItems($customerBranch);
};
děkuji za odpověď
Chcete-li odeslat komentář, přihlaste se