Grid examples¶
Eleven complete grid definitions, each trimmed from one that ships with Nebula. Every
key used here is a key the PHP engine reads, and every file on this page validates
against grid.schema.json.
Each example keeps the id of the file it came from, because that is what the layout
XML mounts and what an overlay merges onto. Copying one of these into your own module
under the same file name would make a second base for a grid that already has one, so
to change a shipped grid write an overlay instead: see
Extending a shipped grid. Copy a whole file only when you are
building a grid of your own, and give it your own id.
Where an example declares a key the shipped file does not, the Keys worth noticing list says so. Nothing here is invented: every key is read by the engine, and every value is the shape the engine expects.
For what each key means, see the Grid reference.
A catalog listing with images, a price range and a picker¶
The product grid is the one that exercises the most column types at once. It draws a
thumbnail, formats money with price, filters categories through a searchable
picker instead of a dropdown that would have to render every category into the page,
and paints the status as an inline badge. It also shows the add button in its
dropdown form, where {{defaultAttributeSet}} is replaced with the store's default
attribute set id before render.
{
"id": "product_listing",
"acl": "Magento_Catalog::products",
"dataSource": {
"provider": "product.grid",
"config": {
"attributes": ["name", "sku", "price", "status", "thumbnail"]
}
},
"settings": {
"rowClick": "edit",
"pageSize": 20,
"pageSizes": [10, 20, 50, 100],
"defaultSort": {"field": "entity_id", "direction": "desc"},
"massActions": false,
"cardView": {
"breakpoint": "md",
"primary": "name",
"badge": "status",
"secondary": ["sku", "price"]
},
"addButton": {
"label": "Add Product",
"options": [
{"label": "Simple Product", "url": "catalog/product/new/set/{{defaultAttributeSet}}/type/simple"},
{"label": "Configurable Product", "url": "catalog/product/new/set/{{defaultAttributeSet}}/type/configurable"}
]
}
},
"columns": {
"entity_id": {
"label": "ID",
"type": "text",
"sortable": true,
"filter": false,
"hideBelow": "md",
"position": 10
},
"thumbnail": {
"label": "Image",
"type": "thumbnail",
"position": 20
},
"name": {
"label": "Name",
"type": "text",
"sortable": true,
"filter": "text",
"searchable": true,
"position": 30
},
"category_id": {
"label": "Categories",
"type": "text",
"sortable": false,
"filter": "picker",
"filterSource": "catalog_category",
"hideable": false,
"emptyText": "Not assigned",
"position": 45
},
"sku": {
"label": "SKU",
"type": "text",
"sortable": true,
"filter": "text",
"searchable": true,
"position": 50
},
"price": {
"label": "Price",
"type": "price",
"sortable": true,
"filter": "range",
"position": 60
},
"qty": {
"label": "Quantity",
"type": "text",
"sortable": true,
"filter": "range",
"hideBelow": "lg",
"position": 70
},
"status": {
"label": "Status",
"type": "badge",
"sortable": true,
"filter": "select",
"filterOptions": {"1": "Enabled", "2": "Disabled"},
"options": {
"1": {"label": "Enabled", "class": "success"},
"2": {"label": "Disabled", "class": "neutral"}
},
"position": 80
},
"actions": {
"label": "",
"type": "actions",
"actions": [
{"id": "edit", "label": "Edit", "url": "catalog/product/edit/id/{{entity_id}}"}
],
"position": 999
}
}
}
Keys worth noticing
dataSource.config.attributesis read only by the product provider. It names the EAV attributes to select, so a column whose attribute is missing from that list renders empty.filter: "picker"must be paired withfilterSource. The popover is served bynebula/selection/search, guarded by the selection source's own ACL, so the option list never has to be rendered into the page. The schema enforces the pairing.emptyTextis substituted into the row before the cell renderer runs, so a product with no category shows "Not assigned" escaped and wrapped like any other value. Keep it on text-like columns; alinkcolumn would try to build a URL from it.hideable: falsepins the category column out of the toolbar's column picker.hideBelowis the responsive counterpart: the column drops out below that breakpoint on its own.- A
badgecolumn that also filters needs both maps.optionscarries the label and the chip colour for the cell,filterOptionscarries the label for the dropdown. cardViewandhideBeloware not in the shipped file, which relies on the inferred card layout. Declaring them makes the phone layout a decision rather than a guess.
Source: Nebula_Theme/view/adminhtml/grid/product_listing.json.
An order listing that opens the order¶
Orders are read-only in the grid and every row leads somewhere, so this grid sets
rowUrl: true to say out loud that a click borrows the row's own View action. It sorts
newest first, takes its store and status options from registered sources rather than
from a map in the file, and gates the add button behind a second ACL because listing
orders and creating one are different permissions.
{
"id": "sales_order_grid",
"acl": "Magento_Sales::sales_order",
"dataSource": {
"provider": "grid.collection",
"config": {
"collection": "sales_order.collection"
}
},
"settings": {
"rowUrl": true,
"pageSize": 20,
"pageSizes": [10, 20, 50, 100],
"defaultSort": {"field": "entity_id", "direction": "desc"},
"massActions": false,
"addButton": {
"label": "Create New Order",
"url": "sales/order_create/start",
"acl": "Magento_Sales::create"
}
},
"columns": {
"increment_id": {
"label": "Order #",
"type": "text",
"sortable": true,
"filter": "text",
"searchable": true,
"position": 20
},
"store_id": {
"label": "Store",
"type": "select",
"sortable": true,
"filter": "select",
"filterOptionsSource": "magento.store.system",
"hideBelow": "lg",
"position": 25
},
"created_at": {
"label": "Purchase Date",
"type": "date",
"format": "M j, Y g:i A",
"sortable": true,
"filter": "date",
"position": 30
},
"billing_name": {
"label": "Bill-to Name",
"type": "text",
"sortable": true,
"filter": "text",
"searchable": true,
"position": 40
},
"grand_total": {
"label": "Grand Total",
"type": "price",
"sortable": true,
"filter": "range",
"position": 70
},
"status": {
"label": "Status",
"renderer": "snippet.status_badge",
"sortable": true,
"filter": "select",
"filterOptionsSource": "magento.sales.order.status",
"position": 80
},
"actions": {
"label": "",
"type": "actions",
"actions": [
{"id": "view", "label": "View", "url": "sales/order/view/order_id/{{entity_id}}"}
],
"position": 999
}
}
}
Keys worth noticing
rowUrl: trueis the default said out loud. The engine borrows the first row action that is a GET, carries no confirmation, and opens a record, which here is View.formatis a plain PHP date format. Without it adatecolumn rendersM j, Y, and an order placed at 23:40 is indistinguishable from one placed at 00:10 the same morning. The shipped file leaves the default; this one does not.filterOptionsSourceresolves to a value-to-label map server side. Labels that come from a source are treated as merchant data and are not passed through the translator, unlike labels written into the file.- A column may declare
rendererortype, never both.snippet.status_badgerenders the cell; theselectfilter under it still works, because filtering is driven byfilterand the options, not by the renderer. addButton.aclis opt-in and additive. The grid's ownaclalready authorized the page, so this one only hides the button from an admin who may read orders but not create them.pricecolumns are formatted throughPriceCurrencyInterfacein the store's currency, so the stored number needs no formatting of its own.
Source: Nebula_Theme/view/adminhtml/grid/sales_order_grid.json.
CMS pages with a quick-edit drawer¶
editable turns the grid into something an admin can change without leaving it. The
drawer is a real form: it loads the record through the repository, shows the editable
columns plus any extraFields, and saves as a partial merge, so only the posted keys
change.
{
"id": "cms_page_listing",
"acl": "Magento_Cms::page",
"dataSource": {
"provider": "grid.collection",
"config": {
"collection": "cms_page.collection"
}
},
"settings": {
"pageSize": 20,
"pageSizes": [10, 20, 50],
"defaultSort": {"field": "page_id", "direction": "asc"},
"addButton": {"label": "Add New Page", "url": "cms/page/new"},
"massActions": false
},
"editable": {
"repository": "Magento\\Cms\\Api\\PageRepositoryInterface",
"identifierField": "page_id",
"acl": "Magento_Cms::save",
"extraFields": {
"content_heading": {"type": "text", "label": "Content Heading", "position": 10},
"meta_title": {"type": "text", "label": "Meta Title", "maxLength": 255, "position": 20},
"meta_description": {"type": "textarea", "label": "Meta Description", "position": 30}
}
},
"columns": {
"page_id": {
"label": "ID",
"type": "text",
"sortable": true,
"filter": false,
"position": 10
},
"title": {
"label": "Title",
"type": "text",
"sortable": true,
"filter": "text",
"searchable": true,
"position": 20,
"editable": {"required": true, "maxLength": 255}
},
"identifier": {
"label": "URL Key",
"type": "text",
"sortable": true,
"filter": "text",
"searchable": true,
"position": 30,
"editable": {"required": true, "maxLength": 100}
},
"is_active": {
"label": "Status",
"renderer": "snippet.status_badge",
"sortable": true,
"filter": "select",
"position": 40,
"editable": {"options": {"1": "Enabled", "0": "Disabled"}}
},
"creation_time": {
"label": "Created",
"type": "date",
"sortable": true,
"filter": "date",
"hiddenByDefault": true,
"position": 50
},
"update_time": {
"label": "Modified",
"type": "date",
"sortable": true,
"filter": "date",
"position": 60
},
"actions": {
"label": "",
"type": "actions",
"actions": [
{"id": "edit", "label": "Edit", "url": "cms/page/edit/page_id/{{page_id}}"}
],
"position": 999
}
}
}
Keys worth noticing
editable.aclis the write permission and is never defaulted to the grid's readacl. Magento separatesMagento_Cms::pagefromMagento_Cms::saveand so does this. Omit it and the grid silently becomes read-only, with the reason in the log.editable.repositorymust exposegetById()andsave(), andgetById()must return a model with a data bag. For an entity with no such repository usemodelplusresourceinstead, the way the search term listing does.- A column with
editable.optionsrenders as a select in the drawer whatever its display type, so a status painted by a badge snippet is still editable. Anything else must be atextcolumn, and theidentifierFieldis never editable. extraFieldsare fields the drawer shows that the grid does not display. They take the same shape as a form field, so any of the form field types works, and they join the editable columns in the save whitelist.hiddenByDefaultstarts a column off screen while leaving it declared, filterable and listed in the column picker. An admin's own choice wins from then on.extraFieldsis the one addition here; the shipped file has theeditableblock without it.
Source: Nebula_Theme/view/adminhtml/grid/cms_page_listing.json.
A grid scoped to one parent record¶
A grid on a customer's edit page must show that customer's orders and nothing else.
dataSource.config.scope binds the collection to one parent: it filters field by the
value of the request parameter param, and when the request carries no value the grid
renders empty rather than every row in the table.
{
"id": "customer_orders_grid",
"acl": "Magento_Sales::sales_order",
"dataSource": {
"provider": "grid.collection",
"config": {
"collection": "sales_order.collection",
"scope": {"field": "customer_id", "param": "customer_id"}
}
},
"settings": {
"rowUrl": "sales/order/view/order_id/{{entity_id}}",
"pageSize": 10,
"pageSizes": [10, 20, 50],
"defaultSort": {"field": "created_at", "direction": "desc"},
"massActions": false,
"emptyState": {
"title": "This customer has no orders yet",
"description": "Orders placed as a guest with the same email address are not listed here, because they carry no customer id."
}
},
"columns": {
"increment_id": {
"label": "Order #",
"type": "text",
"sortable": true,
"filter": "text",
"searchable": true,
"position": 10
},
"created_at": {
"label": "Purchase Date",
"type": "date",
"sortable": true,
"filter": "date",
"position": 20
},
"status": {
"label": "Status",
"renderer": "snippet.status_badge",
"sortable": true,
"filter": "select",
"filterOptionsSource": "magento.sales.order.status",
"position": 40
},
"grand_total": {
"label": "Grand Total",
"type": "price",
"sortable": true,
"filter": "range",
"position": 50
},
"actions": {
"label": "",
"type": "actions",
"actions": [
{"id": "view", "label": "View", "url": "sales/order/view/order_id/{{entity_id}}"}
],
"position": 999
}
}
}
Keys worth noticing
scopefails closed. A missing parameter yields an empty grid, not the whole table, which is the only safe default for a grid embedded in someone else's page.- The scope parameter is carried on every link the grid renders and on the export URL, so paging, sorting and exporting stay inside the record.
rowUrlas a string is the explicit form: the route template is interpolated with{{field}}placeholders from the row. Use it when the destination is not the row's own opening action, or when the actions column holds nothing the engine may borrow.pageSizeis lower than a full-page grid's because the grid shares a page with other panels.pageSizesstill lists the larger choices for an admin who wants them.emptyStateis worth writing wherever emptiness has a cause the filters do not explain. The default copy tells the reader to adjust filters, which here would be wrong advice. The shipped file leaves the default; this one overrides it.
Source: Nebula_Theme/view/adminhtml/grid/customer_orders_grid.json.
The grid the generator writes¶
This is close to what bin/magento nebula:crud:generate produces, so it is the
smallest definition with everything a CRUD screen needs: a range filter on the numeric
id, a boolean column, a mass delete, and a row Delete that posts instead of
navigating.
{
"id": "cms_block_listing",
"acl": "Nebula_CrudFixture::cmsblock",
"dataSource": {
"provider": "grid.collection",
"config": {
"collection": "cms_block.collection"
}
},
"settings": {
"pageSize": 20,
"pageSizes": [10, 20, 50],
"defaultSort": {"field": "block_id", "direction": "asc"},
"addButton": {"label": "Add New Cms Block", "url": "nebulacrudfixture/cmsblock/new"},
"massActionIdField": "block_id",
"massActions": [
{
"id": "delete",
"label": "Delete",
"url": "nebulacrudfixture/cmsblock/massDelete",
"confirm": "Delete selected?"
}
]
},
"columns": {
"block_id": {
"label": "Entity ID",
"type": "text",
"sortable": true,
"filter": "range",
"position": 10
},
"title": {
"label": "Block Title",
"type": "text",
"sortable": true,
"filter": "text",
"searchable": true,
"position": 20
},
"identifier": {
"label": "Block String Identifier",
"type": "text",
"sortable": true,
"filter": "text",
"searchable": true,
"position": 30
},
"creation_time": {
"label": "Block Creation Time",
"type": "date",
"sortable": true,
"filter": "date",
"hiddenByDefault": true,
"position": 40
},
"is_active": {
"label": "Is Block Active",
"type": "boolean",
"sortable": true,
"filter": "select",
"filterOptions": {"1": "Yes", "0": "No"},
"position": 60
},
"actions": {
"label": "",
"type": "actions",
"actions": [
{
"id": "edit",
"label": "Edit",
"url": "nebulacrudfixture/cmsblock/edit/block_id/{{block_id}}"
},
{
"id": "delete",
"label": "Delete",
"url": "nebulacrudfixture/cmsblock/delete/block_id/{{block_id}}",
"method": "post",
"confirm": "Are you sure?"
}
],
"position": 999
}
}
}
Keys worth noticing
method: "post"renders a form-key protected POST instead of a link, which is what a controller implementingHttpPostActionInterfacerequires. Pair it withconfirmfor anything destructive.- A POST action and a confirmed action are both skipped when the engine looks for a row
URL to borrow, so this grid's row click lands on Edit and never on Delete. No
rowUrlkey is needed to get that. - Give every row action an
id. A list whose items carry ids is merged by id when another module overlays the grid; a list without ids is replaced wholesale, and an overlay that meant to add one action silently deletes the rest. booleanis a fixed Yes/No chip pair with nothing to declare. Itsselectfilter still needsfilterOptions, because the filter is a dropdown of stored values, not of rendered chips.filter: "range"on a numeric id gives a From/To pair. The provider readsblock_id_fromandblock_id_toand builds one condition.massActionIdFieldnames the row field each checkbox submits. Without it the checkboxes submitid, which this table does not have.
Source: Nebula_CrudFixture/view/adminhtml/grid/cms_block_listing.json.
See also Your first grid.
Review moderation: mass actions that ask a question¶
Reviews are joined across three tables, so almost every column names its own DB
expressions, and the Update Status action needs a value before it can run. additional
collects that value in the confirmation modal and posts it alongside the selected ids.
{
"id": "review_product",
"acl": "Magento_Review::reviews_all",
"dataSource": {
"provider": "grid.review.collection",
"config": {
"collection": "review_product.collection"
}
},
"settings": {
"pageSize": 20,
"pageSizes": [10, 20, 50, 100],
"defaultSort": {"field": "created_at", "direction": "desc"},
"massActionIdField": "review_id",
"massActions": [
{
"id": "delete",
"label": "Delete",
"field": "reviews",
"url": "review/product/massDelete/ret/index",
"confirm": "Are you sure you want to delete the selected reviews?"
},
{
"id": "update_status",
"label": "Update Status",
"field": "reviews",
"url": "review/product/massUpdateStatus/ret/index",
"additional": [
{
"name": "status",
"label": "Status",
"type": "select",
"optionsSource": "magento.review.status"
}
]
}
]
},
"columns": {
"review_id": {
"label": "ID",
"type": "text",
"sortable": true,
"filter": false,
"hideBelow": "md",
"index": "review_id",
"sortIndex": "rt.review_id",
"filter_index": "rt.review_id",
"position": 10
},
"created_at": {
"label": "Created",
"type": "date",
"sortable": true,
"filter": "date",
"hideBelow": "lg",
"index": "review_created_at",
"sortIndex": "rt.created_at",
"filter_index": "rt.created_at",
"position": 20
},
"status": {
"label": "Status",
"type": "badge",
"sortable": true,
"filter": "select",
"filterOptionsSource": "magento.review.status",
"index": "status_id",
"sortIndex": "rt.status_id",
"filter_index": "rt.status_id",
"options": {
"1": {"label": "Approved", "class": "success"},
"2": {"label": "Pending", "class": "warning"},
"3": {"label": "Not Approved", "class": "error"}
},
"position": 30
},
"title": {
"label": "Title",
"type": "text",
"sortable": true,
"filter": false,
"searchable": true,
"index": "title",
"sortIndex": "rdt.title",
"filter_index": "rdt.title",
"position": 40
},
"visible_in": {
"label": "Visibility",
"type": "text",
"sortable": false,
"filter": "select",
"filterOptionsSource": "nebula.store.view",
"index": "visible_in",
"filter_index": "stores",
"hideBelow": "xl",
"position": 82
},
"actions": {
"label": "",
"type": "actions",
"actions": [
{"id": "edit", "label": "Edit", "url": "review/product/edit/id/{{review_id}}/ret/index"}
],
"position": 999
}
}
}
Keys worth noticing
- The three index keys are three different jobs.
indexis the row field the cell reads,sortIndexis the expression in the ORDER BY, andfilter_indexis the expression in the WHERE. A joined column needs the qualifier in the last two and the collection's alias in the first, which is whycreated_atreadsreview_created_atbut sorts onrt.created_at. fieldrenames the POST array the checkboxes submit. It defaults toids; this vendor controller readsreviews, so the definition says so rather than wrapping the controller.additionalrenders inside the confirmation modal, before the post.selectis the only widget it renders, and each entry takes eitheroptionsinline oroptionsSource, never both.- A mass action without
confirmstill asks, using a generic question built from the label. Writeconfirmwhere the generic wording would understate the consequence. hideBelowhere is a priority order rather than a hide list. ID drops first, then the date, and the long text columns drop before the product name does.- A
badgemay take its filter labels from a source while keeping its own colour map, which is whatfilterOptionsSourceplusoptionsdoes on the status column.
Source: Nebula_Theme/view/adminhtml/grid/review_product.json.
Mass actions for a stock ui_component controller¶
Some Magento mass-action controllers do not read a list of ids at all. They resolve the
selection through Magento\Ui\Component\MassAction\Filter, which needs the name of a
ui_component. For those, post under the field selected and name the component in
namespace; the grid then submits what the vendor controller expects, unchanged.
{
"id": "search_synonyms_grid",
"acl": "Magento_Search::synonyms",
"dataSource": {
"provider": "grid.collection",
"config": {
"collection": "search_synonyms.collection"
}
},
"settings": {
"rowUrl": true,
"pageSize": 20,
"pageSizes": [10, 20, 50, 100],
"defaultSort": {"field": "group_id", "direction": "asc"},
"addButton": {"label": "New Synonym Group", "url": "search/synonyms/new"},
"massActionIdField": "group_id",
"massActions": [
{
"id": "delete",
"label": "Delete",
"field": "selected",
"namespace": "search_synonyms_grid",
"url": "search/synonyms/massDelete",
"confirm": "Are you sure you want to delete the selected synonym groups?"
}
]
},
"columns": {
"group_id": {
"label": "ID",
"type": "text",
"sortable": true,
"filter": false,
"position": 10
},
"synonyms": {
"label": "Synonyms",
"type": "text",
"sortable": true,
"filter": "text",
"searchable": true,
"position": 20
},
"store_id": {
"label": "Store View",
"type": "select",
"sortable": true,
"filter": "select",
"filterOptionsSource": "magento.search.store.options",
"position": 40
},
"actions": {
"label": "",
"type": "actions",
"actions": [
{"id": "edit", "label": "Edit", "url": "search/synonyms/edit/group_id/{{group_id}}"}
],
"position": 999
}
}
}
Keys worth noticing
namespaceis the stock ui_component name, not the Nebula grid id, even when the two read the same. It is the string the vendor controller's filter looks up.namespaceis only useful with"field": "selected". Together they reproduce exactly what the stock listing posts, which is the point: no controller is replaced, no plugin is added.- Reach for this only when the controller demands it. A controller that reads a plain
array of ids needs neither key, and
massActionIdFieldalone is enough. filterOptionsSourceis resolved once per render byColumnLoader, then handed to the filter template as a plain map, so a source that hits the database costs one call per page and not one per row.rowUrl: truewith a single Edit action is the ordinary case. The grid has no quick-edit drawer, so a row click opens the edit page.
Source: Nebula_Theme/view/adminhtml/grid/search_synonyms_grid.json.
Cache management: an in-memory list¶
Not every grid is backed by a database table. The cache and index management grids wrap Magento's in-memory list collections, which hold a fixed set of rows built in PHP. There is nothing to sort or filter on, so every column turns both off, and the toolbar grows the two flush buttons that belong to the screen rather than to a row.
{
"id": "cache_management",
"acl": "Magento_Backend::cache",
"dataSource": {
"provider": "grid.collection",
"config": {
"collection": "cache_types.collection"
}
},
"settings": {
"pageSize": 50,
"pageSizes": [20, 50, 100],
"massActions": [
{
"id": "enable",
"label": "Enable",
"field": "types",
"url": "adminhtml/cache/massEnable",
"developerOnly": true
},
{
"id": "disable",
"label": "Disable",
"field": "types",
"url": "adminhtml/cache/massDisable",
"developerOnly": true
},
{
"id": "refresh",
"label": "Refresh",
"field": "types",
"url": "adminhtml/cache/massRefresh"
}
],
"buttons": [
{
"label": "Flush Magento Cache",
"url": "adminhtml/cache/flushSystem",
"variant": "secondary"
},
{
"label": "Flush Cache Storage",
"url": "adminhtml/cache/flushAll",
"variant": "danger",
"confirm": "The cache storage may contain additional data. Are you sure that you want to flush it?"
}
]
},
"columns": {
"cache_type": {
"label": "Cache Type",
"type": "text",
"sortable": false,
"filter": false,
"index": "cache_type",
"position": 10
},
"description": {
"label": "Description",
"type": "text",
"sortable": false,
"filter": false,
"index": "description",
"hideBelow": "lg",
"position": 20
},
"tags": {
"label": "Associated Tags",
"type": "text",
"sortable": false,
"filter": false,
"index": "tags",
"hideBelow": "xl",
"position": 30
},
"status": {
"label": "Status",
"type": "badge",
"sortable": false,
"filter": false,
"index": "status",
"options": {
"1": {"label": "Enabled", "class": "success"},
"0": {"label": "Disabled", "class": "error"}
},
"position": 40
}
}
}
Keys worth noticing
grid.collectionwraps anything extendingMagento\Framework\Data\Collection, not only database collections, so an in-memory list needs no provider of its own.sortable: falseandfilter: falseon every column are deliberate. An in-memory collection has no select to sort or filter, and a header that looked clickable but did nothing would be worse than a plain one.developerOnlydrops the action entirely outside developer mode. Keep at least one action without the flag, or a selection in production has nothing it can do.buttonsare toolbar buttons, not row actions. They navigate to a route, take avariantofprimary,secondaryordanger, and take an optionalconfirm.- There is no
addButtonand nodefaultSort, because there is nothing to add and nothing to order. Every key here is optional; a grid declares only what it has. - No
massActionIdField, so each checkbox submits the row'sidfield, which is what the cache type list calls its key.
Source: Nebula_System/view/adminhtml/grid/cache_management.json. The index
management grid is the same shape with indexer_list.collection and
massActionIdField: "indexer_id".
A report: calendar dates and no add button¶
A report lists rows that already happened. Nothing is created from it, so it has no add
button and no mass actions, and every column is a filter on a large result set. The
report_date column is the one that matters: it is a calendar date stored as a
datetime at midnight, and converting it to the store timezone would show the day
before, so it opts out with timezone: false.
{
"id": "paypal_settlement_report",
"acl": "Magento_Paypal::paypal_settlement_reports",
"dataSource": {
"provider": "grid.collection",
"config": {
"collection": "paypal_settlement.collection"
}
},
"settings": {
"pageSize": 20,
"pageSizes": [10, 20, 50, 100],
"defaultSort": {"field": "report_date", "direction": "desc"},
"massActions": false
},
"columns": {
"report_date": {
"label": "Report Date",
"type": "date",
"sortable": true,
"filter": "date",
"timezone": false,
"index": "report_date",
"position": 10
},
"transaction_id": {
"label": "Transaction ID",
"type": "text",
"sortable": true,
"filter": "text",
"searchable": true,
"index": "transaction_id",
"position": 30
},
"transaction_event_code": {
"label": "Event",
"type": "select",
"sortable": true,
"filter": "select",
"filterOptionsSource": "magento.paypal.settlement.event",
"index": "transaction_event_code",
"position": 60
},
"transaction_initiation_date": {
"label": "Start Date",
"type": "date",
"format": "M j, Y g:i A",
"sortable": true,
"filter": "date",
"index": "transaction_initiation_date",
"position": 70
},
"gross_transaction_amount": {
"label": "Gross Amount",
"type": "price",
"sortable": true,
"filter": "range",
"index": "gross_transaction_amount",
"position": 90
},
"fee_amount": {
"label": "Fee Amount",
"type": "price",
"sortable": true,
"filter": false,
"index": "fee_amount",
"hideBelow": "lg",
"position": 100
},
"actions": {
"label": "",
"type": "actions",
"actions": [
{"id": "view", "label": "View", "url": "paypal/paypal_reports/details/id/{{row_id}}"}
],
"position": 999
}
}
}
Keys worth noticing
timezone: falsekeeps the column out of store-timezone conversion, for the displayed value and for the filter bounds alike. A value with no time of day is never converted regardless, so a column stored as a plain date needs no flag.massActions: falseis not the same as omitting the key.falseremoves the row checkboxes and the mass-action bar; omitting it leaves an empty list, which renders the same today but says nothing about intent.pageSizesis worth raising on a report. A report is read in bulk, and the interactive ceiling is 200 rows a page. An export raises the ceiling to 100000 and logs a warning when the result set is larger.filter: "range"on a money column readsgross_transaction_amount_fromandgross_transaction_amount_to. The fee column leaves the filter off, because two range pairs over the same rows rarely narrow anything.defaultSorton a report should be the column the reader scans by, which for a settlement report is the most recent date first.- There is nothing to add, so there is no
addButtonand noeditableblock.
Source: Nebula_Reports/view/adminhtml/grid/paypal_settlement_report.json.
Import history: link columns and table-qualified indexes¶
Two things here that no other example shows. A link column turns the cell value into
an anchor whose target comes from href, and the columns carry table-qualified
index values, because the collection joins the admin user table and an unqualified
history_id would be ambiguous in the SQL.
{
"id": "import_history_listing",
"acl": "Magento_ImportExport::history",
"dataSource": {
"provider": "grid.collection",
"config": {
"collection": "import_history.collection"
}
},
"settings": {
"pageSize": 20,
"pageSizes": [10, 20, 50, 100],
"defaultSort": {"field": "history_id", "direction": "desc"},
"massActions": false
},
"columns": {
"history_id": {
"label": "ID",
"type": "text",
"sortable": true,
"filter": false,
"index": "main_table.history_id",
"position": 10
},
"started_at": {
"label": "Start Date & Time",
"type": "date",
"format": "M j, Y g:i A",
"sortable": true,
"filter": "date",
"index": "main_table.started_at",
"position": 20
},
"username": {
"label": "User",
"type": "text",
"sortable": true,
"filter": "text",
"searchable": true,
"index": "link_table.username",
"position": 30
},
"imported_file": {
"label": "Imported File",
"type": "link",
"href": "adminhtml/history/download/filename/{{imported_file}}",
"sortable": false,
"filter": false,
"searchable": true,
"index": "main_table.imported_file",
"position": 40
},
"error_file": {
"label": "Error File",
"type": "link",
"href": "adminhtml/history/download/filename/{{error_file}}",
"sortable": false,
"filter": false,
"position": 50
},
"summary": {
"label": "Summary",
"type": "text",
"sortable": false,
"filter": false,
"hideBelow": "lg",
"position": 70
}
}
}
Keys worth noticing
hrefis interpolated with{{field}}placeholders from the row, then built through the URL builder when it names an admin route, so the link carries the secret key. A value that is already absolute, protocol-relative, root-relative or a bare#is passed through untouched.- A
linkcell renders no anchor at all when the value is empty. An import that produced no error file would otherwise get a zero-width link pointing at a download route with no filename, so do not addemptyTextto alinkcolumn. - A table-qualified
indexis the fix for an ambiguous column in a joined collection. It is used for the displayed value, the filter and the sort. - This grid has no
actionscolumn. The trailing button group is a convention, not a requirement, and a grid whose only actions are links inside cells does not need one. - With no actions column and no
rowUrl, the row click has nothing to borrow and the rows are inert. That is correct here: there is no import history detail page. searchableon the file name puts it in the toolbar's free-text box, which is how an admin finds the run that imported one particular file.
Source: Nebula_System/view/adminhtml/grid/import_history_listing.json.
An overlay that adds one column¶
The smallest useful file on this page. It has no id, so the engine treats it as a
patch onto the base definition of the same name and merges it in module load order.
{
"columns": {
"page_layout": {"label": "Layout", "type": "text", "filter": "text", "position": 35}
}
}
Keys worth noticing
- An overlay must omit
id. A file that sets one is a base, and two bases for the same grid is a merge conflict waiting to happen. - A column in an overlay is a patch, so
labelandpositionare only required because this column is new. Changing an existing column takes only the keys that change. - The column still needs data.
cms_page.collectionalready selectspage_layout; a column the collection does not select renders empty and cannot be filtered. - Sequence your module after the one that ships the base grid, or your file may load first and become the base itself.
Source: the pattern is documented in
Extending a shipped grid, which covers $remove, merging row
actions by id, and the rest.
Custom column renderer¶
The ten built-in column types are a DI array, not a hard-coded list. A renderer is any
class implementing Nebula\Component\Api\V1\ColumnRendererInterface, and the
shipped ones all extend AbstractColumnRenderer, which handles escaping and delegates
the markup to a .phtml partial. Override getTemplate() to name the partial and
toViewData() to shape what the partial receives.
<?php
declare(strict_types=1);
namespace Vendor\Module\Renderer\Column;
use Nebula\Grid\Renderer\Column\AbstractColumnRenderer;
class ProgressRenderer extends AbstractColumnRenderer
{
public function getComponentName(): string
{
return 'nebulaColumn_progress';
}
public function getTemplate(): string
{
return 'Vendor_Module::column/progress.phtml';
}
/**
* @param array<string, mixed> $column
* @param array<string, mixed> $item
* @return array<string, mixed>
*/
protected function toViewData(array $column, string $key, array $item): array
{
$raw = (float) ($item[$key] ?? 0);
return [
'column' => $column,
'key' => $key,
'item' => $item,
'value' => $raw,
'percent' => max(0.0, min(100.0, $raw)),
];
}
}
render() comes from the base class: it calls getTemplate(), and renders that partial
with whatever toViewData() returned. Return an empty string from getTemplate() to
compose the cell in PHP instead, in which case the base class escapes the raw value for
you. getComponentName() names the Alpine component for a column that is enhanced in
the browser; the convention is nebulaColumn_<type>.
Bind it under columnRenderers, keyed by the name a column will ask for:
<type name="Nebula\Component\Model\RendererPool">
<arguments>
<argument name="columnRenderers" xsi:type="array">
<item name="progress" xsi:type="object">Vendor\Module\Renderer\Column\ProgressRenderer</item>
</argument>
</arguments>
</type>
The array is additive, so your etc/di.xml adds to the ten built-in entries without
touching them. A column then selects the renderer by that key:
A custom type is read by the engine and rejected by the schema
RendererPool resolves any bound key, and an unknown type falls back to text
rather than failing. But grid.schema.json closes type to the ten built-ins, so a
definition using a custom type fails bin/magento nebula:lint, and no flag relaxes
that enum. The schema-clean route to a custom cell is a snippet: renderer
accepts snippet.<id>, the dispatcher checks it before the type, and the snippet's
own JSON names the template, which can be any .phtml with a view model behind it.
See Snippets. Reach for a DI-bound renderer when you want a
reusable column type in PHP and can live with the lint failure; reach for a snippet
when the linter has to pass.
Source: adapted from
code/Nebula/Grid/Renderer/Column/BadgeRenderer.php and
code/Nebula/Grid/Renderer/Column/LinkRenderer.php, which are the two shipped
renderers that override toViewData(). The dispatch order is in
code/Nebula/Grid/Renderer/Column/ColumnDispatcher.php, the registry in
code/Nebula/Component/Model/RendererPool.php.
Validate before you ship¶
Run the linter. It validates every grid, form, snippet and component in every enabled module against its Draft 2020-12 schema, and the grid schema is strict: a misspelt key is an error, not a shrug.
bin/magento nebula:lint
bin/magento nebula:lint --path=app/code/Vendor/Module # one module or one file
bin/magento nebula:lint --fix # reformat, alphabetize, auto-position
Every JSON example on this page was validated against
code/Nebula/Component/schemas/grid.schema.json with a Draft 2020-12 validator
before it was published, so each one is a file you can drop in and lint.
Keys these examples do not use
Three things are worth knowing about but had nowhere honest to appear.
The html column type is registered and renders its value unescaped, but no
shipped definition uses it, and the only safe source for it is markup the server
itself produced. The template column key and a renderer naming a
Vendor_Module::path.phtml template are both read by the dispatcher but rejected by
the schema. settings.cardView.columns is accepted by the schema and ignored by the
engine, which drives the card layout from primary, badge and secondary only.
The Grid reference
keeps the full list of both mismatches.