Live Validace formulářů
- redhead
- Člen | 1313
Kód zde je zastaralý, viz extras/live-form-validation
Zdravím,
zviditelňuji tímto tady můj LiveClientScript, který upravuje chování JS validace formulářů, zobrazováním chyb ‚za běhu‘ namísto po submitování formu. Není to úplně tak zjednodušené jak bych chtěl, ale funguje to zatím dobře.
Je to převážně stejný kód jako InstantClientScript, pouze byla upravena funkčnost JavaScriptu.
S nepozměněným kódem pro zobrazení/schování chyby je bohužel nutné do stránek/šablon includovat .js soubor, který v případě chyby:
- zobrazuje textovou chybu v tagu ‚span‘ s CSS třídou ‚form-error-message‘ vedle chybného controlu
- přidá chybnému controlu CSS třídu ‚form-control-error‘
Při odstranění chyby změny zmizí.
PHP classa
final class LiveClientScript extends Object
{
/** @var string JavaScript code */
public $doAlert = 'addError(element, message)';
/** @var string JavaScript code */
public $doRemove = 'removeError(element)';
/** @var string JavaScript code */
public $doToggle = 'if (element) element.style.display = visible ? "" : "none";';
/** @var string JavaScript event handler name for validating form on submit */
public $validateFunction;
/** @var string JavaScript event handler name for validating controls on fly */
public $validateControlFunction;
/** @var string JavaScript event handler name */
public $toggleFunction;
/** @var string */
public $validateScript;
/** @var string */
public $toggleScript;
/** @var bool */
private $central;
/** @var Form */
private $form;
public function __construct(Form $form)
{
$this->form = $form;
$name = ucfirst($form->getName()); //ucfirst(strtr($form->getUniqueId(), Form::NAME_SEPARATOR, '_'));
$this->validateFunction = 'validate' . $name;
$this->validateControlFunction = 'validate' . $name . 'Control';
$this->toggleFunction = 'toggle' . $name;
}
public function enable()
{
$this->validateScript = '';
$this->toggleScript = '';
$this->central = TRUE;
foreach ($this->form->getControls() as $control) {
$script = $this->getValidateScript($control->getRules());
if ($script) {
$id = $control->htmlId;
$setId = $control->getControlPrototype()->id;
if($setId)
$id = $setId;
$this->validateScript .= "if(sender.id=='".$id."' ";
foreach($control->rules as $rule)
if ($rule->type === Rule::CONDITION) { // this is condition
$this->validateScript .= "|| sender.id=='".$rule->control->htmlId."' ";
}
$this->validateScript .= ") {\n\t\t";
$this->validateScript .= "\n\t\tvar validated_element = document.getElementById('$id')\n\t";
$this->validateScript .= "\n\t\t$script \n\t";
$this->validateScript .= "} \n\t";
}
$this->toggleScript .= $this->getToggleScript($control->getRules());
if ($control instanceof ISubmitterControl && $control->getValidationScope() !== TRUE) {
$this->central = FALSE;
}
}
if ($this->validateScript || $this->toggleScript)
{
foreach ($this->form->getComponents(TRUE, 'Nette\Forms\IFormControl') as $control) {
$control->getControlPrototype()->onchange("$this->validateControlFunction(this);", TRUE);
$control->getControlPrototype()->onblur("$this->validateControlFunction(this);", TRUE);
if($control instanceof TextBase)
$control->getControlPrototype()->onkeyup("$this->validateControlFunction(this);", TRUE);;
}
foreach ($this->form->getComponents(TRUE, 'Nette\Forms\ISubmitterControl') as $control) {
if ($control->getValidationScope()) {
$control->getControlPrototype()->onclick("return $this->validateFunction()", TRUE);
}
}
}
}
/**
* Generates the client side validation script.
* @return string
*/
public function renderClientScript()
{
$s = '';
if ($this->validateScript) {
$s .= "function $this->validateControlFunction(sender) {\n\t"
. "var element, message, res;\n\t"
. "var b = true;\n\t"
//. "switch(sender.id) {"
. $this->validateScript
//. "}\n"
. "return b;\n}\n"
. "function $this->validateFunction() {\n\t var b = true;\n";
$i=0;
foreach ($this->form->getComponents(TRUE, 'Nette\Forms\IFormControl') as $control)
{
$id = $control->htmlId;
$setId = $control->getControlPrototype()->id;
if($setId)
$id = $setId;
$s .= "\t b = $this->validateControlFunction(document.getElementById('$id')) && b; \n";
}
$s .= "\treturn b; \n}\n";
}
if ($this->toggleScript) {
$s .= "function $this->toggleFunction(sender) {\n\t"
. "var element, visible, res;\n\t"
. $this->toggleScript
. "\n}\n\n"
. "$this->toggleFunction(null);\n";
}
if ($s) {
return "<script type=\"text/javascript\">\n"
. "/* <![CDATA[ */\n"
. $s
. "/* ]]> */\n"
. "</script>";
}
}
private function getValidateScript(Rules $rules, $onlyCheck = FALSE)
{
$res = '';
foreach ($rules as $rule) {
if (!is_string($rule->operation)) continue;
if (strcasecmp($rule->operation, 'Nette\Forms\InstantClientScript::javascript') === 0) {
$res .= "$rule->arg\n\t";
continue;
}
$script = $this->getClientScript($rule->control, $rule->operation, $rule->arg);
if (!$script) continue;
if (!empty($rule->message)) { // this is rule
if ($onlyCheck) {
$res .= "$script\n\tif (" . ($rule->isNegative ? '' : '!') . "res) { return false; }\n\t";
} else {
$res .= "$script \n\t\t"
. "if (" . ($rule->isNegative ? '' : '!') . "res) { \n\t\t\t"
. "message = " . json_encode((string) vsprintf($rule->control->translate($rule->message), (array) $rule->arg)) . "; "
. "$this->doAlert ; b = false;"
. "\n\t\t} else {\n"
. "\t\t\t$this->doRemove ;"
. "}";
}
}
if ($rule->type === Rule::CONDITION) { // this is condition
$innerScript = $this->getValidateScript($rule->subRules, $onlyCheck);
if ($innerScript) {
$res .= "$script\n\tif (" . ($rule->isNegative ? '!' : '') . "res) {\n\t\t" . str_replace("\n\t\t", "\n\t\t\t", rtrim($innerScript)) . "\n\t\t} else { $this->doRemove }\n\t";
if (!$onlyCheck && $rule->control instanceof ISubmitterControl) {
$this->central = FALSE;
}
}
}
}
return $res;
}
private function getToggleScript(Rules $rules, $cond = NULL)
{
$s = '';
foreach ($rules->getToggles() as $id => $visible) {
$s .= "visible = true; {$cond}element = document.getElementById('" . $id . "');\n\t"
. ($visible ? '' : 'visible = !visible; ')
. $this->doToggle
. "\n\t";
}
foreach ($rules as $rule) {
if ($rule->type === Rule::CONDITION && is_string($rule->operation)) {
$script = $this->getClientScript($rule->control, $rule->operation, $rule->arg);
if ($script) {
$res = $this->getToggleScript($rule->subRules, $cond . "$script visible = visible && " . ($rule->isNegative ? '!' : '') . "res;\n\t");
if ($res) {
$el = $rule->control->getControlPrototype();
if ($el->getName() === 'select') {
$el->onchange("$this->toggleFunction(this)", TRUE);
} else {
$el->onclick("$this->toggleFunction(this)", TRUE);
//$el->onkeyup("$this->toggleFunction(this)", TRUE);
}
$s .= $res;
}
}
}
}
return $s;
}
private function getValueScript(IFormControl $control)
{
$tmp = "element = document.getElementById(" . json_encode($control->getHtmlId()) . ");\n\t\t";
switch (TRUE) {
case $control instanceof Checkbox:
return $tmp . "var val = element.checked;\n\t\t";
case $control instanceof RadioList:
return "for (var val=null, i=0; i<" . count($control->getItems()) . "; i++) {\n\t\t\t"
. "element = document.getElementById(" . json_encode($control->getHtmlId() . '-') . "+i);\n\t\t\t"
. "if (element.checked) { val = element.value; break; }\n\t\t"
. "}\n\t";
default:
return $tmp . "var val = element.value.replace(/^\\s+|\\s+\$/g, '');\n\t\t";
}
}
private function getClientScript(IFormControl $control, $operation, $arg)
{
$operation = strtolower($operation);
switch (TRUE) {
case $control instanceof HiddenField || $control->isDisabled():
return NULL;
case $operation === ':filled' && $control instanceof RadioList:
return $this->getValueScript($control) . "res = val !== null;";
case $operation === ':submitted' && $control instanceof SubmitButton:
return "element=null; res=sender && sender.name==" . json_encode($control->getHtmlName()) . ";";
case $operation === ':equal' && $control instanceof MultiSelectBox:
$tmp = array();
foreach ((is_array($arg) ? $arg : array($arg)) as $item) {
$tmp[] = "element.options[i].value==" . json_encode((string) $item);
}
$first = $control->isFirstSkipped() ? 1 : 0;
return "element = document.getElementById(" . json_encode($control->getHtmlId()) . ");\n\tres = false;\n\t\t"
. "for (var i=$first;i<element.options.length;i++)\n\t\t\t"
. "if (element.options[i].selected && (" . implode(' || ', $tmp) . ")) { res = true; break; }";
case $operation === ':filled' && $control instanceof SelectBox:
return "element = document.getElementById(" . json_encode($control->getHtmlId()) . ");\n\t\t"
. "res = element.selectedIndex >= " . ($control->isFirstSkipped() ? 1 : 0) . ";";
case $operation === ':filled' && $control instanceof TextInput:
return $this->getValueScript($control) . "res = val!='' && val!=" . json_encode((string) $control->getEmptyValue()) . ";";
case $operation === ':minlength' && $control instanceof TextBase:
return $this->getValueScript($control) . "res = val.length>=" . (int) $arg . ";";
case $operation === ':maxlength' && $control instanceof TextBase:
return $this->getValueScript($control) . "res = val.length<=" . (int) $arg . ";";
case $operation === ':length' && $control instanceof TextBase:
if (!is_array($arg)) {
$arg = array($arg, $arg);
}
return $this->getValueScript($control) . "res = " . ($arg[0] === NULL ? "true" : "val.length>=" . (int) $arg[0]) . " && "
. ($arg[1] === NULL ? "true" : "val.length<=" . (int) $arg[1]) . ";";
case $operation === ':email' && $control instanceof TextBase:
return $this->getValueScript($control) . 'res = /^[^@]+@[^@]+\.[a-z]{2,6}$/i.test(val);';
case $operation === ':url' && $control instanceof TextBase:
return $this->getValueScript($control) . 'res = /^.+\.[a-z]{2,6}(\\/.*)?$/i.test(val);';
case $operation === ':regexp' && $control instanceof TextBase:
if (strncmp($arg, '/', 1)) {
throw new InvalidStateException("Regular expression '$arg' must be JavaScript compatible.");
}
return $this->getValueScript($control) . "res = $arg.test(val);";
case $operation === ':integer' && $control instanceof TextBase:
return $this->getValueScript($control) . "res = /^-?[0-9]+$/.test(val);";
case $operation === ':float' && $control instanceof TextBase:
return $this->getValueScript($control) . "res = /^-?[0-9]*[.,]?[0-9]+$/.test(val);";
case $operation === ':range' && $control instanceof TextBase:
return $this->getValueScript($control) . "res = " . ($arg[0] === NULL ? "true" : "parseFloat(val)>=" . json_encode((float) $arg[0])) . " && "
. ($arg[1] === NULL ? "true" : "parseFloat(val)<=" . json_encode((float) $arg[1])) . ";";
case $operation === ':filled' && $control instanceof FormControl:
return $this->getValueScript($control) . "res = val!='';";
case $operation === ':valid' && $control instanceof FormControl:
return $this->getValueScript($control) . "res = function(){\n\t" . $this->getValidateScript($control->getRules(), TRUE) . "return true; }();";
case $operation === ':equal' && $control instanceof FormControl:
if ($control instanceof Checkbox) $arg = (bool) $arg;
$tmp = array();
foreach ((is_array($arg) ? $arg : array($arg)) as $item) {
if ($item instanceof IFormControl) { // compare with another form control?
$tmp[] = "val==function(){var element;" . $this->getValueScript($item). "return val;}()";
} else {
$tmp[] = "val==" . json_encode($item);
}
}
return $this->getValueScript($control) . "res = (" . implode(' || ', $tmp) . ");";
}
}
public static function javascript()
{
return TRUE;
}
}
JS funkce
js vložte do .js souboru a includujte do šablon:
<script>
function hasClass(ele, cls) {
var classes = ele.className.split(" ");
for (var i=0;i<classes.length;i++)
if (classes[i].indexOf(cls) == 0)
return true;
return false;
}
function addClass(ele,cls) {
if (!this.hasClass(ele,cls)) ele.className += " "+cls;
}
function removeClass(ele,cls) {
if (hasClass(ele,cls)) {
var classes = ele.className.split(" ");
ele.className = '';
i = 0;
for (var i=0;i<classes.length;i++)
if (classes[i].indexOf(cls) != 0)
{
if(i==0) ele.className += classes[i];
else ele.className += ' '+classes[i];
i++;
}
}
}
function addError(sender, message)
{
addClass(sender, 'form-control-error');
var id = sender.id+'_message';
var el = document.getElementById(id);
if(!el)
{
el = document.createElement('span');
el.className = 'form-error-message';
el.innerHTML = message;
el.id = id;
var parent = sender.parentNode;
if(parent.lastchild == sender) {
parent.appendChild(el);
} else {
parent.insertBefore(el, sender.nextSibling);
}
}
else
{
el.style.display = 'inline';
el.innerHTML = message;
}
}
function removeError(sender)
{
removeClass(sender, 'form-control-error');
var el = document.getElementById(sender.id+'_message');
if(el)
el.style.display = 'none';
}
</script>
CSS třídy se upravte podle svého, třeba s nějakou hezkou error ikonkou a červeným písmem ;)
Použití
Nejdůležitější částí je zprovoznění:
//vytvoříme form:
$this->form = new AppForm($this, 'test');
// ... přidáme controls a validační pravidla ...
//a vytvoříme nový Client Script a v Rendereru ho nasetujeme (tím se přepíše původní Instant)
$this->form->getRenderer()->setClientScript(new LiveClientScript($this->form));
Pozn.: na pořadí tvorby controls a pravidel a client scriptu nezáleží.
Můžete si samozřejmě upravit chování zobrazení/schování chyby podle svého přepsáním doAlert a doRemove proměnných s vaší funkčností (případně použitím nějaké JS libky jako jQuery nebo Prototype), například:
//po nasetování LiveClientScriptu
$script = $this->form->getRenderer()->getClientScript();
$script->doAlert = '$(element).addClassName("control_error").highlight();';
$script->doRemove = '$(element).removeClassName("control_error");';
//kde můžete použít js proměnné 'element' jako DOM objekt controlu, který chybu vyvolal
//a string 'message' - validační chybová hláška
Editoval redhead (2. 9. 2009 18:34)
- romansklenar
- Člen | 655
Vypadá to pěkně, dobrá práce! Byla by škoda aby to tu zapadlo ve fóru, nechceš to přidat do extras?
- Honza Marek
- Člen | 1664
Zkoušim něco podobného, ale úplně jinak. Vytvořit clientScript, který
by fungoval s pluginem jQuery Validation. Zatím mám takový koncept, kde snad
funguje $formControl->addRule(Form::FILLED)
.
class jQueryValidationClientScript {
/** @var Form */
protected $form;
public function __construct(Form & $form) {
// kokotské (lepší by to bylo jako parametr renderClientScript)
$this->form = $form;
}
public function enable() {
// kokotské, ale ConventionalRenderer to vyžaduje
}
public function renderClientScript() {
$rules = array();
foreach ($this->form->getControls() as $control) {
foreach ($control->getRules() as $rule) {
if ($rule->operation === ":filled") {
$rules[$control->getName()]["required"] = true;
}
}
}
if (!$this->form->getElementPrototype()->id) {
$this->form->getElementPrototype()->id = $this->form->getName();
}
return "<script type=\"text/javascript\">\n"
. "/* <![CDATA[ */\n"
. 'jQuery("#' . $this->form->getElementPrototype()->id . '").validate('
. json_encode($rules)
. ');'
. "/* ]]> */\n"
. "</script>";
}
}
Uvidím ještě kolik tam bude zádrhelů, třeba to časem vzdám, co já vim… .)
- redhead
- Člen | 1313
reflex: pravda, opraveno (to je tak když člověk kopíruje a změní to tady místo aby to ještě vyzkoušel na localu :D )
btw: ta odebíraná třída měla být ‚form-control-error‘, neboť se odebírá od toho controlu a ne od té error zprávy.
Do extras to nejspíš dám.
Editoval redhead (14. 7. 2009 9:15)
- Honza Marek
- Člen | 1664
Vyrobit třídu BaseForm (poděděný AppForm) a nastavit to v konstruktoru ;)
// EDIT: Hm… tak to asi fungovat nebude. Leda by redhead hodil obsah metody enable do metody renderClientScript, pak by to mohlo jít.
Editoval Honza M. (18. 7. 2009 18:29)
- redhead
- Člen | 1313
Vyrobit třídu BaseForm (poděděný AppForm) a nastavit to v konstruktoru ;)
Vyzkoušel jsem to a jde to, nevím kde by to mohlo haprovat, Nette neznám tolik do hloubky, takže jestli je tam nějaká ultra mega über vychytávka na to tohle, tak možná. Lepší by bylo zeptat se na toto Davida.
Tento kód pro mě u dema fungoval.
class BaseForm extends AppForm
{
public function __construct(IComponentContainer $parent = null, $name = null)
{
parent::__construct($parent, $name);
$this->form->getRenderer()->setClientScript(new LiveClientScript($this));
//pripadne upraveni JS funkci pres doAlert a doRemove...
}
}
posloucháš radiohead?
Radiohead ani ne, i když mi to last.fm furt cpe pod nos, jsem mimo jiné spíš metalhead :D
- redhead
- Člen | 1313
malý bug fix:
pro získání DOM objektu controlu, který chybu vyvolal, používejte proměnnou ‚validated_element‘ místo ‚element‘
$script->doAlert = 'mojeZobrazChybuFunkce(validated_element, message)';
$script->doRemove = 'mojeSkryjChybuFunkce(validated_element)';
EDIT: ehm, má někdo tušení, jak nahradit archiv v extras archivem novým (s novou verzí)?? Poslání stejnojmenného souboru nefunguje.
Editoval redhead (23. 7. 2009 23:37)
- marek.dusek
- Člen | 99
trosku jsem kod upravil tak, aby:
- korektne validoval i vice podminek na jednom elementu zaroven (puvodne pokud A bylo spatne a B dobre, B smazalo zpravy o A – jestli mi rozumite ;)
- na required hodnoty zacal upozornovat az po pokusu odeslat formular (po vzoru jquery.validate – osobne me rozciluje, kdyz jen kliknu do policka a uz na me vyskoci hlaska)
Snippety:
final class LiveClientScript extends Object
{
...
/** @var string JavaScript variable name indicating whether was the current form submitted */
public $validateCalled;
...
public function __construct(Form $form)
{
...
$this->validateCalled = 'validate' . $name . 'Called';
}
...
public function renderClientScript()
{
$s = '';
if ($this->validateScript) {
$s .= "function $this->validateControlFunction(sender) {\n\t"
. "var element, message, res;\n\t"
. "var b = true;\n\t"
//. "switch(sender.id) {"
. $this->validateScript
//. "}\n"
. "return b;\n}\n"
. "var $this->validateCalled = false;\n"
. "function $this->validateFunction() {\n\t var b = true;\n";
$s .= "\t $this->validateCalled = true;\n";
...
}
...
private function getValidateScript(Rules $rules, $onlyCheck = FALSE)
{
...
} else {
$res .= "$script \n\t\t"
. "if (" . ($rule->isNegative ? '' : '!') . "res) { \n\t\t\t"
. "message = " . json_encode((string) vsprintf($rule->control->translate($rule->message), (array) $rule->arg)) . "; "
. "$this->doAlert ; \n\t\t\t"
. "bb = !($this->validateCalled || "
.intval(strtolower($rule->operation) != ':filled').");"
. "\n\t\t}";
....
if ($res) {
$res = <<<EOT
var bb = true;
$res
if (bb) {
$this->doRemove;
} else {
b = false;
}
EOT;
}
return $res;
}
- redhead
- Člen | 1313
Pravda, pravdoucí. Díky Marku! Přidám to tam (až zjistím, jak nahrát nový archiv)
Jinak to s těma required controlama je myslím subjektivní, navíc mě se
po kliknutí control nevaliduje, možná tak po stisku TAB, což vím že je
nežádoucí, to by možná šlo ošetřit :)
Ale jinak s tím nemám problém..
A nebo udělat kompromis a udělat na to nějaký option, čili by se rozhodl
až programátor zda validovat nebo nevalidovat až po submitu.
Nebo mě teď můžete všichni virtuálně ukamenovat a já pak uznám svou
chybu :D
Jinak ví někdo jak nahrát novej archiv do extras???
Díky
- Honza Marek
- Člen | 1664
Teoreticky by mohlo stačit nahrát soubor se stejným jménem. Ale kdysi tam byla nějaká chyba, tak to třeba nebude fungovat.
- o5
- Člen | 416
redhead na wiki v extras napsal:
Validuje se totiž control po controlu ‚za běhu‘ při vyplňování formuláře, nikoli až při odeslání formuláře
Co je u tebe ‚za běhu‘?
hodim si tam demo a vyplnuju:
- Username zadam samotne a
- Pokud stisknu TAB tak na me hned vyskoci Počet znaků musí být větší než 5 – ovsem nic jsem zatim nezadal, podle meho by se mela objevit informace, ze jsem zadal spatne prvni policko (to se objevi az po stisknuti Login)
- redhead
- Člen | 1313
no za běhu, během plnění formuláře a ne až při jeho odeslání.. opravdu nevím jak bych Live Validation řekl česky aby to vypovědělo co to dělá, jesli máš nějaký výraz proto, tak sem s ním. Prostě to na tebe neřve JS alertový okýnka – pro každou jednotlivou chybu, takže opravím jednu, klik zase chyba, opravím, klik, zase chyba, aaaa … Ale chyby se objeví najednou podle nějaké (upravitelné funkce)
to s tím TAB je hodně špatné.. taky mě to štve. Jde o to, že ta validace se provádí na (mimo jiné) i onKeyUp event, do nějž TAB samozřejmě patří. Možná by šlo nějak získat stisknutý tlačítko, ale řekl bych že to nebude cross-browser všude stejné. Ale podívám se na to, jesli by to šlo udělat.
A to s tím zobrazením až po kliknutí na Login: přečti si příspěvek od Marka Duška o pár postů výš (konkrétně bod 2)
- romansklenar
- Člen | 655
Napadá někoho jak na ajaxovém formuláři zajistit odeslání až po úspěšné validaci pomocí této komponenty?
- redhead
- Člen | 1313
no řešení jsem našel, ale trochu prasácké. Při volání jQuery eventu click nad submitem se (ještě jednou, tedy celkem 2× při kliknutí) provede validace a pokud vrátí true volá se ajaxSubmit:
<script>
$("form :submit").click(function () {
var fce = eval($(this).attr('onclick'));
if(fce())
$(this).ajaxSubmit();
return false;
});
</script>
- romansklenar
- Člen | 655
Já zkusil použít myšlenku Honzy Kuchaře, která tu někde na fóru zazněla: „nejdřív provést validaci, poté všechny eventy kromě odeslání a nakonec odeslat“, musí se ale sáhnout do pluginu na ajaxové odesílání formulářů od Honzy Marka, ještě to ale není ideální.
- 22
- Člen | 1478
dotaz k live validaci..funguje to pouze nad appForm nebo i nad samostatnym
Form?
Protože následující kod se nejak nema k činnosti :-(
<?php
$form1 = new Form();
$form1 ->addGroup();
$form1 ->addText('mail','e-mail:')
->setEmptyValue('@')
->addRule(Form::FILLED,'E-mailova adresa musí být vyplněna.')
->addRule(Form::EMAIL,'Neplatná e-mailova adresa.');
$form1 ->addText('subject','Předmět:')
->addRule(Form::FILLED,'Zadejte důvod Vaší zprávy.',50);
$form1 ->addTextArea('text','Text:')
->addRule(Form::FILLED,'Zadejte text Vaší zprávy. ')
->addRule(Form::MAX_LENGTH,'Zpráva je příliš dlouhá',1024);
$form1 ->addGroup();
$form1 ->addSubmit('send','Odeslat');
$form1 ->getRenderer()->setClientScript(new LiveClientScript($form1));
$form1 ->render();
?>