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#
- 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.
- The presenter into
app/UI/Admin/<Module>/Presenters/XxxPresenter.php:final,extends BasePresenter,$translatorDomain, a private property for the edited record, a constructor takingDataGridFactoryand the manager. - The actions:
actionEdit(int $id)loads into the property and calls$this->error()when nothing is found;actionAdd()nulls the property;renderAdd()/renderEdit()setpageTitleand the languages.renderAdd()ends withsetView('edit'). - The grid in
createComponentGrid()— the data source is the manager, not a query. Do not forgetsetIncludes()for whatever the grid actually shows, andsetItemsPerPage($this->getAdminItemsPerPage()). - The form into its own trait
Presenters/Traits/Forms/XxxFormTrait.php(procedure below). - The templates
Templates/<Presenter>/show.latteandedit.latte. - A row in
cms_admin_system_pageswith thepresenter(with the leading colon,:Admin:Blog:Tag) andactionpair, plus translations incms_admin_system_page_texts. - A menu item and its link to the page
(
cms_admin_system_page_menu_item_relations). - The translation keys into
app/Locale/<module>/<locale>/admin.<LOCALE>.neonunderadmin.<module>.presenters.<presenter>— in every language right away. - An RBAC key for actions not everyone should see; conditional display in the grid
through
isAllowed(). - 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.
createComponentEditForm()in theFormTraitpassescreateForm()a callback returningbool— 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;
}
- 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.
- 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;
}
- 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 |
- The way back is per form. The address of the previous page is taken from
Refereron the first GET render, stored in the session under a random key, and the key goes into the hiddenbackKeyfield. 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#
AdminFormHelpersTraitgives yougetFormLanguages()andstr();AdminEditFormComponentsTraitthe language tabs and the button bar.- 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'));
}
- When building the entity look the translation up through
getTranslation($langId)and create the missing ones includingsetParent(). includes: ['translations']belongs in the load insideactionEdit()— 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