Components#
A reusable piece of a page with its own logic, template and possibly its own
signals. The base: app/Core/Base/BaseComponent.php.
When to make a component at all#
| Situation | What to do |
|---|---|
| A chunk of markup repeats but has no logic | a partial in the template (_something.latte) |
| It repeats together with logic (loads data, reacts to clicks) | a component |
| I need to redraw part of the page over AJAX | a component with a {snippet} |
| It is one page and nowhere else | leave it in the presenter |
| State in the URL (a filter, a date range) that must not leak into the presenter's other links | a component with #[Persistent] properties (pattern ProductStatisticsControl) — a persistent presenter parameter is added to every link the presenter builds |
A component is not an extra folder — it is a boundary
What is inside may have its own state and signals and can be redrawn on its own. When you do not need that, a partial is enough and cheaper to understand.
Folder structure#
Components/AdvertListComponent/
AdvertList.php the component
AdvertListFactory.php the factory (extends BaseComponentFactory)
Traits/ logic split up (loading, filters, actions)
Templates/
Default/
Style_1.latte the default look
Style_2.latte another look of the same thing
Elements/
Actions.latte a piece shared between the styles
Shared pieces into Elements/, not three copies
Whatever repeats across all styles (price, buttons, the user badge) belongs in
Elements/. Three copies announce themselves by a change being made in two
places out of three — and it looks right until somebody switches the style.
How one comes into being#
A component is never constructed directly. Next to it sits a factory class that extends
App\Core\Base\BaseComponentFactory, receives its dependencies through the constructor and
hands the component over through initialized() (which stores $control and runs
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));
}
}
The factory does not have to be registered anywhere: search in config/common.neon
registers every class in app/UI as a service. Neither the presenter nor
createComponentXxx() is needed — the template asks for the component by name and
ComponentTrait::createStandardComponent() translates it to the class:
| Name in the template | Class |
|---|---|
frontBlogArticleListProvider |
App\UI\Front\Blog\Components\ArticleListProviderComponent\ArticleListProvider |
frontComcatLastAdvertList |
App\UI\Front\Comcat\Components\LastAdvertListComponent\LastAdvertList |
The front/admin prefix and the second word give the namespace, the rest is the component
name. Lookup order: a createComponent<Name>() method in the presenter (wins), the service
<Class>Factory, the service <Class>. A name starting with multi goes to the Multiplier.
In the template it is then {control frontBlogArticleListProvider}, or
{control frontBlogArticleListProvider:row $orderBy, 5}; the part after the colon calls
renderRow().
Two traits injecting a property of the same name will NOT merge
It surfaces as an error while the container is being built, not while you write the code — so it looks like a configuration problem even though it is a name clash in traits.
How a component finds its template#
In three steps, first hit wins:
1. Templates/<Theme>/<Variant>/Style_1.latte (exactly this theme and device)
2. Templates/<Theme>/default/Style_1.latte (this theme, all devices)
3. Templates/Default/Style_1.latte (the shared default)
The look is chosen by an argument when rendering; renderStyle2() resolves to
Style_2.latte automatically, so no method has to be written for it.
A new theme need not copy components
It inherits Templates/Default/ and overrides only what it wants different.
A component template has NO domain-aware translator
Unlike a presenter. Texts are translated in PHP and arrive in the template ready.
Paths in {include} are relative to the file
Moving a template breaks every nested include it has.
The theme is the theme name (Theme::getName(), directory template9 → Template9), the
variant is the device with a capital letter (Desktop). This holds for front and
administration themes alike: a component template for the admin theme template1 would live
in Templates/Template1/default/ next to Templates/Default/.
A template part per theme#
The same lookup is available for single parts the main template pulls in with {include}.
BaseComponent::resolveThemeTemplatePath($dir, $file) returns the path in the same order
(and finally $file directly in $dir); the component passes it to the template in a
variable:
// the component's render()
$this->template->footTemplate = $this->resolveThemeTemplatePath(__DIR__, 'Parts/_foot.latte');
A theme then replaces just that one part and takes the rest of the main template from
Templates/Default/. DataGrid does this with its row actions and footer, see
Working with tables.
A hard-coded path in {include} bypasses the theme
{include 'Parts/_foot.latte'} always includes the file next to the main template. A
part meant to be overridable in a theme must go through a variable from
resolveThemeTemplatePath().
The shared modal window#
The modal is not drawn in Latte. A shared shell is mounted once in the layout and components merely put content into it and open it.
$this->getModalWindow() // from ModalAwareTrait
->setTitle('Reply to the ad')
->setSize('modal-lg')
->setContent($html) // finished HTML, typically a rendered partial
->open();
| Property | How it is |
|---|---|
| Where the shell is | once in the admin @layout.latte as {control modalWindow} |
| Who fills it | any presenter or component beneath it |
| State | none — neither title nor content is stored in the session, they live for this request only |
| Opening over AJAX | only {snippet modalBody} is redrawn and modalShow with an id goes into the response; modals.js does the showing |
| Several windows at once | not supported — it is one shared shell |
That is why a form from another component can go into it
The content is an ordinary HTML string. A component renders its own partial into a string and passes it to the modal; the modal knows nothing about it and does not need to.
The component must run under a presenter that provides the modal
getModalWindow() checks this and otherwise throws, naming the component. It is
not pedantry — without the shell in the layout the window would have nowhere to
appear.
A required field in a hidden modal blocks the WHOLE form from submitting
The browser refuses to submit because of a field the user cannot see, and does not say why — the page simply stops responding to the submit. Requiredness therefore has to be toggled by whether the modal is open.
Signals#
A handle*() method serves a component action (delete, toggle, load more). It is
called by an ordinary URL the component generates for itself.
A signal is a public URL — it must verify the request's origin
Without that check it can be triggered from a foreign page in the name of the
signed-in user. Same-origin checking on handle*() is mandatory, not optional.
AJAX redraws only the INSIDE of a snippet
Attributes of the element the snippet sits on (classes, data-) keep their
original values. Whatever must change has to be inside.
In an AJAX response Nette renders only the invalidated snippets and calls a component's
render() without arguments. Neither the template that passes the arguments
({control x:tile, 3, false, true}) nor the layout runs then. A component with render arguments
must therefore secure them itself:
| When | Where the arguments come from |
|---|---|
| full render | from the template; the component remembers them (CategoryList::$lastRenderArgs) |
| AJAX signal | from the signal: the link passes them as parameters (showAll!, tab!), the handler checks and stores them |
| render without arguments | func_num_args() === 0 → the stored ones are used |
Procedure: a component with render arguments and an AJAX signal
- Store the full-render arguments in a component property.
- Pass the same arguments as signal parameters in the signal link.
- In
handle*()check them (style only from an allowed list, because it goes into the template path; cap the counts), store them and callredrawControl()on a named snippet. - In
render()called without arguments, use the stored ones. - Put state that must survive a full reload without JS into a
#[Persistent]parameter.
Without stored arguments the AJAX response comes out empty
render() without arguments draws the default style, which does not contain the named
snippet, and the payload has nothing to send. A click on "All categories" then does nothing
and the error shows nowhere.
Two mounts of one instance break the snippet and the persistent parameter
Insert a component that appears on the page several times with different state through
{multicontrol frontBazaarCategoryList-<key>:…}: each key is its own instance. Plain
{control} twice is one instance with shared state and one snippet name.
The form is {multicontrol <component>-<key>:<style> <arguments>}: the component name is
the part before the first dash, the instance key is one word without a dash (Nette reads
a further dash as a path to a nested component and fails with "Component with name '…'
does not exist.") and the render arguments follow after a space, not a comma
(Unexpected ',' at compile time).
Confirming a writing link#
Delete links and other writing links in the administration are confirmed by a styled dialog
from datagrid.js instead of window.confirm. It works inside a grid and outside it (row
actions in a component): the dialog texts come from the .data-grid ancestor, outside a
grid from the attributes of the link itself.
Attribute on <a> |
What happens after confirmation |
|---|---|
data-grid-confirm="…" alone |
question text; lets the element's own action through (the link opens, the form submits with the right button) |
data-grid-post="page" |
POST of an empty form to href, the server answers with a redirect |
data-grid-post="ajax" |
POST through naja, redraws snippets |
data-grid-confirm-ajax |
the same through naja (a confirmed AJAX signal, e.g. sorting tree children) |
data-confirm-title, data-confirm-yes, data-confirm-no, data-confirm-variant |
custom texts and colour of the confirm button (default danger) |
A form in the modal receives the id of the edited record in a hidden field, not in a signal
parameter: the component form action does not carry the parameters of the add! or edit!
signal that opened the modal. A hidden field can be rewritten, so the component checks it
against the owner context (see
Admin presenter).
Procedure: deleting an item in an edit tab
- Mark the
handleDelete()signal#[Requires(methods: 'POST')]and start it with therequirePrivilege()andrequireContextOwner()gates. - Render the link without the
ajaxclass:href="{link delete! …}#<tab id>",data-grid-post="page"anddata-grid-confirm="{_'confirmDelete'}". - After deleting, set a flash and redirect to the record's edit page. The browser keeps the
fragment from
hrefacross the redirect, so the same tab stays open.
Example: app/UI/Admin/Bazaar/Components/PaymentItemsComponent/ (template and handleDelete()).
The ajax class on a confirmed link sends the request before the dialog asks
Naja binds its .ajax handler directly to the element, so it runs before the delegated
confirmation. The delete happens even when the user clicks Cancel in the dialog. For a
confirmed AJAX request use data-grid-confirm-ajax or data-grid-post="ajax", never the
ajax class.
A writing signal that opens with GET gets triggered by a robot or prefetch
A link in a signed-in session can be fired by browser prefetching too. That is why a
writing signal accepts only POST and the link sends it through data-grid-post.
A new component, step by step#
- Create the folder on the pattern above — the component, the factory,
Templates/Default/. - Split the logic into traits once it outgrows one file (loading apart from actions).
- Name it by the convention (
front<Module><Name>) so that the template finds it without registration; writecreateComponent<Name>()in the presenter only when the component needs settings from the presenter. - Write the template into
Templates/Default/— so every theme inherits it. - Translate texts in PHP, send them to the template ready.
- Click through it — including the AJAX path if the component redraws.
Fixing a shared base is a change in every module that uses it
A component with a shared ancestor for three modules means a fix for one hits all three. Walk all of them — not just the one you are fixing.
Navigation by level#
The side panel with categories and regions used to draw the whole tree: CategoryList and
RegionList loaded and hydrated every node on every request (461 classifieds categories, about
9.5 thousand regions) to show one level. The replacement always shows one level and reads
each level with a small cached query by parent.
| Component | Control in the template |
|---|---|
Bazaar\CategoryChildrenList, Bazaar\RegionChildrenList |
{control frontBazaarCategoryChildrenList}, {control frontBazaarRegionChildrenList} |
Comcat\… |
frontComcat…ChildrenList |
Blog\CategoryChildrenList, Blog\RegionChildrenList |
frontBlogCategoryChildrenList, frontBlogRegionChildrenList |
Discussion\CategoryChildrenList |
frontDiscussionCategoryChildrenList |
Eshop\ProductCategoryChildrenList (no counts — the subtree total would need the whole tree) |
frontEshopProductCategoryChildrenList |
the region base System\RegionChildrenList |
a module is a thin subclass (pattern RegionList) |
It is wired in aside-left.latte of all 18 themes for five modules (about 90 files); none of
them calls the full-tree drawing any more.
What it consists of#
| Part | Job |
|---|---|
ChildrenNavigationTrait |
loads one level (parentId = <current> or IS NULL for roots) and the parent for the way back; selects the template — a module's or theme's copy takes precedence over the shared one |
CategoryChildrenTrait |
data preparation for categories over CategoryRailTrait; the module supplies the manager, entity, address translator, categoryRailHref() and categoryChildrenRootHref() |
Base/Front/Components/ChildrenNavigation/Templates/Default/Style_1.latte |
the shared flat template |
RegionChildrenListComponent/Traits/InitTrait |
the same for regions |
The result has the shape li.active > a.active: the way back (.menu-item--parent), the
selected node and its children in a nested <ul>. It is the same structure as the
original tree, so theme styles apply without touching CSS. The component finds its node in the
presenter (getCurrentCategory() / getCurrentRegion()) and accepts it only if it belongs
to its module — a component placed on another module's page does not adopt a foreign
category.
Categories support the styles Style_1 (flat), Style_4 and Style_6 (rail); another style
throws InvalidArgumentException.
Links and the placeholder segment -#
A link keeps the selected region when the category changes and the category when the
region changes; a change always starts on page 1. In Bazaar and Comcat the address segments
are <category>/<region>, so both must be explicit: if the category is missing, - is
written, otherwise the region would shift into the category's place. Discussion and the e-shop
do not need -. A root category has no parent, so the back link leads to the overview of
roots.
Every translator must handle the placeholder -
The category translators answer - without a warmup and without a query. A new translator
that does not know - sends it to the database on every link, or produces a wrong link.
Factory: return $this->control is not let through by PHPStan
The type is BaseComponent|null. Return the result of initialized(), or use a local
variable $control.
How to wire it into your own theme
Put {control front<Module>CategoryChildrenList} and
{control front<Module>RegionChildrenList} into aside-left.latte. The look (the back
link, the active row) is decided by the theme with the styles .menu-item--parent and
.active; the styles for details/summary/.has-children from the old tree are no longer
used.
Shared category lists#
The CategoryList component (ProductCategoryList in the e-shop) has one shared set of
templates for five modules (Blog, Bazaar, Comcat, Discussion, Eshop) in
app/Core/Base/Front/Components/CategoryList/Templates/Default/. CategoryRailTrait prepares
their data: it precomputes the differences between modules (link target, icon, item count)
into the plain arrays $railRows and $railParent, so the template knows no entity.
| Style | What it shows | Nodes |
|---|---|---|
Style_2 |
tiles without children | roots (sortedItems) |
Style_4 |
subcategory strip with an icon and a way back | children of the current category, roots outside a category |
Style_5 |
tiles with three children | roots (sortedItems) |
Style_6 |
row of links with a way back | like Style_4 |
For Style_4 and Style_6 the difference between null and [] in currentBranch matters:
null means "not in a category, show the roots", an empty array "in a leaf category, nothing
to show".
The template is looked up in this order, the first existing one wins:
1. <module>/Templates/<Theme>/<Variant>/Style_X.latte (copy for the current theme)
2. <module>/Templates/Default/Style_X.latte (module copy for all themes)
3. Base/Front/Components/CategoryList/Templates/Default/Style_X.latte
Bazaar, Comcat, Discussion and Blog all have their own Templates/Default/Style_2.latte, so the
shared Style_2 is drawn only by the e-shop today (counted with
find app/UI/Front -path '*CategoryList*' -name 'Style_2.latte').
Procedure: wiring the shared styles into a module#
- Use
CategoryRailTraitin the category list component. - Implement
categoryRailHref(int $categoryId)— the only thing a module supplies. Build the link with$this->getPresenter()->link(); a link to a foreign page needs theslugof the target page, otherwise it leads to a 404. - In
render()fillsortedItems,currentBranchandcurrentParent, then callprepareCategoryRail($style)and right after itsetTemplateFile($this->categoryRailTemplateDir(__DIR__, $style), $style . '.latte'). - The item count comes from the default
categoryRailCount()viagetAdvertCount(); otherwise override it (the e-shop counts the subtree), or returnnulland no count is drawn.
Call prepareCategoryRail() only right before setTemplateFile()
Bazaar still filters sortedItems (preferred categories) in between. An earlier call hands
the tiles the unfiltered tree.
Never look the template up with a glob across all of the module's themes
A copy from another theme (Templates/Template15/Desktop/Style_4.latte) would be found
while rendering a different theme too; when the module has no Templates/Default/Style_4.latte,
the file does not exist and the page fails with a 500 in every theme without its own copy.
The lookup therefore copies the steps of BaseComponent::setTemplateFile() for the current
theme only.
Finished components worth reading#
| Component | Where | What makes it interesting |
|---|---|---|
| DataGrid | app/UI/Admin/System/Components/ |
a table with filters, ordering and bulk actions |
| Picker | same place | picking an entity instead of a <select> |
| Modal window | same place | the shared shell described above |
| Advert listing | app/UI/Front/Bazaar/Components/AdvertRendererComponent/ |
three looks plus Elements/ |
| Navigation by level | app/UI/Front/Bazaar/Components/CategoryChildrenListComponent/ |
a thin subclass over a shared base |
The visual side of the front-end components is in the component catalogue.