| ← Back to UI Components | ← Back to Table of Contents |
9.1 Dataverse Grid system
The Dataverse Grid is KMP’s standard server-driven list component. It combines
CakePHP authorization and queries, declarative column metadata, saved or system
views, tenant-scoped filter option caching, nested Turbo Frames, and the
grid-view Stimulus controller.
Use it for searchable or filterable entity lists. Use a normal table for small, static data that does not need pagination, saved views, or partial refresh.
Source map
| Concern | Owning source |
|---|---|
| Query, filters, views, sorting, pagination, export | app/src/Controller/DataverseGridTrait.php |
| Column contract | app/src/KMP/GridColumns/BaseGridColumns.php and concrete *GridColumns.php classes |
| Saved-view normalization | app/src/KMP/GridViewConfig.php and app/src/Services/GridViewService.php |
| Conditional association/query planning | app/src/KMP/DataverseGridQueryContext.php |
| Client state and frame navigation | app/assets/js/controllers/grid-view-controller.js |
| Lazy shell and frame responses | app/templates/element/dv_grid*.php |
| Table, rows, actions, toolbar | app/templates/element/dataverse_table*.php, grid_view_toolbar.php, and grid_view_tabs.php |
| CSV output | app/src/Services/CsvExportService.php |
| Stable row targets | app/src/KMP/GridRowDomId.php |
Grid views and application records live in the active tenant database. Persisted
filter options are keyed with TenantAwareCache::tenantScopedKey(); do not
bypass that boundary with an unscoped cache key.
Request flow
- The index template renders
dv_gridwith a uniquegridKey, outerframeId, and data URL. - That element lazy-loads the outer frame. The controller authorizes and scopes
a base query before calling
processDataverseGrid(). dv_grid_contentrenders the toolbar and inner{frameId}-tableframe.- Search, filter, sort, view, column, and pagination changes reload only the table frame. The response contains JSON grid state plus the table.
grid-viewmirrors relevant query parameters to browser history and handlespopstate.
The outer frame owns toolbar metadata; the table response deliberately uses a reduced metadata mode for smaller follow-up responses.
Implement a grid
1. Define columns
Create a class under core or the owning plugin’s src/KMP/GridColumns
directory and extend BaseGridColumns.
<?php
declare(strict_types=1);
namespace App\KMP\GridColumns;
final class ExampleGridColumns extends BaseGridColumns
{
public static function getColumns(): array
{
return [
'name' => [
'key' => 'name',
'label' => 'Name',
'type' => 'string',
'sortable' => true,
'searchable' => true,
'filterable' => true,
'defaultVisible' => true,
'required' => true,
'clickAction' => 'navigate:/examples/view/:id',
'clickActionPermission' => 'view',
],
'branch_id' => [
'key' => 'branch_id',
'label' => 'Branch',
'type' => 'relation',
'sortable' => true,
'filterable' => true,
'filterType' => 'dropdown',
'filterOptionsSource' => 'Branches',
'renderField' => 'branch.name',
'queryField' => 'Branches.name',
'filterQueryField' => 'Examples.branch_id',
'defaultVisible' => true,
'requiresContain' => ['Branches'],
],
];
}
}
Common metadata:
| Key | Meaning |
|---|---|
key, label, type |
Stable state key, heading, and renderer type |
defaultVisible, required |
Initial visibility and whether the column can be hidden |
sortable, searchable, filterable |
Enable server-side operations |
queryField |
Qualified SQL field for relation sorting/selecting |
customSortHandler |
Static class/method handler for expression-based sorting |
renderField |
Entity/array path used for display and data-mode export |
filterQueryField |
Qualified field used by automatic filtering |
filterType |
Typically dropdown, is-populated, or date-range |
filterOptions, filterOptionsSource |
Static or dynamically loaded dropdown values |
lockedFilter, showInFilterMenu |
Protect a contextual filter or hide it from the add-filter menu |
skipAutoFilter, customFilterHandler |
Opt into grid-specific filtering logic |
clickAction, clickActionPermission |
Cell navigation/modal behavior and authorization requirement |
exportable, exportOnly, exportValue |
CSV inclusion and custom value extraction |
requiresContain, requiresFields, requiresComputed |
Query/enrichment dependencies for active columns |
Column keys are the public grid-state contract. Do not rename one without considering saved views and bookmarked query strings.
2. Add the data action
The controller uses DataverseGridTrait. Authorize the model, apply the
index scope to the base query, and then process the grid.
public function gridData(CsvExportService $csvExportService)
{
$baseQuery = $this->Examples->find()->contain(['Branches']);
$baseQuery = $this->Authorization->applyScope($baseQuery, 'index');
$result = $this->processDataverseGrid([
'gridKey' => 'Examples.index.main',
'gridColumnsClass' => ExampleGridColumns::class,
'baseQuery' => $baseQuery,
'tableName' => 'Examples',
'defaultSort' => ['Examples.name' => 'asc'],
'defaultPageSize' => 25,
'canExportCsv' => true,
]);
if (!empty($result['isCsvExport'])) {
return $this->handleCsvExport(
$result,
$csvExportService,
'examples',
);
}
$this->renderDataverseGridResponse(
$result,
'examples-grid',
'examples',
);
}
renderDataverseGridResponse() selects dv_grid_content or
dv_grid_table from the Turbo-Frame request header. Some older controllers
set the same view variables and templates manually; new work should use the
helper when its response shape fits.
For expensive associations, call resolveDataverseGridQueryContext() before
building the query and load only dependencies required by visible, searched,
filtered, or sorted columns. See MembersController::gridData() for a current
example.
3. Render the shell
<?= $this->element('dv_grid', [
'gridKey' => 'Examples.index.main',
'frameId' => 'examples-grid',
'dataUrl' => $this->Url->build(['action' => 'gridData']),
]) ?>
The frameId must be unique on the page. Embedded grids need a stable
context-specific ID and data route. Preserve the convention
{frameId} / {frameId}-table.
Processing options
processDataverseGrid() requires gridKey, gridColumnsClass,
baseQuery, tableName, and defaultSort. Important optional keys are:
| Option | Default / contract |
|---|---|
defaultPageSize |
25 |
disablePagination |
false; only use with a query already bounded by date or another hard limit |
systemViews, defaultSystemView, queryCallback |
Enable code-defined views |
showAllTab |
True without system views; false with system views |
canAddViews |
True without system views; false with system views |
canFilter, canExportCsv, showFilterPills, showViewTabs, enableColumnPicker |
UI/processing feature switches |
lockedFilters |
Filter keys a user cannot remove or clear |
enableBulkSelection, bulkSelection, bulkActions |
Accessible row selection and bulk actions |
bulkSelectionDataFields, bulkSelectionDisabledField, bulkSelectionHideDisabledControl |
Bulk-action row data and disabled-row behavior |
metadataMode |
Normally inferred: full for outer responses and table for inner-frame responses |
canFilter: false rejects user-provided filters but still applies filters
defined by the selected system view. Authorization scope is never a grid option:
apply it to baseQuery before processing.
Views, filters, and sorting
Saved views are member-specific records keyed by gridKey. System views come
from getSystemViews() on the column class. Both can define filters, visible
columns, sort, search, and page size. View selection and user preferences are
resolved on the server; do not trust client JSON as authorization or scope.
Custom renderers using dv_custom can reuse grid_view_tabs.php when they
enable showViewTabs and canAddViews. Keep the grid-state JSON inside the
{frameId}-table frame so the shared controller can refresh the tabs. Transient
navigation such as a calendar’s year, month, week, or display mode belongs in
sticky query state rather than a saved view, so selecting a view does not reopen
an outdated date range.
Calendar mode links live in the static toolbar. The calendar controller synchronizes
their selected styling, visible checkmark, and aria-current from the successfully
rendered inner frame on connection. Event-title and Details links leave the calendar
frame with data-turbo-frame="_top" to open a full gathering page.
GridViewConfig accepts flat filters and nested expression trees. Supported
operators are eq, neq, gt, gte, lt, lte, contains,
startsWith, endsWith, in, notIn, isNull, isNotNull, and
dateRange. Sort directions are asc and desc. Normalized page sizes are
between 10 and 100.
Use column lockedFilter metadata or the processing lockedFilters option
for embedded context such as a member ID. A locked UI filter is not an
authorization boundary; the base query and policy scope must enforce the same
constraint.
Dynamic dropdown sources can be:
- a table name such as
Branches; - a table configuration with value/label fields, conditions, and order;
- an application-setting list;
- a named static method on a class.
The trait caches these per request and in the tenant-scoped
grid_filter_options cache.
CSV export
?export=csv returns an export result instead of paginated data.
handleCsvExport() authorizes the table’s export action and supports:
- query mode for database-backed fields; and
- data mode when computed values, virtual properties, or enriched relations must already be present.
Value resolution prefers exportValue, then renderField, then the direct
column value. Export only data the current identity is authorized to view; hide
PII columns before calling the trait, as MembersGridColumns::setIncludePii()
does.
Row actions and Turbo Streams
Rows receive stable IDs derived from the table frame:
examples-grid-table becomes examples-grid-row-{id}.
TurboResponseTrait can replace or remove one row after a modal save and can
fall back to replacing the table frame. See
Hotwire navigation for the response recipe.
Accessibility and security checklist
- Apply
authorizeModel(), entity authorization, andapplyScope()in the controller; client feature flags never grant access. - Keep labels meaningful and escape displayed values.
- Give icon-only actions an accessible name and bulk-selection checkboxes a record-specific label.
- Preserve sortable-header state, selected view/tab state, focus visibility, loading announcements, and the empty-state status message.
- Keep contextual filters enforced in the query even if a URL is edited.
- Use tenant-aware cache keys and the active tenant connection for all grid data.
Verification
For PHP behavior, run the focused controller/service test and PHPCS on changed
PHP files. For grid-view or element behavior, run:
cd app
npm run test:js
npm run dev
Use the relevant Playwright journey for nested frames, filters/history, modal streams, bulk actions, or keyboard/focus behavior.