Snippets¶
A snippet is a reusable piece of a screen defined once and used from many grids and forms. It lives at view/adminhtml/snippet/<id>.json in any module, and a column, fieldset or layout section uses it with "renderer": "snippet.<id>". Snippet examples shows seven of them adapted from the snippets Nebula ships.
Three flavours exist:
| Flavour | JSON | Renders |
|---|---|---|
| Template snippet | { "id", "template" } |
A .phtml template with a fixed set of variables and an optional view model |
| Badge snippet | { "id", "template", "options" } |
A coloured chip from a value-to-label map |
| Layout snippet | { "id", "type": "row", "children" } |
A row of columns with named slots, used with @use in a layout tree |
Using a snippet¶
In a grid column:
"status": {
"label": "Status",
"renderer": "snippet.status_badge",
"filter": "select",
"filterOptions": { "1": "Enabled", "0": "Disabled" }
}
In a form fieldset, replacing its fields with a rendered panel:
"nebula_passkey_security": {
"label": "Sign-in security",
"position": 25,
"open": true,
"renderer": "snippet.nebula_passkey_admin_status"
}
In a layout-tree form, as a section:
A column or field declares a renderer instead of a type, never both: the grid schema rejects a column that carries both keys. Filtering and sorting are unaffected, because they are driven by the column's filter, index, filter_index and sortIndex keys rather than by its display type.
Template snippets¶
{
"id": "nebula_passkey_admin_status",
"template": "Nebula_Passkey::snippet/admin-user-status.phtml"
}
The template receives these variables through $block->getData():
| Variable | In a grid cell | In a form section or fieldset |
|---|---|---|
record |
the row | the entity data |
nebula_context |
"grid" |
"form" |
view_model |
the bound view model, or null | the bound view model, or null |
column, key, item, value |
the merged column config, the column key, the row, the cell value | |
entity_data, form_block, section |
the entity data, the form block, the section node |
record and nebula_context are the shared half. A template used in both places reads record and branches on nebula_context if it must render differently.
View models¶
A template that needs services asks for a view model rather than reaching for the object manager. Bind one to the snippet id:
<type name="Nebula\Component\Model\SnippetViewModelRegistry">
<arguments>
<argument name="viewModels" xsi:type="array">
<item name="nebula_passkey_admin_status" xsi:type="string">Nebula\Passkey\ViewModel\SecurityStatus</item>
</argument>
</arguments>
</type>
The grid's snippet renderer and the form's section renderer both look the snippet id up in this registry and pass the instance as view_model. Nothing else does, so a snippet's template must be rendered through the engine, not built by hand with createBlock().
Guard the template so a missing binding fails loudly:
<?php
/** @var \Magento\Framework\View\Element\Template $block */
$viewModel = $block->getData('view_model');
if (!$viewModel instanceof \Nebula\Passkey\ViewModel\SecurityStatus) {
throw new \RuntimeException('Snippet "nebula_passkey_admin_status": view_model was not injected.');
}
$record = $block->getData('record');
?>
<section class="nebula-fieldset__body">
<p><?= $escaper->escapeHtml($viewModel->summaryFor((int) ($record['user_id'] ?? 0))) ?></p>
</section>
Inline scripts in a snippet template go through $secureRenderer->renderTag('script', [], $js, false) so they carry the request nonce.
Badge snippets¶
{
"id": "status_badge",
"template": "Nebula_Component::snippet/status_badge.phtml",
"options": {
"1": { "label": "Enabled", "class": "success" },
"0": { "label": "Disabled", "class": "error" }
}
}
class is one of success, error, warning, info, neutral, mapped to the theme's chip styles.
A badge snippet is a template snippet: the chip comes from the template, and options is data the template reads. A snippet with "type": "badge" and no template renders the escaped raw value instead of a chip.
The shipped badge reads the column's filterOptions first and falls back to its own options, so the label in the cell and the label in the filter dropdown cannot disagree. Eleven grids share status_badge and their values mean different things; a column that declares its own map gets its own words. The colour is borrowed from the snippet only when the snippet's label agrees with the column's.
For a simple value-to-chip column you do not need a snippet at all; the badge column type takes the same options map inline.
Layout snippets¶
{
"id": "two_column",
"type": "row",
"children": [
{ "type": "column", "width": "1/2", "slot": "left" },
{ "type": "column", "width": "1/2", "slot": "right" }
]
}
A layout tree uses it with @use and fills the slots:
"layout": [
{
"@use": "two_column",
"slots": {
"left": [ { "type": "section", "id": "general", "fields": ["name", "sku"] } ],
"right": [ { "type": "section", "id": "status", "fields": ["is_active"] } ]
}
}
]
The snippet resolver replaces the node with the snippet's definition and places each slot's nodes in the matching column. Shipped layout snippets: two_column, two_column_sidebar, three_column. Neither @use nor slots is a layout-node property in the form schema, so a form that uses them does not pass bin/magento nebula:lint. The shipped forms write their rows out explicitly, so treat the mechanism as available rather than as the convention.
Shipped snippets¶
| Snippet | Module | Used for |
|---|---|---|
status_badge, boolean_badge, visibility_badge, scope_badge |
Nebula_Component |
Status chips in grids |
product_selector, record_selector, searchable_multiselect |
Nebula_Component |
Pickers inside forms |
rule_conditions |
Nebula_Component |
The condition tree on cart and catalog price rules |
two_column, two_column_sidebar, three_column |
Nebula_Component |
Layout rows |
unsupported_ui_node |
Nebula_Component |
The marker the UI transformer emits for a node it cannot convert |
customer_overview, customer_addresses, customer_orders_lazy, customer_cart, and the other customer panels |
Nebula_Theme |
The customer edit page |
nebula_passkey_admin_status, nebula_passkey_customer_status |
Nebula_Passkey |
Sign-in security panels |
Run ls vendor/nebula/module-core-*/view/adminhtml/snippet/ for the full list on your install.
The picker snippets and their events¶
Each picker hands a pick back its own way. record_selector always dispatches a DOM event and writes no value into your form, so its host has to listen. product_selector posts its staged rows as hidden inputs and dispatches nothing, unless a host puts it in the emit_only mode described below.
record_selector opens a native dialog and searches one endpoint for a business record such as an order, an invoice or a customer. Its block data is search_url, which is required, plus the optional entity_type, value, button_label, modal_title, search_placeholder, empty_label and trigger_class. Without search_url the button still opens the dialog, but no search is made and the list stays on its empty label.
Its behaviour is plain JavaScript with delegated listeners rather than Alpine, and it writes nothing into your form. On a pick it dispatches a bubbling nebula:record-selected from the element that carries data-nebula-record-selector, with detail: { id, label }. Reacting to it is the host's job. An Alpine ancestor listens with an attribute:
Plain JavaScript listens on the root or on any ancestor:
root.addEventListener('nebula:record-selected', function (event) {
console.log(event.detail.id, event.detail.label);
});
product_selector is a preset over the shared selection component, which is Alpine. In its default mode that component owns the staged rows and serialises them as hidden inputs under field_name, so the form posts the selection like any other field and there is nothing to listen for. A host that renders the picker block itself and sets emit_only on it gets a picker that owns no selection: each pick $dispatches nebula-selection:pick with detail: { row } and drops that row from the results, and no hidden input is written. The category merchandiser uses that mode, because its selection is the category's own ordered assignment set and is saved by its own endpoint.
emit_only is block data, not JSON
emit_only is set on the block in PHP before rendering, with $picker->setData('emit_only', true). It is not a key in the snippet JSON and not a product_selector option, so it is only available to a host that builds the picker block itself.
Writing your own¶
- Create
view/adminhtml/snippet/<id>.jsonwithidequal to the file name and atemplate. - Write the template under
view/adminhtml/templates/, readingrecord,valueorentity_dataas needed. - If the template needs services, write a view model class and bind it in
SnippetViewModelRegistry. - Reference it with
"renderer": "snippet.<id>"from a column, a fieldset, or a layout section. - Run
bin/magento nebula:lint; the snippet schema requiresidand accepts only the keys above.
Overriding a shipped snippet¶
Snippets are loaded and merged like grids and forms. A file with the same id in a module that loads later overlays the shipped one, so you can change a badge's labels or point a snippet at your own template without copying the module:
{ "options": { "1": { "label": "Live", "class": "success" }, "0": { "label": "Draft", "class": "neutral" } } }
To change a template without changing the JSON, override the .phtml in your admin theme the usual Magento way.