JSON Grids
Listing pages without ui-component XML. One JSON file declares the columns, data source, filters, and mass actions — Nebula renders the toolbar, table, pager, and Alpine wiring.
The shape in 30 seconds
A grid file lives at view/adminhtml/grid/<id>.json. Like forms, it follows base + overlay merge — any module can extend an existing grid.
{
"id": "product_listing",
"acl": "Magento_Catalog::products",
"dataSource": {
"provider": "grid.collection",
"config": { "collection": "Magento\\Catalog\\Model\\ResourceModel\\Product\\Collection" }
},
"settings": {
"pageSize": 25,
"pageSizes": [10, 25, 50, 100],
"defaultSort": { "field": "entity_id", "direction": "desc" },
"addButton": { "label": "Add Product", "url": "catalog/product/new" },
"massActions": [
{ "id": "delete", "label": "Delete Selected", "url": "catalog/product/massDelete",
"confirm": "Delete these products?" }
]
},
"columns": {
"entity_id": { "label": "ID", "type": "text", "sortable": true, "position": 10 },
"name": { "label": "Name", "type": "text", "sortable": true, "searchable": true, "filter": "text", "position": 20 },
"price": { "label": "Price", "type": "price", "sortable": true, "filter": "range", "position": 30 },
"status": {
"label": "Status", "type": "badge", "filter": "select", "position": 40,
"options": {
"1": { "label": "Enabled", "class": "success" },
"2": { "label": "Disabled", "class": "error" }
}
},
"actions": {
"label": "", "type": "actions", "position": 999,
"actions": [
{ "label": "Edit", "url": "catalog/product/edit/id/{{entity_id}}" }
]
}
}
}That file renders a sortable, filterable, paginated, multi-select admin grid with a primary "Add" button and a delete-with-confirmation mass action.
Anatomy
| Key | Required | Purpose |
|---|---|---|
| id | base only | Snake_case identifier; overlays omit it. |
| acl | no | Magento ACL resource — controls page access |
| dataSource | yes | provider alias + provider config (collection FQCN, identifierParam, etc.) |
| columns | yes | Map of column_id → column definition. Order by `position`. |
| settings | no | Pagination, default sort, mass actions, primary toolbar button |
Column types
Every column needs either a built-in type or a renderer (snippet or template).
| type | What it renders | Notable config |
|---|---|---|
| text | Plain string | sortable, searchable, filter |
| link | Clickable URL | href, target |
| thumbnail | Image cell | src, alt, width, height |
| badge | Colored pill | options: { value → { label, class } } — class ∈ success/error/warning/info/neutral |
| date | Localized date | Parsed by JS Date, formatted per locale |
| price | Currency-formatted amount | Uses Magento PriceCurrencyInterface |
| boolean | Yes/No | trueLabel, falseLabel |
| html | Unescaped HTML | Trusted content only |
| actions | Inline row actions | actions: [{ label, url }] — URL supports {{field}} placeholders |
"status": {
"label": "Status",
"type": "badge",
"sortable": true,
"filter": "select",
"options": {
"1": { "label": "Enabled", "class": "success" },
"0": { "label": "Disabled", "class": "error" }
},
"position": 30
}"price": {
"label": "Price",
"type": "price",
"sortable": true,
"filter": "range",
"position": 40
}"actions": {
"label": "",
"type": "actions",
"position": 999,
"actions": [
{ "label": "Edit", "url": "catalog/product/edit/id/{{entity_id}}" },
{ "label": "View", "url": "catalog/product/view/id/{{entity_id}}" },
{ "label": "Delete", "url": "catalog/product/delete/id/{{entity_id}}", "confirm": "Sure?" }
]
}Per-column properties
| Property | Effect |
|---|---|
| label | Header text |
| position | Render order (0..998; reserve 999 for actions) |
| sortable | Header click toggles sort |
| searchable | Global search box includes this column |
| filter | "text" | "select" | "range" | "date" — adds a filter row |
| filterOptions | { value: label } — for filter:"select" inline options |
| filterOptionsSource | Alias for dynamic options (resolved via OptionSourceResolver) |
| options | For type:"badge" — { cellValue: { label, class } } |
| renderer | Snippet reference or template path (instead of type) |
Mass actions
Declared in settings.massActions. When the user selects rows and picks an action, Nebula POSTs a dynamically-built form to the action URL with ids[] and form_key. Controllers receive the standard $this->getRequest()->getPost('ids').
"settings": {
"massActions": [
{ "id": "delete", "label": "Delete", "url": "catalog/product/massDelete", "confirm": "Are you sure?" },
{ "id": "enable", "label": "Enable", "url": "catalog/product/massEnable" },
{ "id": "disable","label": "Disable", "url": "catalog/product/massDisable" }
]
}nebula-grid.ts — the template only owns checkbox state. Confirmation dialogs use the native browser confirm() when confirm is set.Filters & paging
Filter values submit as URL params:
Text / select / date
?filters[status]=1One value per column id.
Range (price / number / date)
?filters[price_from]=10
&filters[price_to]=100Two URL params per ranged column, suffixed with _from / _to.
Paging via ?page=N&pageSize=N. Page sizes come fromsettings.pageSizes; users get a dropdown.
Custom column — snippet or template
When none of the built-in types fit, point at a snippet:
"thumbnail": {
"label": "Image",
"renderer": "snippet.product_thumbnail",
"position": 5
}Or render a raw phtml template:
"availability": {
"label": "Stock",
"renderer": "Acme_Catalog::grid/column/availability.phtml",
"position": 50
}Render flow
view/adminhtml/grid/<id>.json (any module)
│
▼
DefinitionResolver.resolve('grid', $id)
│ Loader.collect → Merger.merge → GridDefinitionNormalizer
▼
Qoliber\NebulaGrid\Block\Grid
│ getDefinition() → resolved JSON
│ getColumns() → sorted by position
│ getItems() → DataProviderResolver.fetch(provider, config)
▼
view/adminhtml/templates/grid.phtml
│ <div x-data="nebulaGrid({...})">
│ toolbar / header / body / pager
▼
ColumnDispatcher.render(col, key, item)
│ → SnippetRenderer for renderer:"snippet.*"
│ → TemplateRenderer for renderer:"Vendor::template"
│ → RendererPool.getColumnRenderer(type) (BadgeRenderer, PriceRenderer, ...)
▼
Alpine.start()
│ nebulaGrid factory owns row selection + mass-action submissionAuthoring a new grid — checklist
Drop the JSON
Path: YourModule/view/adminhtml/grid/blog_post_listing.json. Snake_case id matches the filename.
Bind a data provider
Either use the generic grid.collection provider with a collection FQCN, or register your own via DataProviderRegistry in di.xml.
"dataSource": {
"provider": "grid.collection",
"config": { "collection": "Acme\\Blog\\Model\\ResourceModel\\Post\\Collection" }
}Define columns
Start with id, label, status, and an actions column. Add more as you go; reorder via position.
Wire the controller
One Index controller that creates a Qoliber\NebulaGrid\Block\Grid with the grid id.
public function execute() {
$page = $this->resultPageFactory->create();
$page->getLayout()->createBlock(
\Qoliber\NebulaGrid\Block\Grid::class,
'blog.post.grid',
['data' => ['grid_id' => 'blog_post_listing']]
);
return $page;
}Add mass action endpoints
Each mass action URL is a regular admin controller. Read $this->getRequest()->getPost('ids'), iterate, save / delete, then return a redirect.
Reference paths
- code/Qoliber/NebulaComponent/schemas/grid.schema.json
- code/Qoliber/NebulaComponent/docs/schemas/GRID.md
- code/Qoliber/NebulaGrid/Block/Grid.php
- code/Qoliber/NebulaGrid/Model/ColumnLoader.php · DataProvider/CollectionProvider.php
- code/Qoliber/NebulaGrid/Renderer/Column/* (BadgeRenderer, PriceRenderer, ActionsRenderer, …)
- design/adminhtml/Qoliber/Nebula/web/ts/grid.ts