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

Admin presenter#

Obrazovka administrace: výpis v DataGridu, pod ním přidání a úprava přes formulář. Základ dělá app/Core/Base/Admin/BasePresenter.php — tahle kapitola je o tom, co k němu musíte dopsat vy.

Co bázový presenter zařizuje sám#

startup() proběhne v tomhle pořadí a všechno z toho dostanete zadarmo:

Krok Co udělá
vypnutí query cache celý admin požadavek čte z databáze, ne z Redisu
kontexty nastavení, téma, lokalizace, jazyk, překladač
checkAuthentication() synchronizace JWT ↔ Nette session
authorize() RBAC — výchozí požadavek je privilegium :Admin
loadUserPrivileges() naplní oprávnění pro isAllowed() v šablonách a gridech
initializePage() dohledá AdminPage podle dvojice presenter + akce

Navíc: createForm() (formulář s tlačítky a návratem), překladač v šabloně, getAdminItemsPerPage() a režim Pickeru.

Admin požadavek má cache vypnutou schválně — nezapínejte ji zpátky

Vypíná se kontext celého požadavku, ne jen admin repository: v administraci běží i frontové managery (assety, téma, uživatel) a ty jdou přes repository, která cache povoluje. Bez toho by se do formuláře mohl dostat záznam z cache — a admin formulář je čtení, po kterém následuje zápis. Uložilo by se něco jiného, než co admin viděl.

Doména překladů se v administraci NEodvozuje z namespace

Na rozdíl od Front presenteru ji musíte deklarovat ručně: protected string $translatorDomain = 'admin.blog.presenters.tag';. Bez ní se v šabloně nepřeloží nic a createForm() přeskočí useDomainTranslator() — texty formuláře pak zůstanou jako holé klíče. Podrobně Překlady.

Jak vypadá hotový presenter#

Soubor presenteru drží jen data, akce a továrnu gridu. Formulář je v traitu.

final class TagPresenter extends BasePresenter
{
    use TagFormTrait;                 // createComponentEditForm + uložení
    use AdminFormHelpersTrait;        // getFormLanguages() + str()
    use AdminEditFormComponentsTrait; // jazykové záložky + tlačítka

    protected string $translatorDomain = 'admin.blog.presenters.tag';

    private ?Tag $editedTag = null;

    public function __construct(
        private readonly DataGridFactory $dataGridFactory,
        private readonly TagManager $tagManager,
    ) {
        parent::__construct();
    }

    public function actionEdit(int $id): void
    {
        $tag = $this->tagManager->findAll(
            filters: [new EqualFilter('id', $id)],
            limit: 1,
            includes: ['translations'],
        )->first();

        $this->editedTag = $tag instanceof Tag ? $tag : null;

        if ($this->editedTag === null) {
            $this->error('Štítek nenalezen.');
        }
    }

    public function renderAdd(): void
    {
        $this->template->pageTitle = 'form.headers.add';
        $this->template->languages = $this->getFormLanguages();
        $this->setView('edit');        // add i edit sdílejí jednu šablonu
    }
}
Akce Šablona Co dělá
show Templates/<Presenter>/show.latte výpis, jen {control grid}
add sdílí edit.latte přes setView('edit') prázdný formulář
edit Templates/<Presenter>/edit.latte formulář s výchozími hodnotami

Postup: zakládám nový admin presenter#

  1. Model musí být hotový dřív. Admin varianta entity, manager, service, mapper a repository — viz Konvence kódu, oddíl o entitě a manageru.
  2. Presenter do app/UI/Admin/<Modul>/Presenters/XxxPresenter.php: final, extends BasePresenter, $translatorDomain, private property pro editovaný záznam, konstruktor s DataGridFactory a manažerem.
  3. Akce: actionEdit(int $id) načte do property a při nenalezení volá $this->error(); actionAdd() property vynuluje; renderAdd() / renderEdit() nastaví pageTitle a jazyky. renderAdd() končí setView('edit').
  4. Grid v createComponentGrid() — zdroj dat je manager, ne dotaz. Nezapomeňte setIncludes() na to, co grid opravdu zobrazuje, a setItemsPerPage($this->getAdminItemsPerPage()).
  5. Formulář do vlastního traitu Presenters/Traits/Forms/XxxFormTrait.php (postup níž).
  6. Šablony Templates/<Presenter>/show.latte a edit.latte.
  7. Řádek v cms_admin_system_pages s dvojicí presenter (s úvodní dvojtečkou, :Admin:Blog:Tag) a action, plus překlady v cms_admin_system_page_texts.
  8. Položku menu a její vazbu na stránku (cms_admin_system_page_menu_item_relations).
  9. Překladové klíče do app/Locale/<modul>/<locale>/admin.<LOCALE>.neon pod admin.<modul>.presenters.<presenter> — a rovnou ve všech jazycích.
  10. RBAC klíč pro akce, které nemá vidět každý; podmíněné zobrazení v gridu přes isAllowed().
  11. Prokliknout: výpis, filtr, řazení, stránkování, přidání, úprava, smazání a návrat tlačítkem „Zrušit“.

Bez řádku v cms_admin_system_pages nemá stránka titulek ani menu

initializePage() hledá stránku podle přesné dvojice presenter + akce, přičemž sloupec presenter drží plný Nette název s úvodní dvojtečkou. Když řádek chybí, nic nespadne — jen zůstane prázdný titulek, prázdné meta tagy a v menu se nezvýrazní žádná položka. Vypadá to jako chyba šablony.

Hezká admin URL potřebuje překlad stránky, jinak spadne na obecnou routu

AdminRouteFactory zkouší nejdřív hezký tvar admin/<presenterHref>/<actionHref> a bez uloženého překladu vrátí null — použije se fallback admin/<modul>/<presenter>/<akce>. Adresa funguje, jen je ošklivá; nikde se to nehlásí.

Postup: přidávám formulář#

Kostru dělá createForm(), vy dodáte pole a callback, který uloží.

  1. createComponentEditForm() v FormTrait předá do createForm() callback, který vrací bool — úspěch, nebo ne:
protected function createComponentEditForm(): Form
{
    $form = $this->createForm(fn(ArrayHash $values): bool => $this->saveTagForm($values));

    $this->addTranslationFields($form);

    if ($this->getHttpRequest()->isMethod('GET') && $this->getSignal() === null) {
        $this->setTagFormDefaults($form);
    }

    return $form;
}
  1. Výchozí hodnoty plňte jen při běžném GET renderu. Podmínka výš je záměrná — po odeslání formuláře nebo při signálu by defaulty přepsaly to, co uživatel vyplnil.
  2. Callback uloží přes manager, vyhodnotí isSuccess(), pošle flash a při úspěchu nastaví $this->savedEntityId:
private function saveTagForm(ArrayHash $values): bool
{
    $tag = $this->buildTagEntity($values);

    $result = $this->tagManager->save($tag);
    if (!$result->isSuccess()) {
        $this->flashMessage($this->translatorDomain . '.form.messages.save.error', 'danger');
        return false;
    }

    $this->flashMessage($this->translatorDomain . '.form.messages.save.success', 'success');
    $this->savedEntityId = $tag->getId();

    return true;
}
  1. Tlačítka a redirecty už řešit nemusíte:
Tlačítko Chování
Uložit (save) uloží a vrátí se na stránku, ze které jste přišli
Použít (update) uloží a zůstane na editaci
Zrušit (cancel) jen odkaz zpět, nic neukládá
  1. Návrat „zpět“ je per formulář. Adresa předchozí stránky se při prvním GET renderu vezme z Referer, uloží do session pod náhodný klíč a klíč jde do skrytého pole backKey. Souběžné taby si proto návrat nepřepisují.

$savedEntityId nenastavené znamená DUPLIKÁT při druhém „Použít“

Na akci add vede update na redirect('this') — tedy zpátky na prázdný add formulář. Druhé kliknutí na „Použít“ tam založí další nový záznam. Base to ošetřuje přesměrováním na edit?id=…, ale jen když callback $savedEntityId vyplní.

Ukládá se JEN přes save a update

Nette pouští onSuccess pro každý validní submit. Base proto uvnitř kontroluje jméno stisknutého tlačítka a u ostatních se vrátí bez uložení. Když si přidáte vlastní submit (překreslení Pickeru, „přidat řádek“), nebude ukládat — a to je správně. Kdo to nečeká, hledá, proč se data neuložila.

Postup: přidávám vícejazyčná pole#

  1. AdminFormHelpersTraitgetFormLanguages() a str(); AdminEditFormComponentsTrait jazykové záložky a lištu tlačítek.
  2. Jedna sada polí na jazyk, sufix _<langId>:
foreach ($this->getFormLanguages() as $language) {
    $langId = (int) $language->getId();

    $active = $form->addCheckbox('active_' . $langId, 'form.fields.active.label');

    $form->addText('name_' . $langId, 'form.fields.name.label')
        ->setNullable()
        ->addConditionOn($active, $form::EQUAL, true)
        ->setRequired($t('form.fields.name.required'));
}
  1. Při sestavování entity dohledejte překlad přes getTranslation($langId) a chybějící založte i s setParent().
  2. includes: ['translations'] patří do načtení v actionEdit() — bez toho formulář nabídne prázdno.

Povinné pole v nezobrazené jazykové záložce zablokuje odeslání

Proto je setRequired() navěšené addConditionOn($active, …) — povinné je jen tehdy, když je ten překlad označený jako aktivní. Bez podmínky nejde formulář odeslat a prohlížeč přitom skáče na pole, které není vidět.

Nevykreslené pole SMAŽE hodnotu

Pole, které šablona nevykreslí, se neodešle — a uložení ho zapíše jako prázdné. Netýká se to jen addHidden(): totéž udělá n:if na poli formuláře. Data zmizí bez jediné chybové hlášky a přijde se na to podle stížnosti uživatele.

Výchozí hodnoty editace potřebují ToOne vazby v includes

Bez nich formulář nabídne prázdný výběr — a uložení tu vazbu zruší, protože prázdno je platná hodnota.

Režim Pickeru#

Presenter umí posloužit jako obsah Pickeru: vykreslí svůj show grid jako výběrový modál v minimálním layoutu, bez přidání, editace a hromadných akcí. Zapíná se v továrně gridu:

if ($this->picker !== '') {
    $grid->setPickerMode($this->picker)->setPickerLabelProperty('translations.name');
}

Parametry jsou persistentní, aby přežily filtrování a stránkování uvnitř modálu. pickerExclude (a dvojice pickerExcludeSubtreeLft/Rgt u stromů) skryje „Vybrat“ u záznamů, které se vybrat nesmí — typicky aby stránka nemohla být sama sobě rodičem.

Bez setPickerLabelProperty() se popisek vezme z prvního sloupce, který není identifikátor

Někdy to vyjde, někdy ne — a když je ta property u části záznamů prázdná, uživatel po výběru uvidí prázdné pole. Když jediná property nestačí (je potřeba složit víc polí), použijte setPickerLabelCallback(), který dostane celou entitu. Podrobně Picker.

Na co si dát pozor#

Přesměrování na surovou adresu flash zprávu zahodí

redirectUrl() místo redirect() znamená, že uživatel neuvidí ani potvrzení, ani chybu — akce proběhne a vypadá, že se nic nestalo.

Flash v modálním okně potřebuje vlastní kotvu

Bez ní se vykreslí mimo modál, kde ji za otevřeným oknem nikdo nevidí.

Po změně struktury formuláře je potřeba načíst stránku dvakrát

První načtení ještě jede podle staré podoby z mezipaměti. Nezakládejte na prvním pokusu závěr, že oprava nefunguje.

Administrace nesmí používat Front traity ani Front entity

Vypadá to jako úspora — obojí přece existuje. Jenže Front entita je výsledek hydratace odpovědi pro návštěvníka: nese jen to, co Front potřebuje, a mění se podle frontu. Admin, který na ni sáhne, se rozbije při úpravě, která s administrací nemá nic společného.

Kam sáhnout#

Chci Kde
co dělá základ app/Core/Base/Admin/BasePresenter.php
kostru formuláře a návrat zpět tamtéž → createForm(), resolveFormBackKey()
jazykové záložky a tlačítka app/Core/Traits/Admin/Presenters/AdminEditFormComponentsTrait.php
pomocníky formuláře app/Core/Traits/Admin/Presenters/AdminFormHelpersTrait.php
RBAC v administraci app/Core/Traits/Admin/Presenters/PrivilegeCheckTrait.php
načtení stránky a menu app/Core/Traits/Admin/Presenters/PageInitTrait.php
hezké admin adresy app/Core/Routers/Factories/AdminRouteFactory.php
hotový vzor app/UI/Admin/Blog/Presenters/TagPresenter.php + Traits/Forms/TagFormTrait.php

Navazující kapitoly: Práce s tabulkami · Picker · Formuláře · Překlady · Konvence kódu