Komponenta s jednoduchým použitím i pro začátečníky, která umí závislé selectboxy, našeptávač i live validátor

Upozornění: Tohle vlákno je hodně staré a informace nemusí být platné pro současné Nette.
Martin
Člen | 171
+
0
-

Ahoj. Pokusil jsem se stvořit komponentu, která zvládne impementovat do chování formulářů některé běžně používané Ajaxové záležitosti. Zatím zdaleka není hotová, ale pár věcí už umí. Jelikož sám se s Nette (a vlastně i s Ajaxem, jquery, složitějším javascriptem a objektovým php) teprve seznamuji, vím, jak je pro začátečníka těžké se v Nette a jeho používání orientovat. Proto jsem se rozhodl komponentu implementovat tak, aby splňovala následující:

  1. Půjde o jeden soubor, který se nahraje kamkoli v adresářové struktuře Nette a robotLoader se o vše postará. Tedy ne nějaké složité navazování podle návodů, měnících se verzi od verze.
  2. Uživatel komponenty bude moci použít formuláře stejně, jako bez této komponenty, jen v nich bude mít přidanou funkčnost.
  3. Na rozdíl od snippetů bude možné vylepšit chování kteréhokoli prvku formuláře zcela samostatně, nebude tedy nutné obalovat celý formulář. Respektive většinou půjde o dvojici prvků – hodnota jednoho ovlivní vzhled druhého (ale lze i sama sebe nebo svůj label).
  4. Použití komponenty bude záležitostí výlučně php, uživatel nebude muset napsat ani řádek v javascriptu. Přitom php kód pro použití této komponenty musí být stručný, přehledný a intuitivní.
  5. Po odeslání musí funkce getValues() vrátit vše zvalidované podle právě aktuálního stavu formuláře (tedy třeba s úplně jinými prvky, než byly přítomny v továrničce).
  6. Vše musí jít vykreslovat všemi známými způsoby včetně conventionalRendereru, aniž by bylo nutné do nich cokoli přidávat.

Na obrázku vidíte ukázku, implementující závislé selectboxy (horní 2 řádky formuláře), závislý input box (dle výběru v druhém selectboxu se mění na readonly text), primitivní live validaci (label 4. řádku mění svoji výzvu podle vyplněného textu) a velmi jednoduchý našeptávač (pátý řádek doplňuje jména podle prvního písmena):

Ukázka

Samozřejmě si můžete vyzkoušet tuto ukázku naživo .

Zdaleka to není hotové. Vše sice již splňuje výše stanovené požadavky, ale zbývá dořešit pár věcí:

  1. Je to pomalé (souvisí s dalším bodem)
  2. Zatím se přenáší vždy html, dodělávám kód pro přenášení pouze dat tam, kde to stačí.
  3. Zatím to umí pracovat jen s kontroly formuláře na nejvyšší úrovni. Pro zanořené budu muset doplnit dostatečně stručnou identifikaci těchto prvků. (viz poznámka ve starší diskusi: Hosiplan )
  4. „Ovládání“ komponenty je už velmi intuitivní, ale chci to ještě stručnější. Našeptávač maximálně na řádek, závislé selectboxy maximálně na dva atd.
  5. Není to tak úplně lazy. Ale vlastně si tím nejsem jist.

Kód použití komponenty z ukázky :

class HomepagePresenter extends BasePresenter
{
    	function createComponentMyForm($name)
	{
		$form = new AjaxForm($this, $name);//Stejná jako AppForm, jen navíc metody addAjax...
                $form->addSelect('pristup', 'Vyber:', array(1 => 'Nechci změnit', 2 => 'Změnit'));
                $form->addSelect('hodnota', 'Změň:', array(1 => 'Input', 2 => 'String'));
		$form->addText('text', 'Text:', 70);
		$form->addText('slova', 'Napiš dvě slova:', 70);
		$form->addText('septej', 'Napiš písmenko:', 70);

                $form['pristup']->addAjaxOnChange($this, 'ajaxSelfChangeSelect', $form['pristup'])
                        ->addAjaxOnChange($this, 'ajaxChangeSecondSelect', $form['hodnota']);
                                //'pristup' ovlivní sám sebe i 'hodnotu'
                $form['hodnota']->addAjaxOnChange($this, 'ajaxChangeText', $form['text']);
                                //'hodnota' ovlivní 'text'
                $form['slova']->addAjaxOnKeyUp($this, 'ajaxChangeSlova', $form['slova'], AjaxForm::LABEL, AjaxForm::VALUE);
                                //'slova' ovlivní svůj label, netřeba předávat celé html
                $form['jmeno']->addAjaxOnKeyUp($this, 'ajaxDoplnSlovo', $form['jmeno'], AjaxForm::CONTROL, AjaxForm::VALUE);
                                //'septej' ovlivní sám sebe, netřeba předávat celé html
                $form->addSubmit('send', 'Odeslat');
		$form->onSubmit[] = callback($this, 'valuesSubmitted');

                return $form;
	}


        public function ajaxSelfChangeSelect($form, $itemFrom, $value, $itemTo)
        {
            if ($value == 1)
            {
                  $itemTo->setItems(array(1 => 'Tento seznam seřadit vzestupně', 2 => 'Tento seznam seřadit sestupně'));
                  $itemTo->setDefaultValue(1);
            }
            else //Změním pořadí, mohu požít i cokoli z původního formuláře, obvykle tady samozřejmě spíše něco načtu z databáze:
            {
                  $itemTo->setItems(array(2 => 'Tento seznam seřadit sestupně', 1 => 'Tento seznam seřadit vzestupně'));
                  $itemTo->setDefaultValue(2);
            }
            return $itemTo->getControl();
        }
//atd.

___________________________________

Dejte vědět, co si o tom myslíte. Případně (pokud to někdo ví) zda nevyrábím něco, co vlastně ve formulářích už je, nebo brzy bude. Mám někam nahrát zdrojový kód komponenty? On by se sem vešel (cca 100 řádků), ale už by se to tu dost znepřehlednilo. A můžete mi někdo napsat, jak se vlastně připojuje na Nette chat? Kdykoli to zkusím, vidím velké bílé okénko pro svoji zprávu, ale žádné zprávy od ostatních.

Editoval Martin (20. 4. 2011 9:53)

Martin
Člen | 171
+
0
-

Napadla mi drobnost – možná to ani není díra, ale možná jsou to dveře od stodoly. Dost dobře není možné si pamatovat celou historii od vytvoření formuláře po jeho odeslání. Session se k tomu moc nehodí. Pro uložení posledně použitých hodnot, podle kterých se nastavuje formulář před závěrečnou validací, využívám skrytá pole formuláře a do komponenty posílám inline javascriptem p5es jquery. Název callbacku není problém zkontrolovat – v továrničce se ukládá do pole. Identifikátory (odesilatel a adresát live požadavku) také snad zneužitelné nejsou (i když znalý útočník by možná vhodný nezabezpečený název odhadnout mohl). Ale čeho se bojím: uloženou poslední hodnotu nelze zvalidovat, pokud je v callbacku například postupně procházen rozsáhlý strom v databázi a nemám k dispozici kompletní historii. Samozřejmě v callbacku se musí z databáze pouze číst, ne do ní zapisovat. Zapisuje se až po závěrečné validaci v OnSubmit() atp. Jde mi ale o to, jestli:
1. takto provedená validace nemůže být ovlivněna podstrčením hodnot k předchozímu čtení,
2. samotné čtení z databáze pomocí nezvalidované hodnoty třeba self::$database->table($this->tablepage)->where(potenciálně nebezpečná hodnota) nepředstavuje nějaké riziko. Pomůže tuto hodnotu před vstupem do callbacku escapovat? Stačí volat callback s parametrem addslashes((string) $value)? A co opačný směr – z komponenty do klientského prohlížeče? Pokus o použití htmlspecialchars nebyl asi to pravé.

Předpokládám ale, že to je obecný problém live komponent.

Editoval Martin (9. 4. 2011 22:35)

bojovyletoun
Člen | 667
+
0
-

jen pro informaci.. řešil jsem live odeslání formulářů při změně. hodí se na to event onInput, ale to je docela novinka, myslím že ie to zas neumí. Tak to řeším zatím přes keyup/change. Stačilo by change, jenže k eventu dojde až po kliknutí pryč. Přidávám tam keyup, jenže to zachytí i pohyb šipkami.
Máte nějaké řešení, které funguje ideálně (jako onInput)?

//	$(this).find('select, input[type!=text]').bind('change input',handle);
//	$(this).find('textarea, input[type=text]').bind('input keyup',handle);
	$(this).find('textarea, input, select').bind('change keyup',handle);
Filip Procházka
Moderator | 4668
+
0
-

Já hledal nějaké optimální řešení a dospěl jsem k tomuto monstru: https://gist.github.com/849040, ale funguje… :D

bojovyletoun
Člen | 667
+
0
-

mě taky napadlo si ukládat hodnotu prvku do nějakého registru, konkrétní řešení jsem nehledal. Ale je to škoda, že pro tak jednoduchou věc neexistuje jednoduché řešení (nevím jak je na tom s podporou onInput)

Milo
Nette Core | 1283
+
0
-

Používám něco v tomto smyslu:

<script>
$(document).ready(function(){
	$inputs = $('.xxx');	// Selector

	$inputs.keydown(function(){
		if( !$(this).data('locked') )
		{
			$(this).data('locked', true);
			$(this).data('oldVal', $(this).val());
		}
	});

	$inputs.keyup(function(){
		if( $(this).data('locked') && $(this).data('oldVal') != $(this).val() )
		{
			// Zmena
		}
		$(this).data('locked', false);
	});
});
</script>
Milo
Nette Core | 1283
+
0
-

Jo a co jsem původně chtěl… :)

Martine, pastnul bys někam zdroják? Rád bych vyzkoušel… třeba na Pastebin.

Martin
Člen | 171
+
0
-

Já se za ten zdroják trochu stydím. Zbastlil jsem to, aby mi fungoval v aplikaci, kterou už strašně dlouho dávám do kupy. Takže nemám čas to teď vyčistit, je na tom určitě vidět, že se s Nette teprve seznamuji. Ale je to tak krátké, že to mohu dát i sem s tím, že je to zcela prozatímní, například tam ještě není ani to zanoření komponent. Předpokládám, že to bude jen drobná změna ve stylu [presenter-]komponenta-subkomponenta-…-kontrol.

<?php

/**
 * My Application
 *
 * @copyright  Copyright (c) 2010 John Doe
 * @package    MyApplication
 */

namespace Nette\Application;

use Nette;

class AjaxForm extends Nette\Application\AppForm implements ISignalReceiver
{
	const CONTROL = FALSE,
		LABEL = TRUE,
                HTML = 0,
                VALUE = 1,
		VALUES = 2;


        protected $AjaxCallback = array();

        public function addAjaxCallback($prescallback)
        {
            $this->AjaxCallback[$prescallback] = $prescallback;
        }

        public function getAjaxCallback($prescallback)
        {
            if (array_key_exists($prescallback, $this->AjaxCallback))
            {
                return $this->AjaxCallback[$prescallback];
            }
            return NULL;
        }

        public static function addSubmitOnChange(Nette\Forms\FormControl $control)
        {
            $form = $control->getForm();
            $formName = $form->getName();
            $jsCode = '$("#frm-'.$formName.'").submit();';
            $control->getControlPrototype()->onChange($jsCode);
            return $control;
        }

        public static function addAjaxCallbackToFormControl(Nette\Forms\FormControl $control, $callback, $presenter, $prescallback, /*$pres, $handle,*/ $appControlTo = NULL, $appControlLabel = AjaxForm::CONTROL, $appControlType = AjaxForm::HTML)
        {
            $form = $control->getForm();
            $formName = $form->getName();
            $form->addAjaxCallback($prescallback);
            $pres = $presenter->getName();
            $handle = 'ChangeAjaxFormControl';
            $old_on_callback = $control->getControlPrototype()->$callback;
            if (is_null($appControlTo))
            {
                $appControlTo = $control;
                $applyToId = 'this.id';
                $applyToName = $control->name;//'this.name';
            }
            else
            {
                 $applyToName = $appControlTo->getHtmlName();
                $applyToId = $appControlTo->getHtmlId();
            }
            $applyToCallback = $applyToName.'Callback';
            if (!$form->offsetExists($applyToCallback))
            {
                $form->addHidden($applyToCallback, "NULL");
            }
            $applyToCallbackHtmlId = $form[$applyToCallback]->getHtmlId();

            $applyToIdent = $applyToName.'Ident';
            if (!$form->offsetExists($applyToIdent))
            {
                $form->addHidden($applyToIdent, 'NULL');
            }
            $applyToIdentId = $form[$applyToIdent]->getHtmlId();

            $applyToValue = $applyToName.'Value';
            if (!$form->offsetExists($applyToValue))
            {
                $form->addHidden($applyToValue, 'NULL');
            }
            $applyToValueHtmlId = $form[$applyToValue]->getHtmlId();

            $applyToApplyTo = $applyToName.'ApplyTo';
            if (!$form->offsetExists($applyToApplyTo))
            {
                $form->addHidden($applyToApplyTo, 'NULL');
            }
            $applyToApplyToHtmlId = $form[$applyToApplyTo]->getHtmlId();

            $applyToApplyToId = $applyToName.'ApplyToId';
            if (!$form->offsetExists($applyToApplyToId))
            {
                $form->addHidden($applyToApplyToId, 'NULL');
            }
            $applyToApplyToIdHtmlId = $form[$applyToApplyToId]->getHtmlId();

            if ($appControlLabel)
            {
                  $applyToDesc = '("label[for=\''.$applyToId.'\']")';
            }
            else
            {
                $applyToDesc = '("#"+"'.$applyToId.'")';
            }

            if ($appControlType == AjaxForm::HTML)
            {
                $color = '$'.$applyToDesc.'.css("background-color", "yellow");';
            }
            else
            {
                $color = '';
            }


            $jsCallback = $color.'$.get("?do="+"'.$formName.'-'.$handle.'"+"&presenter="+"'./*'AjaxForm'*/$pres.
                '", {"callback": "'.$prescallback.'","ident": this.name, "value": this.value, "applyto": "'.$applyToName.'", "applytoid": "'.$applyToId.
                '"}, function(data) {$'.$applyToDesc.'.replaceWith(data);}, "html");
                $("#'.$applyToCallbackHtmlId.'").val("'.$prescallback.'");
                $("#'.$applyToIdentId.'").val(this.name);
                $("#'.$applyToValueHtmlId.'").val(this.value);
                $("#'.$applyToApplyToHtmlId.'").val("'.$applyToName.'");
                $("#'.$applyToApplyToIdHtmlId.'").val("'.$applyToId.'");';
            $control->getControlPrototype()->$callback($old_on_callback.$jsCallback);
            return $control;
        }

        public static function addAjaxOnChangeToFormControl(Nette\Forms\FormControl $control, $presenter, $prescallback, /*$pres, $handle,*/ $appControlTo = NULL, $appControlLabel = FALSE)
        {
            return self::addAjaxCallbackToFormControl($control, 'onchange', $presenter, $prescallback, /*$pres, $handle,*/ $appControlTo, $appControlLabel);
        }

        public static function addAjaxOnClickToFormControl(Nette\Forms\FormControl $control, $presenter, $prescallback, /*$pres, $handle,*/ $appControlTo = NULL, $appControlLabel = FALSE)
        {
            return self::addAjaxCallbackToFormControl($control, 'onclick', $presenter, $prescallback, /*$pres, $handle,*/ $appControlTo, $appControlLabel);
        }

        public static function addAjaxOnKeyUpToFormControl(Nette\Forms\FormControl $control, $presenter, $prescallback, /*$pres, $handle,*/ $appControlTo = NULL, $appControlLabel = FALSE)
        {
            return self::addAjaxCallbackToFormControl($control, 'onkeyup', $presenter, $prescallback, /*$pres, $handle,*/ $appControlTo, $appControlLabel);
        }

        public function handleChangeAjaxFormControl($callback, $ident, $value, $applyto, $applytoid)
        {
            $presenter = $this->getPresenter();
            $callablecb = $this->getAjaxCallback($callback);
            if (!is_null($callablecb))
            {
                $itemFrom = $this[$ident];
                $itemTo = $this[$applyto];
                $evalue = addslashes((string) $value);
                $res = $presenter->$callback($this, $itemFrom , $evalue, $itemTo);
                echo $res; //htmlspecialchars((string) $res, ENT_NOQUOTES);
            }
            else
            {
                //Zalogovat chybu, pravděpodobně CSRF útok.
            }
            // konec zpracování
            $presenter->terminate();
        }

	public function __construct(/*Nette\Application\PresenterComponent $presenter, */Nette\IComponentContainer $parent = NULL, $name = NULL)
	{
		parent::__construct($parent, $name);
                Nette\Forms\FormControl::extensionMethod('addAjaxCallback', 'Nette\Application\AjaxForm::addAjaxCallbackToFormControl');
                Nette\Forms\FormControl::extensionMethod('addAjaxOnChange', 'Nette\Application\AjaxForm::addAjaxOnChangeToFormControl');
                Nette\Forms\FormControl::extensionMethod('addAjaxOnClick', 'Nette\Application\AjaxForm::addAjaxOnClickToFormControl');
                Nette\Forms\FormControl::extensionMethod('addAjaxOnKeyUp', 'Nette\Application\AjaxForm::addAjaxOnKeyUpToFormControl');
                Nette\Forms\FormControl::extensionMethod('addSubmitOnChange', 'Nette\Application\AjaxForm::addSubmitOnChange');
	}

Editoval Martin (20. 4. 2011 9:54)

Martin
Člen | 171
+
0
-

Dokončení:

        public function getValues() {
            //Zde z values vyberuj postupně pro všechny kontroly handler a pokud je v poli, tak zavolám callback s parametry.
            $new_values = parent::getValues();
            $iterator = $this->getControls();
            foreach ($iterator as $name => $control) {
                $handler = $name.'Callback';
                if (array_key_exists($handler, $new_values) && $new_values[$handler] != 'NULL')
                {
                    $method = $new_values[$handler];
                    //Tady musi byt presenter, ne this:
                    $pres = $this->getPresenter();
                    $itemFrom = $this[$new_values[$name.'Ident']];
                    $itemTo = $this[$new_values[$name.'ApplyTo']];
                    $pres->$method($this, $itemFrom, $new_values[$name.'Value'], $itemTo);
                }
            }
            //Odstraním hodnoty skrytých polí
            $new_values = parent::getValues();
            $iterator = $this->getControls();
            foreach ($iterator as $name => $control) {
                $handler = $name.'Callback';
                if (array_key_exists($handler, $new_values))
                {
                    unset($new_values[$handler]);
                    unset($new_values[$name.'Ident']);
                    unset($new_values[$name.'Value']);
                    unset($new_values[$name.'ApplyTo']);
                    unset($new_values[$name.'ApplyToId']);
                }
            }

//            Zde už je výsledek ve $new_values použitelný
            return $new_values;

        }

	/**
	 * Calls signal handler method.
	 * @param  string
	 * @return void
	 * @throws BadSignalException if there is not handler method
	 */
	public function signalReceived($signal)
	{
		if ($signal === 'submit') {
                    return parent::signalReceived($signal);
		} else {
                        $method = $signal == NULL ? NULL : 'handle' . $signal;
                        $rc = $this->getReflection();
                        if ($rc->hasMethod($method)) {
                            $rm = $rc->getMethod($method);
                            if ($rm->isPublic() && !$rm->isAbstract() && !$rm->isStatic()) {
                                    $presenter = $this->getPresenter();
                                    $params = $presenter->getParam();
                                    $rm->invokeNamedArgs($this, $params);
                                    return TRUE;
                            }
                        }
                }

                throw new Nette\Application\BadSignalException("There is no handler for signal '$signal' in class {$this->reflection->name}.");
       }

}

Nakonec v té pracně dokončované aplikaci jsem to použil na více místech, než jsem původně předpokládal, přecijen se ta základní myšlenka hodí. Kdyby se to vyčistilo a dodělalo, třeba se to někomu bude hodit. Řešení problému se správnou událostí, rozebíraného výše, samozřejmě rád zakomponuji. Také budu muset kromě jiného zjistit, jak se správně doplňuje text třeba při našeptávání, teď je to jen ukázka hrubou silou.

Editoval Martin (13. 4. 2011 12:54)

kralik
Člen | 230
+
0
-

Ahoj,

mooc se mi líbí tato komponentka.

Chtěl bych se zeptat zda se na této komponentě dále pracuje a dále se vyvíjí?

Mooc díky

Martin
Člen | 171
+
0
-

Moc ne, jsem tu poprvé po půl roce. Kromě toho, ona byla funkční, ale naprosto špatně napsaná. Spíš to měla být inspirace pro někoho, kdo to napíše správně. M.