Skip to content

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:

{ "id": "customer_overview_section", "type": "section", "renderer": "snippet.customer_overview" }

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

view/adminhtml/snippet/nebula_passkey_admin_status.json
{
    "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:

etc/di.xml
<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

view/adminhtml/snippet/status_badge.json
{
    "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

view/adminhtml/snippet/two_column.json
{
    "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:

<div x-data="{ recordId: '' }" @nebula:record-selected="recordId = $event.detail.id">

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

  1. Create view/adminhtml/snippet/<id>.json with id equal to the file name and a template.
  2. Write the template under view/adminhtml/templates/, reading record, value or entity_data as needed.
  3. If the template needs services, write a view model class and bind it in SnippetViewModelRegistry.
  4. Reference it with "renderer": "snippet.<id>" from a column, a fieldset, or a layout section.
  5. Run bin/magento nebula:lint; the snippet schema requires id and 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:

Vendor_Module/view/adminhtml/snippet/status_badge.json
{ "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.