Komponenty#
Znovupoužitelný kus stránky s vlastní logikou, šablonou a případně vlastními
signály. Základ: app/Core/Base/BaseComponent.php.
Kdy komponentu vůbec dělat#
| Situace | Řešení |
|---|---|
| Kus markupu se opakuje, ale nemá logiku | partial v šabloně (_neco.latte) |
| Opakuje se i s logikou (načítá data, reaguje na kliknutí) | komponenta |
| Potřebuji překreslit jen část stránky přes AJAX | komponenta se {snippet} |
| Jde o jednu stránku a nikde jinde | nechte to v presenteru |
| Stav v adrese (filtr, rozsah dat), který nemá prosakovat do ostatních odkazů presenteru | komponenta s #[Persistent] vlastnostmi (vzor ProductStatisticsControl) — persistentní parametr presenteru se přidá do každého odkazu, který presenter vyrobí |
Komponenta není složka navíc — je to hranice
Co je uvnitř, může mít vlastní stav a signály a dá se to překreslit samo o sobě. Když to nepotřebujete, partial stačí a je levnější na pochopení.
Struktura složky#
Components/AdvertListComponent/
AdvertList.php komponenta
AdvertListFactory.php továrna (dědí BaseComponentFactory)
Traits/ rozklad logiky (načítání, filtry, akce)
Templates/
Default/
Style_1.latte výchozí podoba
Style_2.latte jiná podoba téhož
Elements/
Actions.latte kus sdílený mezi styly
Části uvnitř Elements/, ne trojí kopie
Co se opakuje ve všech stylech (cena, tlačítka, odznak uživatele), patří do
Elements/. Trojí kopie se pozná tak, že se změna udělá na dvou místech ze tří
— a vypadá to správně, dokud někdo nepřepne styl.
Jak vzniká#
Komponenta se nevytváří přímo. Vedle ní leží třída továrny, která dědí
App\Core\Base\BaseComponentFactory, závislosti dostane konstruktorem a komponentu předá
přes initialized() (ta uloží $control a spustí initControl()):
// ArticleListProviderFactory.php
class ArticleListProviderFactory extends BaseComponentFactory
{
public function __construct(
private readonly ArticleManager $articleManager,
private readonly ArticleListRendererFactory $articleListFactory,
) {}
public function create(): ArticleListProvider
{
return $this->initialized(new ArticleListProvider($this->articleManager, $this->articleListFactory));
}
}
Továrnu není třeba nikam zapisovat: search v config/common.neon registruje jako služby
všechny třídy v app/UI. Presenter ani createComponentXxx() nepotřebuje — o komponentu si
řekne šablona jménem a ComponentTrait::createStandardComponent() ho přeloží na třídu:
| Jméno v šabloně | Třída |
|---|---|
frontBlogArticleListProvider |
App\UI\Front\Blog\Components\ArticleListProviderComponent\ArticleListProvider |
frontComcatLastAdvertList |
App\UI\Front\Comcat\Components\LastAdvertListComponent\LastAdvertList |
Předpona front/admin a druhé slovo dají jmenný prostor, zbytek jméno komponenty. Pořadí
hledání: metoda createComponent<Jméno>() v presenteru (má přednost), služba
<Třída>Factory, služba <Třída>. Jméno začínající multi jde do Multiplieru.
V šabloně pak {control frontBlogArticleListProvider}, případně
{control frontBlogArticleListProvider:row $orderBy, 5}; část za dvojtečkou volá
renderRow().
Dva traity se stejně pojmenovanou injektovanou vlastností se NESLOUČÍ
Projeví se to chybou při sestavení kontejneru, ne při psaní kódu — takže to vypadá jako chyba konfigurace, i když je to kolize jmen v traitech.
Jak si komponenta hledá šablonu#
Ve třech krocích, první existující vyhrává:
1. Templates/<Šablona>/<Varianta>/Style_1.latte (přesně tahle šablona a zařízení)
2. Templates/<Šablona>/default/Style_1.latte (tahle šablona, všechna zařízení)
3. Templates/Default/Style_1.latte (společná výchozí podoba)
Podoba se volí argumentem při vykreslení; renderStyle2() se automaticky přeloží
na Style_2.latte, takže se kvůli tomu nemusí psát metoda.
Nová šablona nemusí kopírovat komponenty
Přebere Templates/Default/ a přepíše jen to, co chce jinak.
V šabloně komponenty NENÍ překladač s doménou
Na rozdíl od presenteru. Texty se překládají v PHP a do šablony jdou hotové.
Cesty v {include} jsou relativní k souboru
Přesun šablony rozbije všechny její vnořené includy.
Šablonou se myslí jméno tématu (Theme::getName(), adresář template9 → Template9),
varianta je zařízení s velkým písmenem (Desktop). Platí to pro témata frontu i
administrace: šablona komponenty pro admin téma template1 by ležela v
Templates/Template1/default/ vedle Templates/Default/.
Část šablony podle tématu#
Stejné hledání je k dispozici i pro jednotlivé části, které hlavní šablona vkládá přes
{include}. BaseComponent::resolveThemeTemplatePath($dir, $soubor) vrátí cestu podle
stejného pořadí (a nakonec $soubor přímo v $dir); komponenta ji předá do šablony
proměnnou:
// render() komponenty
$this->template->footTemplate = $this->resolveThemeTemplatePath(__DIR__, 'Parts/_foot.latte');
Téma pak nahradí jen tu jednu část a zbytek hlavní šablony bere z Templates/Default/.
Takhle to dělá DataGrid s akcemi řádku a patičkou, viz
Práce s tabulkami.
Natvrdo zapsaná cesta v {include} téma obejde
{include 'Parts/_foot.latte'} vždy vloží soubor vedle hlavní šablony. Část, kterou
má jít v tématu přepsat, musí jít přes proměnnou z resolveThemeTemplatePath().
Sdílené modální okno#
Modál se v Latte nekreslí. V layoutu je jednou namountovaný sdílený shell a komponenty do něj jen vloží obsah a otevřou ho.
$this->getModalWindow() // z ModalAwareTrait
->setTitle('Odpověď na inzerát')
->setSize('modal-lg')
->setContent($html) // hotové HTML, typicky vyrenderovaný partial
->open();
| Vlastnost | Jak to je |
|---|---|
| Kde je shell | jednou v admin @layout.latte jako {control modalWindow} |
| Kdo ho plní | kterýkoli presenter nebo komponenta pod ním |
| Stav | žádný — titulek ani obsah se neukládají do session, žijí jen v tomhle requestu |
| Otevření přes AJAX | překreslí se jen {snippet modalBody} a do odpovědi se zapíše modalShow s id; zobrazení dělá modals.js |
| Víc oken naráz | nepodporuje se — je to jeden sdílený shell |
Proto se do modálu dá vložit i formulář z jiné komponenty
Obsah je obyčejný řetězec HTML. Komponenta si vyrenderuje svůj partial do řetězce a pošle ho do modálu; modál o ní nic neví a nemusí.
Komponenta musí běžet pod presenterem, který modál poskytuje
getModalWindow() to kontroluje a jinak vyhodí výjimku se jménem komponenty.
Není to obtěžování — bez shellu v layoutu by se okno nemělo kde zobrazit.
Povinné pole ve skrytém modálu zablokuje odeslání celého formuláře
Prohlížeč odmítne odeslat formulář kvůli poli, které uživatel nevidí, a neřekne proč — stránka jen přestane reagovat na odeslání. Povinnost se proto musí přepínat podle toho, jestli je modál otevřený.
Signály#
Metoda handle*() obsluhuje akci komponenty (smazat, přepnout, načíst další).
Volá se běžnou adresou, kterou si komponenta vygeneruje.
Signál je veřejná adresa — musí si ověřit původ požadavku
Bez kontroly ho jde vyvolat z cizí stránky jménem přihlášeného uživatele.
Kontrola stejného původu je u handle*() povinná, ne volitelná.
AJAX překreslí jen VNITŘEK snippetu
Atributy elementu, na kterém snippet visí (třídy, data-), zůstanou původní.
Co se má měnit, musí být uvnitř.
V AJAX odpovědi Nette vykresluje jen invalidované snippety a render() komponenty volá
bez argumentů. Šablona, která argumenty předává ({control x:tile, 3, false, true}), ani
layout se při tom nespouští. Komponenta s argumenty renderu si je proto musí zajistit sama:
| Kdy | Odkud argumenty |
|---|---|
| plný render | z šablony; komponenta si je zapamatuje (CategoryList::$lastRenderArgs) |
| AJAX signál | ze signálu: odkaz je předá jako parametry (showAll!, tab!), handler je ověří a uloží |
| render bez argumentů | func_num_args() === 0 → použije uložené |
Postup: komponenta s argumenty renderu a AJAX signálem
- Argumenty plného renderu ulož do vlastnosti komponenty.
- V odkazu na signál předej stejné argumenty jako parametry signálu.
- V
handle*()je ověř (styl jen z povoleného seznamu, protože jde do cesty šablony; počty omez stropem), ulož a zavolejredrawControl()na pojmenovaný snippet. - V
render()při volání bez argumentů vezmi uložené. - Stav, který má přežít plný reload bez JS, dej do
#[Persistent]parametru.
Bez uložených argumentů vyjde AJAX odpověď prázdná
render() bez argumentů vykreslí výchozí styl, ve kterém pojmenovaný snippet není,
a payload nemá co poslat. Klik na „Všechny kategorie“ pak neudělá nic a chyba se nikde
neukáže.
Dva mounty jedné instance si rozbijí snippet i persistentní parametr
Komponentu, která je na stránce víckrát s jiným stavem, vkládej přes
{multicontrol frontBazaarCategoryList-<klíč>:…}: každý klíč je vlastní instance. Prosté
{control} dvakrát je jedna instance se společným stavem a jedním jménem snippetu.
Tvar zápisu je {multicontrol <komponenta>-<klíč>:<styl> <argumenty>}: jméno komponenty
je část před první pomlčkou, klíč instance je jedno slovo bez pomlčky (další pomlčku Nette
čte jako cestu k vnořené komponentě a spadne na „Component with name '…' does not exist.“)
a argumenty renderu následují za mezerou, ne za čárkou (Unexpected ',' při kompilaci).
Potvrzení zapisujícího odkazu#
Mazání a jiné zapisující odkazy v administraci potvrzuje stylovaný dialog z datagrid.js
místo window.confirm. Funguje v gridu i mimo něj (řádkové akce v komponentě): texty
dialogu bere z předka .data-grid, mimo grid z atributů samotného odkazu.
Atribut na <a> |
Co se stane po potvrzení |
|---|---|
jen data-grid-confirm="…" |
text otázky; propustí původní akci prvku (odkaz se otevře, formulář se odešle se správným tlačítkem) |
data-grid-post="page" |
POST prázdným formulářem na href, server odpoví přesměrováním |
data-grid-post="ajax" |
POST přes naja, překreslí snippety |
data-grid-confirm-ajax |
totéž přes naja (potvrzený AJAX signál, třeba seřazení dětí stromu) |
data-confirm-title, data-confirm-yes, data-confirm-no, data-confirm-variant |
vlastní texty a barva potvrzovacího tlačítka (výchozí danger) |
Formulář v modálu dostává id editovaného záznamu v hidden poli, ne v parametru signálu:
akce formuláře komponenty nenese parametry signálu add! nebo edit!, kterým se modál
otevřel. Hidden pole jde přepsat, proto ho komponenta ověří proti kontextu vlastníka (viz
Admin presenter).
Postup: smazání položky v záložce editace
- Signál
handleDelete()označ#[Requires(methods: 'POST')]a začni branamirequirePrivilege()arequireContextOwner(). - Odkaz vykresli bez třídy
ajax:href="{link delete! …}#<id záložky>",data-grid-post="page"adata-grid-confirm="{_'confirmDelete'}". - Po smazání nastav flash a přesměruj na editaci záznamu. Fragment z
hrefprohlížeč po přesměrování zachová, takže zůstane otevřená stejná záložka.
Vzor: app/UI/Admin/Bazaar/Components/PaymentItemsComponent/ (šablona i handleDelete()).
Třída ajax na potvrzovaném odkazu odešle požadavek dřív, než se dialog zeptá
Naja váže obsluhu .ajax přímo na prvek, takže doběhne před delegovaným potvrzením.
Smazání proběhne i tehdy, když uživatel v dialogu klikne na Zrušit. Pro potvrzený AJAX
použij data-grid-confirm-ajax nebo data-grid-post="ajax", nikdy třídu ajax.
Zapisující signál otevřitelný GETem spustí robot nebo prefetch
Odkaz v přihlášené relaci může vyvolat i přednačtení prohlížeče. Proto zapisující
signál přijímá jen POST a odkaz ho posílá přes data-grid-post.
Nová komponenta krok za krokem#
- Založit složku podle vzoru výš — komponenta, továrna,
Templates/Default/. - Logiku rozdělit do traitů, když přeroste jeden soubor (načítání zvlášť od akcí).
- Pojmenovat podle konvence (
front<Modul><Jméno>), aby ji šablona našla bez registrace;createComponent<Jméno>()v presenteru piš jen tehdy, když komponenta potřebuje nastavení z presenteru. - Šablonu psát do
Templates/Default/— ať ji převezme každá šablona. - Texty překládat v PHP, do šablony posílat hotové.
- Prokliknout — včetně AJAX cesty, pokud komponenta překresluje.
Oprava sdíleného základu je změna ve všech modulech, které ho používají
Komponenta se společným předkem pro tři moduly znamená, že oprava kvůli jednomu zasáhne všechny tři. Projděte je všechny — ne jen ten, kvůli kterému opravujete.
Navigace po úrovních#
Postranní panel s kategoriemi a regiony dřív kreslil celý strom: CategoryList a
RegionList načetly a hydratovaly každý uzel při každém požadavku (461 kategorií
inzerce, kolem 9,5 tisíce regionů), aby ukázaly jednu úroveň. Náhrada ukazuje vždy jednu
úroveň a každou úroveň čte malým cachovaným dotazem podle rodiče.
| Komponenta | Ovládací prvek v šabloně |
|---|---|
Bazaar\CategoryChildrenList, Bazaar\RegionChildrenList |
{control frontBazaarCategoryChildrenList}, {control frontBazaarRegionChildrenList} |
Comcat\… |
frontComcat…ChildrenList |
Blog\CategoryChildrenList, Blog\RegionChildrenList |
frontBlogCategoryChildrenList, frontBlogRegionChildrenList |
Discussion\CategoryChildrenList |
frontDiscussionCategoryChildrenList |
Eshop\ProductCategoryChildrenList (bez počtů — součet podstromu by vyžadoval celý strom) |
frontEshopProductCategoryChildrenList |
základ regionů System\RegionChildrenList |
modul je tenká podtřída (vzor RegionList) |
Zapojeno je v aside-left.latte všech 18 šablon u pěti modulů (kolem 90 souborů); žádný
už nevolá kreslení plného stromu.
Z čeho se skládá#
| Část | Úkol |
|---|---|
ChildrenNavigationTrait |
načte jednu úroveň (parentId = <aktuální> nebo IS NULL pro kořeny) a rodiče pro cestu zpět; vybírá šablonu — kopie modulu nebo motivu má přednost před sdílenou |
CategoryChildrenTrait |
příprava dat pro kategorie nad CategoryRailTrait; modul dodá manager, entitu, překladač adres, categoryRailHref() a categoryChildrenRootHref() |
Base/Front/Components/ChildrenNavigation/Templates/Default/Style_1.latte |
sdílená plochá šablona |
RegionChildrenListComponent/Traits/InitTrait |
totéž pro regiony |
Výsledek má tvar li.active > a.active: cesta zpět (.menu-item--parent), vybraný uzel
a jeho potomci ve vnořeném <ul>. Je to stejná struktura jako u původního stromu, takže
se styly témat uplatní bez zásahu do CSS. Komponenta si hledá svůj uzel v presenteru
(getCurrentCategory() / getCurrentRegion()) a přijme ho jen, když patří jejímu
modulu — komponenta vložená na stránku jiného modulu nepřevezme cizí kategorii.
Kategorie podporují styly Style_1 (plochý), Style_4 a Style_6 (kolejnicové); jiný styl
vyhodí InvalidArgumentException.
Odkazy a zástupný segment -#
Odkaz zachová vybraný region při změně kategorie a kategorii při změně regionu; změna
vždy začíná na straně 1. V Bazaaru a Comcatu jsou segmenty adresy <kategorie>/<region>, a
proto musí být obě výslovné: chybí-li kategorie, píše se -, jinak by se region
posunul na místo kategorie. Discussion a e-shop - nepotřebují. Kořenová kategorie nemá
rodiče, takže odkaz zpět vede na přehled kořenů.
Zástupný segment - musí umět každý překladač
Kategoriové překladače na - odpovídají bez warmupu a bez dotazu. Nový překladač, který
- nezná, ho pošle do databáze při každém odkazu, nebo vyrobí chybný odkaz.
Factory: return $this->control PHPStan nepustí
Typ je BaseComponent|null. Vrať výsledek initialized(), nebo použij lokální proměnnou
$control.
Jak zapojit do vlastního tématu
Do aside-left.latte vlož {control front<Modul>CategoryChildrenList} a
{control front<Modul>RegionChildrenList}. Vzhled (odkaz zpět, aktivní řádek) určuje
téma stylem .menu-item--parent a .active; nově nepoužité jsou styly pro
details/summary/.has-children ze starého stromu.
Sdílené výpisy kategorií#
Komponenta CategoryList (v e-shopu ProductCategoryList) má v pěti modulech (Blog,
Bazaar, Comcat, Discussion, Eshop) jednu sdílenou sadu šablon
v app/Core/Base/Front/Components/CategoryList/Templates/Default/. Data pro ně připraví
CategoryRailTrait: rozdíly mezi moduly (cíl odkazu, ikona, počet položek) předpočítá do
prostých polí $railRows a $railParent, takže šablona žádnou entitu nezná.
| Styl | Co ukazuje | Uzly |
|---|---|---|
Style_2 |
dlaždice bez potomků | kořeny (sortedItems) |
Style_4 |
pás podkategorií s ikonou a cestou zpět | děti aktuální kategorie, mimo kategorii kořeny |
Style_5 |
dlaždice se třemi potomky | kořeny (sortedItems) |
Style_6 |
řádek odkazů s cestou zpět | jako Style_4 |
U Style_4 a Style_6 rozhoduje rozdíl null × [] v currentBranch: null znamená
„nejsme v kategorii, ukaž kořeny“, prázdné pole „jsme v listové kategorii, není co ukázat“.
Šablona se hledá v tomto pořadí, první existující vyhrává:
1. <modul>/Templates/<Šablona>/<Varianta>/Style_X.latte (kopie pro aktuální šablonu)
2. <modul>/Templates/Default/Style_X.latte (kopie modulu pro všechny šablony)
3. Base/Front/Components/CategoryList/Templates/Default/Style_X.latte
Vlastní Templates/Default/Style_2.latte mají Bazaar, Comcat, Discussion i Blog, takže
sdílený Style_2 dnes kreslí jen e-shop (spočítáno
find app/UI/Front -path '*CategoryList*' -name 'Style_2.latte').
Postup: zapojit sdílené styly do modulu#
- Použij
CategoryRailTraitv komponentě výpisu kategorií. - Implementuj
categoryRailHref(int $categoryId)— jediné, co modul dodává. Odkaz skládej přes$this->getPresenter()->link(); na cizí stránku potřebujeslugcílové stránky, jinak vede na 404. - V
render()naplňsortedItems,currentBranchacurrentParent, pak zavolejprepareCategoryRail($style)a hned za nímsetTemplateFile($this->categoryRailTemplateDir(__DIR__, $style), $style . '.latte'). - Počet položek dodá výchozí
categoryRailCount()zgetAdvertCount(); jinak ho přepiš (e-shop počítá podstrom), nebo vraťnulla počet se nevykreslí.
prepareCategoryRail() volej až těsně před setTemplateFile()
Bazaar mezitím ještě filtruje sortedItems (preferované kategorie). Dřívější volání
podstrčí dlaždicím nefiltrovaný strom.
Šablonu nehledej globem přes všechna témata modulu
Kopie z jiného tématu (Templates/Template15/Desktop/Style_4.latte) by se našla i při
vykreslování jiného tématu; když modul nemá Templates/Default/Style_4.latte, soubor
neexistuje a stránka spadne na 500 v každém tématu bez vlastní kopie. Hledání proto
kopíruje kroky BaseComponent::setTemplateFile() jen pro aktuální téma.
Hotové komponenty k okoukání#
| Komponenta | Kde | Čím je zajímavá |
|---|---|---|
| DataGrid | app/UI/Admin/System/Components/ |
tabulka s filtry, řazením a hromadnými akcemi |
| Picker | tamtéž | výběr entity místo <select> |
| Modální okno | tamtéž | sdílený shell popsaný výš |
| Výpis inzerátů | app/UI/Front/Bazaar/Components/AdvertRendererComponent/ |
tři podoby + Elements/ |
| Navigace po úrovních | app/UI/Front/Bazaar/Components/CategoryChildrenListComponent/ |
tenká podtřída nad sdíleným základem |
Vizuální podobu komponent frontu ukazuje katalog komponent.