Собственные элементы форм
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('Ник:');
Жизненный цикл элемента
Прежде чем перейти к более интересному элементу, полезно знать, что и когда с элементом происходит. Форма и её элементы – это компоненты, образующие дерево. У этого есть одно приятное следствие: элементу не нужно ничего выяснять самому, обо всём важном в нужный момент позаботится фреймворк:
- В момент, когда вы присоедините элемент к отправленной форме, сама
форма вызовет у него
loadHttpData(). В нём элемент считывает своё отправленное значение, как мы сейчас покажем. Он никогда не работает напрямую с$_POSTи ему совершенно неважно, вложен ли он в контейнеры. - При отправке формы происходит проверка: вычисляются правила,
добавленные через
addRule(), и работают они со значением изgetValue(). - Тот, кто затем вызовет
$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', 'Почтовый индекс:');