Форми в презентерите

Nette Forms значително улесняват създаването и обработката на уеб форми. В тази глава ще се запознаете с използването на форми в презентерите.

Ако се интересувате как да ги използвате напълно самостоятелно без останалата част от framework-а, ръководството за самостоятелна употреба е за вас.

Първа форма

Нека опитаме да напишем проста форма за регистрация. Кодът ѝ ще бъде следният:

use Nette\Application\UI\Form;

$form = new Form;
$form->addText('name', 'Име:');
$form->addPassword('password', 'Парола:');
$form->addSubmit('send', 'Регистрирай се');
$form->onSuccess[] = [$this, 'formSucceeded'];

и ще се покаже в браузъра по следния начин:

Формата в презентера е обект от класа Nette\Application\UI\Form, неговият предшественик Nette\Forms\Form е предназначен за самостоятелна употреба. Добавихме към нея така наречените елементи име, парола и бутон за изпращане. И накрая, редът с $form->onSuccess казва, че след изпращане и успешна валидация трябва да се извика методът $this->formSucceeded().

От гледна точка на презентера, формата е обикновен компонент. Затова се третира като компонент и се включва в презентера чрез фабричен метод. Ще изглежда така:

use Nette;
use Nette\Application\UI\Form;

class HomePresenter extends Nette\Application\UI\Presenter
{
	protected function createComponentRegistrationForm(): Form
	{
		$form = new Form;
		$form->addText('name', 'Име:');
		$form->addPassword('password', 'Парола:');
		$form->addSubmit('send', 'Регистрирай се');
		$form->onSuccess[] = [$this, 'formSucceeded'];
		return $form;
	}

	public function formSucceeded(Form $form, $data): void
	{
		// тук обработваме данните, изпратени от формата
		// $data->name съдържа името
		// $data->password съдържа паролата
		$this->flashMessage('Бяхте успешно регистриран.');
		$this->redirect('Home:');
	}
}

И в шаблона рендираме формата с тага {control}:

<h1>Регистрация</h1>

{control registrationForm}

И това всъщност е всичко :-) Имаме функционална и перфектно защитена форма.

И сега вероятно си мислите, че това беше твърде прибързано, чудите се как е възможно да се извика методът formSucceeded() и какви са параметрите, които получава. Разбира се, прави сте, това заслужава обяснение.

Nette всъщност идва със свеж механизъм, който наричаме Холивудски стил. Вместо вие като разработчик постоянно да питате дали нещо се е случило („формата изпратена ли е?“, „изпратена ли е валидно?“, „фалшифицирана ли е?“), казвате на framework-а „когато формата е валидно попълнена, извикай този метод“ и оставяте останалата работа на него. Ако програмирате на JavaScript, този стил на програмиране ви е добре познат. Пишете функции, които се извикват, когато настъпи определено събитие. И езикът им предава съответните аргументи.

Точно така е изграден и горният код на презентера. Масивът $form->onSuccess представлява списък от PHP callback-ове, които Nette извиква в момента, когато формата е изпратена и правилно попълнена (т.е. е валидна). В рамките на жизнения цикъл на презентера това е така нареченият сигнал, така че те се извикват след метода action* и преди метода render*. И на всеки callback предава като първи параметър самата форма, а като втори – изпратените данни под формата на обект ArrayHash. Можете да пропуснете първия параметър, ако не се нуждаете от обекта на формата. А вторият параметър може да бъде по-хитър, но за това по-късно.

Обектът $data съдържа ключовете name и password с данните, които потребителят е попълнил. Обикновено данните се изпращат директно за по-нататъшна обработка, което може да бъде например вмъкване в база данни. По време на обработката обаче може да възникне грешка, например потребителското име вече е заето. В такъв случай предаваме грешката обратно към формата чрез addError() и я оставяме да се рендира отново, заедно със съобщението за грешка.

$form->addError('Извиняваме се, потребителското име вече се използва.');

Освен onSuccess съществува и onSubmit: callback-овете се извикват винаги след изпращане на формата, дори ако тя не е попълнена правилно. И също onError: callback-овете се извикват само ако изпращането не е валидно. Те се извикват дори ако във onSuccess или onSubmit направим формата невалидна чрез addError().

След обработка на формата пренасочваме към следващата страница. Това предотвратява нежелано повторно изпращане на формата чрез бутона обнови, назад или чрез движение в историята на браузъра.

Опитайте да добавите и други елементи на формата.

Достъп до елементите

Формата е компонент на презентера, в нашия случай наречена registrationForm (според името на фабричния метод createComponentRegistrationForm), така че навсякъде в презентера можете да получите достъп до формата чрез:

$form = $this->getComponent('registrationForm');
// алтернативен синтаксис: $form = $this['registrationForm'];

Отделните елементи на формата също са компоненти, така че можете да получите достъп до тях по същия начин:

$input = $form->getComponent('name'); // или $input = $form['name'];
$button = $form->getComponent('send'); // или $button = $form['send'];

Елементите се премахват с помощта на unset:

unset($form['name']);

Правила за валидация

Споменахме думата валидна, но формата все още няма правила за валидация. Нека поправим това.

Името ще бъде задължително, затова го маркираме с метода setRequired(), чийто аргумент е текстът на съобщението за грешка, което ще се покаже, ако потребителят не попълни името. Ако не посочим аргумент, ще се използва съобщението за грешка по подразбиране.

$form->addText('name', 'Име:')
	->setRequired('Моля, въведете име');

Опитайте да изпратите формата без попълнено име и ще видите, че ще се покаже съобщение за грешка и браузърът или сървърът ще я отхвърлят, докато не попълните полето.

В същото време няма да измамите системата, като напишете само интервали в полето. Няма начин. Nette автоматично премахва водещите и крайните интервали. Опитайте. Това е нещо, което винаги трябва да правите с всеки едноредов вход, но често се забравя. Nette го прави автоматично. (Можете да опитате да измамите формата и да изпратите многоредов низ като име. Дори тук Nette няма да се обърка и ще преобразува новите редове в интервали.)

Формата винаги се валидира от страна на сървъра, но също така се генерира JavaScript валидация, която се извършва мигновено и потребителят научава за грешката веднага, без да е необходимо да изпраща формата до сървъра. За това отговаря скриптът netteForms.js. Вмъкнете го в шаблона на лейаута:

<script src="https://unpkg.com/nette-forms@3"></script>

Ако погледнете изходния код на страницата с формата, може да забележите, че Nette вмъква задължителните елементи в елементи с CSS клас required. Опитайте да добавите следния стил в шаблона и етикетът „Име“ ще стане червен. Така елегантно ще маркираме задължителните елементи за потребителите:

<style>
.required label { color: maroon }
</style>

Добавяме допълнителни правила за валидация с метода addRule(). Първият параметър е правилото, вторият отново е текстът на съобщението за грешка, а може да последва и аргумент на правилото за валидация. Какво означава това?

Ще разширим формата с ново незадължително поле „възраст“, което трябва да бъде цяло число (addInteger()) и освен това в допустим диапазон ($form::Range). И тук ще използваме третия параметър на метода addRule(), с който ще предадем на валидатора необходимия диапазон като двойка [от, до]:

$form->addInteger('age', 'Възраст:')
	->addRule($form::Range, 'Възрастта трябва да е между 18 и 120', [18, 120]);

Ако потребителят не попълни полето, правилата за валидация няма да бъдат проверени, тъй като елементът е незадължителен.

Тук възниква възможност за малък рефакторинг. В съобщението за грешка и в третия параметър числата са посочени дублирано, което не е идеално. Ако създавахме многоезични форми и съобщението, съдържащо числа, беше преведено на няколко езика, евентуалната промяна на стойностите би била затруднена. Поради тази причина е възможно да се използват плейсхолдъри %d и Nette ще допълни стойностите:

	->addRule($form::Range, 'Възрастта трябва да бъде от %d до %d години', [18, 120]);

Да се върнем към елемента password, който също ще направим задължителен и ще проверим минималната дължина на паролата ($form::MinLength), отново с използване на плейсхолдър:

$form->addPassword('password', 'Парола:')
	->setRequired('Изберете парола')
	->addRule($form::MinLength, 'Паролата трябва да съдържа поне %d знака', 8);

Ще добавим към формата и поле passwordVerify, където потребителят ще въведе паролата още веднъж, за проверка. С помощта на правилата за валидация ще проверим дали двете пароли са еднакви ($form::Equal). И като параметър ще дадем препратка към първата парола с помощта на квадратни скоби:

$form->addPassword('passwordVerify', 'Парола за проверка:')
	->setRequired('Моля, въведете паролата отново за проверка')
	->addRule($form::Equal, 'Паролите не съвпадат', $form['password'])
	->setOmitted();

С помощта на setOmitted() маркирахме елемент, чиято стойност всъщност не ни интересува и който съществува само с цел валидация. Стойността не се предава на $data.

С това имаме напълно функционална форма с валидация както в PHP, така и в JavaScript. Възможностите за валидация на Nette са много по-широки, могат да се създават условия, според тях да се показват и скриват части от страницата и т.н. Всичко ще научите в главата за валидация на форми.

Стойности по подразбиране

Обикновено задаваме стойности по подразбиране на елементите на формата:

$form->addEmail('email', 'Имейл')
	->setDefaultValue($lastUsedEmail);

Често е полезно да се зададат стойности по подразбиране на всички елементи едновременно. Например, когато формата се използва за редактиране на записи. Прочитаме записа от базата данни и задаваме стойностите по подразбиране:

//$row = ['name' => 'John', 'age' => '33', /* ... */];
$form->setDefaults($row);

Извиквайте setDefaults() след дефинирането на елементите.

Рендиране на формата

По подразбиране формата се рендира като таблица. Отделните елементи отговарят на основното правило за достъпност – всички етикети са написани като <label> и са свързани със съответния елемент на формата. При кликване върху етикета курсорът автоматично се появява в полето на формата.

На всеки елемент можем да задаваме произволни HTML атрибути. Например, да добавим placeholder:

$form->addInteger('age', 'Възраст:')
	->setHtmlAttribute('placeholder', 'Моля, попълнете възрастта');

Има наистина много начини за рендиране на форма, така че на това е посветена отделна глава за рендиране.

Мапване към класове

Да се върнем към метода formSucceeded(), който във втория параметър $data получава изпратените данни като обект ArrayHash. Тъй като това е генеричен клас, нещо като stdClass, при работа с него ще ни липсва известно удобство, като например подсказване на свойствата в редакторите или статичен анализ на кода. Това може да се реши, като за всяка форма имаме конкретен клас, чиито свойства представляват отделните елементи. Напр.:

class RegistrationFormData
{
	public string $name;
	public ?int $age;
	public string $password;
}

Алтернативно можете да използвате конструктор:

class RegistrationFormData
{
	public function __construct(
		public string $name,
		public int $age,
		public string $password,
	) {
	}
}

Свойствата на класа с данни могат да бъдат и enum-и и те ще бъдат автоматично мапнати.

Как да кажем на Nette да ни връща данните като обекти от този клас? По-лесно, отколкото си мислите. Достатъчно е само да посочите класа като тип на параметъра $data в обработващия метод:

public function formSucceeded(Form $form, RegistrationFormData $data): void
{
	// $data е инстанция на RegistrationFormData
	$name = $data->name;
	// ...
}

Като тип може да се посочи и array и тогава данните ще бъдат предадени като масив.

По подобен начин може да се използва и функцията getValues(), на която предаваме името на класа или обекта за хидратиране като параметър:

$data = $form->getValues(RegistrationFormData::class);
$name = $data->name;

Ако формите образуват многостепенна структура, съставена от контейнери, създайте отделен клас за всеки:

$form = new Form;
$person = $form->addContainer('person');
$person->addText('firstName');
/* ... */

class PersonFormData
{
	public string $firstName;
	public string $lastName;
}

class RegistrationFormData
{
	public PersonFormData $person;
	public ?int $age;
	public string $password;
}

Мапването след това разпознава от типа на свойството $person, че трябва да мапне контейнера към класа PersonFormData. Ако свойството съдържа масив от контейнери, посочете типа array и предайте класа за мапване директно на контейнера:

$person->setMappedType(PersonFormData::class);

Можете да генерирате дизайна на класа с данни за формата с помощта на метода Nette\Forms\Blueprint::dataClass($form), който ще го изведе на страницата на браузъра. След това е достатъчно да маркирате кода с кликване и да го копирате в проекта.

Множество бутони

Ако формата има повече от един бутон, обикновено трябва да разграничим кой от тях е бил натиснат. Можем да създадем собствена обработваща функция за всеки бутон. Ще я зададем като хендлър за събитието onClick:

$form->addSubmit('save', 'Запази')
	->onClick[] = [$this, 'saveButtonPressed'];

$form->addSubmit('delete', 'Изтрий')
	->onClick[] = [$this, 'deleteButtonPressed'];

Тези хендлъри се извикват само в случай на валидно попълнена форма, точно както при събитието onSuccess. Разликата е, че като първи параметър вместо формата може да се предаде изпращащият бутон, в зависимост от типа, който посочите:

public function saveButtonPressed(Nette\Forms\Controls\Button $button, $data)
{
	$form = $button->getForm();
	// ...
}

Когато формата се изпрати с бутона Enter, се счита, че е изпратена с първия бутон.

Събитие onAnchor

Когато изграждаме формата във фабричния метод (като например createComponentRegistrationForm), тя все още не знае дали е била изпратена, нито с какви данни. Но има случаи, когато трябва да знаем изпратените стойности, например по-нататъшният вид на формата зависи от тях, или ни трябват за зависими selectbox-ове и т.н.

Затова можете да оставите частта от кода, която изгражда формата, да бъде извикана едва в момента, когато тя е т. нар. „закотвена“, т.е. вече е свързана с презентера и знае своите изпратени данни. Предаваме такъв код в масива $onAnchor:

$country = $form->addSelect('country', 'Държава:', $this->model->getCountries());
$city = $form->addSelect('city', 'Град:');

$form->onAnchor[] = function () use ($country, $city) {
	// тази функция ще се извика, когато формата знае дали е била изпратена и с какви данни
	// следователно може да се използва методът getValue()
	$val = $country->getValue();
	$city->setItems($val ? $this->model->getCities($val) : []);
};

Защита от уязвимости

Nette Framework поставя голям акцент върху сигурността и затова стриктно се грижи за добрата защита на формите. Прави го напълно прозрачно и не изисква ръчна настройка.

Освен че защитава формите от атаки Cross Site Scripting (XSS) и Cross-Site Request Forgery (CSRF), той извършва много малки защити, за които вече не е нужно да мислите.

Например, филтрира всички контролни знаци от входовете и проверява валидността на UTF-8 кодирането, така че данните от формата винаги ще бъдат чисти. При select box-ове и radio list-ове проверява дали избраните елементи са наистина от предлаганите и не е имало фалшификация. Вече споменахме, че при едноредови текстови входове премахва знаците за край на ред, които нападателят може да е изпратил. При многоредови входове пък нормализира знаците за край на ред. И така нататък.

Nette решава вместо вас рисковете за сигурността, за които много програмисти дори не подозират, че съществуват.

Споменатата CSRF атака се състои в това, че нападателят примамва жертвата към страница, която незабелязано в браузъра на жертвата изпълнява заявка към сървъра, на който жертвата е влязла, и сървърът смята, че заявката е изпълнена от жертвата по нейна воля. Затова Nette предотвратява изпращането на POST форма от друг домейн. Ако по някаква причина искате да изключите защитата и да позволите изпращането на формата от друг домейн, използвайте:

$form->allowCrossOrigin(); // ВНИМАНИЕ! Изключва защитата!

Тази защита използва SameSite cookie, наречена _nss. Защитата чрез SameSite cookie може да не е 100% надеждна, затова е препоръчително да включите и защита чрез токен:

$form->addProtection();

Препоръчваме да защитавате по този начин формите в административната част на сайта, които променят чувствителни данни в приложението. Framework-ът се защитава срещу CSRF атака чрез генериране и проверка на оторизационен токен, който се съхранява в сесията. Затова е необходимо да имате отворена сесия преди показването на формата. В административната част на сайта обикновено сесията вече е стартирана поради влизането на потребителя. В противен случай стартирайте сесията с метода Nette\Http\Session::start().

Същата форма в множество презентери

Ако трябва да използвате една и съща форма в множество презентери, препоръчваме да създадете фабрика за нея, която след това да предадете на презентера. Подходящо място за такъв клас е например директорията app/Forms.

Фабричният клас може да изглежда например така:

use Nette\Application\UI\Form;

class SignInFormFactory
{
	public function create(): Form
	{
		$form = new Form;
		$form->addText('name', 'Име:');
		$form->addSubmit('send', 'Вход');
		return $form;
	}
}

Искаме от класа да произведе формата във фабричния метод за компоненти в презентера:

public function __construct(
	private SignInFormFactory $formFactory,
) {
}

protected function createComponentSignInForm(): Form
{
	$form = $this->formFactory->create();
	// можем да променим формата, тук например променяме етикета на бутона
	$form['send']->setCaption('Продължи');
	$form->onSuccess[] = [$this, 'signInFormSuceeded']; // и добавяме хендлър
	return $form;
}

Хендлърът за обработка на формата може да бъде предоставен и от фабриката:

use Nette\Application\UI\Form;

class SignInFormFactory
{
	public function create(): Form
	{
		$form = new Form;
		$form->addText('name', 'Име:');
		$form->addSubmit('send', 'Вход');
		$form->onSuccess[] = function (Form $form, $data): void {
			// тук извършваме обработката на формата
		};
		return $form;
	}
}

И така, направихме бързо въведение във формите в Nette. Опитайте да разгледате и директорията examples в дистрибуцията, където ще намерите още вдъхновение.

версия: 4.0