Formularze w prezenterach

Nette Forms znacznie ułatwiają tworzenie i przetwarzanie formularzy internetowych. W tym rozdziale zapoznasz się z używaniem formularzy wewnątrz prezenterów.

Jeśli interesuje Cię, jak używać ich całkowicie samodzielnie bez reszty frameworka, przeznaczony jest dla Ciebie przewodnik do samodzielnego użycia.

Pierwszy formularz

Spróbujemy napisać prosty formularz rejestracyjny. Jego kod będzie następujący:

use Nette\Application\UI\Form;

$form = new Form;
$form->addText('name', 'Imię:');
$form->addPassword('password', 'Hasło:');
$form->addSubmit('send', 'Zarejestruj');
$form->onSuccess[] = [$this, 'formSucceeded'];

a w przeglądarce wyświetli się tak:

Formularz w prezenterze to obiekt klasy Nette\Application\UI\Form, jej poprzednik Nette\Forms\Form jest przeznaczony do samodzielnego użytku. Dodaliśmy do niego tzw. elementy imię, hasło i przycisk wysyłania. A na końcu linia z $form->onSuccess mówi, że po wysłaniu i pomyślnej walidacji ma zostać wywołana metoda $this->formSucceeded().

Z punktu widzenia prezentera formularz jest zwykłym komponentem. Dlatego traktuje się go jak komponent i włączamy go do prezentera za pomocą metody fabrykującej. Będzie to wyglądać tak:

use Nette;
use Nette\Application\UI\Form;

class HomePresenter extends Nette\Application\UI\Presenter
{
	protected function createComponentRegistrationForm(): Form
	{
		$form = new Form;
		$form->addText('name', 'Imię:');
		$form->addPassword('password', 'Hasło:');
		$form->addSubmit('send', 'Zarejestruj');
		$form->onSuccess[] = [$this, 'formSucceeded'];
		return $form;
	}

	public function formSucceeded(Form $form, $data): void
	{
		// tutaj przetwarzamy dane wysłane formularzem
		// $data->name zawiera imię
		// $data->password zawiera hasło
		$this->flashMessage('Zostałeś pomyślnie zarejestrowany.');
		$this->redirect('Home:');
	}
}

A w szablonie formularz renderujemy znacznikiem {control}:

<h1>Rejestracja</h1>

{control registrationForm}

I to właściwie wszystko :-) Mamy działający i doskonale zabezpieczony formularz.

A teraz pewnie myślisz, że to było za szybko, zastanawiasz się, jak to możliwe, że wywołuje się metoda formSucceeded() i jakie są parametry, które otrzymuje. Oczywiście, masz rację, to zasługuje na wyjaśnienie.

Nette bowiem wprowadza świeży mechanizm, który nazywamy Hollywood style. Zamiast tego, abyś jako programista musiał ciągle pytać, czy coś się wydarzyło („czy formularz został wysłany?”, „czy został wysłany poprawnie?” i „czy nie doszło do jego sfałszowania?”), mówisz frameworkowi „kiedy formularz będzie poprawnie wypełniony, wywołaj tę metodę” i zostawiasz dalszą pracę jemu. Jeśli programujesz w JavaScripcie, ten styl programowania znasz doskonale. Piszesz funkcje, które są wywoływane, gdy nastąpi określone zdarzenie. A język przekazuje im odpowiednie argumenty.

Właśnie tak zbudowany jest również powyższy kod prezentera. Tablica $form->onSuccess reprezentuje listę callbacków PHP, które Nette wywoła w momencie, gdy formularz zostanie wysłany i poprawnie wypełniony (tj. jest ważny). W ramach cyklu życia prezentera jest to tzw. sygnał, wywoływane są więc po metodzie action* i przed metodą render*. A każdemu callbackowi przekazuje jako pierwszy parametr sam formularz, a jako drugi wysłane dane w postaci obiektu ArrayHash. Pierwszy parametr możesz pominąć, jeśli obiekt formularza nie jest potrzebny. A drugi parametr potrafi być sprytniejszy, ale o tym później.

Obiekt $data zawiera właściwości name i password z danymi, które wypełnił użytkownik. Zazwyczaj dane od razu wysyłamy do dalszego przetwarzania, co może być na przykład wstawienie do bazy danych. Podczas przetwarzania może jednak pojawić się błąd, na przykład nazwa użytkownika jest już zajęta. W takim przypadku błąd przekazujemy z powrotem do formularza za pomocą addError() i pozwalamy mu wyrenderować się ponownie, wraz z komunikatem błędu.

$form->addError('Przepraszamy, nazwa użytkownika jest już zajęta.');

Oprócz onSuccess istnieje jeszcze onSubmit: callbacki są wywoływane zawsze po wysłaniu formularza, nawet jeśli nie jest on poprawnie wypełniony. A dalej onError: callbacki są wywoływane tylko jeśli wysłanie nie jest ważne. Wywołają się nawet wtedy, jeśli w onSuccess lub onSubmit unieważnimy formularz za pomocą addError().

Po przetworzeniu formularza przekierowujemy na następną stronę. Zapobiega to niechcianemu ponownemu wysłaniu formularza przyciskiem odśwież, wstecz lub poruszaniem się w historii przeglądarki.

Spróbuj dodać również inne elementy formularza.

Dostęp do elementów

Formularz jest komponentem prezentera, w naszym przypadku nazwanym registrationForm (według nazwy metody fabrykującej createComponentRegistrationForm), więc gdziekolwiek w prezenterze dostaniesz się do formularza za pomocą:

$form = $this->getComponent('registrationForm');
// alternatywna składnia: $form = $this['registrationForm'];

Komponentami są również poszczególne elementy formularza, dlatego dostaniesz się do nich w ten sam sposób:

$input = $form->getComponent('name'); // lub $input = $form['name'];
$button = $form->getComponent('send'); // lub $button = $form['send'];

Elementy usuwa się za pomocą unset:

unset($form['name']);

Reguły walidacyjne

Padło tu słowo ważny, ale formularz na razie nie ma żadnych reguł walidacyjnych. Naprawmy to.

Imię będzie obowiązkowe, dlatego oznaczymy je metodą setRequired(), której argumentem jest tekst komunikatu błędu, który wyświetli się, jeśli użytkownik nie wypełni imienia. Jeśli nie podamy argumentu, użyty zostanie domyślny komunikat błędu.

$form->addText('name', 'Imię:')
	->setRequired('Proszę podać imię');

Spróbuj wysłać formularz bez wypełnionego imienia, a zobaczysz, że wyświetli się komunikat błędu, a przeglądarka lub serwer będzie go odrzucać, dopóki nie wypełnisz pola.

Jednocześnie systemu nie oszukasz, wpisując w pole na przykład same spacje. Nic z tego. Nette lewo- i prawostronne spacje automatycznie usuwa. Wypróbuj to. To rzecz, którą powinieneś zawsze robić z każdym jednoliniowym inputem, ale często się o tym zapomina. Nette robi to automatycznie. (Możesz spróbować oszukać formularz i jako imię wysłać wieloliniowy string. Nawet tutaj Nette nie da się zmylić i znaki nowej linii zamieni na spacje.)

Formularz zawsze waliduje się po stronie serwera, ale generuje się również walidacja JavaScriptowa, która przebiega błyskawicznie, a użytkownik dowiaduje się o błędzie natychmiast, bez konieczności wysyłania formularza na serwer. Za to odpowiada skrypt netteForms.js. Wstaw go do szablonu layoutu:

<script src="https://unpkg.com/nette-forms@3"></script>

Jeśli spojrzysz do kodu źródłowego strony z formularzem, możesz zauważyć, że Nette obowiązkowe elementy wstawia do elementów z klasą CSS required. Spróbuj dodać do szablonu następujący arkusz stylów, a etykieta „Imię” będzie czerwona. Elegancko w ten sposób oznaczamy użytkownikom obowiązkowe elementy:

<style>
.required label { color: maroon }
</style>

Kolejne reguły walidacyjne dodajemy metodą addRule(). Pierwszy parametr to reguła, drugi to ponownie tekst komunikatu błędu, a może jeszcze nastąpić argument reguły walidacyjnej. Co to oznacza?

Formularz rozszerzymy o nowe nieobowiązkowe pole „wiek”, które musi być liczbą całkowitą (addInteger()) i dodatkowo w dozwolonym zakresie ($form::Range). I tutaj właśnie wykorzystamy trzeci parametr metody addRule(), którym przekażemy walidatorowi wymagany zakres jako parę [od, do]:

$form->addInteger('age', 'Wiek:')
	->addRule($form::Range, 'Wiek musi być od 18 do 120', [18, 120]);

Jeśli użytkownik nie wypełni pola, reguły walidacyjne nie będą sprawdzane, ponieważ element jest nieobowiązkowy.

Tutaj powstaje przestrzeń na drobny refactoring. W komunikacie błędu i w trzecim parametrze liczby są podane podwójnie, co nie jest idealne. Gdybyśmy tworzyli formularze wielojęzyczne, a komunikat zawierający liczby byłby przetłumaczony na wiele języków, utrudniłoby to ewentualną zmianę wartości. Z tego powodu możliwe jest użycie symboli zastępczych %d, a Nette uzupełni wartości:

	->addRule($form::Range, 'Wiek musi być od %d do %d lat', [18, 120]);

Wróćmy do elementu password, który również uczynimy obowiązkowym i jeszcze zweryfikujemy minimalną długość hasła ($form::MinLength), ponownie z wykorzystaniem symbolu zastępczego:

$form->addPassword('password', 'Hasło:')
	->setRequired('Wybierz hasło')
	->addRule($form::MinLength, 'Hasło musi mieć co najmniej %d znaków', 8);

Dodamy do formularza jeszcze pole passwordVerify, gdzie użytkownik poda hasło jeszcze raz, do kontroli. Za pomocą reguł walidacyjnych sprawdzimy, czy oba hasła są takie same ($form::Equal). A jako parametr podamy odwołanie do pierwszego hasła za pomocą nawiasów kwadratowych:

$form->addPassword('passwordVerify', 'Hasło do kontroli:')
	->setRequired('Proszę podać hasło jeszcze raz do kontroli')
	->addRule($form::Equal, 'Hasła się nie zgadzają', $form['password'])
	->setOmitted();

Za pomocą setOmitted() oznaczyliśmy element, na którego wartości właściwie nam nie zależy i który istnieje tylko w celu walidacji. Wartość nie zostanie przekazana do $data.

Tym samym mamy gotowy w pełni funkcjonalny formularz z walidacją w PHP i JavaScript. Możliwości walidacyjne Nette są znacznie szersze, można tworzyć warunki, pozwalać według nich wyświetlać i ukrywać części strony itp. Wszystkiego dowiesz się w rozdziale o walidacji formularzy.

Wartości domyślne

Elementom formularza często ustawiamy wartości domyślne:

$form->addEmail('email', 'E-mail')
	->setDefaultValue($lastUsedEmail);

Często przydaje się ustawienie wartości domyślnych wszystkim elementom jednocześnie. Na przykład, gdy formularz służy do edycji rekordów. Odczytujemy rekord z bazy danych i ustawiamy wartości domyślne:

//$row = ['name' => 'John', 'age' => '33', /* ... */];
$form->setDefaults($row);

Wywołuj setDefaults() dopiero po zdefiniowaniu elementów.

Renderowanie formularza

Standardowo formularz renderuje się jako tabela. Poszczególne elementy spełniają podstawową zasadę dostępności – wszystkie etykiety są zapisane jako <label> i powiązane z odpowiednim elementem formularza. Po kliknięciu na etykietę kursor automatycznie pojawia się w polu formularza.

Każdemu elementowi możemy ustawiać dowolne atrybuty HTML. Na przykład dodać placeholder:

$form->addInteger('age', 'Wiek:')
	->setHtmlAttribute('placeholder', 'Proszę wypełnić wiek');

Sposobów renderowania formularza jest naprawdę wiele, dlatego poświęcono temu osobny rozdział o renderowaniu.

Mapowanie na klasy

Wróćmy do metody formSucceeded(), która w drugim parametrze $data otrzymuje wysłane dane jako obiekt ArrayHash. Ponieważ jest to klasa generyczna, coś jak stdClass, podczas pracy z nią będzie nam brakować pewnego komfortu, jak na przykład podpowiadania właściwości w edytorach czy statycznej analizy kodu. Można by to rozwiązać, tworząc dla każdego formularza konkretną klasę, której właściwości reprezentują poszczególne elementy. Np.:

class RegistrationFormData
{
	public string $name;
	public ?int $age;
	public string $password;
}

Alternatywnie możesz wykorzystać konstruktor:

class RegistrationFormData
{
	public function __construct(
		public string $name,
		public int $age,
		public string $password,
	) {
	}
}

Właściwości klasy danych mogą być również enumami i zostaną automatycznie zmapowane.

Jak powiedzieć Nette, aby zwracał nam dane jako obiekty tej klasy? Łatwiej niż myślisz. Wystarczy tylko podać klasę jako typ parametru $data w metodzie obsługującej:

public function formSucceeded(Form $form, RegistrationFormData $data): void
{
	// $data jest instancją RegistrationFormData
	$name = $data->name;
	// ...
}

Jako typ można również podać array a wtedy dane przekaże jako tablicę.

Podobnym sposobem można używać również funkcji getValues(), której nazwę klasy lub obiekt do hydratacji przekażemy jako parametr:

$data = $form->getValues(RegistrationFormData::class);
$name = $data->name;

Jeśli formularze tworzą wielopoziomową strukturę złożoną z kontenerów, utwórz dla każdego osobną klasę:

$form = new Form;
$person = $form->addContainer('person');
$person->addText('firstName');
/* ... */

class PersonFormData
{
	public string $firstName;
	public string $lastName;
}

class RegistrationFormData
{
	public PersonFormData $person;
	public ?int $age;
	public string $password;
}

Mapowanie następnie z typu właściwości $person rozpozna, że ma mapować kontener na klasę PersonFormData. Jeśli właściwość zawierałaby tablicę kontenerów, podaj typ array a klasę do mapowania przekaż bezpośrednio kontenerowi:

$person->setMappedType(PersonFormData::class);

Projekt klasy danych formularza możesz sobie wygenerować za pomocą metody Nette\Forms\Blueprint::dataClass($form), która wypisze go na stronie przeglądarki. Kod następnie wystarczy kliknięciem zaznaczyć i skopiować do projektu.

Wiele przycisków

Jeśli formularz ma więcej niż jeden przycisk, zazwyczaj potrzebujemy rozróżnić, który z nich został naciśnięty. Możemy dla każdego przycisku utworzyć własną funkcję obsługującą. Ustawimy ją jako handler dla zdarzenia onClick:

$form->addSubmit('save', 'Zapisz')
	->onClick[] = [$this, 'saveButtonPressed'];

$form->addSubmit('delete', 'Usuń')
	->onClick[] = [$this, 'deleteButtonPressed'];

Te handlery są wywoływane tylko w przypadku poprawnie wypełnionego formularza, tak samo jak w przypadku zdarzenia onSuccess. Różnica polega na tym, że jako pierwszy parametr zamiast formularza może zostać przekazany przycisk wysyłania, zależy to od typu, który podasz:

public function saveButtonPressed(Nette\Forms\Controls\Button $button, $data)
{
	$form = $button->getForm();
	// ...
}

Kiedy formularz zostanie wysłany przyciskiem Enter, traktuje się to tak, jakby został wysłany pierwszym przyciskiem.

Zdarzenie onAnchor

Kiedy w metodzie fabrykującej (jak np. createComponentRegistrationForm) budujemy formularz, ten jeszcze nie wie, czy został wysłany, ani z jakimi danymi. Są jednak przypadki, gdy potrzebujemy znać wysłane wartości, na przykład od nich zależy dalsza postać formularza, lub potrzebujemy ich do zależnych pól wyboru itp.

Część kodu budującego formularz możesz więc pozwolić wywołać dopiero w momencie, gdy jest tzw. zakotwiczony, czyli jest już połączony z prezenterem i zna swoje wysłane dane. Taki kod przekażemy do tablicy $onAnchor:

$country = $form->addSelect('country', 'Państwo:', $this->model->getCountries());
$city = $form->addSelect('city', 'Miasto:');

$form->onAnchor[] = function () use ($country, $city) {
	// ta funkcja zostanie wywołana dopiero, gdy formularz będzie wiedział, czy został wysłany i z jakimi danymi
	// można więc używać metody getValue()
	$val = $country->getValue();
	$city->setItems($val ? $this->model->getCities($val) : []);
};

Ochrona przed podatnościami

Nette Framework kładzie duży nacisk na bezpieczeństwo i dlatego skrupulatnie dba o dobre zabezpieczenie formularzy. Robi to całkowicie transparentnie i nie wymaga ręcznego ustawiania czegokolwiek.

Oprócz tego, że formularze chronią przed atakiem Cross Site Scripting (XSS)Cross-Site Request Forgery (CSRF), wykonuje wiele drobnych zabezpieczeń, o których Ty już nie musisz myśleć.

Na przykład odfiltrowuje ze wejść wszystkie znaki kontrolne i sprawdza poprawność kodowania UTF-8, dzięki czemu dane z formularza zawsze będą czyste. W polach wyboru i listach radio sprawdza, czy wybrane pozycje były rzeczywiście z oferowanych i czy nie doszło do fałszerstwa. Już wspominaliśmy, że w jednoliniowych wejściach tekstowych usuwa znaki końca linii, które mógł tam wysłać atakujący. W wieloliniowych wejściach z kolei normalizuje znaki końca linii. I tak dalej.

Nette rozwiązuje za Ciebie ryzyka bezpieczeństwa, o których wielu programistów nawet nie wie, że istnieją.

Wspomniany atak CSRF polega na tym, że atakujący zwabia ofiarę na stronę, która niepozornie w przeglądarce ofiary wykonuje żądanie do serwera, na którym ofiara jest zalogowana, a serwer uważa, że żądanie wykonała ofiara z własnej woli. Dlatego Nette zapobiega wysyłaniu formularza POST z innej domeny. Jeśli z jakiegoś powodu chcesz wyłączyć ochronę i pozwolić na wysyłanie formularza z innej domeny, użyj:

$form->allowCrossOrigin(); // UWAGA! Wyłącza ochronę!

Ta ochrona wykorzystuje ciasteczko SameSite o nazwie _nss. Ochrona za pomocą ciasteczka SameSite może nie być w 100% niezawodna, dlatego warto włączyć jeszcze ochronę za pomocą tokenu:

$form->addProtection();

Zalecamy w ten sposób chronić formularze w administracyjnej części witryny, które zmieniają wrażliwe dane w aplikacji. Framework broni się przed atakiem CSRF poprzez wygenerowanie i weryfikację tokenu autoryzacyjnego, który jest przechowywany w sesji. Dlatego konieczne jest, aby przed wyświetleniem formularza sesja była otwarta. W administracyjnej części witryny zazwyczaj sesja jest już uruchomiona z powodu logowania użytkownika. W przeciwnym razie uruchom sesję metodą Nette\Http\Session::start().

Ten sam formularz w wielu prezenterach

Jeśli potrzebujesz użyć jednego formularza w wielu prezenterach, zalecamy stworzenie dla niego fabryki, którą następnie przekażesz do prezentera. Odpowiednim miejscem dla takiej klasy jest np. katalog app/Forms.

Klasa fabryki może wyglądać na przykład tak:

use Nette\Application\UI\Form;

class SignInFormFactory
{
	public function create(): Form
	{
		$form = new Form;
		$form->addText('name', 'Imię:');
		$form->addSubmit('send', 'Zaloguj się');
		return $form;
	}
}

Klasę poprosimy o wyprodukowanie formularza w metodzie fabrykującej na komponenty w prezenterze:

public function __construct(
	private SignInFormFactory $formFactory,
) {
}

protected function createComponentSignInForm(): Form
{
	$form = $this->formFactory->create();
	// możemy formularz zmodyfikować, tutaj na przykład zmieniamy etykietę na przycisku
	$form['send']->setCaption('Kontynuuj');
	$form->onSuccess[] = [$this, 'signInFormSuceeded']; // i dodajemy handler
	return $form;
}

Handler do przetwarzania formularza może być również dostarczony już z fabryki:

use Nette\Application\UI\Form;

class SignInFormFactory
{
	public function create(): Form
	{
		$form = new Form;
		$form->addText('name', 'Imię:');
		$form->addSubmit('send', 'Zaloguj się');
		$form->onSuccess[] = function (Form $form, $data): void {
			// tutaj wykonamy przetwarzanie formularza
		};
		return $form;
	}
}

Tak, mamy za sobą szybkie wprowadzenie do formularzy w Nette. Spróbuj jeszcze zajrzeć do katalogu examples w dystrybucji, gdzie znajdziesz dalszą inspirację.

wersja: 4.0