Znovupoužití formulářů na více místech

V Nette máte k dispozici několik možností, jak použít stejný formulář na více místech a neduplikovat kód. V tomto článku si ukážeme různá řešení, včetně těch, kterým byste se měli vyhnout.

Továrna na formuláře

Jedním ze základních přístupů k použití stejné komponenty na více místech je vytvoření metody nebo třídy, která tuto komponentu generuje, a následné volání této metody na různých místech aplikace. Takové metodě nebo třídě se říká továrna. Nezaměňujte prosím s návrhovým vzorem factory method, který popisuje specifický způsob využití továren a s tímto tématem nesouvisí.

Jako příklad si vytvoříme továrnu, která bude sestavovat editační formulář:

use Nette\Application\UI\Form;

class FormFactory
{
	public function createEditForm(): Form
	{
		$form = new Form;
		$form->addText('title', 'Titulek:');
		// zde se přidávají další formulářová pole
		$form->addSubmit('send', 'Odeslat');
		return $form;
	}
}

Nyní můžete použít tuto továrnu na různých místech ve vaší aplikaci, například v presenterech nebo komponentách. A to tak, že si ji vyžádáme jako závislost. Nejprve tedy třídu zapíšeme do konfiguračního souboru:

services:
	- FormFactory

A poté ji použijeme v presenteru:

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

	protected function createComponentEditForm(): Form
	{
		$form = $this->formFactory->createEditForm();
		$form->onSuccess[] = function () {
			// zpracování odeslaných dat
		};
		return $form;
	}
}

Formulářovou továrnu můžete rozšířit o další metody pro vytváření dalších druhů formulářů podle potřeby vaší aplikace. A samozřejmě můžeme přidat i metodu, která vytvoří základní formulář bez prvků, a tu budou ostatní metody využívat:

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

	public function createEditForm(): Form
	{
		$form = $this->createForm();
		$form->addText('title', 'Titulek:');
		// zde se přidávají další formulářová pole
		$form->addSubmit('send', 'Odeslat');
		return $form;
	}
}

Metoda createForm() zatím nedělá nic užitečného, ale to se rychle změní.

Závislosti továrny

Časem se ukáže, že potřebujeme, aby formuláře byly multijazyčné. To znamená, že všem formulářům musíme nastavit tzv. translator. Za tím účelem upravíme třídu FormFactory tak, aby přijímala objekt Translator jako závislost v konstruktoru, a předáme jej formuláři:

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

	// ...
}

Jelikož metodu createForm() volají i ostatní metody tvořící specifické formuláře, stačí translator nastavit jen v ní. A máme hotovo. Není potřeba měnit kód žádného presenteru nebo komponenty, což je skvělé.

Více továrních tříd

Alternativně můžete vytvořit více tříd pro každý formulář, který chcete použít ve vaší aplikaci. Tento přístup může zvýšit čitelnost kódu a usnadnit správu formulářů. Původní FormFactory necháme vytvářet jen čistý formulář se základní konfigurací (například s podporou překladů) a pro editační formulář vytvoříme novou továrnu EditFormFactory.

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

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


// ✅ použití kompozice
class EditFormFactory
{
	public function __construct(
		private FormFactory $formFactory,
	) {
	}

	public function create(): Form
	{
		$form = $this->formFactory->create();
		// zde se přidávají další formulářová pole
		$form->addSubmit('send', 'Odeslat');
		return $form;
	}
}

Velmi důležité je, aby vazba mezi třídami FormFactory a EditFormFactory byla realizována kompozicí, nikoliv objektovou dědičností:

// ⛔ TAKHLE NE! SEM DĚDIČNOST NEPATŘÍ
class EditFormFactory extends FormFactory
{
	public function create(): Form
	{
		$form = parent::create();
		$form->addText('title', 'Titulek:');
		// zde se přidávají další formulářová pole
		$form->addSubmit('send', 'Odeslat');
		return $form;
	}
}

Použití dedičnosti by bylo v tomto případě zcela kontraproduktivní. Na problémy byste narazili velmi rychle. Třeba ve chvíli, kdybyste chtěli přidat metodě create() parametry; PHP by zahlásilo chybu, že se její signatura liší od rodičovské. Nebo při předávání závislosti do třídy EditFormFactory přes konstruktor. Nastala by situace, které říkáme constructor hell.

Obecně je lepší dávat přednost kompozici před dědičností.

Obsluha formuláře

Obsluha formuláře, která se zavolá po úspěšném odeslání, může být také součástí tovární třídy. Bude fungovat tak, že odeslaná data předá modelu ke zpracování. Případné chyby předá zpět do formuláře. Model v následujícím příkladu reprezentuje třída Facade:

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

	public function create(): Form
	{
		$form = $this->formFactory->create();
		$form->addText('title', 'Titulek:');
		// zde se přidávají další formulářová pole
		$form->addSubmit('send', 'Odeslat');
		$form->onSuccess[] = [$this, 'processForm'];
		return $form;
	}

	public function processForm(Form $form, array $data): void
	{
		try {
			// zpracování odeslaných dat
			$this->facade->process($data);

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

Samotné přesměrování ale necháme na presenteru. Ten přidá události onSuccess další handler, který přesmerování provede. Díky tomu bude možné formulář použít v různých presenterech a v každém přesměrovat jinam.

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

Toto řešení využívá vlastnost formulářů, že když se nad formulářem nebo jeho prvkem zavolá addError(), už další handler onSuccess se nevolá.

Dědění od třídy Form

Sestavený formulář nemá být potomkem formuláře. Jinými slovy, nepoužívejte toto řešení:

// ⛔ TAKHLE NE! SEM DĚDIČNOST NEPATŘÍ
class EditForm extends Form
{
	public function __construct(Translator $translator)
	{
		parent::__construct();
		$form->addText('title', 'Titulek:');
		// zde se přidávají další formulářová pole
		$form->addSubmit('send', 'Odeslat');
		$form->setTranslator($translator);
	}
}

Místo sestavování formuláře v konstruktoru použijte továrnu.

Je potřeba si uvědomit, že třída Form je v první řadě nástrojem pro sestavení formuláře, tedy form builder. A sestavený formulář lze chápat jako její produkt. Jenže produkt není specifickým případem builderu, není mezi nimi vazba is a tvořící základ dědičnosti.

Komponenta s formulářem

Zcela jiný přístup představuje tvorba komponenty, jejíž součástí je formulář. To dává nové možnosti, například renderovat formulář specifickým způsobem, neboť součástí komponenty je i šablona. Nebo lze využít signály pro AJAXovou komunikaci a donačítání informací do formuláře, například pro napovídání, atd.

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', 'Titulek:');
		// zde se přidávají další formulářová pole
		$form->addSubmit('send', 'Odeslat');
		$form->onSuccess[] = [$this, 'processForm'];

		return $form;
	}

	public function processForm(Form $form, array $data): void
	{
		try {
			// zpracování odeslaných dat
			$this->facade->process($data);

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

		// vyvolání události
		$this->onSave($this, $data);
	}
}

Ještě vytvoříme továrnu, která bude tuto komponentu vyrábět. Stačí zapsat její rozhraní:

interface EditControlFactory
{
	function create(): EditControl;
}

A přidat do konfiguračního souboru:

services:
	- EditControlFactory

A nyní už můžeme továrnu vyžádat a použít v presenteru:

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');
			// nebo přesměrujeme na výsledek editace, např.:
			// $this->redirect('detail', ['id' => $data->id]);
		};

		return $control;
	}
}