Přeskočit obsah
V
Pro vývojáře
Architektura, konvence, jádro systému a bezpečnost
UI vrstva / Komponenty

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');
{include $footTemplate}

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

  1. Argumenty plného renderu ulož do vlastnosti komponenty.
  2. V odkazu na signál předej stejné argumenty jako parametry signálu.
  3. V handle*() je ověř (styl jen z povoleného seznamu, protože jde do cesty šablony; počty omez stropem), ulož a zavolej redrawControl() na pojmenovaný snippet.
  4. V render() při volání bez argumentů vezmi uložené.
  5. 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

  1. Signál handleDelete() označ #[Requires(methods: 'POST')] a začni branami requirePrivilege() a requireContextOwner().
  2. Odkaz vykresli bez třídy ajax: href="{link delete! …}#<id záložky>", data-grid-post="page" a data-grid-confirm="{_'confirmDelete'}".
  3. Po smazání nastav flash a přesměruj na editaci záznamu. Fragment z href prohlíž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#

  1. Založit složku podle vzoru výš — komponenta, továrna, Templates/Default/.
  2. Logiku rozdělit do traitů, když přeroste jeden soubor (načítání zvlášť od akcí).
  3. 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.
  4. Šablonu psát do Templates/Default/ — ať ji převezme každá šablona.
  5. Texty překládat v PHP, do šablony posílat hotové.
  6. 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.

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#

  1. Použij CategoryRailTrait v komponentě výpisu kategorií.
  2. Implementuj categoryRailHref(int $categoryId) — jediné, co modul dodává. Odkaz skládej přes $this->getPresenter()->link(); na cizí stránku potřebuje slug cílové stránky, jinak vede na 404.
  3. V render() naplň sortedItems, currentBranch a currentParent, pak zavolej prepareCategoryRail($style) a hned za ním setTemplateFile($this->categoryRailTemplateDir(__DIR__, $style), $style . '.latte').
  4. Počet položek dodá výchozí categoryRailCount() z getAdvertCount(); jinak ho přepiš (e-shop počítá podstrom), nebo vrať null a 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.

Nastavení cookies