Závislé selectboxy: snadná úloha v čistém Nette a JS

David Grudl
Nette Core | 7636
+
+23
-

Závislé selectboxy jsou velmi snadná úloha na řešení v čistém Nette a JS.

Nejprve si připravím nějaký datový model, který mi bude vracet položky pro hlavní (main) select box a pro podřízený (dependent):

class Model
{
	public function getMainItems(): array
	{
		return ...
	}

	public function getDependentItems($main): array
	{
		return ...
	}
}

Dále nějaký EndpointPresenter, prostě API, abych ty položky mohl tahat i na straně prohlížeče jako JSON:

class EndpointPresenter extends Nette\Application\UI\Presenter
{
	public function __construct(
		private Model $model,
	) {}

	public function actionMyData($id): void
	{
		$items = $this->model->getDependentItems($id);
		$this->sendJson($items);
	}
}

No a teď udělám formulář se dvěma selectboxy, které provážu. Položky do podřízeného dávám až v onAnchor (příp. onValidate), tedy ve chvíli, kdy formulář už zná hodnoty odeslané uživatelem, což v době vytváření formuláře ještě neví.

class DemoPresenter extends Nette\Application\UI\Presenter
{
	public function __construct(
		private Model $model,
	) {}

	protected function createComponentForm(): Form
	{
		$form = new Form;
		$main = $form->addSelect('main', 'hlavní:', $this->model->getMainItems())
			->setPrompt('----');
		$dependent = $form->addSelect('dep', 'podřízený:');
		$form->onAnchor[] = fn() =>
			$dependent->setItems($main->getValue() ? $this->model->getDependentItems($main->getValue()) : []);

		// <-- SEM JESTE NECO DOPLNIM
		return $form;
	}
}

Tohle by už mělo samo o sobě fungovat (i bez JS), tak, že uživatel vybere první položku, odešle formulář, vybere druhou a znovu odešle.

Doplním JS a AJAX. A tím nejčistějším způsobem, tj. do formuláře pouze přidám data- atributy, ve kterých si pošlu do HTML (a potažmo JS) informaci, které selectboxy jsou provázané (hlavní select bude mít v atributu data-dependent název podřízeného) a z jakého URL získám položky (zapíšu do data-url), abych mohl následně provázání vyřešit i v JavaScriptu:

$main
	->setHtmlAttribute('data-url', $this->link('Endpoint:myData', '#')) // # is placeholder
	->setHtmlAttribute('data-dependent', $dependent->getHtmlName());
return $form;

A zbývá napsat obslužný JS. Dnes se to dá snadno udělat i bez jQuery, v čistém JS. Následující kód je univerzální, není vázaný na konkrétní dva selectboxy, ale prováže jakékoliv selectboxy na stránce, stačí jim jen přidat ony dva data- attributy.

// najdeme vsechny hlavní selectboxy co maji podrizeny selectbox
document.querySelectorAll('select[data-dependent]').forEach(function (main) {
	// a když uživatel změní vybranou položku…
	main.addEventListener('change', function () {
		let dependent = main.form[main.dataset.dependent]; // podrizeny <select>
		let url = main.dataset.url; // URL pro našeptávání
		// ...udelame pozadavek na Endpoint presenter a posleme 'id'
		fetch(url.replace(encodeURIComponent('#'), encodeURIComponent(main.value)))
			.then(response => response.json())
			// a nahrajeme do podrizeneho nove data
			.then(data => updateSelectbox(dependent, data));
	});
});

// vloží nové <options> do <select>
function updateSelectbox(select, items)
{
	select.innerHTML = ''; // odstranime vse
	for (var id in items) { // vložime nové
		var el = document.createElement('option');
		el.setAttribute('value', id);
		el.innerText = items[id];
		select.appendChild(el);
	}
}

A je to :)

kralik
Člen | 213
+
0
-

Super, už to zkouším.

Jak by toto bylo možné rozšířit o další 2 scénáře použití?
Pomohl by někdo?

  1. Jeden hlavní select box + dva další selectboxy na něm závislé
  • tedy změní se hodnota v hlavním selectboxu a dojde k načtení položek do dvou dalších selectboxů
  1. 3 select boxy
  • 1. hlavní – vybere se hodnota a načtou se data do 2. selectboxu
  • 2. vybere se hodnota a dle ní se načtou data do 3. select boxu
  • 3. vybere se hodnota

Děkuji :-)

Editoval kralik (12. 1. 7:18)

Polki
Člen | 524
+
0
-

A co varianta s JS, kdy je v selectech málo hodnot a nechci zatěžovat server ajaxem?

David Grudl
Nette Core | 7636
+
+2
-

Tak to zkuste doplnit.

kralik
Člen | 213
+
0
-

Prosím můžete někdo poradit jak upravit kód pro použití na scénáře A a B(viz. výše)?

Bohužel já nejsem schopen tuto úpravu vymyslet.

Moc díky

Milo
Nette Core | 1248
+
0
-

Dá se to udělat mnoha způsoby. Například:

add A – do data-dependent neuložíš jednu hodnotu, ale pole hodnot, tedy htmlname každého závislého selectboxu. Následně v actionMyData() pošleš data jako dvouúrovňové pole. A nakonec v JS v change eventu to zase zpracuješ jako pole, tedy o jeden foreach více.

add B – stačí nastavit data atributy i u závislých selectboxů.

chemix
Nette Core | 1226
+
+6
-

Co to dat na blog jako clanek @DavidGrudl ? prijde mi ze to je ten typ kucharek co by tam byl take fajn … ve foru to zapadne mezi dotazy jak udelat zavisle selectboxy …

David Grudl
Nette Core | 7636
+
0
-

Tohle bylo v podstatě napsané z hlavy, nemám to odzkoušené :) Takže bych ještě počkal.

kralik
Člen | 213
+
0
-

Milo napsal(a):

Dá se to udělat mnoha způsoby. Například:

add A – do data-dependent neuložíš jednu hodnotu, ale pole hodnot, tedy htmlname každého závislého selectboxu. Následně v actionMyData() pošleš data jako dvouúrovňové pole. A nakonec v JS v change eventu to zase zpracuješ jako pole, tedy o jeden foreach více.

add B – stačí nastavit data atributy i u závislých selectboxů.

mohl bys ukázat přímo v příkladech pro scénáře A i B?
Díky

kralik
Člen | 213
+
-5
-

Opravdu nikdo nepomůže s kódem pro scénáře A i B?

Díky

Kamil Valenta
Člen | 501
+
+3
-

Proč to neuděláš jak psal @Milo ?
To není pomoc?
Příklad != vypracování implementace pro Tebe.

Ukaž jak jsi to použil a na čem jsi narazil.

David Grudl
Nette Core | 7636
+
+3
-

Pokud bych měl více nezávislých na jednom hlavním, tak by návrh změnil, a místo aby hlavní odkazoval na jeden podřízený, tak by podřízení odkazovali na jeden hlavní.

PHP:

// místo nastavování atributů na $main by se použil $dependent:
$dependent
	->setHtmlAttribute('data-url', $this->link('Endpoint:myData', '#'))
	->setHtmlAttribute('data-depends', $main->getHtmlName()); // a odkaz na main

a JS:


// najdeme všechny podřízené selectboxy
document.querySelectorAll('select[data-depends]').forEach(function (dependent) {
	let main = dependent.form[dependent.dataset.depends]; // hlavní <select>

	// a když uživatel změní vybranou položku…
	main.addEventListener('change', function () {
		let url = dependent.dataset.url; // URL pro našeptávání
		// ...uděláme požadavek na Endpoint presenter a pošleme klíč
		fetch(url.replace(encodeURIComponent('#'), encodeURIComponent(main.value)))
			.then(response => response.json())
			// a nahrajeme do podřízeného nová data
			.then(data => updateSelectbox(dependent, data));
	});
});

// vloží nové <options> do <select>
function updateSelectbox(select, items)
{
	select.innerHTML = ''; // odstraníme vše
	for (var id in items) { // vložime nové
		var el = document.createElement('option');
		el.setAttribute('value', id);
		el.innerText = items[id];
		select.appendChild(el);
	}
}
David Grudl
Nette Core | 7636
+
+3
-

Pokud by byla kaskáda tří selectboxů, tak prostě přidám třetí prvek a doplním analogicky data atributy a $onAnchor. V případě způsobu provázání dle přechozího komentáře s data-depends by to bylo:

		$s1 = $form->addSelect('s1', null, $this->model->getMainItems())
			->setPrompt('----');

		$s2 = $form->addSelect('s2')
			->setHtmlAttribute('data-depends', $s1->getHtmlName())
			->setHtmlAttribute('data-url', $this->link('Endpoint:myData', '#'));

		$form->onAnchor[] = fn() =>
			$s2->setItems($s1->getValue() ? $this->model->getDependentItems($s1->getValue()) : []);

		$s3 = $form->addSelect('s3')
			->setHtmlAttribute('data-depends', $s2->getHtmlName())
			->setHtmlAttribute('data-url', $this->link('Endpoint:myData', '#')); # jiny odkaz

		$form->onAnchor[] = fn() =>
			$s3->setItems($s2->getValue() ? $this->model->getDependentItems($s2->getValue()) : []); # jina metoda

Samozřejmě je potřeba do modelu a endpointu doplnit dalsi metody generujici data pro ten třetí prvek.

Petr Parolek
Člen | 394
+
0
-

@DavidGrudl trochu OT – bude kod funkční na PHP < 8.0? Jedná se mi o syntaxy fn

David Grudl
Nette Core | 7636
+
+1
-

Nepamatuju si od které verze existuje fn. Kde fn není, použije se obdoba s function. Základy programování v PHP tu snad neřešíme.

David Grudl
Nette Core | 7636
+
+1
-

A nakonec, pokud je málo hodnot a nepotřebuju AJAX, tak není potřeba vytvářet EndpointPresenter, všechny položky si vyexportuju do $subItems a vložím do data atributu data-items:

$subItems= $this->model->getMainItems();
array_walk($subItems, fn(&$v, $k) => $v = $this->model->getDependentItems($k));

$dependent = $form->addSelect('dep', 'podřízený:')
	->setHtmlAttribute('data-items', $subItems)
	->setHtmlAttribute('data-depends', $main->getHtmlName());

a pak je načtu přímo:

	// a když uživatel změní vybranou položku…
	main.addEventListener('change', function () {
		// pokud existuje data-items, vložíme položky přímo
		if (dependent.dataset.items) {
			let items = JSON.parse(dependent.dataset.items);
			updateSelectbox(dependent, items[main.value])
			return;
		}

		// ...jinak uděláme požadavek na Endpoint presenter a pošleme klíč
		...
	});

(Zase vycházím z kodu, kde se vazba dělá přes data-depends)

kralik
Člen | 213
+
0
-

Ahoj,
zkouším scénář A, jeden hlavní select box a dva selectboxy na něm závislé.

Presenter

protected function createComponentFormAddDoc(): UI\Form {
$form = new UI\Form();
...
$main = $form->addSelect('zavod', 'Závod', $zavod)
                ->setHtmlAttribute('class','form-control')
                ->setDefaultValue($this->mf)
                ->addRule($form::FILLED,'ZÁVOD musí být vybrán');

$dependent = $form->addSelect('oddeleni', 'Oddělení',$odd)
                        ->setHtmlAttribute('class','form-control')
                        ->setDefaultValue('')
                        ->addRule($form::FILLED,'ODDĚLENÍ musí být vybráno');

$dWf =  $form->addSelect('wf', 'Workflow', $wf[$this->mf])
                        ->setHtmlAttribute('class','form-control')
                        ->setDefaultValue('')
                        ->addRule($form::FILLED,'WORKFLOW musí být vybráno');

$form->onAnchor[] = fn () =>
            $dependent->setItems($this->mainModel->getDepOddeleni($main->getValue() ?? 'default'));
...
$dependent->setHtmlAttribute('data-url', $this->link('myData','#'))
	        ->setHtmlAttribute('data-depends',$main->getHtmlName());

addobj.latte

// najdeme vsechny hlavní selectboxy co maji podrizeny selectbox
    document.querySelectorAll('select[data-depends]').forEach(function (dependent) {
        // a když uživatel změní vybranou položku…
        let placeholder = encodeURIComponent('#');
	    let main = dependent.form[dependent.dataset.main]; // hlavní <select>

        // a když uživatel změní vybranou položku…
        main.addEventListener('change', function () {l}
            let url = dependent.dataset.url; // URL pro našeptávání

            // ...udelame pozadavek na Endpoint presenter a posleme 'sid'
            fetch(url.replace(placeholder, encodeURIComponent(main.value)))
                .then(response => response.json())
                // a nahrajeme do podrizeneho nove data
                .then( data => {

                    updateSelectbox(dependent, data);
                });

        {r});
    });

    // vloží nové <options> do <select>
    function updateSelectbox(select, items)
    {
        select.innerHTML = ''; // odstranime vse
        for (var id in items) { // vložime nové
            var el = document.createElement('option');
            el.setAttribute('value', id);
            el.innerText = items[id];
            select.appendChild(el);
        }
    }
  • v konzoli dostávám chybu „main is undefined“
  • nevím jak do toho zakomponovat i druhý selectbox ($dWf), který je závislý na $main

Předem moc díky za pomoc

dsar
Backer | 39
+
0
-

In my opinion having also an example with snippets would promote the Nette-way of doing AJAX stuff (especially for those ones that don't like JavaScript, like me) and furthermore simplify things for newbies (like above).

Even without snippets, since the form works even without JavaScript (a good thing), the code could be reduced by submitting the form and updating the whole form's body

David Grudl
Nette Core | 7636
+
0
-

@kralik v JS byla chyba, místo dependent.dataset.main má by dependent.dataset.depends

David Grudl
Nette Core | 7636
+
0
-

A máš tam $form->onSuccess[] = ...?

kralik
Člen | 213
+
0
-

David Grudl napsal(a):

A máš tam $form->onSuccess[] = ...?

ano, mám toto.

	$form->onSuccess[] = [$this, 'submitFormAdddoc'];

Ještě jsem to zkoušel a musel jsem do formuláře přidat fn i pro druhý selectbox.

..
 $form->onAnchor[] = fn () =>
            $dWf->setItems($this->mainModel->getDepWf($main->getValue() ?? 'default'));

Již se zdá, že to běhá v pořádku.
Jdu ještě vyzkoušet scénář B (se třemi vzájemně provázanými selectboxy).

Pak jsem kdyžtak dám celé řešení.

Díky

Editoval kralik (19. 1. 12:55)

kralik
Člen | 213
+
0
-

Super, funguje to krásně.
Níže shrnuji celé řešení.

Jeden hlavní selectbox a dva závislí

  • celkem 3 selectboxy, změnou hodnoty 1. selectboxu dojde k načtení hodnot do dalších dvou selectboxů

PRESENTER

public function actionOptionOddeleni($sid): void{

        if($sid){
          $data = $this->mainModel->getDepOddeleni($sid);

		      $this->sendJson($data);
        }


	}

    public function actionOptionWf($sid): void{

        if($sid){
          $data = $this->mainModel->getDepWf($sid);

		      $this->sendJson($data);
        }


	}

protected function createComponentFormAddDoc(): UI\Form {
...
$main = $form->addSelect('zavod', 'Závod', $zavod)
                ->setHtmlAttribute('class','form-control')
                ->setDefaultValue($this->mf)
                ->addRule($form::FILLED,'ZÁVOD musí být vybrán');
$dependent = $form->addSelect('oddeleni', 'Oddělení',$odd)
                        ->setHtmlAttribute('class','form-control')
                        ->setDefaultValue('')
                        ->addRule($form::FILLED,'ODDĚLENÍ musí být vybráno');

$form->onAnchor[] = fn () =>
       $dependent->setItems($this->mainModel->getDepOddeleni($main->getValue() ?? 'default'));

$dWf =  $form->addSelect('wf', 'Workflow', $wf[$this->mf])
              ->setHtmlAttribute('class','form-control')
              ->setDefaultValue('')
              ->addRule($form::FILLED,'WORKFLOW musí být vybráno');
$form->onAnchor[] = fn () =>
       $dWf->setItems($this->mainModel->getDepWf($main->getValue() ?? 'default'));
...

$dependent->setHtmlAttribute('data-url', $this->link('optionOddeleni','#'))
	        ->setHtmlAttribute('data-depends',$main->getHtmlName());

$dWf->setHtmlAttribute('data-url', $this->link('optionWf','#'))
	        ->setHtmlAttribute('data-depends',$main->getHtmlName());
}

LATTE

<script>
    // najdeme vsechny hlavní selectboxy co maji podrizeny selectbox
    document.querySelectorAll('select[data-depends]').forEach(function (dependent) {
        // a když uživatel změní vybranou položku…
        let placeholder = encodeURIComponent('#');
	    let main = dependent.form[dependent.dataset.depends]; // hlavní <select>

        // a když uživatel změní vybranou položku…
        main.addEventListener('change', function () {l}
            let url = dependent.dataset.url; // URL pro našeptávání

            // ...udelame pozadavek na Endpoint presenter a posleme 'sid'
            fetch(url.replace(placeholder, encodeURIComponent(main.value)))
                .then(response => response.json())
                // a nahrajeme do podrizeneho nove data
                .then( data => {
                    updateSelectbox(dependent, data);
                });
        {r});
    });

    // vloží nové <options> do <select>
    function updateSelectbox(select, items)
    {
        select.innerHTML = ''; // odstranime vse
        for (var id in items) { // vložime nové
            var el = document.createElement('option');
            el.setAttribute('value', id);
            el.innerText = items[id];
            select.appendChild(el);
        }
    }

</script>

Tři závislé selectbox

  • celkem 3 selectboxy, změnou hodnoty 1. selectboxu dojde k načtení hodnot do 2. selectboxů
  • změnou hodnoty ve 2. selectboxu dojde k načtení hodnot do 3. selectboxů

PRESENTER

public function actionOptionLinky($sid): void{

        if($sid){
            $data = $this->mainModel->getDSlinka($sid);

		        $this->sendJson($data);
        }


	}

    public function actionOptionPracoviste($lin): void{

        if($lin){
            $data = $this->mainModel->getDSpracoviste($lin);

		        $this->sendJson($data);
        }

	}

protected function createComponentFormAddDoc(): UI\Form {
...
$main = $form->addSelect('sektor', 'Sektor/Oddělení:', $sek)
               ->setPrompt('Vyberte')
               ->setHtmlAttribute('class','form-control')
               ->addRule($form::FILLED,'Sektor/Oddělení musí být vybrán');

$linka = $form->addSelect('linka', 'Linka')
                ->setHtmlAttribute('class','form-control')
                ->setPrompt('Nejdříve vyberte sektor/oddělení')
                ->setHtmlAttribute('data-depends',$main->getHtmlName())
                ->setHtmlAttribute('data-url', $this->link('optionLinky','#'));

$form->onAnchor[] = fn () =>
               $linka->setItems($main->getValue() ? $this->mainModel->getDSlinka($main->getValue()) : []);

$pracoviste = $form->addSelect('pracoviste', 'Pracoviště')
                ->setHtmlAttribute('class','form-control')
                ->setPrompt('Nejdříve vyberte sektor/oddělení')
                ->setHtmlAttribute('data-depends',$linka->getHtmlName())
                ->setHtmlAttribute('data-url', $this->link('optionPracoviste','#'));

$form->onAnchor[] = fn () =>
                $pracoviste->setItems($linka->getValue() ? $this->mainModel->getDSpracoviste($linka->getValue()) : []);


}

LATTE

<script>
     // najdeme vsechny hlavní selectboxy co maji podrizeny selectbox
    document.querySelectorAll('select[data-depends]').forEach(function (dependent) {
        // a když uživatel změní vybranou položku…
        let placeholder = encodeURIComponent('#');
	    let main = dependent.form[dependent.dataset.depends]; // hlavní <select>

        // a když uživatel změní vybranou položku…
        main.addEventListener('change', function () {l}
            let url = dependent.dataset.url; // URL pro našeptávání

            // ...udelame pozadavek na Endpoint presenter a posleme 'sid'
            fetch(url.replace(placeholder, encodeURIComponent(main.value)))
                .then(response => response.json())
                // a nahrajeme do podrizeneho nove data
                .then( data => {
                    updateSelectbox(dependent, data);
                });
        {r});
    });

    // vloží nové <options> do <select>
    function updateSelectbox(select, items)
    {
        select.innerHTML = ''; // odstranime vse
        for (var id in items) { // vložime nové
            var el = document.createElement('option');
            el.setAttribute('value', id);
            el.innerText = items[id];
            select.appendChild(el);
        }
    }
</script>

Děkuji všem za pomoc