Zależne selectboxy elegancko w Nette i czystym JavaScript
Jak stworzyć powiązane selectboxy, gdzie po wybraniu wartości w jednym dynamicznie ładowane są opcje do drugiego? W Nette i czystym JavaScript jest to łatwe zadanie. Pokażemy rozwiązanie, które jest czyste, wielokrotnego użytku i bezpieczne.

Model danych
Jako przykład stworzymy formularz zawierający selectboxy dla wyboru kraju i miasta.
Najpierw przygotujemy model danych, który będzie zwracał pozycje dla obu selectboxów. Prawdopodobnie będzie je pobierał z bazy danych. Dokładna implementacja nie jest istotna, dlatego tylko zasygnalizujemy, jak będzie wyglądał interfejs:
class World
{
public function getCountries(): array
{
return ...
}
public function getCities($country): array
{
return ...
}
}
Ponieważ całkowita liczba miast jest naprawdę duża, będziemy je
pobierać za pomocą AJAXu. W tym celu stworzymy EndpointPresenter
,
czyli API, które będzie nam zwracać miasta w poszczególnych krajach
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);
}
}
Gdyby miast było mało (np. na innej planecie 😉), lub gdyby model
reprezentował dane, których po prostu nie jest wiele, moglibyśmy przekazać
je od razu wszystkie jako tablicę do JavaScriptu i zaoszczędzić żądania
AJAX. W takim przypadku EndpointPresenter
nie byłby potrzebny.
Formularz
I przejdźmy do samego formularza. Stworzymy dwa pola wyboru i połączymy
je, czyli ustawimy pozycje dziecka (city
) w zależności od
wybranej wartości rodzica (country
). Ważne jest to, że zrobimy
to w event handlerze onAnchor,
czyli w momencie, gdy formularz zna już wartości przekazane przez
użytkownika.
class DemoPresenter extends Nette\Application\UI\Presenter
{
public function __construct(
private World $world,
) {}
protected function createComponentForm(): Form
{
$form = new Form;
$country = $form->addSelect('country', 'Kraj:', $this->world->getCountries())
->setPrompt('----');
$city = $form->addSelect('city', 'Miasto:');
// <-- tutaj coś jeszcze dodamy
$form->onAnchor[] = fn() =>
$city->setItems($country->getValue()
? $this->world->getCities($country->getValue())
: []);
// $form->onSuccess[] = ...
return $form;
}
}
Tak stworzony formularz będzie działał również bez JavaScriptu. A to tak, że użytkownik najpierw wybierze kraj, wyśle formularz, następnie pojawi się oferta miast, wybierze jedno z nich i wyśle formularz ponownie.
Nas jednak interesuje dynamiczne ładowanie miast za pomocą JavaScriptu.
Najczystszym sposobem podejścia do tego jest wykorzystanie atrybutów
data-
, w których prześlemy do HTML (a tym samym JS) informację
o tym, które selectboxy są powiązane i skąd mają czerpać dane.
Każdemu podrzędnemu selectboxowi przekażemy atrybut
data-depends
z nazwą elementu nadrzędnego oraz dalej albo
data-url
z URL, skąd ma pobierać pozycje za pomocą AJAXu, albo
data-items
, gdzie wszystkie warianty od razu podamy.
Zacznijmy od wariantu AJAXowego. Przekażemy nazwę elementu nadrzędnego
country
i link do Endpoint:cities
. Znak
#
używamy jako placeholder, a JavaScript będzie zamiast niego
wstawiał wybrany przez użytkownika klucz.
$city = $form->addSelect('city', 'Miasto:')
->setHtmlAttribute('data-depends', $country->getHtmlName())
->setHtmlAttribute('data-url', $this->link('Endpoint:cities', '#'));
A wariant bez AJAXu? Przygotujemy tablicę wszystkich krajów i wszystkich
ich miast, którą przekażemy do atrybutu data-items
:
$items = [];
foreach ($this->world->getCountries() as $id => $name) {
$items[$id] = $this->world->getCities($id);
}
$city = $form->addSelect('city', 'Miasto:')
->setHtmlAttribute('data-depends', $country->getHtmlName())
->setHtmlAttribute('data-items', $items);
I pozostaje napisać obsługę JavaScript.
Obsługa JavaScript
Poniższy kod jest uniwersalny, nie jest powiązany z konkretnymi
selectboxami country
i city
z przykładu, ale
powiąże dowolne selectboxy na stronie, wystarczy im tylko ustawić wspomniane
atrybuty data-
.
Kod jest napisany w czystym vanilla JS, nie wymaga więc jQuery ani innej biblioteki.
// znajdujemy na stronie wszystkie podrzędne selectboxy
document.querySelectorAll('select[data-depends]').forEach((childSelect) => {
let parentSelect = childSelect.form[childSelect.dataset.depends]; // nadrzędny <select>
let url = childSelect.dataset.url; // atrybut data-url
let items = JSON.parse(childSelect.dataset.items || 'null'); // atrybut data-items
// gdy użytkownik zmieni wybraną pozycję w nadrzędnym selectboxie...
parentSelect.addEventListener('change', () => {
// jeśli istnieje atrybut data-items...
if (items) {
// ładujemy nowe pozycje bezpośrednio do podrzędnego selectboxa
updateSelectbox(childSelect, items[parentSelect.value]);
}
// jeśli istnieje atrybut data-url...
if (url) {
// wykonujemy żądanie AJAX do endpointu z wybraną pozycją zamiast placeholdera
fetch(url.replace(encodeURIComponent('#'), encodeURIComponent(parentSelect.value)))
.then((response) => response.json())
// i ładujemy nowe pozycje do podrzędnego selectboxa
.then((data) => updateSelectbox(childSelect, data));
}
});
});
// nadpisuje <options> w <select>
function updateSelectbox(select, items)
{
select.innerHTML = ''; // usuwamy wszystko
for (let id in items) { // wstawiamy nowe
let el = document.createElement('option');
el.setAttribute('value', id);
el.innerText = items[id];
select.appendChild(el);
}
}
Więcej elementów i możliwość ponownego użycia
Rozwiązanie nie jest ograniczone do dwóch selectboxów, można stworzyć kaskadę trzech lub więcej zależnych od siebie elementów. Na przykład dodamy wybór ulicy, który będzie zależny od wybranego miasta:
$street = $form->addSelect('street', 'Ulica:')
->setHtmlAttribute('data-depends', $city->getHtmlName())
->setHtmlAttribute('data-url', $this->link('Endpoint:streets', '#'));
$form->onAnchor[] = fn() =>
$street->setItems($city->getValue() ? $this->world->getStreets($city->getValue()) : []);
Również może więcej selectboxów zależeć od jednego wspólnego.
Wystarczy tylko analogicznie ustawić atrybuty data-
i wypełnienie pozycji za pomocą setItems()
.
Przy czym nie trzeba robić żadnej ingerencji w kod JavaScript, który działa uniwersalnie.
Bezpieczeństwo
Nawet w tych przykładach nadal zachowane są wszystkie mechanizmy bezpieczeństwa, którymi dysponują formularze w Nette. W szczególności, że każdy selectbox kontroluje, czy wybrany wariant jest jednym z oferowanych, a więc atakujący nie może podstawić innej wartości.
Rozwiązanie działa w Nette 2.4 i nowszych, próbki kodu są napisane
dla PHP 8. Aby działały w starszych wersjach, należy zastąpić property
promotion oraz fn()
z
function () use (...) { ... }
.
Aby przesłać komentarz, proszę się zalogować