Zależne selectboxy elegancko w Nette i czysty JS
Jak stworzyć łańcuchowe selectboxy, gdzie po wybraniu wartości w jednym, opcje są dynamicznie aktualizowane w drugim? Jest to łatwe zadanie w Nette i czystym JavaScripcie. Pokażemy rozwiązanie, które jest czyste, wielokrotnego użytku end secure.
Model danych
Jako przykład, stwórzmy formularz zawierający pola wyboru do wyboru kraju i miasta.
Najpierw przygotujemy model danych, który będzie zwracał wpisy dla obu pól wyboru. Prawdopodobnie będzie on pobierał je z bazy danych. Dokładna implementacja nie jest niezbędna, więc podpowiedzmy tylko, jak będzie wyglądał interfejs:
class World
{
public function getCountries(): array
{
return ...
}
public function getCities($country): array
{
return ...
}
}
Ponieważ łączna liczba miast jest naprawdę duża, będziemy je pobierać
za pomocą AJAX-a. W tym celu stworzymy EndpointPresenter
, API,
które zwróci miasta w każdym kraju 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);
}
}
Jeśli miast jest niewiele (na przykład na innej planecie 😉 ), lub jeśli
model reprezentuje dane, których jest po prostu niewiele, moglibyśmy
przekazać je wszystkie jako tablicę do JavaScript i zapisać żądania AJAX.
W takim przypadku nie byłoby potrzeby korzystania z
EndpointPresenter
.
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', 'Country:', $this->world->getCountries())
->setPrompt('----');
$city = $form->addSelect('city', 'City:');
// <-- dodamy tutaj coś jeszcze
$form->onAnchor[] = fn() =>
$city->setItems($country->getValue()
? $this->world->getCities($country->getValue())
: []);
// $form->onSuccess[] = ...
return $form;
}
}
Tak utworzony formularz będzie działał bez użycia JavaScript. Odbywa się to w ten sposób, że użytkownik najpierw wybiera kraj, przesyła formularz, następnie pojawia się menu miast, wybiera jedno z nich i ponownie przesyła formularz.
Jednak my jesteśmy zainteresowani dynamicznym ładowaniem miast przy użyciu
JavaScript. Najczystszym sposobem podejścia do tego jest użycie atrybutów
data-
, w których wysyłamy do HTML (a więc i JS) informacje
o tym, które pola wyboru są powiązane i skąd pobierać dane.
Dla każdego pola wyboru dziecka przekazujemy atrybut
data-depends
z nazwą elementu nadrzędnego, a następnie albo
data-url
z adresem URL, z którego należy pobrać elementy za
pomocą AJAX-a, albo atrybut data-items
, w którym bezpośrednio
wymieniamy wszystkie opcje.
Zacznijmy od wariantu AJAX-owego. Przekazujemy nazwę elementu nadrzędnego
country
oraz referencję do Endpoint:cities
. Używamy
znaku #
jako placeholder, a JavaScript umieści zamiast niego
wybrany przez użytkownika klucz.
$city = $form->addSelect('city', 'City:')
->setHtmlAttribute('data-depends', $country->getHtmlName())
->setHtmlAttribute('data-url', $this->link('Endpoint:cities', '#'));
A wariant bez AJAX-a? Przygotowujemy tablicę wszystkich krajów
i wszystkich jego miast, którą przekazujemy do atrybutu
data-items
:
$items = [];
foreach ($this->world->getCountries() as $id => $name) {
$items[$id] = $this->world->getCities($id);
}
$city = $form->addSelect('city', 'City:')
->setHtmlAttribute('data-depends', $country->getHtmlName())
->setHtmlAttribute('data-items', $items);
Obsługa JavaScript
Poniższy kod jest uniwersalny, nie jest związany z konkretnymi
selectboxami country
i city
z przykładu, ale
połączy dowolne selectboxy na stronie, wystarczy ustawić wspomniane atrybuty
data-
.
Kod jest napisany w czystym vanilla JS, więc nie wymaga jQuery ani żadnej innej biblioteki.
// znajdź wszystkie dzieci selectbox na stronie
document.querySelectorAll('select[data-depends]').forEach((childSelect) => {
let parentSelect = childSelect.form[childSelect.dataset.depends]; // parent <select>
let url = childSelect.dataset.url; // atrybut data-url
let items = JSON.parse(childSelect.dataset.items || 'null'); // atrybut data-items
// gdy użytkownik zmienia wybrany element w selekcji rodzica...
parentSelect.addEventListener('change', () => {
// jeśli atrybut data-items istnieje...
if (items) {
// załaduj nowe elementy bezpośrednio do pola wyboru dziecka
updateSelectbox(childSelect, items[parentSelect.value]);
}
// jeśli atrybut data-url istnieje...
if (url) {
// wykonujemy żądanie AJAX do punktu końcowego z wybranym elementem zamiast placeholder
fetch(url.replace(encodeURIComponent('#'), encodeURIComponent(parentSelect.value)))
.then((response) => response.json())
// i ładujemy nowe elementy do pola wyboru dziecka
.then((data) => updateSelectbox(childSelect, data));
}
});
});
// zastępuje <options> w <select>
function updateSelectbox(select, items)
{
select.innerHTML = ''; // usuń wszystko
for (let id in items) { // wstawić nowy
let el = document.createElement('option');
el.setAttribute('value', id);
el.innerText = items[id];
select.appendChild(el);
}
}
Więcej elementów i możliwość ponownego wykorzystania
Rozwiązanie nie ogranicza się do dwóch pól wyboru, można stworzyć kaskadę trzech lub więcej elementów zależnych. Na przykład dodajemy wybór ulicy, który zależy od wybranego miasta:
$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()) : []);
Również wiele pól wyboru może zależeć od jednego wspólnego. Wystarczy
analogicznie ustawić atrybuty data-
i zaludnić elementy za
pomocą setItems()
.
Nie ma absolutnie żadnej potrzeby wykonywania jakichkolwiek modyfikacji kodu JavaScript, który działa uniwersalnie.
Bezpieczeństwo
Nawet w tych przykładach zachowane są wszystkie mechanizmy bezpieczeństwa, które posiadają formularze Nette. W szczególności każde pole wyboru sprawdza, czy wybrana opcja jest jedną z oferowanych, a więc atakujący nie może podrobić 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ć