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

πριν από 3 χρόνια Από David Grudl  

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

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

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

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

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 δεν θα ήταν απαραίτητος.

Φόρμα

Και ας πάμε στην ίδια τη φόρμα. Θα δημιουργήσουμε δύο selectboxes και θα τα συνδέσουμε, δηλαδή στο εξαρτώμενο (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', 'Χώρα:', $this->world->getCountries())
			->setPrompt('----');

		$city = $form->addSelect('city', 'Πόλη:');
		// <-- εδώ θα προσθέσουμε κάτι ακόμα αργότερα

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

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

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

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

Σε κάθε εξαρτώμενο selectbox θα περάσουμε το attribute data-depends με το όνομα του γονικού στοιχείου και στη συνέχεια είτε το data-url με το URL από όπου πρέπει να λαμβάνει τα στοιχεία μέσω AJAX, είτε το data-items, όπου θα αναφέρουμε απευθείας όλες τις παραλλαγές.

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

$city = $form->addSelect('city', 'Πόλη:')
	->setHtmlAttribute('data-depends', $country->getHtmlName())
	->setHtmlAttribute('data-url', $this->link('Endpoint:cities', '#'));

Και η παραλλαγή χωρίς AJAX; Θα προετοιμάσουμε έναν πίνακα με όλες τις χώρες και όλες τις πόλεις τους, τον οποίο θα περάσουμε στο attribute data-items:

$items = [];
foreach ($this->world->getCountries() as $id => $name) {
	$items[$id] = $this->world->getCities($id);
}

$city = $form->addSelect('city', 'Πόλη:')
	->setHtmlAttribute('data-depends', $country->getHtmlName())
	->setHtmlAttribute('data-items', $items);

Και απομένει να γράψουμε τον χειριστή JavaScript.

Χειριστής JavaScript

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

Ο κώδικας είναι γραμμένος σε καθαρή 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

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

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

// αντικαθιστά τα <options> στο <select>
function updateSelectbox(select, items)
{
	select.innerHTML = ''; // αφαιρούμε τα πάντα
	for (let id in items) { // εισάγουμε νέα
		let el = document.createElement('option');
		el.setAttribute('value', id);
		el.innerText = items[id];
		select.appendChild(el);
	}
}

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

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

$street = $form->addSelect('street', 'Οδός:')
	->setHtmlAttribute('data-depends', $city->getHtmlName())
	->setHtmlAttribute('data-url', $this->link('Endpoint:streets', '#'));

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

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

Παράλληλα, δεν χρειάζεται να γίνει καμία παρέμβαση στον κώδικα JavaScript, ο οποίος λειτουργεί καθολικά.

Ασφάλεια

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


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

Πρόσφατες δημοσιεύσεις