Повторное использование форм в нескольких местах

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

Фабрика форм

Одним из основных подходов к использованию одного и того же компонента в нескольких местах является создание метода или класса, который генерирует этот компонент, и последующий вызов этого метода в разных частях приложения. Такой метод или класс называется фабрикой. Пожалуйста, не путайте с паттерном проектирования factory method, который описывает специфический способ использования фабрик и не связан с этой темой.

В качестве примера создадим фабрику, которая будет собирать форму редактирования:

use Nette\Application\UI\Form;

class FormFactory
{
	public function createEditForm(): Form
	{
		$form = new Form;
		$form->addText('title', 'Заголовок:');
		// здесь добавляются другие поля формы
		$form->addSubmit('send', 'Отправить');
		return $form;
	}
}

Теперь вы можете использовать эту фабрику в разных местах вашего приложения, например, в презентерах или компонентах. Для этого запросим ее как зависимость. Сначала запишем класс в конфигурационный файл:

services:
	- FormFactory

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

class MyPresenter extends Nette\Application\UI\Presenter
{
	public function __construct(
		private FormFactory $formFactory,
	) {
	}

	protected function createComponentEditForm(): Form
	{
		$form = $this->formFactory->createEditForm();
		$form->onSuccess[] = function () {
			// обработка отправленных данных
		};
		return $form;
	}
}

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

class FormFactory
{
	public function createForm(): Form
	{
		$form = new Form;
		return $form;
	}

	public function createEditForm(): Form
	{
		$form = $this->createForm();
		$form->addText('title', 'Заголовок:');
		// здесь добавляются другие поля формы
		$form->addSubmit('send', 'Отправить');
		return $form;
	}
}

Метод createForm() пока не делает ничего полезного, но это быстро изменится.

Зависимости фабрики

Со временем выяснится, что нам нужно, чтобы формы были многоязычными. Это означает, что всем формам нужно установить так называемый translator. Для этого изменим класс FormFactory так, чтобы он принимал объект Translator как зависимость в конструкторе, и передадим его форме:

use Nette\Localization\Translator;

class FormFactory
{
	public function __construct(
		private Translator $translator,
	) {
	}

	public function createForm(): Form
	{
		$form = new Form;
		$form->setTranslator($this->translator);
		return $form;
	}

	// ...
}

Поскольку метод createForm() вызывают и другие методы, создающие специфические формы, достаточно установить translator только в нем. И готово. Нет необходимости менять код какого-либо презентера или компонента, что замечательно.

Несколько фабричных классов

Альтернативно, вы можете создать несколько классов для каждой формы, которую хотите использовать в вашем приложении. Этот подход может повысить читаемость кода и упростить управление формами. Исходную FormFactory оставим создавать только чистую форму с базовой конфигурацией (например, с поддержкой переводов), а для формы редактирования создадим новую фабрику EditFormFactory.

class FormFactory
{
	public function __construct(
		private Translator $translator,
	) {
	}

	public function create(): Form
	{
		$form = new Form;
		$form->setTranslator($this->translator);
		return $form;
	}
}


// ✅ использование композиции
class EditFormFactory
{
	public function __construct(
		private FormFactory $formFactory,
	) {
	}

	public function create(): Form
	{
		$form = $this->formFactory->create();
		// здесь добавляются другие поля формы
		$form->addSubmit('send', 'Отправить');
		return $form;
	}
}

Очень важно, чтобы связь между классами FormFactory и EditFormFactory была реализована композицией, а не объектным наследованием:

// ⛔ ТАК НЕ НАДО! НАСЛЕДОВАНИЕ ЗДЕСЬ НЕУМЕСТНО
class EditFormFactory extends FormFactory
{
	public function create(): Form
	{
		$form = parent::create();
		$form->addText('title', 'Заголовок:');
		// здесь добавляются другие поля формы
		$form->addSubmit('send', 'Отправить');
		return $form;
	}
}

Использование наследования в этом случае было бы совершенно контрпродуктивным. Вы очень быстро столкнулись бы с проблемами. Например, в тот момент, когда вы захотели бы добавить параметры к методу create(); PHP выдал бы ошибку, что его сигнатура отличается от родительской. Или при передаче зависимости в класс EditFormFactory через конструктор. Возникла бы ситуация, которую мы называем constructor hell.

В целом, лучше отдавать предпочтение композиции перед наследованием.

Обработка формы

Обработка формы, которая вызывается после успешной отправки, также может быть частью фабричного класса. Она будет работать так, что передаст отправленные данные модели для обработки. Возможные ошибки передаст обратно в форму. Модель в следующем примере представляет класс Facade:

class EditFormFactory
{
	public function __construct(
		private FormFactory $formFactory,
		private Facade $facade,
	) {
	}

	public function create(): Form
	{
		$form = $this->formFactory->create();
		$form->addText('title', 'Заголовок:');
		// здесь добавляются другие поля формы
		$form->addSubmit('send', 'Отправить');
		$form->onSuccess[] = [$this, 'processForm'];
		return $form;
	}

	public function processForm(Form $form, array $data): void
	{
		try {
			// обработка отправленных данных
			$this->facade->process($data);

		} catch (AnyModelException $e) {
			$form->addError('...');
		}
	}
}

Само перенаправление оставим на презентере. Он добавит к событию onSuccess еще один обработчик, который выполнит перенаправление. Благодаря этому форму можно будет использовать в разных презентерах и в каждом перенаправлять в другое место.

class MyPresenter extends Nette\Application\UI\Presenter
{
	public function __construct(
		private EditFormFactory $formFactory,
	) {
	}

	protected function createComponentEditForm(): Form
	{
		$form = $this->formFactory->create();
		$form->onSuccess[] = function () {
			$this->flashMessage('Запись была сохранена');
			$this->redirect('Homepage:');
		};
		return $form;
	}
}

Это решение использует свойство форм, что если над формой или ее элементом вызывается addError(), то следующий обработчик onSuccess уже не вызывается.

Наследование от класса Form

Собранная форма не должна быть потомком формы. Другими словами, не используйте это решение:

// ⛔ ТАК НЕ НАДО! НАСЛЕДОВАНИЕ ЗДЕСЬ НЕУМЕСТНО
class EditForm extends Form
{
	public function __construct(Translator $translator)
	{
		parent::__construct();
		$this->addText('title', 'Заголовок:');
		// здесь добавляются другие поля формы
		$this->addSubmit('send', 'Отправить');
		$this->setTranslator($translator);
	}
}

Вместо сборки формы в конструкторе используйте фабрику.

Нужно понимать, что класс Form — это в первую очередь инструмент для сборки формы, то есть form builder. А собранную форму можно рассматривать как ее продукт. Но продукт не является специфическим случаем билдера, между ними нет связи is a, составляющей основу наследования.

Компонент с формой

Совершенно другой подход представляет собой создание компонента, частью которого является форма. Это дает новые возможности, например, рендерить форму специфическим образом, поскольку частью компонента является и шаблон. Или можно использовать сигналы для AJAX-коммуникации и дозагрузки информации в форму, например, для подсказок и т.д.

use Nette\Application\UI\Form;

class EditControl extends Nette\Application\UI\Control
{
	public array $onSave = [];

	public function __construct(
		private Facade $facade,
	) {
	}

	protected function createComponentForm(): Form
	{
		$form = new Form;
		$form->addText('title', 'Заголовок:');
		// здесь добавляются другие поля формы
		$form->addSubmit('send', 'Отправить');
		$form->onSuccess[] = [$this, 'processForm'];

		return $form;
	}

	public function processForm(Form $form, array $data): void
	{
		try {
			// обработка отправленных данных
			$this->facade->process($data);

		} catch (AnyModelException $e) {
			$form->addError('...');
			return;
		}

		// вызов события
		$this->onSave($this, $data);
	}
}

Еще создадим фабрику, которая будет производить этот компонент. Достаточно записать ее интерфейс:

interface EditControlFactory
{
	function create(): EditControl;
}

И добавить в конфигурационный файл:

services:
	- EditControlFactory

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

class MyPresenter extends Nette\Application\UI\Presenter
{
	public function __construct(
		private EditControlFactory $controlFactory,
	) {
	}

	protected function createComponentEditForm(): EditControl
	{
		$control = $this->controlFactory->create();

		$control->onSave[] = function (EditControl $control, $data) {
			$this->redirect('this');
			// или перенаправим на результат редактирования, напр.:
			// $this->redirect('detail', ['id' => $data->id]);
		};

		return $control;
	}
}