Skip to content
V
For developers
Architecture, conventions, the core and security
UI layer / Working with tables

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#

  1. Inject the grid factory into the presenter (DataGridFactory) and write createComponentGrid(): DataGrid.
  2. The source and the basics:
$grid = $this->dataGridFactory->create();
$grid->setDataSource($this->tagManager)
    ->setIncludes(['translations'])
    ->setItemsPerPage($this->getAdminItemsPerPage())
    ->setDefaultOrder('id', 'desc')
    ->setAddLink('add');
  1. The columns through Column::create(); whatever should be filterable gets setFilter():
$grid->addColumn(
    Column::create('name')
        ->setLabel($t('columns.name'))
        ->setProperty('translations.name')   // a dotted path across a relation
        ->setSortable()
        ->setWidth(60)
        ->setFilter(TextFilter::create())
);
  1. Row actions — either a link (setLink()) or a signal (setSignal()). Edit gets setAlwaysVisible(), 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'))
);
  1. Bulk actions through GroupAction::create() — custom behaviour gets setCallback(). They render in the "Bulk actions" panel below the table.
  2. The template contains only {control grid}.
  3. 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:

$grid->setDefaultOrder('type')->setDefaultOrder('name');   // ORDER BY type ASC, name ASC

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.

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):

Column::create('name')
    ->setLink($canEdit ? ':Admin:System:User:edit' : null, ['id' => 'id']);
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#

  1. Register the action with a privilege suffix:
$grid->addGroupAction(
    GroupAction::create('exportXml')
        ->setPrivilege('export')
        ->setCallback(fn(array $ids) => $this->exportXml($ids))
);
  1. Create the key :Admin:<Module>:<Presenter>:export in the privilege table and allow it for the admin role.
  2. 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

  1. Call enableCsvExport() with the file name in the grid factory (example app/UI/Admin/Company/Presenters/WorkListPresenter.php).
  2. A custom renderer whose prepare() returns no text key (it draws HTML, a toggle, an icon) overrides toCsvValue(), otherwise the export gets the raw value.
  3. 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

Cookie settings