Повторне використання форм у різних місцях

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

Фабрика форм

Один з основних підходів до використання одного і того ж компонента в різних місцях – це створення методу або класу, який генерує компонент, а потім виклик цього методу в різних місцях програми. Такий метод або клас називається фабрикою. Будь ласка, не плутайте з паттерном проектування фабричний метод, який описує специфічний спосіб використання фабрик і не має відношення до цієї теми.

Для прикладу, давайте створимо фабрику, яка буде створювати форму редагування:

use Nette\Application\UI\Form;

class FormFactory
{
	public function createEditForm(): Form
	{
		$form = new Form;
		$form->addText('title', 'Title:');
		// тут додаються додаткові поля форми
		$form->addSubmit('send', 'Save');
		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', 'Title:');
		// тут додаються додаткові поля форми
		$form->addSubmit('send', 'Save');
		return $form;
	}
}

Метод createForm() поки що не робить нічого корисного, але це швидко зміниться.

Заводські залежності

З часом стане очевидно, що нам потрібно, щоб форми були багатомовними. Це означає, що нам потрібно налаштувати перекладач для всіх форм. Для цього ми модифікуємо клас 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() також викликається іншими методами, які створюють конкретні форми, нам потрібно встановити транслятор тільки в цьому методі. І все готово. Не потрібно змінювати код доповідача або компонента, що дуже добре.

Більше фабричних класів

Крім того, ви можете створити кілька класів для кожної форми, яку хочете використовувати у своєму додатку. Такий підхід може підвищити читабельність коду і полегшити керування формами. Залиште оригінальний 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', 'Save');
		return $form;
	}
}

Дуже важливо, що зв'язування між класами FormFactory та EditFormFactory реалізовано за допомогою композиції, а не успадкування об'єктів:

// НІ! СПАДЩИНА ТУТ НЕ МАЄ ЗНАЧЕННЯ
class EditFormFactory extends FormFactory
{
	public function create(): Form
	{
		$form = parent::create();
		$form->addText('title', 'Title:');
		// тут додаються додаткові поля форми
		$form->addSubmit('send', 'Save');
		return $form;
	}
}

Використання успадкування в цьому випадку було б абсолютно контрпродуктивним. Ви б дуже швидко зіткнулися з проблемами. Наприклад, якщо ви захочете додати параметри до методу create(); PHP повідомить про помилку, що його сигнатура відрізняється від батьківської. Або при передачі залежності класу EditFormFactory через конструктор. Це призведе до того, що ми називаємо пеклом конструктора.

Як правило, краще віддавати перевагу композиції, а не успадкуванню.

Обробка форм

Обробник форми, який викликається після успішного відправлення, також може бути частиною фабричного класу. Він буде працювати, передаючи надіслані дані моделі для обробки. Будь-які помилки будуть передані назад у форму. У наступному прикладі модель представлена класом Facade:

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

	public function create(): Form
	{
		$form = $this->formFactory->create();
		$form->addText('title', 'Title:');
		// тут додаються додаткові поля форми
		$form->addSubmit('send', 'Save');
		$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('Záznam byl uložen');
			$this->redirect('Homepage:');
		};
		return $form;
	}
}

Це рішення використовує властивість форм, яка полягає в тому, що коли на формі або її елементі викликається addError(), наступний обробник onSuccess не викликається.

Успадкування від класу Form

Побудована форма не повинна бути дочірньою формою. Іншими словами, не використовуйте це рішення:

// НІ! СПАДЩИНА ТУТ НЕ МАЄ ЗНАЧЕННЯ
class EditForm extends Form
{
	public function __construct(Translator $translator)
	{
		parent::__construct();
		$form->addText('title', 'Title:');
		// тут додаються додаткові поля форми
		$form->addSubmit('send', 'Save');
		$form->setTranslator($translator);
	}
}

Замість того, щоб створювати форму в конструкторі, скористайтеся фабрикою.

Важливо розуміти, що клас Form – це насамперед інструмент для збирання форми, тобто конструктор форм. А зібрану форму можна вважати його продуктом. Однак продукт не є окремим випадком конструктора, між ними немає зв'язку 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', 'Title:');
		// тут додаються додаткові поля форми
		$form->addSubmit('send', 'Save');
		$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(): Form
	{
		$control = $this->controlFactory->create();

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

		return $control;
	}
}