Формы в презентерах

Nette Forms значительно упрощает создание и обработку веб-форм. В этой главе вы узнаете, как использовать формы внутри презентеров.

Если вы хотите использовать их полностью автономно, без остальной части фреймворка, есть руководство по автономным формам.

Первая форма

Мы постараемся написать простую регистрационную форму. Её код будет выглядеть следующим образом:

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 HomepagePresenter 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('Homepage:');
	}
}

А рендеринг в шаблоне осуществляется с помощью тега {control}:

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

{control registrationForm}

И это всё :-) У нас есть функциональная и идеально защищенная форма.

Теперь вы, вероятно, думаете, что это было слишком быстро, задаваясь вопросом, как возможно, что вызывается метод formSucceeded() и какие параметры он получает. Конечно, вы правы, это заслуживает объяснения.

Nette придумала классный механизм, который мы назвали Голливудский стиль. Вместо того, чтобы постоянно спрашивать, произошло ли что-то («была ли форма отправлена?», «была ли она отправлена правильно?», или «не была ли она подделана?»), вы говорите фреймворку: «когда форма заполнена правильно, вызовите этот метод». Если вы программируете на JavaScript, вы знакомы с этим стилем программирования. Вы пишете функции, которые вызываются при наступлении определенного события. И язык передает им соответствующие аргументы.

Вот как построен приведенный выше код презентера. Массив $form->onSuccess представляет собой список обратных вызовов PHP, которые Nette будет вызывать, когда форма будет отправлена и правильно заполнена. В рамках жизненного цикла презентера это так называемый сигнал, поэтому они вызываются после метода action* и перед методом render*. И он передает каждому обратному вызову саму форму в первом параметре и отправленные данные в виде объекта ArrayHash во втором. Вы можете опустить первый параметр, если вам не нужен объект формы. Второй параметр может быть ещё более удобным, но об этом позже.

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

$form->addError('Извините, имя пользователя уже используется.');

В дополнение к onSuccess, существует также onSubmit: обратные вызовы всегда вызываются после отправки формы, даже если она заполнена неправильно. И, наконец, onError: обратные вызовы вызываются только в том случае, если отправка недействительна. Они вызываются, даже если мы аннулируем форму в 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']);

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

Слово valid было использовано несколько раз, но форма ещё не имеет правил валидации. Давайте исправим это.

Имя будет обязательным, поэтому мы пометим его методом setRequired(), аргументом которого является текст сообщения об ошибке, которое будет выведено, если пользователь не заполнит его. Если аргумент не указан, используется сообщение об ошибке по умолчанию.

$form->addText('name', 'Имя:')
	->setRequired('Пожалуйста, введите имя.');

Попробуйте отправить форму без заполненного имени, и вы увидите, что появится сообщение об ошибке, и браузер или сервер будет отклонять форму, пока вы не заполните её.

В то же время вы не сможете обмануть систему, набрав в поле ввода, например, только пробелы. Ни за что. Nette автоматически обрезает левые и правые пробельные символы. Попробуйте. Это то, что вы всегда должны делать с каждым однострочным вводом, но об этом часто забывают. Nette делает это автоматически. (Вы можете попытаться обмануть форму и отправить многострочную строку в качестве имени. Даже здесь Nette не будет обманут, и переносы строк будут заменены на пробелы).

Форма всегда проверяется на стороне сервера, но также генерируется проверка JavaScript, что происходит быстро, и пользователь сразу же узнает об ошибке, без необходимости отправлять форму на сервер. Этим занимается скрипт netteForms.js. Вставьте его в шаблон макета:

<script src="https://nette.github.io/resources/js/3/netteForms.min.js"></script>

Если вы посмотрите в исходный код страницы с формой, вы можете заметить, что Nette вставляет обязательные поля в элементы с CSS-классом required. Попробуйте добавить следующий стиль в шаблон, и метка “Имя” будет красного цвета. Мы элегантно помечаем обязательные поля для пользователей:

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

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

Форма получит ещё один необязательный элемент ввода age с условием, что он должен быть числом (addInteger()) и находиться в определенных границах ($form::Range). И здесь мы будем использовать третий аргумент addRule(), сам диапазон:

$form->addInteger('age', 'Возраст:')
	->addRule($form::Range, 'Вы должны быть старше 18 лет и иметь возраст до 120 лет.', [18, 120]);

Если пользователь не заполнит поле, правила валидации не будут проверены, поскольку поле является необязательным.

Очевидно, что здесь есть место для небольшого рефакторинга. В сообщении об ошибке и в третьем параметре числа перечислены в двух экземплярах, что не идеально. Если бы мы создавали многоязычную форму и сообщение, содержащее числа, пришлось бы переводить на несколько языков, это усложнило бы изменение значений. По этой причине можно использовать символы-заменители %d:

	->addRule($form::Range, 'Вы должны быть старше %d лет и иметь возраст до %d лет.', [18, 120]);

Вернемся к полю пароль, сделаем его обязательным и проверим минимальную длину пароля ($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 для каждого элемента. Например, добавьте заполнитель:

$form->addInteger('age', 'Возраст:')
	->setHtmlAttribute('placeholder', 'Пожалуйста, заполните возраст');

На самом деле существует множество способов визуализации формы, подробнее в главе Рендеринг.

Сопоставление с классами

Давайте вернемся к обработке данных формы. Метод getValues() возвращает представленные данные в виде объекта ArrayHash. Поскольку это общий класс, что-то вроде stdClass, нам будет не хватать некоторых удобств при работе с ним, таких как завершение кода для свойств в редакторах или статический анализ кода. Эту проблему можно решить, создав для каждой формы отдельный класс, свойства которого представляют отдельные элементы управления. Например:

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

Начиная с PHP 8.0, вы можете использовать эту элегантную нотацию, которая использует конструктор:

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

Как указать Nette возвращать данные в виде объектов этого класса? Легче, чем вы думаете. Все, что вам нужно сделать, это указать класс в качестве типа параметра $data в обработчике:

public function formSucceeded(Form $form, RegistrationFormData $data): void
{
	// $name — экземпляр 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);

Несколько кнопок отправки

Если форма содержит более одной кнопки, нам обычно нужно различать, какая из них была нажата. Мы можем создать собственную функцию для каждой кнопки. Установите его в качестве обработчика для события onClick:

$form->addSubmit('save', 'Сохранить')
	->onClick[] = [$this, 'saveButtonPressed'];

$form->addSubmit('delete', 'Удалить')
	->onClick[] = [$this, 'deleteButtonPressed'];

Эти обработчики также вызываются только в том случае, если форма действительна, как в случае события onSuccess. Разница в том, что первым параметром может быть объект кнопки submit, а не форма, в зависимости от типа, который вы укажете:

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

Когда форма отправляется с помощью кнопки Enter, она обрабатывается так же, как если бы она была отправлена с помощью первой кнопки.

Событие onAnchor

Когда вы создаете форму в фабричном методе (например, createComponentRegistrationForm), она ещё не знает, была ли она отправлена или с какими данными она была отправлена. Но бывают случаи, когда нам необходимо знать переданные значения, возможно, от них зависит, как будет выглядеть форма, или они используются для зависимых списков и т. д.

Поэтому можно сделать так, чтобы код, создающий форму, вызывался, когда она закреплена, т. е. он уже связан с презентером и знает его представленные данные. Мы поместим такой код в массив $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) : []);
};

Vulnerability Protection

Nette Framework прилагает большие усилия для обеспечения безопасности, а поскольку формы являются наиболее распространенным видом пользовательского ввода, формы Nette настолько же хороши, насколько непроницаемы.

В дополнение к защите форм от атак известных уязвимостей, таких как Cross-Site Scripting (XSS) и Cross-Site Request Forgery (CSRF), он выполняет множество мелких задач по обеспечению безопасности, о которых вам больше не нужно думать.

Например, он отфильтровывает все управляющие символы из вводимых данных и проверяет правильность кодировки UTF-8, так что данные из формы всегда будут чистыми. Для полей выбора и радиосписков проверяется, что выбранные элементы действительно были из предложенных и не было подделки. Мы уже упоминали, что для ввода однострочного текста он удаляет символы конца строки, которые злоумышленник может туда отправить. Для многострочных вводов он нормализует символы конца строки. И так далее.

Nette устраняет за вас уязвимости в системе безопасности, о существовании которых большинство программистов даже не подозревают.

Упомянутая CSRF-атака заключается в том, что злоумышленник заманивает жертву посетить страницу, которая молча выполняет запрос в браузере жертвы к серверу, на котором жертва в данный момент зарегистрирована, и сервер считает, что запрос был сделан жертвой по собственному желанию. Таким образом, Nette предотвращает отправку формы через POST из другого домена. Если по какой-то причине вы хотите отключить защиту и разрешить отправку формы с другого домена, используйте:

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

Эта защита использует файл куки SameSite с именем _nss. Поэтому создайте форму перед первым выводом, чтобы можно было отправить куки.

Защита куки-файлов SameSite может быть не на 100% надежной, поэтому хорошей идеей будет включить защиту с помощью токена:

$form->addProtection();

Настоятельно рекомендуется применять эту защиту к формам в административной части вашего приложения, которые изменяют конфиденциальные данные. Фреймворк защищает от атаки 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['login']->setCaption('Продолжить');
	$form->onSuccess[] = [$this, 'signInFormSubmitted']; // и добавить обработчик
	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 в дистрибутиве.