Working with tables#
DataGrid is the administration's listing component: columns, filters, sorting,
pagination, row actions, bulk actions and optionally a tree with drag-to-reorder. The
code lives in app/UI/Admin/System/Components/DataGridComponent/.
The data source is a manager, not a query — the grid assembles the filters, ordering
and paging itself and passes them into findAll().
What a grid is made of#
| Element | Folder | What for |
|---|---|---|
Column, MultiColumn, RelationColumn |
Columns/ |
one column, several values in one column, a column over a relation |
RowAction, GroupAction |
Actions/ |
a row action, a bulk action over the ticked rows |
TextFilter, SelectFilter, MultiSelectFilter, RangeFilter, MappedSelectFilter, PrecomputedIdsFilter, SelectLikeFilter, PresetSelectFilter |
Filters/ |
a filter in the column header |
23 renderers (BoolRenderer, PriceRenderer, QuantityRenderer, ImageRenderer, ToggleRenderer, …) |
Renderers/ |
how a value is rendered |
Procedure: creating a new listing#
- Inject the grid factory into the presenter (
DataGridFactory) and writecreateComponentGrid(): DataGrid. - The source and the basics:
$grid = $this->dataGridFactory->create();
$grid->setDataSource($this->tagManager)
->setIncludes(['translations'])
->setItemsPerPage($this->getAdminItemsPerPage())
->setDefaultOrder('id', 'desc')
->setAddLink('add');
- The columns through
Column::create(); whatever should be filterable getssetFilter():
$grid->addColumn(
Column::create('name')
->setLabel($t('columns.name'))
->setProperty('translations.name') // a dotted path across a relation
->setSortable()
->setWidth(60)
->setFilter(TextFilter::create())
);
- Row actions — either a link (
setLink()) or a signal (setSignal()). Edit getssetAlwaysVisible(), the other actions end up in the "⋯" menu (see Row actions):
$grid->addRowAction(
RowAction::create('edit')->setIcon('edit')->setVariant('success')
->setAlwaysVisible()
->setLink('edit', ['id' => 'id'])
);
$grid->addRowAction(
RowAction::create('delete')->setIcon('cross')->setVariant('danger')
->setSignal('delete')->setConfirm($t('confirmDelete'))
);
- Bulk actions through
GroupAction::create()— custom behaviour getssetCallback(). They render in the "Bulk actions" panel below the table. - The template contains only
{control grid}. - Click through the filter, the sorting, the paging, both actions and the empty state.
setIncludes() has to cover everything the grid displays
A column with a dotted path (translations.name) needs that relation loaded. Without
it no error is printed — just an empty cell, which looks like missing data.
Dotted paths add the joins themselves, but depth costs something
category.parent.name works, it just shows up in the query. On a listing rendering
hundreds of rows a denormalised column is cheaper.
Row actions#
The template Parts/_row_actions.latte splits the row actions into three groups:
| Action | Where it renders |
|---|---|
with RowAction::setAlwaysVisible() |
directly in the row as a labelled button |
| the others | in the "⋯" dropdown at the end of the row |
dangerous (variant danger or icon cross) |
always at the end of the dropdown, separated by a line and in red, regardless of setAlwaysVisible() |
A tree grid also adds "In tree" (flat view) and "Move" to the dropdown. The dropdown
is a Bootstrap dropdown (keyboard, Escape, click outside) with the fixed popper strategy,
so the horizontally scrolling table wrapper does not clip it. It keeps working after an
AJAX redraw of the row.
Convention: the Edit action gets ->setAlwaysVisible(); every listing in the
administration has it. Keep another action in the row only when the user needs it on most
rows.
Without setAlwaysVisible() Edit disappears from the row
The action is not lost, it just hides in "⋯". The listing looks fine, but the main action is one click further and the row shows only the menu.
Delete cannot be put into the row
setAlwaysVisible() on an action with the danger variant or the cross icon has no
effect. That is deliberate: a delete must not sit one click next to Edit.
Bulk actions#
Bulk actions (GroupAction) render in an always visible Bulk actions panel below the
table (Parts/_foot.latte). The panel shows the selection state and, next to the grid's
actions, has two buttons:
| Button | Attribute | What it does |
|---|---|---|
| Select shown | data-grid-select-page |
checks every row of the current page |
| Clear selection | data-grid-select-none |
unchecks everything |
datagrid.js handles them (delegated, so it survives an AJAX redraw) through the panel
root [data-grid-bulk]. Without a selection the action buttons are disabled; "Select
shown" is disabled once everything is selected. The selection texts come from NEON
(data-text-none, data-text-some with %count%), not from JS.
A disabled button is not a check
Without JavaScript the actions stay enabled. The bulk action handler must still check it received a non-empty array of ids, and the server validates the selection as before.
Filter style#
How filters and sorting render is set by the administration setting
admin.dataGrid.filterStyle (module 1, edited in Admin settings next to the items per
page). DataGrid::getFilterStyle() reads it from the SettingContext, so it costs no
extra query.
| Value | Constant | Look |
|---|---|---|
style_1 (default) |
FILTER_STYLE_COLUMNS |
filter row under the column headings, sort arrows in the heading |
style_2 |
FILTER_STYLE_PANEL |
a separate "Search and sort" panel above the table, closed on load |
style_3 |
FILTER_STYLE_HEADER |
filter row under the headings, sorting by a click on the heading (the next click reverses) |
The grid root carries data-filter-style="style_…" for styling. A missing or unknown
value and a grid in Picker mode give style_1. Only the rendering changes: filter field
names, the search submit, persistent parameters and signals are the same in every style,
so presenters do not change because of it. The key is created by the migration
sql/2026-10-29_25_admin_datagrid_filter_style.sql.
A theme's own template for a grid part#
An admin theme can replace the footer and the row actions without copying the whole grid.
DataGrid::render() looks both parts up through BaseComponent::resolveThemeTemplatePath()
and passes the path to the template:
| Part | Template variable | Default file |
|---|---|---|
| row actions | $rowActionsTemplate |
Templates/Default/Parts/_row_actions.latte |
| footer and bulk action panel | $footTemplate |
Templates/Default/Parts/_foot.latte |
Both admin themes use the default parts today; a theme's own version would live in
Templates/<Theme>/default/Parts/. The lookup order is described in Components.
An overridden part must keep the names and attributes
Bulk action buttons must stay <button n:name="'group__' . …"> (the grid form reads
them by name) and the panel needs data-grid-bulk, data-grid-select-page and
data-grid-select-none, otherwise datagrid.js does not handle the selection. Row
actions must still use buildRowActionHref() and send POST actions through the grid
form.
Column widths#
setWidth(X) gives the header a width-X class. The SCSS generates rules only for
multiples of five from 5 to 100 (@for $i from 1 through 20 { th.width-#{$i * 5} }).
Any value that is not a multiple of five is silently dropped
setWidth(12) produces a width-12 class for which no CSS rule exists — the column
simply has no width set. Nothing breaks and the code looks right; you only notice by
looking at the layout. (Such cases exist in the codebase today.)
Sorting#
setDefaultOrder() takes the direction as a string ('asc' / 'desc') and converts
it itself. The first call discards the built-in id DESC, further calls add another
level:
In a raw orderBy array the direction is a BOOL, not a string
When you assemble the ordering by hand for findAll(), ['id' => true] → ASC,
['id' => false] → DESC. ['id' => 'DESC'] sorts ASCENDING, because a non-empty
string is truthy. Nothing crashes, the listing looks sorted — just the other way round,
so "the last ten" shows the ten oldest.
Without discarding the built-in id DESC the setting would silently do nothing
That is how it used to behave: setDefaultOrder('reports', 'desc') produced
ORDER BY id DESC, reports DESC and the reported-ratings screen failed at its main
purpose. It is fixed today, but it is worth knowing why it works this way.
Custom cell content#
Rendering is handled by a renderer, not by an anonymous function in the column
definition. Ready-made renderers cover most types: BoolRenderer, DateRenderer,
PriceRenderer, ImageRenderer, EmailRenderer, PhoneRenderer, BadgeRenderer,
ToggleRenderer, LinkRenderer, PdfDownloadRenderer and more. Render quantities (stock
level, reservation, batch) with QuantityRenderer::create(): "1.5" instead of 1.500, "5"
instead of 5.000, with the localization separator; the CSV export gets the machine form with a
full stop (1.5).
For links out of a cell there is setLink() directly on the column; for adjusting a
whole page of results at once there is setRowDecorator(), called once after loading.
You can write your own renderer
Implementing CellRenderer is enough. It beats putting HTML in the grid's template —
that one is shared by every listing in the administration.
A link from a cell#
setLink() on a column wraps the whole cell content in a link, with any renderer. The link
parameters are mapped from the row properties (URL parameter => property path):
| Call | What it does |
|---|---|
setLink(null, …) |
turns the link off; handy for a condition on a privilege |
$requiredParams omitted (null) |
the link is generated only when all mapped parameters are filled |
$requiredParams = ['id'] |
only the listed parameters are required |
$requiredParams = [] |
the link is always generated, even with empty parameters |
The default protects against link() crashing on a row without an id: the cell is printed
as plain text.
Do not put setLink() on a column whose renderer draws <a> itself
EmailRenderer, LinkRenderer or a toggle would end up as a link inside a link. That is
invalid HTML, the browser splits it its own way and the click goes somewhere other than
you expect.
Several items in one cell#
| Renderer | Column value | Where the data comes from |
|---|---|---|
LinkListRenderer |
an array of entities; text, tooltip and link parameters are taken from each item | in one batch in setRowDecorator() into a #[Transient] property of the row, example app/UI/Admin/Eshop/Presenters/Traits/Lists/OrderListTrait.php |
BadgeListRenderer |
badges from a callback over the whole entity | a map the presenter loads up front; no query per row |
A column with either of them is neither sorted nor filtered. The BadgeListRenderer badge
text is not translated, the template only escapes it (it is database data, e.g. a role name).
The property for LinkListRenderer must return an array, not a Collection
ValueResolver reduces a collection at the end of the path to its first element. The
cell then silently shows one item instead of all of them.
Filters that are not equality#
SelectFilter compares the column value with the option. Four of its subclasses handle
the cases where that is not enough:
| Filter | When | What it builds | Example |
|---|---|---|---|
MappedSelectFilter |
the option is not a column value (a section is picked, the column holds threadId) |
IN (…) from the map option => column values in setValueMap() |
app/UI/Admin/Base/Presenters/CommentPresenter.php |
PrecomputedIdsFilter |
yes/no over an aggregate from another table ("below minimum" across all warehouses) | field IN (:ids) from setIdsProvider(); the provider is called only when the filter is active |
app/UI/Admin/Store/Presenters/StockItemPresenter.php |
SelectLikeFilter |
the column holds a list of values in one field (a JSON array of flags) | a LIKE on the occurrence of the option; only options from setOptions() pass |
app/UI/Admin/System/Presenters/PaymentPresenter.php |
PresetSelectFilter |
the option is a named condition: a group of values or a combination of columns ("Overdue" = unpaid and due date before today) | a ready Core filter from the map option => filter in setPresets(); the filter's column only gives it its place in the header, its paths are not used |
invoice state app/UI/Admin/Invoicer/Presenters/InvoicePresenter.php, payment state groups PaymentPresenter.php |
->setFilter(
PresetSelectFilter::create()
->setOptions($statusLabels) // paid / unpaid / overdue
->setPresets(InvoiceStatusChoice::buildPresets(new \DateTime('today'))) // option => Core filter
)
The value comes from the request (?grid-filters[status]=overdue), so a prefiltered listing
can be linked to. The dashboard widgets and the "waiting for action" cards
link this way. An unknown or empty option does not filter. The conditions live in one place
(InvoiceStatusChoice, PaymentStatusBuckets) that the grid filter, the widget counts and its
links all read, so a card and the listing it opens count the same rows.
MappedSelectFilter: an option with an empty list returns nothing
An empty list is filtered as IN (0). That is on purpose: "show a section with nothing
in it yet" is answered by an empty listing, not by everything. An option missing from
the map altogether does not apply the filter.
PrecomputedIdsFilter needs setTargetField() pointing at a real column
The column the filter sits on usually shows a derived value with no database column.
Without setTargetField() the IN targets exactly that value and the listing does not
work with the filter on. The target is a mapped column: the row's primary key or its
foreign key. An empty id set gives an empty listing, not everything.
Tree listings#
setTreeStructure() tells the grid the records form a tree (a nested set). The grid then
offers three views in its toolbar:
| View | What it shows | How it loads |
|---|---|---|
| List (default) | a flat paged listing with a "Path" column (the row's ancestors) and a "Show in tree" action | filters, sorting and paging in SQL, the ancestors of the page rows in one query |
| Tree | the hierarchy with ▸/▾, arrows between siblings and focus on a subtree | by parents: the root or the focused node plus expanded nodes, no paging |
| Reorder | a nested list with drag and drop | the whole tree; only with enableDragDrop() and the edit privilege |
The chosen view is remembered in the session per grid; setTreeDefaultView(DataGrid::VIEW_TREE)
changes the default. Searching or sorting by a column switches even the Tree view to the
flat listing, because it breaks the hierarchy.
| Tree state | Where it lives |
|---|---|
| expanded nodes | the session, separately per presenter, component and scope |
| focus on a subtree | the persistent parameter treeRoot, the URL can be shared |
| first opening | a tree up to DataGrid::TREE_FULL_LIMIT (500) nodes fully expanded, a larger one only roots; "Expand all" refuses above the limit |
| children of one parent | at most setTreeChildrenLimit() (default 500), the rest as a "… N more" row |
setTreeCollapsible(false) gives a tree that is always fully expanded, without session and
focus, for small trees dragged across (the menu). When it outgrows the limit it behaves as
collapsible as a safeguard and writes a warning to the log.
Moving and deleting are done by the model layer with set-based SQL over lft/rgt;
the grid only calls a manager method:
| Move | Enabled by | Manager method |
|---|---|---|
| ↑/↓ arrows | setTreeStructure() |
moveUp() / moveDown() |
| dragging in the Reorder view | enableDragDrop() |
moveSubtreeTo() with the parent and neighbours as the client saw them |
| sibling order only, no parent change | enableDragDrop(flat: true), example AbstractParameterPresenter |
moveSubtreeTo(), nested targets inactive |
| "Move to…" (a modal choosing the parent and position) | enableDragDrop() and a tree with the right bound rgt |
moveSubtreeTo(), never save() of the entity |
| "Sort alphabetically" (direct children by label in the current language) | the same plus a manager with reorderChildren() |
reorderChildren() in one operation |
| deleting | the delete action |
deleteNode() |
Dragging and the modal both send the parent and neighbours as the user saw them. If the
tree has changed in the meantime, the move returns TREE_STALE and writes nothing; the
grid shows a message and refreshes the listing.
setTreeScope(['menuId' => $id]) handles several trees in one table — it is added to the
listing's filters and to the move queries, so the lft/rgt recalculation does not
touch a foreign tree.
What happens in the model layer during a move (tree lock, the TREE_STALE and
TREE_LOCKED codes, changing the parent from a form) is described in
Trees.
Without setTreeScope() the recalculation touches other trees in the same table
Moving an item in one menu renumbers the other menus too. It does not surface as an error — it surfaces as scrambled ordering somewhere you were not working.
Deleting a node removes the whole subtree
And closes the gap it leaves in lft/rgt. In the listing it looks like deleting a
single row.
Moving to the root of the tree is the riskiest operation
The whole structure is recalculated. On a large tree that takes time, and an interruption leaves the tree inconsistent — and an inconsistent nested set surfaces as randomly disappearing branches, not as an error.
Deleting#
The signal and the bulk action both call the delete method on the manager.
setDeleteMethod('softDelete') switches that to another one — the e-shop grids do that.
A bulk action receives an ARRAY of identifiers
A handler expecting a single id crashes. And because the base is shared by every listing in the administration, that brings down all of them at once — not just the one you were editing.
Privileges in a listing#
The grid derives privilege keys from the attached presenter:
:Admin:<Module>:<Presenter>:<action>, the same as the presenter gates. A manual
isAllowed() condition around addRowAction() is not needed; if it is there, it does no harm.
| What | Key | Without the privilege |
|---|---|---|
the "Add" button from setAddLink('add') |
…:add |
the button is not rendered |
the delete row and bulk action, the delete! signal |
…:del |
the action is not rendered, the signal returns 403 |
| a bulk action with its own callback | the suffix from GroupAction::setPrivilege() |
the button is not rendered, the submit returns 403 |
| inline editing, relation toggle, arrows, dragging, "Move to…", "Sort alphabetically" | …:edit |
the signal returns 403, the Reorder view is not offered |
| CSV export | none of its own | the page read gate applies |
The delete! signal also returns 403 when the grid did not register a delete action at
all. setAddLink() derives the key only from a relative action ('add'); with an absolute
destination (':Admin:Other:Module:add') it does not hide the button. Pass your own key
as the third parameter, '' turns the derivation off. Outside the admin branch (a presenter
whose name does not start with Admin:) the key cannot be derived and the grid neither
hides nor blocks anything.
Procedure: a bulk action not everyone may use#
- Register the action with a privilege suffix:
$grid->addGroupAction(
GroupAction::create('exportXml')
->setPrivilege('export')
->setCallback(fn(array $ids) => $this->exportXml($ids))
);
- Create the key
:Admin:<Module>:<Presenter>:exportin the privilege table and allow it for theadminrole. - Check with a role without the privilege: the button is missing and a hand-submitted form returns 403.
A screen with deleting or writing in the grid needs the del and edit keys
The gates are fail-closed. Without a …:del row (and …:edit for inline editing and
trees) allowed for the admin role, not even the administrator deletes or toggles
anything. For del this is guarded by check 1.7 in
tests/Manual/Admin/privilege_enforcement_audit.php.
A writing grid signal accepts only POST
inlineUpdate!, relationToggle!, delete!, moveUp!, moveDown!, dragDrop! and
sortChildren! carry #[Requires(methods: 'POST')]. A row action with setSignal() is
therefore a button submitted by POST; a reading signal turns that off with
setPost(false). A GET link would be triggered inside a logged-in session by a robot or
by browser prefetch. GridWritingSignalsRequirePostTest guards this.
setVisibleWhen() only hides the button
The per-row condition (fn(object $row): bool, example
app/UI/Admin/Invoicer/Presenters/InvoicePresenter.php) mirrors the entity state; the
delete gate does not handle it. The manager must reject the forbidden state in
beforeDelete(), otherwise a hand-sent signal deletes the row.
CSV export#
enableCsvExport('jobs-' . date('Y-m-d') . '.csv') adds an export button to the toolbar.
The export takes the current filters and sorting but all rows without paging, read
from the manager in batches of 500. The columns are the grid columns without actions; the
cell text comes from CellRenderer::toCsvValue().
| File property | Value |
|---|---|
| delimiter | semicolon |
| encoding | UTF-8 with BOM, otherwise Excel with a Czech locale opens it in another encoding |
| response headers | PrivateDownloadHeaders, the file does not stay in a cache |
Procedure: enabling the export
- Call
enableCsvExport()with the file name in the grid factory (exampleapp/UI/Admin/Company/Presenters/WorkListPresenter.php). - A custom renderer whose
prepare()returns notextkey (it draws HTML, a toggle, an icon) overridestoCsvValue(), otherwise the export gets the raw value. - Check the export with a filter on: it contains only the filtered rows, including those past the first page.
A custom export outside CsvExportTrait must call CsvCellSanitizer
Excel runs a cell starting with =, +, -, @, a tab or CR as a formula, even when
an anonymous visitor entered it through a public form. CsvCellSanitizer prepends an
apostrophe, in a single place right before fputcsv(), not in renderers: a new
renderer would forget the protection. That is why negative numbers get the apostrophe
too.
A bulk action can return a file to download
processGroupAction() calls redirect('this') after the callback, but sendResponse()
in the callback throws AbortException first, so the download goes through without
another signal. The grid form is submitted without AJAX, the response goes straight to
the browser. The error branch (flash and return) lets the redirect run normally.
Pattern: CatalogExportDownloadTrait::sendCatalogExport() (CallbackResponse +
readfile()).
Listing state#
The grid remembers the page, the ordering and the filters.
"The listing shows nothing" is usually a remembered filter from last time
Try resetting it before you go looking for a bug in the code or in the data.
Picker mode#
The grid can render as the content of a Picker — without adding, editing,
deleting and checkboxes, with a "Select" button on the row. Mark an action that should
appear there too with showInPickerMode().
In detail: Admin presenter, the section on Picker mode.
Where to look#
| I want | Where |
|---|---|
| the grid API | app/UI/Admin/System/Components/DataGridComponent/DataGrid.php |
| the column definition | …/Columns/BaseColumn.php, Column.php, MultiColumn.php |
| the filters | …/Filters/ |
| the renderers | …/Renderers/ |
| the grid template | …/Templates/Default/, its parts in …/Templates/Default/Parts/ |
| row actions and the "⋯" menu | …/Actions/RowAction.php, …/Templates/Default/Parts/_row_actions.latte |
| the bulk action panel | …/Templates/Default/Parts/_foot.latte, www/themes/admin/*/desktop/assets/js/datagrid.js |
| privilege gates and signals | …/Traits/HandleTrait.php, DataGrid::isActionAllowed() |
| CSV export | …/Traits/CsvExportTrait.php, …/Utils/CsvCellSanitizer.php |
| tree views and state | …/Traits/TreeStateTrait.php, …/Utils/TreeRowsBuilder.php, TreePathBuilder.php |
| "Move to…" and sorting children | …/Traits/TreeMoveTrait.php |
| the SQL for trees | app/Core/Traits/Api/Models/Repositories/TreeTrait.php |
| the sort direction in SQL | app/Core/Utils/Helpers/Sqls/OrderByHelper.php |
| a finished example | app/UI/Admin/Blog/Presenters/TagPresenter.php |
Follow-up chapters: Admin presenter · Picker · Filters · Forms