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 |
Filters/ |
a filter in the column header |
22 renderers (BoolRenderer, PriceRenderer, 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()):
$grid->addRowAction(
RowAction::create('edit')->setIcon('edit')->setVariant('success')
->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(). - 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.
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.
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.
Tree listings#
setTreeStructure() switches the grid into a tree (a nested set): the listing is ordered
depth-first, the cell is indented by level and rows get arrows to move between siblings.
Moving and deleting are done by the model layer with set-based SQL over lft/rgt;
the grid only calls moveUp() / moveDown() / deleteNode().
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.
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.
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/ |
| 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