Εξαρτώμενα selectboxes κομψά σε Nette και καθαρό JS

2 χρόνια πριν Από το David Grudl  

Πώς να δημιουργήσετε αλυσιδωτά πλαίσια επιλογής, όπου μετά την επιλογή μιας τιμής στο ένα οι επιλογές ενημερώνονται δυναμικά στο άλλο; Αυτό είναι μια εύκολη εργασία στη Nette και την pure JavaScript. Θα παρουσιάσουμε μια λύση που είναι καθαρή, επαναχρησιμοποιήσιμη και ασφαλής.

Μοντέλο δεδομένων

Ως παράδειγμα, ας δημιουργήσουμε μια φόρμα που περιέχει πλαίσια επιλογής για την επιλογή της χώρας και της πόλης.

Αρχικά, θα προετοιμάσουμε ένα μοντέλο δεδομένων που θα επιστρέφει καταχωρήσεις και για τα δύο πλαίσια επιλογής. Πιθανώς θα τις ανακτήσει από τη βάση δεδομένων. Η ακριβής υλοποίηση δεν είναι απαραίτητη, οπότε ας υπαινιχθούμε απλώς πώς θα μοιάζει η διεπαφή:

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

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

Επειδή ο συνολικός αριθμός των πόλεων είναι πολύ μεγάλος, θα τις ανακτήσουμε χρησιμοποιώντας AJAX. Για το σκοπό αυτό, θα δημιουργήσουμε ένα EndpointPresenter, ένα API που θα επιστρέφει τις πόλεις σε κάθε χώρα ως 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);
	}
}

Αν υπάρχουν λίγες πόλεις (για παράδειγμα σε έναν άλλο πλανήτη 😉 ), ή αν το μοντέλο αναπαριστά δεδομένα που απλώς δεν είναι πολλά, θα μπορούσαμε να τις περάσουμε όλες ως πίνακα στη JavaScript και να εξοικονομήσουμε τα αιτήματα AJAX. Σε αυτή την περίπτωση, δεν θα υπήρχε ανάγκη για το EndpointPresenter.

Φόρμα

Και ας προχωρήσουμε στην ίδια τη φόρμα. Θα δημιουργήσουμε δύο πλαίσια επιλογής και θα τα συνδέσουμε, δηλαδή θα ορίσουμε τα στοιχεία του παιδιού (city) ανάλογα με την επιλεγμένη τιμή του γονέα (country). Το σημαντικό είναι ότι αυτό θα το κάνουμε στον χειριστή του συμβάντος onAnchor, δηλαδή τη στιγμή που η φόρμα γνωρίζει ήδη τις τιμές που έχει υποβάλει ο χρήστης.

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:');
		// <-- θα προσθέσουμε κάτι άλλο εδώ

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

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

Η φόρμα που δημιουργείται με αυτόν τον τρόπο θα λειτουργεί χωρίς JavaScript. Αυτό γίνεται με το να επιλέξει ο χρήστης πρώτα μια χώρα, να υποβάλει τη φόρμα, στη συνέχεια θα εμφανιστεί ένα μενού με πόλεις, να επιλέξει μια από αυτές και να υποβάλει ξανά τη φόρμα.

Ωστόσο, μας ενδιαφέρει η δυναμική φόρτωση των πόλεων με τη χρήση JavaScript. Ο πιο καθαρός τρόπος για να το προσεγγίσουμε αυτό είναι να χρησιμοποιήσουμε τα χαρακτηριστικά data-, στα οποία στέλνουμε πληροφορίες στην HTML (και συνεπώς στο JS) σχετικά με το ποια πλαίσια επιλογής συνδέονται και από πού να ανακτήσουμε δεδομένα.

Για κάθε παιδικό πλαίσιο επιλογής, περνάμε ένα χαρακτηριστικό data-depends με το όνομα του γονικού στοιχείου και στη συνέχεια είτε ένα data-url με τη διεύθυνση URL από όπου θα ανακτήσουμε στοιχεία χρησιμοποιώντας AJAX, είτε ένα χαρακτηριστικό data-items όπου παραθέτουμε απευθείας όλες τις επιλογές.

Ας ξεκινήσουμε με την παραλλαγή AJAX. Περνάμε το όνομα του γονικού στοιχείου country και μια αναφορά στο Endpoint:cities. Χρησιμοποιούμε τον χαρακτήρα # ως placeholder και η JavaScript θα βάλει στη θέση του το κλειδί που επέλεξε ο χρήστης.

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

Και η παραλλαγή χωρίς AJAX; Προετοιμάζουμε έναν πίνακα με όλες τις χώρες και όλες τις πόλεις τους, τον οποίο περνάμε στο χαρακτηριστικό 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);

Χειριστής JavaScript

Ο παρακάτω κώδικας είναι καθολικός, δεν συνδέεται με τα συγκεκριμένα πλαίσια επιλογής country και city από το παράδειγμα, αλλά θα συνδέσει οποιαδήποτε πλαίσια επιλογής στη σελίδα, απλά ορίστε τα αναφερόμενα χαρακτηριστικά data-.

Ο κώδικας είναι γραμμένος σε καθαρό vanilla JS, οπότε δεν απαιτεί jQuery ή οποιαδήποτε άλλη βιβλιοθήκη.

// εύρεση όλων των παιδικών selectboxes στη σελίδα
document.querySelectorAll('select[data-depends]').forEach((childSelect) => {
	let parentSelect = childSelect.form[childSelect.dataset.depends]; // γονέας <select>
	let url = childSelect.dataset.url; // attribute data-url
	let items = JSON.parse(childSelect.dataset.items || 'null'); // attribute data-items

	// όταν ο χρήστης αλλάζει το επιλεγμένο στοιχείο στη γονική επιλογή...
	parentSelect.addEventListener('change', () => {
		// εάν υπάρχει το χαρακτηριστικό data-items...
		if (items) {
			// να φορτώσει νέα στοιχεία απευθείας στο παιδικό πλαίσιο επιλογής
			updateSelectbox(childSelect, items[parentSelect.value]);
		}

		// εάν υπάρχει το χαρακτηριστικό data-url...
		if (url) {
			// κάνουμε αίτηση AJAX στο τελικό σημείο με το επιλεγμένο στοιχείο αντί για το placeholder
			fetch(url.replace(encodeURIComponent('#'), encodeURIComponent(parentSelect.value)))
				.then((response) => response.json())
				// και φορτώνουμε νέα στοιχεία στο πλαίσιο επιλογής του παιδιού
				.then((data) => updateSelectbox(childSelect, data));
		}
	});
});

// αντικαθιστά το <options> στο <select>
function updateSelectbox(select, items)
{
	select.innerHTML = ''; // remove all
	for (let id in items) { // Εισαγωγή νέων
		let el = document.createElement('option');
		el.setAttribute('value', id);
		el.innerText = items[id];
		select.appendChild(el);
	}
}

Περισσότερα στοιχεία και δυνατότητα επαναχρησιμοποίησης

Η λύση δεν περιορίζεται σε δύο πλαίσια επιλογής, μπορείτε να δημιουργήσετε μια κλιμάκωση τριών ή περισσότερων εξαρτημένων στοιχείων. Για παράδειγμα, προσθέτουμε μια επιλογή δρόμου που εξαρτάται από την επιλεγμένη πόλη:

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

Επίσης, πολλαπλά πλαίσια επιλογής μπορούν να εξαρτώνται από ένα μόνο κοινό πλαίσιο. Απλά ορίστε τα χαρακτηριστικά data- κατ' αναλογία και συμπληρώστε τα στοιχεία με το setItems().

Δεν υπάρχει απολύτως καμία ανάγκη να κάνετε καμία τροποποίηση στον κώδικα JavaScript, ο οποίος λειτουργεί καθολικά.

Ασφάλεια

Ακόμη και σε αυτά τα παραδείγματα, διατηρούνται όλοι οι μηχανισμοί ασφαλείας που διαθέτουν οι φόρμες Nette. Συγκεκριμένα, κάθε πλαίσιο επιλογής ελέγχει ότι η επιλεγμένη επιλογή είναι μία από τις προσφερόμενες, και έτσι ένας επιτιθέμενος δεν μπορεί να παραποιήσει μια διαφορετική τιμή.


Η λύση λειτουργεί σε Nette 2.4 και μεταγενέστερες εκδόσεις, τα δείγματα κώδικα είναι γραμμένα για PHP 8. Για να λειτουργήσουν σε παλαιότερες εκδόσεις, αντικαταστήστε τα property promotion και fn() με function () use (...) { ... }.

Τελευταίες θέσεις