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

У 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;
	}
}