Komponenty interaktywne

Komponenty to samodzielne obiekty wielokrotnego użytku, które wstawiamy na strony. Mogą to być formularze, datagridy, ankiety, właściwie wszystko, co ma sens używać wielokrotnie. Pokażemy:

  • jak używać komponentów?
  • jak je pisać?
  • czym są sygnały?

Nette ma wbudowany system komponentów. Coś podobnego mogą pamiętać weterani z Delphi lub ASP.NET Web Forms, na czymś zdalnie podobnym opiera się React czy Vue.js. Jednak w świecie frameworków PHP jest to unikalna sprawa.

Przy tym komponenty zasadniczo wpływają na podejście do tworzenia aplikacji. Możesz bowiem składać strony z gotowych jednostek. Potrzebujesz w administracji datagrid? Znajdziesz go na Componette, repozytorium open-source dodatków (czyli nie tylko komponentów) dla Nette i po prostu wstawisz do presentera.

Do presentera możesz włączyć dowolną liczbę komponentów. A do niektórych komponentów możesz wstawiać kolejne komponenty. Powstaje w ten sposób drzewo komponentów, którego korzeniem jest presenter.

Metody fabrykujące

Jak wstawiać komponenty do presentera i następnie ich używać? Zazwyczaj za pomocą metod fabrykujących.

Fabryka komponentów stanowi elegancki sposób na tworzenie komponentów dopiero w chwili, gdy są rzeczywiście potrzebne (lazy / on demand). Cały urok polega na implementacji metody o nazwie createComponent<Name>(), gdzie <Name> to nazwa tworzonego komponentu, która tworzy i zwraca komponent.

class DefaultPresenter extends Nette\Application\UI\Presenter
{
	protected function createComponentPoll(): PollControl
	{
		$poll = new PollControl;
		$poll->items = $this->item;
		return $poll;
	}
}

Dzięki temu, że wszystkie komponenty są tworzone w osobnych metodach, kod zyskuje na przejrzystości.

Nazwy komponentów zawsze zaczynają się małą literą, mimo że w nazwie metody pisane są z dużej.

Fabryk nigdy nie wywołujemy bezpośrednio, wywołują się same w chwili, gdy komponent użyjemy po raz pierwszy. Dzięki temu komponent jest tworzony we właściwym momencie i tylko wtedy, gdy jest rzeczywiście potrzebny. Jeśli komponentu nie użyjemy (np. przy żądaniu AJAX, gdy przesyłana jest tylko część strony, lub przy cachowaniu szablonu), nie zostanie on w ogóle utworzony i oszczędzimy wydajność serwera.

// uzyskujemy dostęp do komponentu i jeśli to było po raz pierwszy,
// wywołuje się createComponentPoll(), która go tworzy
$poll = $this->getComponent('poll');
// alternatywna składnia: $poll = $this['poll'];

W szablonie można wyrenderować komponent za pomocą znacznika {control}. Nie ma więc potrzeby ręcznego przekazywania komponentów do szablonu.

<h2>Głosuj</h2>

{control poll}

Styl Hollywood

Komponenty często używają jednej świeżej techniki, którą lubimy nazywać stylem Hollywood. Na pewno znasz skrzydlate zdanie, które tak często słyszą uczestnicy castingów filmowych: „Nie dzwońcie do nas, my zadzwonimy do was”. I właśnie o to chodzi.

W Nette bowiem, zamiast ciągle pytać („czy formularz został wysłany?”, „czy był poprawny?” lub „czy użytkownik nacisnął ten przycisk?”), mówisz frameworkowi „kiedy to się stanie, wywołaj tę metodę” i zostawiasz dalszą pracę jemu. Jeśli programujesz w JavaScript, ten styl programowania jest Ci dobrze znany. Piszesz funkcje, które są wywoływane, gdy nastąpi określone zdarzenie. A język przekazuje im odpowiednie parametry.

To całkowicie zmienia spojrzenie na pisanie aplikacji. Im więcej zadań możesz zostawić frameworkowi, tym mniej masz pracy. I tym mniej możesz czegoś np. pominąć.

Pisanie komponentu

Pod pojęciem komponent zazwyczaj rozumiemy potomka klasy Nette\Application\UI\Control. (Dokładniej byłoby więc używać terminu „controls”, ale „kontrolki” mają w języku polskim zupełnie inne znaczenie i raczej przyjęły się „komponenty”.) Sam presenter Nette\Application\UI\Presenter jest zresztą również potomkiem klasy Control.

use Nette\Application\UI\Control;

class PollControl extends Control
{
}

Renderowanie

Już wiemy, że do renderowania komponentu używa się znacznika {control componentName}. Ten znacznik właściwie wywołuje metodę render() komponentu, w której dbamy o renderowanie. Do dyspozycji mamy, tak samo jak w presenterze, szablon Latte w zmiennej $this->template, do której przekazujemy parametry. W przeciwieństwie do presentera musimy podać plik z szablonem i zlecić jego wyrenderowanie:

public function render(): void
{
	// wstawiamy do szablonu jakieś parametry
	$this->template->param = $value;
	// i renderujemy go
	$this->template->render(__DIR__ . '/poll.latte');
}

Znacznik {control} umożliwia przekazanie parametrów do metody render():

{control poll $id, $message}
public function render(int $id, string $message): void
{
	// ...
}

Czasami komponent może składać się z kilku części, które chcemy renderować oddzielnie. Dla każdej z nich tworzymy własną metodę renderującą, tutaj w przykładzie np. renderPaginator():

public function renderPaginator(): void
{
	// ...
}

A w szablonie wywołujemy ją za pomocą:

{control poll:paginator}

Dla lepszego zrozumienia warto wiedzieć, jak ten znacznik jest tłumaczony na PHP.

{control poll}
{control poll:paginator 123, 'hello'}

zostanie przetłumaczone jako:

$control->getComponent('poll')->render();
$control->getComponent('poll')->renderPaginator(123, 'hello');

Metoda getComponent() zwraca komponent poll i na tym komponencie wywołuje metodę render(), lub renderPaginator(), jeśli inny sposób renderowania jest podany w znaczniku za dwukropkiem.

Uwaga, jeśli gdziekolwiek w parametrach pojawi się =>, wszystkie parametry zostaną zapakowane do tablicy i przekazane jako pierwszy argument:

{control poll, id: 123, message: 'hello'}

zostanie przetłumaczone jako:

$control->getComponent('poll')->render(['id' => 123, 'message' => 'hello']);

Renderowanie podkomponentu:

{control cartControl-someForm}

zostanie przetłumaczone jako:

$control->getComponent("cartControl-someForm")->render();

Komponenty, podobnie jak presentery, automatycznie przekazują do szablonów kilka użytecznych zmiennych:

  • $basePath to absolutna ścieżka URL do katalogu głównego (np. /eshop)
  • $baseUrl to absolutny URL do katalogu głównego (np. http://localhost/eshop)
  • $user to obiekt reprezentujący użytkownika
  • $presenter to aktualny presenter
  • $control to aktualny komponent
  • $flashes to tablica wiadomości wysłanych przez funkcję flashMessage()

Sygnał

Już wiemy, że nawigacja w aplikacji Nette polega na linkowaniu lub przekierowywaniu do par Presenter:action. Ale co, jeśli chcemy tylko wykonać akcję na aktualnej stronie? Na przykład zmienić sortowanie kolumn w tabeli; usunąć pozycję; przełączyć tryb jasny/ciemny; wysłać formularz; zagłosować w ankiecie; itp.

Ten rodzaj żądań nazywa się sygnałami. I podobnie jak akcje wywołują metody action<Action>() lub render<Action>(), sygnały wywołują metody handle<Signal>(). Podczas gdy pojęcie akcji (lub widoku) jest związane wyłącznie z presenterami, sygnały dotyczą wszystkich komponentów. A więc także presenterów, ponieważ UI\Presenter jest potomkiem UI\Control.

public function handleClick(int $x, int $y): void
{
	// ... przetwarzanie sygnału ...
}

Link, który wywoła sygnał, tworzymy w zwykły sposób, czyli w szablonie za pomocą atrybutu n:href lub znacznika {link}, w kodzie za pomocą metody link(). Więcej w rozdziale Tworzenie linków URL.

<a n:href="click! $x, $y">kliknij tutaj</a>

Sygnał zawsze jest wywoływany na aktualnym presenterze i akcji, nie można go wywołać na innym presenterze lub innej akcji.

Sygnał powoduje więc ponowne załadowanie strony dokładnie tak samo, jak przy pierwotnym żądaniu, tylko dodatkowo wywołuje metodę obsługującą sygnał z odpowiednimi parametrami. Jeśli metoda nie istnieje, rzucany jest wyjątek Nette\Application\UI\BadSignalException, który użytkownikowi wyświetla się jako strona błędu 403 Forbidden.

Snippety i AJAX

Sygnały mogą trochę przypominać AJAX: handlery, które są wywoływane na aktualnej stronie. I masz rację, sygnały naprawdę często są wywoływane za pomocą AJAXu, a następnie przesyłamy do przeglądarki tylko zmienione części strony. Czyli tzw. snippety. Więcej informacji znajdziesz na stronie poświęconej AJAX.

Wiadomości flash

Komponent ma własny magazyn wiadomości flash, niezależny od presentera. Są to wiadomości, które np. informują o wyniku operacji. Ważną cechą wiadomości flash jest to, że są dostępne w szablonie nawet po przekierowaniu. Nawet po wyświetleniu pozostają aktywne przez kolejne 30 sekund – na przykład na wypadek, gdyby z powodu błędnego transferu użytkownik odświeżył stronę – wiadomość mu więc od razu nie zniknie.

Wysyłanie zapewnia metoda flashMessage. Pierwszym parametrem jest tekst wiadomości lub obiekt stdClass reprezentujący wiadomość. Opcjonalnym drugim parametrem jest jej typ (error, warning, info itp.). Metoda flashMessage() zwraca instancję wiadomości flash jako obiekt stdClass, do którego można dodawać kolejne informacje.

$this->flashMessage('Pozycja została usunięta.');
$this->redirect(/* ... */); // i przekierowujemy

W szablonie te wiadomości są dostępne w zmiennej $flashes jako obiekty stdClass, które zawierają właściwości message (tekst wiadomości), type (typ wiadomości) i mogą zawierać już wspomniane informacje użytkownika. Wyrenderujemy je na przykład tak:

{foreach $flashes as $flash}
	<div class="flash {$flash->type}">{$flash->message}</div>
{/foreach}

Przekierowanie po sygnale

Po przetworzeniu sygnału komponentu często następuje przekierowanie. Jest to podobna sytuacja jak w przypadku formularzy – po ich wysłaniu również przekierowujemy, aby przy odświeżeniu strony w przeglądarce nie doszło do ponownego wysłania danych.

$this->redirect('this') // przekierowuje na aktualny presenter i akcję

Ponieważ komponent jest elementem wielokrotnego użytku i zazwyczaj nie powinien mieć bezpośredniego powiązania z konkretnymi presenterami, metody redirect() i link() automatycznie interpretują parametr jako sygnał komponentu:

$this->redirect('click') // przekierowuje na sygnał 'click' tego samego komponentu

Jeśli potrzebujesz przekierować na inny presenter lub akcję, możesz to zrobić za pośrednictwem presentera:

$this->getPresenter()->redirect('Product:show'); // przekierowuje na inny presenter/akcję

Parametry persistentne

Parametry persistentne służą do utrzymywania stanu w komponentach między różnymi żądaniami. Ich wartość pozostaje taka sama nawet po kliknięciu na link. W przeciwieństwie do danych w sesji, są one przesyłane w URL. I to całkowicie automatycznie, w tym w linkach tworzonych w innych komponentach na tej samej stronie.

Masz np. komponent do paginacji treści. Takich komponentów może być na stronie kilka. I chcemy, aby po kliknięciu na link wszystkie komponenty pozostały na swojej aktualnej stronie. Dlatego z numeru strony (page) zrobimy parametr persistentny.

Tworzenie parametru persistentnego w Nette jest niezwykle proste. Wystarczy utworzyć publiczną właściwość i oznaczyć ją atrybutem: (wcześniej używano /** @persistent */)

use Nette\Application\Attributes\Persistent;  // ta linia jest ważna

class PaginatingControl extends Control
{
	#[Persistent]
	public int $page = 1; // musi być public
}

Przy właściwości zalecamy podanie również typu danych (np. int) i można podać również wartość domyślną. Wartości parametrów można walidować.

Podczas tworzenia linku można zmienić wartość parametru persistentnego:

<a n:href="this page: $page + 1">następna</a>

Lub można go zresetować, tj. usunąć z URL. Wtedy przyjmie swoją wartość domyślną:

<a n:href="this page: null">resetuj</a>

Komponenty persistentne

Nie tylko parametry, ale także komponenty mogą być persistentne. W przypadku takiego komponentu jego parametry persistentne są przenoszone również między różnymi akcjami presentera lub między wieloma presenterami. Komponenty persistentne oznaczamy adnotacją przy klasie presentera. Na przykład tak oznaczymy komponenty calendar i poll:

/**
 * @persistent(calendar, poll)
 */
class DefaultPresenter extends Nette\Application\UI\Presenter
{
}

Podkomponentów wewnątrz tych komponentów nie trzeba oznaczać, staną się również persistentne.

W PHP 8 można również użyć atrybutów do oznaczenia komponentów persistentnych:

use Nette\Application\Attributes\Persistent;

#[Persistent('calendar', 'poll')]
class DefaultPresenter extends Nette\Application\UI\Presenter
{
}

Komponenty z zależnościami

Jak tworzyć komponenty z zależnościami, nie „zaśmiecając” sobie presenterów, które będą ich używać? Dzięki sprytnym właściwościom kontenera DI w Nette, podobnie jak przy używaniu klasycznych usług, można pozostawić większość pracy frameworkowi.

Weźmy jako przykład komponent, który ma zależność od usługi PollFacade:

class PollControl extends Control
{
	public function __construct(
		private int $id, //  Id ankiety dla której tworzymy komponent
		private PollFacade $facade,
	) {
	}

	public function handleVote(int $voteId): void
	{
		$this->facade->vote($this->id, $voteId);
		// ...
	}
}

Gdybyśmy pisali klasyczną usługę, nie byłoby problemu. O przekazanie wszystkich zależności zadbałby niewidocznie kontener DI. Jednak z komponentami zazwyczaj postępujemy tak, że ich nową instancję tworzymy bezpośrednio w presenterze w metodach fabrykujących createComponent…(). Ale przekazywanie wszystkich zależności wszystkich komponentów do presentera, aby je następnie przekazać komponentom, jest uciążliwe. I tyle napisanego kodu…

Logicznym pytaniem jest, dlaczego po prostu nie zarejestrujemy komponentu jako klasycznej usługi, nie przekażemy go do presentera, a następnie w metodzie createComponent…() nie zwrócimy? Takie podejście jest jednak nieodpowiednie, ponieważ chcemy mieć możliwość tworzenia komponentu nawet wielokrotnie.

Prawidłowym rozwiązaniem jest napisanie dla komponentu fabryki, czyli klasy, która nam komponent utworzy:

class PollControlFactory
{
	public function __construct(
		private PollFacade $facade,
	) {
	}

	public function create(int $id): PollControl
	{
		return new PollControl($id, $this->facade);
	}
}

Taką fabrykę zarejestrujemy w naszym kontenerze w konfiguracji:

services:
	- PollControlFactory

a na koniec użyjemy jej w naszym presenterze:

class PollPresenter extends Nette\Application\UI\Presenter
{
	public function __construct(
		private PollControlFactory $pollControlFactory,
	) {
	}

	protected function createComponentPollControl(): PollControl
	{
		$pollId = 1; // możemy przekazać nasz parametr
		return $this->pollControlFactory->create($pollId);
	}
}

Świetne jest to, że Nette DI potrafi takie proste fabryki generować, więc zamiast całego jej kodu wystarczy napisać tylko jej interfejs:

interface PollControlFactory
{
	public function create(int $id): PollControl;
}

I to wszystko. Nette wewnętrznie zaimplementuje ten interfejs i przekaże go do presentera, gdzie już możemy go używać. Magicznie doda nam do naszego komponentu również parametr $id i instancję klasy PollFacade.

Komponenty dogłębnie

Komponenty w Nette Application stanowią części aplikacji internetowej wielokrotnego użytku, które wstawiamy na strony i którym poświęcony jest cały ten rozdział. Jakie dokładnie możliwości ma taki komponent?

  1. jest renderowalny w szablonie
  2. wie, którą swoją część ma wyrenderować przy żądaniu AJAX (snippety)
  3. ma możliwość zapisywania swojego stanu w URL (parametry persistentne)
  4. ma możliwość reagowania na akcje użytkownika (sygnały)
  5. tworzy strukturę hierarchiczną (gdzie korzeniem jest presenter)

Każdą z tych funkcji obsługuje któraś z klas linii dziedziczenia. Renderowanie (1 + 2) obsługuje Nette\Application\UI\Control, włączenie do cyklu życia (3, 4) klasa Nette\Application\UI\Component, a tworzenie struktury hierarchicznej (5) klasy Container i Component.

Nette\ComponentModel\Component  { IComponent }
|
+- Nette\ComponentModel\Container  { IContainer }
	|
	+- Nette\Application\UI\Component  { SignalReceiver, StatePersistent }
		|
		+- Nette\Application\UI\Control  { Renderable }
			|
			+- Nette\Application\UI\Presenter  { IPresenter }

Cykl życia komponentu

Cykl życia komponentu

Walidacja parametrów persistentnych

Wartości parametrów persistentnych otrzymanych z URL zapisuje do właściwości metoda loadState(). Sprawdza ona również, czy odpowiada typ danych podany przy właściwości, w przeciwnym razie odpowiada błędem 404 i strona się nie wyświetla.

Nigdy ślepo nie wierz parametrom persistentnym, ponieważ mogą być łatwo nadpisane przez użytkownika w URL. W ten sposób na przykład sprawdzimy, czy numer strony $this->page jest większy niż 0. Odpowiednią drogą jest nadpisanie wspomnianej metody loadState():

class PaginatingControl extends Control
{
	#[Persistent]
	public int $page = 1;

	public function loadState(array $params): void
	{
		parent::loadState($params); // tutaj ustawia się $this->page
		// następuje własna kontrola wartości:
		if ($this->page < 1) {
			$this->error();
		}
	}
}

Proces odwrotny, czyli zebranie wartości z właściwości persistentnych, obsługuje metoda saveState().

Sygnały dogłębnie

Sygnał powoduje ponowne załadowanie strony dokładnie tak samo, jak przy pierwotnym żądaniu (z wyjątkiem przypadku, gdy jest wywoływany przez AJAX) i wywołuje metodę signalReceived($signal), której domyślna implementacja w klasie Nette\Application\UI\Component próbuje wywołać metodę złożoną ze słów handle{signal}. Dalsze przetwarzanie zależy od danego obiektu. Obiekty dziedziczące po Component (tj. Control i Presenter) reagują tak, że próbują wywołać metodę handle{signal} z odpowiednimi parametrami.

Innymi słowy: bierze się definicję funkcji handle{signal} i wszystkie parametry, które przyszły z żądaniem, a do argumentów według nazwy dopasowuje się parametry z URL i próbuje wywołać daną metodę. Np. jako parametr $id przekazuje się wartość z parametru id w URL, jako $something przekazuje się something z URL, itd. A jeśli metoda nie istnieje, metoda signalReceived rzuca wyjątek.

Sygnał może odbierać dowolny komponent, presenter lub obiekt, który implementuje interfejs SignalReceiver i jest podłączony do drzewa komponentów.

Głównymi odbiorcami sygnałów będą Presentery i komponenty wizualne dziedziczące po Control. Sygnał ma służyć jako znak dla obiektu, że ma coś zrobić – ankieta ma zliczyć głos od użytkownika, blok z nowościami ma się rozwinąć i wyświetlić dwa razy więcej nowości, formularz został wysłany i ma przetworzyć dane itp.

URL dla sygnału tworzymy za pomocą metody Component::link(). Jako parametr $destination przekazujemy ciąg {signal}! a jako $args tablicę argumentów, które chcemy przekazać sygnałowi. Sygnał zawsze jest wywoływany na aktualnym presenterze i akcji z aktualnymi parametrami, parametry sygnału są tylko dodawane. Dodatkowo na początku dodawany jest parametr ?do, który określa sygnał.

Jego format to albo {signal}, albo {signalReceiver}-{signal}. {signalReceiver} to nazwa komponentu w presenterze. Dlatego w nazwie komponentu nie może być myślnika – używa się go do oddzielenia nazwy komponentu i sygnału, jednak można w ten sposób zagnieździć kilka komponentów.

Metoda isSignalReceiver() sprawdza, czy komponent (pierwszy argument) jest odbiorcą sygnału (drugi argument). Drugi argument możemy pominąć – wtedy sprawdza, czy komponent jest odbiorcą jakiegokolwiek sygnału. Jako drugi parametr można podać true i tym samym sprawdzić, czy odbiorcą jest nie tylko podany komponent, ale także którykolwiek jego potomek.

W dowolnej fazie poprzedzającej handle{signal} możemy wykonać sygnał ręcznie, wywołując metodę processSignal(), która zajmuje się obsługą sygnału – bierze komponent, który został określony jako odbiorca sygnału (jeśli nie jest określony odbiorca sygnału, jest to sam presenter) i wysyła mu sygnał.

Przykład:

if ($this->isSignalReceiver($this, 'paging') || $this->isSignalReceiver($this, 'sorting')) {
	$this->processSignal();
}

Tym samym sygnał jest wykonany przedwcześnie i nie będzie już ponownie wywoływany.

wersja: 4.0