Ponowne wykorzystanie formularzy w wielu miejscach

Nette oferuje kilka sposobów, jak używać tego samego formularza w wielu miejscach bez duplikowania kodu. W tym artykule omówimy różne rozwiązania, w tym te, których powinieneś unikać.

Fabryka formularzy

Podstawowym podejściem do ponownego wykorzystania komponentu w wielu miejscach jest utworzenie metody albo klasy, która ten komponent tworzy. Taką metodę wywołujemy potem z różnych miejsc aplikacji. Taka metoda albo klasa nazywa się fabryką. Nie myl tego proszę ze wzorcem projektowym metoda fabrykująca, który opisuje szczególny sposób użycia fabryk i nie jest bezpośrednio związany z tym tematem.

Jako przykład utwórzmy fabrykę budującą formularz edycyjny:

use Nette\Application\UI\Form;

class FormFactory
{
	public function createEditForm(): Form
	{
		$form = new Form;
		$form->addText('title', 'Tytuł:');
		// tutaj dodawane są kolejne pola formularza
		$form->addSubmit('send', 'Zapisz');
		return $form;
	}
}

Teraz możesz tej fabryki używać w różnych częściach aplikacji, na przykład w presenterach albo komponentach. Zrobisz to, prosząc o nią jako o zależność. Najpierw zarejestruj klasę w pliku konfiguracyjnym:

services:
	- FormFactory

A potem użyj jej w presenterze:

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

	protected function createComponentEditForm(): Form
	{
		$form = $this->formFactory->createEditForm();
		$form->onSuccess[] = function () {
			// przetwarzanie wysłanych danych
		};
		return $form;
	}
}

Fabrykę formularzy możesz rozszerzyć o kolejne metody tworzące inne rodzaje formularzy, zależnie od potrzeb Twojej aplikacji. I naturalnie możemy dodać metodę tworzącą podstawowy formularz bez elementów, którą pozostałe metody potem wykorzystają:

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

	public function createEditForm(): Form
	{
		$form = $this->createForm();
		$form->addText('title', 'Tytuł:');
		// tutaj dodawane są kolejne pola formularza
		$form->addSubmit('send', 'Zapisz');
		return $form;
	}
}

Metoda createForm() nie robi jeszcze nic szczególnie pożytecznego, ale to się wkrótce zmieni.

Zależności fabryki

Z czasem może się okazać, że formularze mają być wielojęzyczne. Oznacza to, że wszystkim formularzom trzeba ustawić translator. Żeby to osiągnąć, zmodyfikuj klasę FormFactory tak, by przyjmowała obiekt Translator jako zależność w konstruktorze i przekazywała go tworzonemu formularzowi:

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

	// ...
}

Ponieważ metodę createForm() wywołują także pozostałe metody tworzące konkretne formularze, wystarczy ustawić translator właśnie tutaj. I gotowe. Nie trzeba modyfikować kodu żadnego presentera ani komponentu, co jest znakomite.

Więcej klas fabryk

Alternatywnie możesz utworzyć osobne klasy fabryk dla każdego formularza, którego zamierzasz używać w aplikacji. To podejście może poprawić czytelność kodu i uprościć zarządzanie formularzami. Niech pierwotna FormFactory tworzy tylko podstawowy formularz z podstawową konfiguracją (na przykład wsparciem dla tłumaczeń), a dla formularza edycyjnego utwórz nową fabrykę EditFormFactory.

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

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


// ✅ użycie kompozycji
class EditFormFactory
{
	public function __construct(
		private FormFactory $formFactory,
	) {
	}

	public function create(): Form
	{
		$form = $this->formFactory->create();
		// tutaj dodawane są kolejne pola formularza
		$form->addSubmit('send', 'Zapisz');
		return $form;
	}
}

Bardzo ważne jest, żeby relacja między klasami FormFactory i EditFormFactory była zrealizowana przez kompozycję, a nie przez dziedziczenie obiektowe:

// ⛔ NIE! DZIEDZICZENIE TU NIE PASUJE
class EditFormFactory extends FormFactory
{
	public function create(): Form
	{
		$form = parent::create();
		$form->addText('title', 'Tytuł:');
		// tutaj dodawane są kolejne pola formularza
		$form->addSubmit('send', 'Zapisz');
		return $form;
	}
}

Użycie dziedziczenia byłoby tu całkowicie kontrproduktywne. Bardzo szybko napotkałbyś problemy. Na przykład gdybyś chciał dodać do metody create() parametry, PHP zgłosiłoby błąd, bo jej sygnatura różniłaby się od sygnatury rodzica. Albo przy przekazywaniu zależności do klasy EditFormFactory przez konstruktor. Doszłoby do sytuacji, którą nazywamy constructor hell.

Generalnie lepiej preferować kompozycję nad dziedziczeniem.

Obsługa formularza

Handler formularza wywoływany po udanym wysłaniu też może być częścią klasy fabryki. Działa tak, że przekazuje wysłane dane do przetworzenia warstwie modelu. Ewentualne błędy przetwarzania przekazuje z powrotem do formularza. W poniższym przykładzie model reprezentuje klasa Facade:

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

	public function create(): Form
	{
		$form = $this->formFactory->create();
		$form->addText('title', 'Tytuł:');
		// tutaj dodawane są kolejne pola formularza
		$form->addSubmit('send', 'Zapisz');
		$form->onSuccess[] = $this->processForm(...);
		return $form;
	}

	private function processForm(Form $form, array $data): void
	{
		try {
			// przetwarzanie wysłanych danych
			$this->facade->process($data);

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

Samo przekierowanie zostawmy jednak presenterowi. Ten doda do zdarzenia onSuccess kolejny handler, który przekierowanie wykona. Dzięki temu formularza będzie można używać w różnych presenterach, a każdy z nich po sukcesie przekieruje gdzie indziej.

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('Rekord został zapisany');
			$this->redirect('Homepage:');
		};
		return $form;
	}
}

To rozwiązanie wykorzystuje właściwość formularzy, zgodnie z którą jeśli na formularzu albo którymś z jego elementów zostanie wywołane addError(), kolejne handlery onSuccess nie zostaną wywołane.

Dziedziczenie po klasie Form

Zbudowany formularz nie powinien być potomkiem klasy Form. Innymi słowy, unikaj takiego podejścia:

// ⛔ NIE! DZIEDZICZENIE TU NIE PASUJE
class EditForm extends Form
{
	public function __construct(Translator $translator)
	{
		parent::__construct();
		$this->addText('title', 'Tytuł:');
		// tutaj dodawane są kolejne pola formularza
		$this->addSubmit('send', 'Zapisz');
		$this->setTranslator($translator);
	}
}

Zamiast budować formularz w konstruktorze, użyj fabryki.

Ważne jest, żeby zdać sobie sprawę, że klasa Form to przede wszystkim narzędzie do budowania formularzy, czyli form builder. Zbudowany formularz można uznać za jej produkt. Produkt nie jest jednak szczególnym rodzajem buildera; nie zachodzi między nimi relacja is a, która jest podstawą dziedziczenia.

Komponent z formularzem

Zupełnie inne podejście polega na utworzeniu komponentu, który zawiera w sobie formularz. Otwiera to nowe możliwości, na przykład renderowanie formularza w szczególny sposób, bo komponent ma własny szablon. Albo można wykorzystać sygnały do komunikacji AJAX-owej i dynamicznego wczytywania informacji do formularza, na przykład dla podpowiedzi itd.

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', 'Tytuł:');
		// tutaj dodawane są kolejne pola formularza
		$form->addSubmit('send', 'Zapisz');
		$form->onSuccess[] = $this->processForm(...);

		return $form;
	}

	private function processForm(Form $form, array $data): void
	{
		try {
			// przetwarzanie wysłanych danych
			$this->facade->process($data);

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

		// wywołanie zdarzenia
		$this->onSave($this, $data);
	}
}

Utwórzmy jeszcze fabrykę, która ten komponent będzie produkować. Wystarczy zdefiniować jej interfejs:

interface EditControlFactory
{
	function create(): EditControl;
}

I dodać go do pliku konfiguracyjnego:

services:
	- EditControlFactory

Teraz możemy poprosić o fabrykę i użyć jej w presenterze:

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');
			// albo przekierowanie na wynik edycji, np.:
			// $this->redirect('detail', ['id' => $data->id]);
		};

		return $control;
	}
}