Собственные элементы форм

Nette предлагает широкую палитру встроенных элементов форм. Но когда вы столкнётесь с требованием, которого среди них нет, ничего обходить и склеивать не придётся: вы напишете собственный элемент. Он будет уметь всё то же, что и встроенные – проверять, переводиться, отрисовываться – и использоваться будет точно так же.

Покажем это на практическом примере: элемент для ввода даты тремя полями – день, месяц и год. Попутно вы узнаете всё, что нужно знать о написании элементов.

Когда писать собственный элемент, а когда нет

Собственный элемент – самый мощный инструмент, который предлагают формы. И, как всякий мощный инструмент, он должен быть последним выбором, а не первым. Многие ситуации решаются более простыми средствами:

  • Изменение значения решает addFilter(). Хотите допускать пробелы в почтовом индексе или строчные буквы в коде? Фильтр – это несколько строк.
  • Повторяющуюся настройку оборачивают в собственный метод добавления. Добавляете поле почтового индекса с одинаковой проверкой в десяти местах? Сделайте для них именованное сокращение, покажем это в конце.
  • Группе связанных полей служит контейнер. Адресу из улицы, города и индекса собственный элемент не нужен, хватит контейнера с тремя текстовыми полями.
  • Другой внешний вид достигается через setHtmlType() и HTML-атрибуты либо через прототипы.

Собственный элемент имеет смысл в тот момент, когда вам нужно собственное значение: элемент, который снаружи ведёт себя как одно поле с одним значением, но внутри состоит из нескольких инпутов или хранит значение иначе, чем показывает. Дата из трёх полей. Координаты, выбранные щелчком по карте. Ввод тегов с автодополнением.

Анатомия элемента

Каждый собственный элемент наследуется от абстрактного класса Nette\Forms\Controls\BaseControl. От него он получает огромное количество готовой функциональности: хранение значения, правила и условия проверки, сообщения об ошибках, переводы, HTML-атрибуты, метку и связь с отрисовкой. Вы пишете только то, чем ваш элемент отличается.

Минимальный работающий элемент на удивление короток:

use Nette\Forms\Form;
use Nette\Forms\Helpers;
use Nette\Utils\Html;

class SimpleInput extends Nette\Forms\Controls\BaseControl
{
	public function loadHttpData(): void
	{
		$this->setValue($this->getHttpData(Form::DataLine));
	}

	public function getControl(): Html
	{
		return Html::el('input', [
			'type' => 'text',
			'name' => $this->getHtmlName(),
			'id' => $this->getHtmlId(),
			'value' => $this->getValue(),
			'data-nette-rules' => Helpers::exportRules($this->getRules()) ?: null,
		]);
	}
}

Два метода: один говорит, как получить значение из отправленных данных, второй – как элемент отрисовать. На оба мы через минуту посмотрим подробно. Всё остальное – setRequired(), addRule(), setDefaultValue(), переводы – работает уже само.

В форму элемент добавляют методом addComponent() или короче, через квадратные скобки:

$form['nickname'] = new SimpleInput('Ник:');

Жизненный цикл элемента

Прежде чем перейти к более интересному элементу, полезно знать, что и когда с элементом происходит. Форма и её элементы – это компоненты, образующие дерево. У этого есть одно приятное следствие: элементу не нужно ничего выяснять самому, обо всём важном в нужный момент позаботится фреймворк:

  1. В момент, когда вы присоедините элемент к отправленной форме, сама форма вызовет у него loadHttpData(). В нём элемент считывает своё отправленное значение, как мы сейчас покажем. Он никогда не работает напрямую с $_POST и ему совершенно неважно, вложен ли он в контейнеры.
  2. При отправке формы происходит проверка: вычисляются правила, добавленные через addRule(), и работают они со значением из getValue().
  3. Тот, кто затем вызовет $form->getValues() или getValue() у элемента, получит чистое типизированное значение – например, объект DateTimeImmutable, а не тройку строк из формы.

А при отрисовке вызывается getControl(), а для метки – getLabel().

Чтение отправленного значения

В методе loadHttpData() элемент запрашивает своё отправленное значение методом getHttpData(). Его параметр – тип, определяющий, как значение нужно очистить:

тип значение
Form::DataLine однострочный текст: заменяет переводы строк пробелами, обрезает пробелы
Form::DataText многострочный текст: приводит окончания строк к \n
Form::DataFile загрузка файла, экземпляр Nette\Http\FileUpload

Как бы злоумышленник ни старался, результатом всегда будет корректная UTF-8-строка без управляющих символов (либо объект загрузки, либо null). Именно поэтому мы никогда не читаем значение прямо из $_POST: мы потеряли бы все эти гарантии.

Элемент, состоящий из нескольких инпутов, как наша дата, передаёт часть HTML-имени вторым параметром и так считывает свои отдельные подзначения. Он хранит их в собственных свойствах $day, $month и $year типа string:

public function loadHttpData(): void
{
	$this->day = $this->getHttpData(Form::DataLine, '[day]') ?? '';
	$this->month = $this->getHttpData(Form::DataLine, '[month]') ?? '';
	$this->year = $this->getHttpData(Form::DataLine, '[year]') ?? '';
}

Если HTML-имя заканчивается на [], возвращается массив значений. В сочетании с типом Form::DataKeys (то есть Form::DataLine | Form::DataKeys) вы сохраните и его ключи:

$tags = $this->getHttpData(Form::DataLine, '[tags][]');

Отсутствующее значение – это null (для массивов пустой массив). Запрос вообще может не содержать данных элемента, ничто не мешает злоумышленнику отправить что угодно – именно поэтому в примере мы дописываем ?? '', и именно поэтому такой вариант нужно учитывать всегда.

Значение элемента

Элемент хранит своё значение и предоставляет его через тройку методов, договорённостей которых стоит придерживаться.

Метод setValue() принимает значение от программиста – этим же путём идут setDefaultValue() и $form->setDefaults(). Он должен принимать всё, что имеет смысл, преобразовывать значение во внутреннюю форму и выбрасывать исключение на бессмысленном вводе, чтобы ошибка проявилась сразу, а не через загадочное поведение формы. Наша дата принимает DateTimeInterface, строку, метку времени или null и раскладывает их на три поля:

public function setValue(mixed $value): static
{
	if ($value === null) {
		$this->day = $this->month = $this->year = '';
	} else {
		$date = Nette\Utils\DateTime::from($value); // бессмыслица выбросит исключение
		$this->day = $date->format('j');
		$this->month = $date->format('n');
		$this->year = $date->format('Y');
	}
	return $this;
}

Метод getValue(), наоборот, собирает чистое типизированное значение – единственное, что увидит пользователь вашего элемента. Если значение некорректно, он возвращает null. Статический метод validateDate() просто проверяет, что три поля складываются в существующую дату:

public function getValue(): ?DateTimeImmutable
{
	return self::validateDate($this)
		? (new DateTimeImmutable)->setDate((int) $this->year, (int) $this->month, (int) $this->day)->setTime(0, 0)
		: null;
}

А метод isFilled() говорит, заполнил ли пользователь элемент; его использует правило setRequired(). Реализации по умолчанию (непустое значение) часто хватает, но для составного элемента переопределите его согласно его логике:

public function isFilled(): bool
{
	return $this->day !== '' || $this->year !== '';
}

Отрисовка

Метод getControl() возвращает HTML-вид элемента, обычно как объект Html, но подойдёт и обычная строка – это неважно. К объекту Html мы обращаемся в основном при сборке кода, потому что он позволяет строить итоговую разметку безопасно и с приятным API. В вашем распоряжении несколько помощников:

  • getHtmlName() возвращает HTML-атрибут name, включая возможную вложенность в контейнеры (например, invoice[date]). Для составного элемента вы дописываете к нему части имён отдельных инпутов: $name . '[day]'.
  • getHtmlId() возвращает атрибут id, связанный с меткой.
  • Helpers::exportRules($this->getRules()) экспортирует правила проверки для атрибута data-nette-rules, благодаря чему для вашего элемента заработает и проверка на JavaScript. Атрибут ставится на первый инпут элемента.
  • Helpers::createSelectBox($items, $optionAttrs, $selected) собирает элемент <select> из массива пунктов (вложенные массивы отрисовываются как <optgroup>) и возвращает его как Html – удобно для поля месяца в нашей дате.
  • Helpers::createInputList($items, $inputAttrs, $labelAttrs) порождает список элементов <input>, обёрнутых в <label> (радиокнопки или флажки), и возвращает его строкой.

Первое поле нашей даты создаётся, стало быть, так:

public function getControl(): Html
{
	$name = $this->getHtmlName();
	return Html::el()
		->addHtml(Html::el('input', [
			'name' => $name . '[day]',
			'id' => $this->getHtmlId(),
			'value' => $this->day,
			'type' => 'number',
			'data-nette-rules' => Helpers::exportRules($this->getRules()) ?: null,
		]))
		->addHtml(/* ... select для месяца и input для года ... */);
}

Метку отрисовывает getLabel(), и её реализация по умолчанию обычно подходит. Только осторожно: у составного элемента её атрибут for указывает на getHtmlId(), поэтому этот id дайте первому инпуту – ровно как в примере.

Чтобы составной элемент можно было отрисовывать в шаблоне по частям (например, {input birthdate:day}), переопределите методы getControlPart($key) и getLabelPart($key), возвращающие элемент Html для данной части – точно так же, как это делают CheckboxList и RadioList.

Если вы переопределяете getControl(), помните, что BaseControl::getControl() ещё и помечает элемент как отрисованный через setOption('rendered', true). Вызовите его тоже (или вызовите parent::getControl()), когда в одной форме сочетаете ручную и автоматическую отрисовку, чтобы элемент не отрисовался дважды. (Пример DateInput выше опускает это для краткости.)

Полный пример: DateInput

Все описанные части вместе, дополненные выпадающим списком для выбора месяца, вы найдёте в готовом элементе DateInput среди примеров прямо в репозитории.

Обратите внимание, что в конструкторе элемент добавляет себе правило проверки, которое следит за осмысленностью даты. Бессмысленный ввод, например 31 февраля, тем самым проявляется как обычная ошибка проверки формы:

public function __construct($label = null)
{
	parent::__construct($label);
	$this->addRule(self::validateDate(...), 'Дата некорректна.');
}

А использование? Точно как у встроенных элементов:

$form['birthdate'] = (new DateInput('Дата рождения:'))
	->setDefaultValue(new DateTime('2000-01-01'))
	->setRequired('Когда вы родились?');

$date = $form->getValues()->birthdate; // ?DateTimeImmutable

В шаблоне Latte вы отрисуете его привычным тегом {input birthdate} или {label birthdate /}, как и любой другой элемент.

Проверка

Встроенные правила проверки работают с собственным элементом сразу – они действуют со значением из getValue(). Наш DateInput может, например, использовать Form::Min для самой ранней допустимой даты. О том, как написать собственные правила вместе с их JavaScript-двойником, рассказано в главе Собственные правила и условия.

Собственный метод добавления

Встроенные элементы мы добавляем удобными методами $form->addText() и им подобными. У собственного элемента такого метода нет, поэтому вы добавляете его обычным присваиванием – это одинаково работает в форме и в контейнере, и это понимают редакторы и статический анализ:

$form['birthdate'] = new DateInput('Дата рождения:');

Если вы хотите сократить добавление, сохранив автодополнение, пригодится статический фабричный метод прямо на элементе. Он работает и во вложенных контейнерах, чего метод на потомке класса Form не смог бы: вложенные контейнеры о нём не знают:

class DateInput extends Nette\Forms\Controls\BaseControl
{
	public static function addTo(
		Nette\Forms\Container $container,
		string $name,
		?string $label = null,
	): self {
		return $container[$name] = new self($label);
	}
}

// работает в форме и в любом контейнере:
DateInput::addTo($form, 'birthdate', 'Дата рождения:');

Тот же подход работает и как именованное сокращение для повторяющейся настройки встроенного элемента:

final class ZipInput
{
	public static function addTo(
		Nette\Forms\Container $container,
		string $name,
		?string $label = null,
	): Nette\Forms\Controls\TextInput {
		return $container->addText($name, $label)
			->addRule(Nette\Forms\Form::Pattern, 'Почтовый индекс должен состоять ровно из 5 цифр', '[0-9]{5}');
	}
}

ZipInput::addTo($form, 'zip', 'Почтовый индекс:');
версия: 4.x