Komponenty a ovládací prvky

Komponenty jsou samostatné znovupoužitelné objekty, které vkládáme do stránek. Mohou to být formuláře, datagridy, ankety, vlastně cokoliv, co má smysl používat opakovaně. Ukážeme si:

  • jak používat komponenty?
  • jak je psát?
  • co jsou to signály?

Nette má v sobě vestavěný komponentový systém. Něco podobného mohou pamětníci znát z Delphi nebo ASP.NET Web Forms, na něčem vzdáleně podobném je postaven React nebo Vue.js. Nicméně ve světě PHP frameworků jde o unikátní záležitost.

Přitom komponenty zásadním způsobem ovlivňují přístup k tvorbě aplikacím. Můžete totiž stránky skládat z předpřipravených jednotek. Potřebujete v administraci datagrid? Najdete jej na Componette, repositáři open-source doplňků (tedy nejen komponent) pro Nette a jednoduše vložíte do presenteru.

Do presenteru můžete začlenit libovolný počet komponent. A do některých komponent můžete vkládat další komponenty. Vzniká tak komponentový strom, jehož kořenem je presenter.

Tovární metody

Jak se do presenteru komponenty vkládají a následně používají? Obvykle pomocí továrních metod.

Továrna na komponenty představuje elegantní způsob, jak komponenty vytvářet teprve ve chvíli, kdy jsou skutečně potřeba (lazy / on demand). Celé kouzlo spočívá v implementaci metody s názvem createComponent<Name>(), kde <Name> je název vytvářené komponenty, a která komponentu vytvoří a vrátí.

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

Díky tomu, že jsou všechny komponenty vytvářeny v samostatných metodách, získává kód na přehlednosti.

Názvy komponent začínají vždy malým písmenem, přestože se v názvu metody píší s velkým.

Továrny nikdy nevoláme přímo, zavolají se samy ve chvíli, kdy komponentu poprvé použijeme. Díky tomu je komponenta vytvořena ve správný okamžik a pouze v případě, když je skutečně potřeba. Pokud komponentu nepoužijeme (třeba při AJAXovém požadavku, kdy se přenáší jen část stránky, nebo při cachování šablony), nevytvoří se vůbec a ušetříme výkon serveru.

// přistoupíme ke komponentě a pokud to bylo poprvé,
// zavolá se createComponentPoll() která ji vytvoří
$poll = $this->getComponent('poll');
// alternativní syntax: $poll = $this['poll'];

V šabloně je možné vykreslit komponentu pomocí značky {control}. Není proto potřeba manuálně komponenty předávat do šablony.

<h2>Hlasujte</h2>

{control poll}

Hollywood style

Komponenty běžně používají jednu svěží techniku, které rádi říkáme Hollywood style. Určitě znáte okřídlenou větu, kterou tak často slyší účastníci filmových konkurzů: „Nevolejte nám, my vám zavoláme“. A právě o tu jde.

V Nette totiž místo toho, abyste se museli neustále na něco ptát („byl formulář odeslaný?“, „bylo to validní?“ nebo „stiskl uživatel tohle tlačítko?“), řeknete frameworku „až se to stane, zavolej tuhle metodu“ a necháte další práci na něm. Pokud programujete v JavaScriptu, tento styl programování důvěrně znáte. Píšete funkce které se volají, až nastane určitá událost. A jazyk jim předá příslušné parametry.

Tohle zcela mění pohled na psaní aplikací. Čím víc úkolů můžete nechat na frameworku, tím méně máte práce vy. A tím méně toho můžete třeba opomenout.

Píšeme komponentu

Pod pojmem komponenta obvykle myslíme potomka třídy Nette\Application\UI\Control. (Přesnější by tedy bylo používat termín „controls“, ale „kontroly“ mají v češtině zcela jiný význam a spíš se ujaly „komponenty“.) Samotný presenter Nette\Application\UI\Presenter je mimochodem také potomkem třídy Control.

use Nette\Application\UI\Control;

class PollControl extends Control
{
}

Vykreslení

Už víme, že k vykreslení komponenty se používá značka {control component-name}. Ta vlastně zavolá metodu render() komponenty, ve které se postáráme o vykreslení. K dispozici máme, úplně stejně jako v presenteru, Latte šablonu v proměnné $this->template, do které předáme parametry. Na rozdíl od presenteru musíme uvést soubor se šablonou a nechat ji vykreslit:

public function render()
{
	// vložíme do šablony nějaké parametry
	$this->template->param = $value;
	// a vykreslíme ji
	$this->template->render(__DIR__ . '/poll.latte');
}

Značka {control} umožňuje do metody render() předat parametry:

{control poll $id, $message}
public function render($id, $message)
{
	...
}

Někdy se může komponenta skládat z několika částí, které chceme vykreslovat odděleně. Pro každou z nich si vytvoříme vlastní vykreslovací metodu, zde v příkladu třeba renderPaginator():

public function renderPaginator()
{
	...
}

A v šabloně ji pak vyvoláme pomocí:

{control poll:paginator}

Komponenty, stejně jako presentery, předávají do šablon několik užitečných proměnných automaticky:

  • $basePath je absolutní URL cesta ke kořenovému adresáři (např. /eshop)
  • $baseUrl je absolutní URL ke kořenovému adresáři (např. http://localhost/eshop)
  • $user je objekt reprezentující uživatele
  • $presenter je aktuální presenter
  • $control je aktuální komponenta
  • $flashes pole zpráv zaslaných funkcí flashMessage()

Signál

Už víme, že navigace v Nette aplikaci spočívá v odkazování nebo přesměrování na dvojice Presenter:action. Ale co když jen chceme provést akci na aktuální stránce? Například změnit řazení sloupců v tabulce; smazat položku; přepnout světlý/tmavý režim; odeslat formulář; hlasovat v anketě; atd.

Tomuto druhu požadavků se říká signály. A podobně jako akce vyvolávají metody action<Action>() nebo render<Action>(), signály volají metody handle<Signal>(). Zatímco pojem akce (nebo view) souvisí čistě jen s presentery, signály se týkají všech komponent. A tedy i presenterů, protože UI\Presenter je potomkem UI\Control.

public function handleClick($x, $y)
{
	// ... processing of signal ...
}

Odkaz, který zavolá signál, vytvoříme obvyklým způsobem, tedy v šabloně atributem n:href nebo značkou {link}, v kódu metodou link(). Více v kapitole Vytváření odkazů URL.

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

Signál se vždy volá na aktuálním presenteru a view, tudíž není možné jej vyvolat na jiném presenteru nebo view.

Signál tedy způsobí znovunačtení stránky úplně stejně jako při původním požadavku, jen navíc zavolá obslužnou metodu signálu s příslušnými parametry. Pokud metoda neexistuje, vyhodí se výjimka Nette\Application\UI\BadSignalException, která se uživateli zobrazí jako chybová stránka 403 Forbidden.

Snippety a AJAX

Signály vám možná trošku připomínají AJAX: handlery, které se vyvolávají na aktuální stránce. A máte pravdu, signály se opravdu často volají pomocí AJAXu a následně přenášíme do prohlížeče pouze změněné části stránky. Neboli tzv. snippety. Více informací naleznete na stránce věnované AJAXu.

Flash zprávy

Komponenta má své vlastní úložiště flash zpráv nezávislé na presenteru. Jde o zprávy, které např. informují o výsledku operace. Důležitým rysem flash zpráv je to, že jsou v šabloně k dispozici i po přesměrování. I po zobrazení zůstanou živé ještě další 3 sekundy – například pro případ, že by z důvodu chybného přenosu uživatel dal stránku obnovit – zpráva mu tedy hned nezmizí.

Zasílání obstarává metoda flashMessage. Prvním parametrem je text zprávy a nepovinným druhým parametrem její typ (error, warning, info apod.). Metoda flashMessage() vrací instanci flash zprávy, které je možné přidávat další informace.

$this->flashMessage('Položka byla smazána.');
$this->redirect(...); // a přesměrujeme

Šabloně jsou tyto zprávy k dispozici v proměnné $flashes jako objekty stdClass, které obsahují vlastnosti message (text zprávy), type (typ zprávy) a mohou obsahovat již zmíněné uživatelské informace. Vykreslíme je třeba takto:

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

Persistentní parametry

Často se stává, že je v komponentách potřeba držet nějaký parametr pro uživatele po celou dobu, kdy se s komponentou pracuje. Může to být například číslo stránky ve stránkování. Takový parametr označíme jako persistentní pomocí anotace @persistent.

class PollControl extends Control
{
	/** @persistent */
	public $page = 1;

	...
}

Tento parametr bude automaticky přenášen v každém odkazu jako GET parametr, a to až do chvíle, kdy uživatel stránku s touto komponentou opustí.

Nikdy slepě nevěřte persistentním parametrům, protože mohou být snadno podvrženy (přepsáním v URL adrese stránky). Ověřte si například, zda je číslo stránky v platném rozsahu.

Persistentní komponenty

Nejen parametry, ale také komponenty mohou být persistentní. U takové komponenty se její persistentní parametry přenáší i mezi různými akcemi presenteru nebo mezi více presentery. Persistentní komponenty značíme anotací u třídy presenteru. Třeba takto označíme komponenty calendar a poll:

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

Podkomponenty uvnitř těchto komponent není třeba značit, stanou se persistentní taky.

Komponenty se závislostmi

Co když ale naše komponenta potřebuje nějaké závislosti k tomu, aby fungovala, třeba PollControl potřebuje PollManager, přes který by hlasovala a ukládala ankety? Pak je předáme přes konstruktoru:

class PollControl extends Control
{
	/** @var PollManager */
	private $pollManager;

	/** @var int Id ankety pro kterou vytváříme komponentu */
	private $pollId;

	public function __construct($pollId, PollManager $pollManager)
	{
		$this->pollManager = $pollManager;
		$this->pollId = $pollId;
	}

	public function handleVote($voteId)
	{
		$this->pollManager->vote($pollId, $voteId);
		//...
	}
}

No jo, ale jak se to do toho konstruktoru dostane? Na to si napíšeme továrnu, tedy třídu, která nám komponentu vytvoří. Tahle továrna se bude chovat jako služba, takže ji zároveň musíme zaregistrovat jako službu do konfigurace.

class PollControlFactory
{
	/** @var PollManager */
	private $pollManager;

	public function __construct(PollManager $pollManager)
	{
		$this->pollManager = $pollManager;
	}

	/**
	 * @return PollControl
	 */
	public function create($pollId)
	{
		return new PollControl($pollId, $this->pollManager);
	}
}

Takhle továrnu zaregistrujeme do našeho kontejneru v konfiguraci:

services:
	- PollControlFactory

a nakonec ji použijeme v našem presenteru:

class PollPresenter extends Nette\UI\Application\Presenter
{
	/** @var PollControlFactory */
	private $pollControlFactory;

	public function __construct(PollControlFactory $pollControlFactory)
	{
		$this->pollControlFactory = $pollControlFactory;
	}

	protected function createComponentPollControl()
	{
		$pollId = 1; // můžeme si předat náš parametr
		return $this->pollControlFactory->create($pollId);
	}
}

Naštěstí Nette takovéhle jednoduché továrny umí generovat, takže místo ní můžeme napsat jenom její interface a továrnu nám vygeneruje DI kontejner:

interface PollControlFactory
{
	/**
	 * @return PollControl
	 */
	public function create($pollId);
}

Interface zaregistrujeme do našeho kontejneru v konfiguraci:

services:
	- PollControlFactory

a do presenteru si ho opět necháme předat a pracujeme s ním jako s původní továrnou.

A to je vše. Nette vnitřně tento interface naimplementuje a předá do presenteru, kde jej už můžeme používat. Magicky nám právě do naší komponenty přidá i parametr $pollId a instanci třídy PollManager.

Další použití komponent v souvislosti s DI je popsáno tady

Komponenty do hloubky

Komponenty bývají ve většině případů vykreslitelné. Vedle nich však existují i nevykreslitelné komponenty. Stejně tak některé komponenty mohou mít potomky, jiné zase ne. Nette Framework pro všechny tyto typy komponent zavádí několik tříd a rozhraní.

Dědičnost objektů nám umožňuje třídy zařadit do hierarchické struktury, stejně jako je to v reálném světě. Můžeme totiž vytvářet nové třídy odvozením od jiných. Tyto odvozené třídy jsou pak potomkem původní třídy a dědí jeho členské proměnné a metody. Odvozená třída může přidávat další funkcionalitu (metody a členské proměnné) k již zděděným schopnostem.

Ke správnému pochopení „jak věci pracují“, je potřeba vědět, kde má která třída své kořeny.

Nette\ComponentModel\Component  { IComponent }
|
+- Nette\ComponentModel\Container  { IContainer }
   |
   +- Nette\Application\UI\Component  { ISignalReceiver, IStatePersistent }
      |
      +- Nette\Application\UI\Control  { IPartiallyRenderable }
         |
         +- Nette\Application\UI\Presenter  { IPresenter }

Nette\ComponentModel\IComponent

Rozhraní Nette\ComponentModel\IComponent musí implementovat každá komponenta. Vyžaduje metodu getName() vracející její název a metodu getParent() vracející jejího rodiče. Obojí lze nastavit metodou setParent() – první parametr je rodič a druhý název komponenty.

Nette\ComponentModel\Component

Nette\ComponentModel\Component je standardní implementací IComponent. Je společným předkem všech komponent, vycházejí z ní všechny prvky formulářů. Obsahuje metody zjišťují příbuznost objektů a hlavně propojení (provázání) s rodiči:

lookup($type) vyhledá v hierarchii směrem nahoru objekt požadované třídy nebo rozhraní. Například $component->lookup(Nette\Application\UI\Presenter::class) vrací presenter, pokud je k němu, i přes několik úrovní, komponenta připojena.

lookupPath($type) vrací tzv. cestu, což je řetězec vzniklý spojením jmen všech komponent na cestě mezi aktuální a hledanou komponentou. Takže např. $component->lookupPath(Nette\Application\UI\Presenter::class) vrací jedinečný identifikátor komponenty vůči presenteru.

Nette\ComponentModel\IContainer

Rodičovské komponenty kromě rozhraní IComponent implementují i Nette\ComponentModel\IContainer, které obsahuje metody pro přidání, odebrání, získání a iterací nad komponentami. Komponenty pak mohou vytvářet hierarchii – např. presentery mohou obsahovat formuláře obsahující textová políčka a tlačítka. Celý strom komponent je tedy tvořen větvemi v podobě objektů IContainer a listů IComponent.

Nette\ComponentModel\Container

Nette\ComponentModel\Container je standardní implementací rozhraní IContainer. Je předkem například formuláře a tříd Control či Presenter.

Disponuje metodami pro snadné přidávání, získávání a odstraňování objektů a samozřejmě iteraci nad svým obsahem. Při pokusu o získání nedefinovaného potomka je zavolána továrna createComponent($name). Metoda createComponent($name) zavolá v aktuální komponentě metodu createComponent<název komponenty> a jako parametr jí předá název komponenty. Vytvořená komponenta je poté přidána do aktuální komponenty jako její potomek. Těmto metodám říkáme továrny na komponenty a mohou je implementovat potomci třídy Container.

Nette\Application\UI\Component

Třída Nette\Application\UI\Component je předek všech komponent používaných v presenteru. Komponenty presenteru jsou objekty, které si presenter uchovává počas svého životního cyklu.

Mají schopnost vzájemně ovlivňovat ostatní poděděné komponenty, ukládat své stavy do URL a odpovídat na uživatelské příkazy (signály) a nemusí být vykreslitelné.

Nette\Application\UI\Control

Control je vykreslitelná komponenta, znovupoužitelná součást webové aplikace, které se věnuje celá tato kapitola. Tuto třídu (nebo její potomky) máme obvykle na mysli, když hovoříme o komponentách. Navíc si umí pamatovat, kterou svou část má vykreslit při AJAXovém požadavku, jak jsme si už ukázali.

Control nepředstavuje výseč stránky, ale její logickou část. Je možné ji renderovat opakovaně, nebo podmíněně a klidně pokaždé s jinou šablonou.

Znovupoužitelná součást aplikace.

Monitorování předků

Komponentový model Nette umožňuje velmi dynamickou práci se stromem (komponenty můžeme vyjímat, přesouvat, přidávat), proto by byla chyba se spoléhat na to, že po vytvoření komponenty je hned (v konstruktoru) znám rodič, rodič rodiče atd. Většinou totiž rodič při vytvoření vůbec známý není.

Jak poznat, kdy byla komponenta připojena do stromu presenteru? Sledovat změnu rodiče nestačí, protože k presenteru mohl být připojen třeba rodič rodiče. Pomůže metoda monitor($type). Každá komponenta může monitorovat libovolný počet tříd a rozhraní. Připojení nebo odpojení je ohlášeno zavoláním metody attached($obj) resp. detached($obj), kde $obj je objekt sledované třídy.

Pro lepší pochopení příklad: třída UploadControl, reprezentující formulářový prvek pro upload souborů v Nette Forms, musí formuláři nastavit atribut enctype na hodnotu multipart/form-data. V době vytvoření objektu ale k žádnému formuláři připojena být nemusí. Ve kterém okamžiku tedy formulář modifikovat? Řešení je jednoduché – v konstruktoru se požádá o monitoring:

class UploadControl extends Nette\Forms\Controls\BaseControl
{
	public function __construct($label)
	{
		$this->monitor(Nette\Forms\Form::class);
		// ...
	}

	// ...
}

a jakmile je formulář k dispozici, zavolá se metoda attached:

protected function attached($form)
{
	parent::attached($form);

	if ($form instanceof Nette\Forms\Form) {
		$form->getElementPrototype()->enctype = 'multipart/form-data';
	}
}

Od nette/component-model v2.4 je preferovaný způsob předat callbacky přímo metodě monitor($type, $attached = null, $detached = null):

class UploadControl extends Nette\Forms\Controls\BaseControl
{
	public function __construct($label)
	{
		$this->monitor(Nette\Forms\Form::class, function ($form) {
			$form->getElementPrototype()->enctype = 'multipart/form-data';
		});
	}
}

Iterování nad dětmi

K iterování slouží metoda getComponents($deep = false, $type = null). První parametr určuje, zda se mají komponenty procházet do hloubky (neboli rekurzivně). S hodnotou true tedy nejen projde všechny komponenty, jichž je rodičem, ale také potomky svých potomků atd. Druhý parametr slouží jako volitelný filtr podle tříd nebo rozhraní.

Například takto nějak se interně provádí ověření validace prvků:

$valid = true;
foreach ($form->getComponents(true, Nette\Forms\IControl::class) as $control) {
	if (!$control->getRules()->validate()) {
		$valid = false;
		break;
	}
}

Signály do hloubky

Signál způsobí znovunačtení stránky úplně stejně jako při původním požadavku (kromě případu, kdy je volán AJAXem) a vyvolá metodu signalReceived($signal), jejíž výchozí implementace ve třídě Nette\Application\UI\Component se pokusí zavolat metodu složenou ze slov handle{signal}. Další zpracování je na daném objektu. Objekty, které dědí od Component (tzn. Control a Presenter) reagují tak, že se snaží zavolat metodu handle{signal} s příslušnými parametry.

Jinými slovy: vezme se definice funkce handle{signal} a všechny parametry, které přišly s požadavkem, a k argumentům se podle jména dosadí parametry z URL a pokusí se danou metodu zavolat. Např. jako prametr $id se předá hodnota z parametru id v URL, jako $something se předá something z URL, atd. A pokud metoda neexistuje, metoda signalReceived vyvolá výjimku.

Signál může přijímat jakákoliv komponenta, presenter nebo objekt, který implementuje rozhraní ISignalReceiver a je připojený do stromu komponent.

Mezi hlavní příjemce signálů budou patřit Presentery a vizuální komponenty dědící od Control. Signál má sloužit jako znamení pro objekt, že má něco udělat – anketa si má započítat hlas od uživatele, blok s novinkami se má rozbalit a zobrazit dvakrát tolik novinek, formulář byl odeslán a má zpracovat data a podobně.

URL pro signál vytváříme pomocí metody Component::link(). Jako parametr $destination předáme řetězec {signal}! a jako $args pole argumentů, které chceme signálu předat. Signál se vždy volá na aktuální view s aktuálními parametry, parametry signálu se jen přidají. Navíc se přidává hned na začátku parametr ?do, který určuje signál.

Jeho formát je buď {signal}, nebo {signalReceiver}-{signal}. {signalReceiver} je název komponenty v presenteru. Proto nemůže být v názvu komponenty pomlčka – používá se k oddělení názvu komponenty a signálu, je ovšem možné takto zanořit několik komponent.

Metoda isSignalReceiver() ověří, zda je komponenta (první argument) příjemcem signálu (druhý argument). Druhý argument můžeme vynechat – pak zjišťuje, jestli je komponenta příjemcem jakéhokoliv signálu. Jako druhý parameter lze uvést true a tím ověřit, jestli je příjemcem nejen uvedená komponenta, ale také kterýkoliv její potomek.

V kterékoliv fázi předcházející handle{signal} můžeme vykonat signál manuálně zavoláním metody processSignal(), která si bere na starosti vyřízení signálu – vezme komponentu, která se určila jako příjemce signálu (pokud není určen příjemce signálu, je to presenter samotný) a pošle jí signál.

Příklad:

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

Tím je signál provedený předčasně a už se nebude znovu volat.


Související články na blogu

Vylepšit tuto stránku