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 Filters/ a filter in the column header
22 renderers (BoolRenderer, PriceRenderer, 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()):
$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'))
);
  1. Bulk actions through GroupAction::create() — custom behaviour gets setCallback().
  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.

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.

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