Skip to content
V
For developers
Architecture, conventions, the core and security
UI layer / Admin presenter

Admin presenter#

An administration screen: a listing in a DataGrid, with adding and editing through a form underneath. The groundwork is done by app/Core/Base/Admin/BasePresenter.php — this chapter is about what you have to write on top of it.

What the base presenter does for you#

startup() runs in this order and you get all of it for free:

Step What it does
turning off the query cache the whole admin request reads from the database, not from Redis
contexts settings, theme, localisation, language, translator
checkAuthentication() JWT ↔ Nette session synchronisation
authorize() RBAC — the default requirement is the :Admin privilege
loadUserPrivileges() fills in the permissions for isAllowed() in templates and grids
initializePage() looks up the AdminPage by the presenter + action pair

On top of that: createForm() (a form with buttons and a way back), the translator in the template, getAdminItemsPerPage() and the Picker mode.

The admin request has the cache off on purpose — do not turn it back on

What is switched off is the context of the whole request, not just the admin repository: front managers run inside the administration too (assets, theme, user) and those go through a repository that allows caching. Without this, a cached record could reach the form — and an admin form is a read followed by a write. Something other than what the administrator saw would get saved.

In the administration the translation domain is NOT derived from the namespace

Unlike a Front presenter, you have to declare it by hand: protected string $translatorDomain = 'admin.blog.presenters.tag';. Without it nothing in the template is translated and createForm() skips useDomainTranslator() — the form texts then stay as bare keys. In detail: Translations.

What a finished presenter looks like#

The presenter file holds only data, actions and the grid factory. The form lives in a trait.

final class TagPresenter extends BasePresenter
{
    use TagFormTrait;                 // createComponentEditForm + saving
    use AdminFormHelpersTrait;        // getFormLanguages() + str()
    use AdminEditFormComponentsTrait; // language tabs + buttons

    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('Tag not found.');
        }
    }

    public function renderAdd(): void
    {
        $this->template->pageTitle = 'form.headers.add';
        $this->template->languages = $this->getFormLanguages();
        $this->setView('edit');        // add and edit share one template
    }
}
Action Template What it does
show Templates/<Presenter>/show.latte the listing, just {control grid}
add shares edit.latte via setView('edit') an empty form
edit Templates/<Presenter>/edit.latte a form with default values

Procedure: creating a new admin presenter#

  1. The model has to exist first. The Admin variant of the entity, the manager, service, mapper and repository — see Code conventions, the section on entities and managers.
  2. The presenter into app/UI/Admin/<Module>/Presenters/XxxPresenter.php: final, extends BasePresenter, $translatorDomain, a private property for the edited record, a constructor taking DataGridFactory and the manager.
  3. The actions: actionEdit(int $id) loads into the property and calls $this->error() when nothing is found; actionAdd() nulls the property; renderAdd() / renderEdit() set pageTitle and the languages. renderAdd() ends with setView('edit').
  4. The grid in createComponentGrid() — the data source is the manager, not a query. Do not forget setIncludes() for whatever the grid actually shows, and setItemsPerPage($this->getAdminItemsPerPage()).
  5. The form into its own trait Presenters/Traits/Forms/XxxFormTrait.php (procedure below).
  6. The templates Templates/<Presenter>/show.latte and edit.latte.
  7. A row in cms_admin_system_pages with the presenter (with the leading colon, :Admin:Blog:Tag) and action pair, plus translations in cms_admin_system_page_texts.
  8. A menu item and its link to the page (cms_admin_system_page_menu_item_relations).
  9. The translation keys into app/Locale/<module>/<locale>/admin.<LOCALE>.neon under admin.<module>.presenters.<presenter> — in every language right away.
  10. An RBAC key for actions not everyone should see; conditional display in the grid through isAllowed().
  11. Click through: listing, filter, sorting, pagination, adding, editing, deleting and the way back via the "Cancel" button.

Without a row in cms_admin_system_pages the page has no title and no menu

initializePage() looks the page up by the exact presenter + action pair, where the presenter column holds the full Nette name with a leading colon. When the row is missing nothing crashes — the title and meta tags just stay empty and no menu item is highlighted. It looks like a template bug.

A pretty admin URL needs the page translation, otherwise it falls back to the generic route

AdminRouteFactory tries the pretty form admin/<presenterHref>/<actionHref> first and returns null without a stored translation — the admin/<module>/<presenter>/<action> fallback is used instead. The address works, it is just ugly; nothing reports it.

Procedure: adding a form#

The skeleton is done by createForm(); you supply the fields and a callback that saves.

  1. createComponentEditForm() in the FormTrait passes createForm() a callback returning bool — success or not:
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. Fill the defaults only on a plain GET render. The condition above is deliberate — after a submit or during a signal the defaults would overwrite what the user typed.
  2. The callback saves through the manager, evaluates isSuccess(), sends a flash and on success sets $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. The buttons and redirects are already handled:
Button Behaviour
Save (save) saves and returns to the page you came from
Apply (update) saves and stays on the edit screen
Cancel (cancel) just a link back, saves nothing
  1. The way back is per form. The address of the previous page is taken from Referer on the first GET render, stored in the session under a random key, and the key goes into the hidden backKey field. Concurrent tabs therefore do not overwrite each other's return path.

An unset $savedEntityId means a DUPLICATE on the second “Apply”

On the add action update leads to redirect('this') — that is, back to an empty add form. A second click on "Apply" creates another new record there. The base class handles it by redirecting to edit?id=…, but only when the callback fills $savedEntityId in.

Only save and update save

Nette fires onSuccess for every valid submit. The base class therefore checks the name of the clicked button internally and returns without saving for the others. When you add your own submit (a Picker redraw, "add a row"), it will not save — and that is correct. Anyone not expecting it goes looking for why the data was not stored.

Procedure: adding multilingual fields#

  1. AdminFormHelpersTrait gives you getFormLanguages() and str(); AdminEditFormComponentsTrait the language tabs and the button bar.
  2. One set of fields per language, with a _<langId> suffix:
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. When building the entity look the translation up through getTranslation($langId) and create the missing ones including setParent().
  2. includes: ['translations'] belongs in the load inside actionEdit() — without it the form offers nothing.

A required field in a hidden language tab blocks submitting

That is why setRequired() hangs off addConditionOn($active, …) — the field is required only when that translation is marked active. Without the condition the form cannot be submitted and the browser jumps to a field that is not visible.

A field that is not rendered ERASES the value

A field the template does not render is not submitted — and saving writes it as empty. This is not limited to addHidden(): n:if on a form field does exactly the same. The data disappears without a single error message and you find out from a user complaint.

Edit defaults need the ToOne relations in includes

Without them the form offers an empty selection — and saving clears that relation, because empty is a valid value.

Picker mode#

A presenter can serve as the content of a Picker: it renders its show grid as a selection modal in a minimal layout, without adding, editing and bulk actions. It is switched on in the grid factory:

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

The parameters are persistent so they survive filtering and paging inside the modal. pickerExclude (and the pickerExcludeSubtreeLft/Rgt pair for trees) hides "Select" on records that must not be picked — typically so that a page cannot become its own parent.

Without setPickerLabelProperty() the label is taken from the first non-identifier column

Sometimes that works out, sometimes it does not — and when that property is empty for part of the records, the user ends up with an empty field after picking. When a single property is not enough (several fields have to be combined), use setPickerLabelCallback(), which receives the whole entity. In detail: Picker.

What to watch out for#

Redirecting to a raw URL throws the flash message away

redirectUrl() instead of redirect() means the user sees neither the confirmation nor the error — the action happens and it looks like nothing did.

A flash inside a modal needs its own anchor

Without one it renders outside the modal, where nobody sees it behind the open window.

After changing a form's structure the page has to be loaded twice

The first load still runs on the old shape from the cache. Do not conclude from the first attempt that your fix does not work.

The administration must not use Front traits or Front entities

It looks like a saving — both already exist, after all. But a Front entity is the result of hydrating a response for a visitor: it carries only what the front end needs and changes with it. An administration that reaches for it breaks on a change that has nothing to do with the administration.

Where to look#

I want Where
what the base class does app/Core/Base/Admin/BasePresenter.php
the form skeleton and the way back the same file → createForm(), resolveFormBackKey()
the language tabs and buttons app/Core/Traits/Admin/Presenters/AdminEditFormComponentsTrait.php
the form helpers app/Core/Traits/Admin/Presenters/AdminFormHelpersTrait.php
RBAC in the administration app/Core/Traits/Admin/Presenters/PrivilegeCheckTrait.php
loading the page and the menu app/Core/Traits/Admin/Presenters/PageInitTrait.php
pretty admin addresses app/Core/Routers/Factories/AdminRouteFactory.php
a finished example app/UI/Admin/Blog/Presenters/TagPresenter.php + Traits/Forms/TagFormTrait.php

Follow-up chapters: Working with tables · Picker · Forms · Translations · Code conventions