Bağımlı seçim kutuları Nette ve saf JS'de zarif bir şekilde

2 yıl önce Kimden David Grudl  

Birinde bir değer seçtikten sonra diğerinde seçeneklerin dinamik olarak güncellendiği zincirleme seçim kutuları nasıl oluşturulur? Bu, Nette ve saf JavaScript'te kolay bir iştir. Temiz, yeniden kullanılabilir ve güvenli bir çözüm göstereceğiz.

Veri modeli

Örnek olarak, ülke ve şehir seçimi için seçim kutuları içeren bir form oluşturalım.

İlk olarak, her iki seçim kutusu için girdileri döndürecek bir veri modeli hazırlayacağız. Muhtemelen bunları veritabanından alacaktır. Tam uygulama önemli değildir, bu nedenle arayüzün nasıl görüneceğine dair ipucu verelim:

class World
{
	public function getCountries(): array
	{
		return ...
	}

	public function getCities($country): array
	{
		return ...
	}
}

Toplam şehir sayısı gerçekten çok fazla olduğu için bunları AJAX kullanarak alacağız. Bu amaçla, her ülkedeki şehirleri JSON olarak döndürecek bir API olan bir EndpointPresenter oluşturacağız:

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);
	}
}

Az sayıda şehir varsa (örneğin başka bir gezegende 😉) veya model çok fazla olmayan verileri temsil ediyorsa, hepsini JavaScript'e dizi olarak aktarabilir ve AJAX isteklerinden tasarruf edebiliriz. Bu durumda EndpointPresenter adresine gerek kalmayacaktır.

Form

Ve formun kendisine geçelim. İki seçim kutusu oluşturacağız ve bunları birbirine bağlayacağız, yani üst öğenin (country) seçilen değerine bağlı olarak alt (city) öğeleri ayarlayacağız. Önemli olan, bunu onAnchor olay işleyicisinde, yani formun kullanıcı tarafından gönderilen değerleri zaten bildiği anda yapmamızdır.

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:');
		// <-- buraya başka bir şey ekleyeceğiz

		$form->onAnchor[] = fn() =>
			$city->setItems($country->getValue()
				? $this->world->getCities($country->getValue())
				: []);

		// $form->onSuccess[] = ...
		return $form;
	}
}

Bu şekilde oluşturulan form JavaScript olmadan çalışacaktır. Bu, kullanıcının önce bir ülke seçmesi, formu göndermesi, ardından bir şehir menüsü görünmesi, bunlardan birini seçmesi ve formu tekrar göndermesi ile yapılır.

Ancak, JavaScript kullanarak şehirleri dinamik olarak yüklemekle ilgileniyoruz. Buna yaklaşmanın en temiz yolu, HTML'ye (ve dolayısıyla JS'ye) hangi seçim kutularının bağlantılı olduğu ve verilerin nereden alınacağı hakkında bilgi gönderdiğimiz data- niteliklerini kullanmaktır.

Her alt seçim kutusu için, üst öğenin adını içeren bir data-depends özniteliği ve ardından AJAX kullanarak öğeleri alacağımız URL'yi içeren bir data-url veya tüm seçenekleri doğrudan listelediğimiz bir data-items özniteliği iletiriz.

AJAX varyantı ile başlayalım. Üst öğenin adını country ve bir referansı Endpoint:cities adresine iletiyoruz. # karakterini yer tutucu olarak kullanıyoruz ve JavaScript bunun yerine kullanıcının seçtiği anahtarı koyacaktır.

$city = $form->addSelect('city', 'City:')
	->setHtmlAttribute('data-depends', $country->getHtmlName())
	->setHtmlAttribute('data-url', $country->link('Endpoint:cities', '#'));

Peki ya AJAX'sız varyant? Tüm ülkelerin ve tüm şehirlerinin bir dizisini hazırlıyoruz ve bunu data-items özelliğine aktarıyoruz:

$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);

JavaScript işleyicisi

Aşağıdaki kod evrenseldir, örnekteki belirli country ve city seçim kutularına bağlı değildir, ancak sayfadaki herhangi bir seçim kutusunu bağlayacaktır, sadece belirtilen data- niteliklerini ayarlayın.

Kod saf vanilya JS ile yazılmıştır, bu nedenle jQuery veya başka bir kütüphane gerektirmez.

// sayfadaki tüm alt seçim kutularını bulma
document.querySelectorAll('select[data-depends]').forEach((childSelect) => {
	let parentSelect = childSelect.form[childSelect.dataset.depends]; // parent <select>
	let url = childSelect.dataset.url; // öznitelik data-url
	let items = JSON.parse(childSelect.dataset.items || 'null'); // öznitelik data-items

	// kullanıcı üst seçimdeki seçili öğeyi değiştirdiğinde...
	parentSelect.addEventListener('change', () => {
		// data-items özniteliği mevcutsa...
		if (items) {
			// yeni öğeleri doğrudan alt seçim kutusuna yükleyin
			updateSelectbox(childSelect, items[parentSelect.value]);
		}

		// data-url özniteliği mevcutsa...
		if (url) {
			// yer tutucu yerine seçilen öğe ile uç noktaya AJAX isteği yaparız
			fetch(url.replace(encodeURIComponent('#'), encodeURIComponent(parentSelect.value)))
				.then((response) => response.json())
				// ve alt seçim kutusuna yeni öğeler yükleyin
				.then((data) => updateSelectbox(childSelect, data));
		}
	});
});

// replaces <options> in <select>
function updateSelectbox(select, items)
{
	select.innerHTML = ''; // remove all
	for (let id in items) { // yeni ekle
		let el = document.createElement('option');
		el.setAttribute('value', id);
		el.innerText = items[id];
		select.appendChild(el);
	}
}

Daha fazla öğe ve yeniden kullanılabilirlik

Çözüm iki seçim kutusuyla sınırlı değildir, üç veya daha fazla bağımlı öğeden oluşan bir kaskad oluşturabilirsiniz. Örneğin, seçilen şehre bağlı bir sokak seçimi ekliyoruz:

$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()) : []);

Ayrıca, birden fazla seçim kutusu tek bir ortak kutuya bağlı olabilir. Sadece data- niteliklerini benzer şekilde ayarlayın ve öğeleri setItems() ile doldurun.

Evrensel olarak çalışan JavaScript kodunda herhangi bir değişiklik yapmaya kesinlikle gerek yoktur.

Güvenlik

Bu örneklerde bile, Nette formlarının sahip olduğu tüm güvenlik mekanizmaları hala korunmaktadır. Özellikle, her seçim kutusu seçilen seçeneğin sunulan seçeneklerden biri olduğunu kontrol eder ve böylece bir saldırgan farklı bir değeri taklit edemez.


Çözüm Nette 2.4 ve sonrasında çalışır, kod örnekleri PHP 8 için yazılmıştır. Eski sürümlerde çalışması için property promotion ve fn() yerine function () use (...) { ... } yazın.