Skip to content

'Edit attributes' page

Simon Frost edited this page Nov 11, 2020 · 1 revision

Anatomy of Magento 2: The Admin 'Edit attribute' page

Notes taken against Magento 2.2.8 (Paving Direct).

Preparing the data

  • The loading process is tightly bound to the Magento_Swatches module - is this due to the attribute (material) we're loading? Is Magento_Swatches used to render other attributes?

The block class (\Magento\Eav\Block\Adminhtml\Attribute\Edit\Options\Options) calls \Magento\Eav\Block\Adminhtml\Attribute\Edit\Options\Options::getJsonConfig, which returns an array of metadata relating to the attribute options. This includes the attribute options themselves (including all the data needed for the JS to build the grid later) and also two flags; isSortable and isReadOnly.

getJsonConfig calls getOptionValues, which gets the attribute option value collection and passes it to \Magento\Eav\Block\Adminhtml\Attribute\Edit\Options\Options::_prepareOptionValues.

This method returns an array of data objects, each one representing an option value. It sets the default value and input type for each value. For attributes with select frontend types, the input type is set to 'radio'. If it is multiselect, it is set to 'checkbox'. For all other frontend types the input type is set to an empty string.

The items in the option values collection is processed in batches of 200.

System attributes and user-defined attributes are subject to different processing. \Magento\Eav\Block\Adminhtml\Attribute\Edit\Options\Options::_prepareUserDefinedAttributeOptionValues also sets whether checkboxes are checked or not on page load.

Rendering the form

PHTML

The form is built from a combination of 'classic' PHTML templates and JS components.

The option values grid uses a different template for different types of attribute. There are three in the core:

  • html/vendor/magento/module-catalog/view/adminhtml/templates/catalog/product/attribute/options.phtml
  • html/vendor/magento/module-swatches/view/adminhtml/templates/catalog/product/attribute/text.phtml
  • html/vendor/magento/module-swatches/view/adminhtml/templates/catalog/product/attribute/visual.phtml

The form for checkbox attributes is defined in html/vendor/magento/module-catalog/view/adminhtml/templates/catalog/product/attribute/options.phtml.

The thead and tfooter are generated using standard PHTML, but the tbody is populated using JS. A template of each row is defined in the script id="row-template" type="text/x-magento-template" block in the PHTML template.

The values of each row are provided by the method \Magento\Eav\Block\Adminhtml\Attribute\Edit\Options\Options::getOptionValues. These values are passed through htmlspecialchars_decode before being output to the template as a json_encoded string.

The JS bit

The PHTML template defines a script type="text/x-magento-init" block, which initialises two UI components:

  • Magento_Catalog/js/options: Constructs and populates the option values grid;
  • Magento_Catalog/catalog/product/attribute/unique-validate: Presumably validates that multiselect attributes have a value selected
<script type="text/x-magento-init">
    {
        "*": {
            "Magento_Catalog/js/options": {
                "attributesData": <?= /* @noEscape */ json_encode($values, JSON_HEX_QUOT) ?>,
                "isSortable":  <?= (int)(!$block->getReadOnly() && !$block->canManageOptionDefaultOnly()) ?>,
                "isReadOnly": <?= (int)$block->getReadOnly() ?>
            },
            "Magento_Catalog/catalog/product/attribute/unique-validate": {
                "element": "required-dropdown-attribute-unique",
                "message": "<?= $block->escapeHtml(__("The value of Admin must be unique.")) ?>"
            }
        }
    }
</script>

Values from the PHTML template/block class are passed into these AMD modules by assigning them to properties of a config object, which is passed into the constructor of the AMD component:

define([ /* blah, blah */], function (jQuery, mageTemplate, rg) {
    'use strict';

    return function (config) {
        var optionPanel = jQuery('#manage-options-panel'),
            attributeOption = {
                // Note how 'config.isReadOnly' is defined by the "isReadOnly" property set in the PHTML template
                isReadOnly: config.isReadOnly,

Building the grid

The whole option values table is wrapped in a div with an id of manage-options-panel. A callback is registered on the onRender event which encapsulates all the logic to load and display the grid:

    optionPanel.on('render', function () {
        attributeOption.ignoreValidate();

        if (attributeOption.rendered) {
            return false;
        }
        jQuery('body').trigger('processStart');
        attributeOption.renderWithDelay(config.attributesData, 0, 100, 300);
        attributeOption.bindRemoveButtons();
    });

The grid is validated using the jQuery validator plugin. The ignoreValidate method call tells the plugin to ignore validating input, select and textarea form fields inside the grid.

The AMD component defines an attributeOption object, which encapsulates all the logic necessary to render each option (and therefore row) of the grid.

attributeOption.rendered is a flag which determines which stops the field from being re-rendered if it has already been rendered. It is initialised to 0 on creation of the component and is set to 1 when the component detects the field has already been rendered in the renderWithDelay method.

processStart is an event registered on the body element. It triggers all the events registered on that element.

The rendering process is kicked off in the attributeOption.renderWithDelay method. It does three things:

  1. Creates the DOM elements for the rows;
  2. Inserts them into the table
  3. Recursively calls itself to render all the rows of the grid using steps one and two. It also makes use of setTimeout to render the rows in batches of 100 at a time, spaced 300 milliseconds apart. The delay is the fourth parameter of attributeOption.renderWithDelay, the batch size is defined by the second and third parameters (the first parameter is the config.attributesData json object passed in from the PHTML template).

attributeOption.bindRemoveButtons appears to be a hidden dependency on the Magento_Swatches module. It registers events to the delete buttons added on each row of the table grid - but only for elements with an id of swatch-visual-options-panel (e.g. Like the one defined in html/vendor/magento/module-swatches/view/adminhtml/templates/catalog/product/attribute/visual.phtml).

Adding new rows

In the Magento_Catalog/js/options component checks for an element with the id of add_new_option_button. If it exists, an onClick event is registered, which binds the attributeOption.add method to it. The arguments passed to that method when clicked are the attributeOption object (which represents the row being added), an empty object of data and the flag true.

The last parameter is important because the attributeOption.add method is used both to create the table grid on page load and also to add new rows after the page has finished loading. If the flag is true, the attributeOption.render method is executed at the end of the add method so that the new row is added to the table grid immediately.

The row template (or the case of the template rendered twice)

An interesting feature of the table grid is that the DOm elements for the rows are created according to a template, which is defined in the PHTML template (html/vendor/magento/module-catalog/view/adminhtml/templates/catalog/product/attribute/options.phtml):

<script id="row-template" type="text/x-magento-template">
        <tr <% if (data.rowClasses) { %>class="<%- data.rowClasses %>"<% } %>>
            <td class="col-draggable">
                <?php if (!$block->getReadOnly() && !$block->canManageOptionDefaultOnly()) :?>
                    <div data-role="draggable-handle" class="draggable-handle"
                         title="<?= $block->escapeHtmlAttr(__('Sort Option')) ?>">
                    </div>
                <?php endif; ?>
                <input data-role="order" type="hidden" name="option[order][<%- data.id %>]"  value="<%- data.sort_order %>" <?php if ($block->getReadOnly() || $block->canManageOptionDefaultOnly()) :?> disabled="disabled"<?php endif; ?>/>
            </td>
            <td class="col-default control-table-actions-cell">
                <input class="input-radio" type="<%- data.intype %>" name="default[]" value="<%- data.id %>" <%- data.checked %><?php if ($block->getReadOnly()) :?>disabled="disabled"<?php endif;?>/>
            </td>
            <?php foreach ($stores as $_store) :?>
            <td class="col-<%- data.id %>"><input name="option[value][<%- data.id %>][<?= (int) $_store->getId() ?>]" value="<%- data.store<?= /* @noEscape */ (int) $_store->getId() ?> %>" class="input-text<?php if ($_store->getId() == \Magento\Store\Model\Store::DEFAULT_STORE_ID) :?> required-option required-unique<?php endif; ?>" type="text" <?php if ($block->getReadOnly() || $block->canManageOptionDefaultOnly()) :?> disabled="disabled"<?php endif;?>/></td>
            <?php endforeach; ?>
            <td id="delete_button_container_<%- data.id %>" class="col-delete">
                <input type="hidden" class="delete-flag" name="option[delete][<%- data.id %>]" value="" />
                <?php if (!$block->getReadOnly() && !$block->canManageOptionDefaultOnly()) :?>
                    <button id="delete_button_<%- data.id %>" title="<?= $block->escapeHtmlAttr(__('Delete')) ?>" type="button"
                        class="action- scalable delete delete-option"
                        >
                        <span><?= $block->escapeHtml(__('Delete')) ?></span>
                    </button>
                <?php endif;?>
            </td>
        </tr>
    </script>

This template uses its own template variables (the data referenced inside <%- ... -%> blocks), as well as the usual PHP variables (enclosed within <?php ... ?>).

In effect, what Magento have done here is create a template which is rendered twice - once by PHP (on the server side) and once by JavaScript (on the client side).

PHP is used to generate the columns of the table row and then attributeOption.add passes the data from config.attributesData to mage/template (html/lib/web/mage/template). mage/template is basically a wrapper for Underscore.js.

mage/template renders the template, populating the dynamic aspects of the table grid (like form field names, values, IDs and the delete buttons for each row). The returned string is added to the elements object, ready to be added to the DOM by the attributeOption.render method.

Saving values

On save, all the form fields in the table are serialised and posted under the value 'serialized_options', e.g:

serialized_options: ["option%5Border%5D%5B406%5D=1&option%5Bvalue%5D%5B406%5D%5B0%5D=Concrete&option%5Bvalue%5D%5B406%5D%5B1%5D=&crawlable%5B%5D=406&option%5Bdelete%5D%5B406%5D=","option%5Border%5D%5B458%5D=2&option%5Bvalue%5D%5B458%5D%5B0%5D=Granite&option%5Bvalue%5D%5B458%5D%5B1%5D=&crawlable%5B%5D=458&option%5Bdelete%5D%5B458%5D=","option%5Border%5D%5B870%5D=3&option%5Bvalue%5D%5B870%5D%5B0%5D=Grout&option%5Bvalue%5D%5B870%5D%5B1%5D=&option%5Bdelete%5D%5B870%5D=","option%5Border%5D%5B650%5D=4&option%5Bvalue%5D%5B650%5D%5B0%5D=Limestone+%2F+Sandstone&option%5Bvalue%5D%5B650%5D%5B1%5D=&crawlable%5B%5D=650&option%5Bdelete%5D%5B650%5D=","option%5Border%5D%5B387%5D=5&option%5Bvalue%5D%5B387%5D%5B0%5D=Limestone&option%5Bvalue%5D%5B387%5D%5B1%5D=&option%5Bdelete%5D%5B387%5D=","option%5Border%5D%5B766%5D=6&option%5Bvalue%5D%5B766%5D%5B0%5D=Liquid&option%5Bvalue%5D%5B766%5D%5B1%5D=&option%5Bdelete%5D%5B766%5D=","option%5Border%5D%5B774%5D=7&option%5Bvalue%5D%5B774%5D%5B0%5D=Marble&option%5Bvalue%5D%5B774%5D%5B1%5D=&option%5Bdelete%5D%5B774%5D=","option%5Border%5D%5B719%5D=8&option%5Bvalue%5D%5B719%5D%5B0%5D=Metal&option%5Bvalue%5D%5B719%5D%5B1%5D=&option%5Bdelete%5D%5B719%5D=","option%5Border%5D%5B718%5D=9&option%5Bvalue%5D%5B718%5D%5B0%5D=Pointfix&option%5Bvalue%5D%5B718%5D%5B1%5D=&option%5Bdelete%5D%5B718%5D=","option%5Border%5D%5B872%5D=10&option%5Bvalue%5D%5B872%5D%5B0%5D=Polymer+Bond&option%5Bvalue%5D%5B872%5D%5B1%5D=&option%5Bdelete%5D%5B872%5D=","option%5Border%5D%5B459%5D=11&option%5Bvalue%5D%5B459%5D%5B0%5D=Porcelain&option%5Bvalue%5D%5B459%5D%5B1%5D=&option%5Bdelete%5D%5B459%5D=","option%5Border%5D%5B461%5D=12&option%5Bvalue%5D%5B461%5D%5B0%5D=Reconstituted&option%5Bvalue%5D%5B461%5D%5B1%5D=&option%5Bdelete%5D%5B461%5D=","option%5Border%5D%5B160%5D=13&option%5Bvalue%5D%5B160%5D%5B0%5D=Sandstone&option%5Bvalue%5D%5B160%5D%5B1%5D=&option%5Bdelete%5D%5B160%5D=","option%5Border%5D%5B803%5D=14&option%5Bvalue%5D%5B803%5D%5B0%5D=Semiconductor&option%5Bvalue%5D%5B803%5D%5B1%5D=&option%5Bdelete%5D%5B803%5D=","option%5Border%5D%5B398%5D=15&option%5Bvalue%5D%5B398%5D%5B0%5D=Slate&option%5Bvalue%5D%5B398%5D%5B1%5D=&option%5Bdelete%5D%5B398%5D=","option%5Border%5D%5B765%5D=16&option%5Bvalue%5D%5B765%5D%5B0%5D=Stainless+Steel&option%5Bvalue%5D%5B765%5D%5B1%5D=&option%5Bdelete%5D%5B765%5D=","option%5Border%5D%5B720%5D=17&option%5Bvalue%5D%5B720%5D%5B0%5D=Wood&option%5Bvalue%5D%5B720%5D%5B1%5D=&option%5Bdelete%5D%5B720%5D=","option%5Border%5D%5B804%5D=18&option%5Bvalue%5D%5B804%5D%5B0%5D=N%2FA&option%5Bvalue%5D%5B804%5D%5B1%5D=&option%5Bdelete%5D%5B804%5D=","option%5Border%5D%5B1030%5D=19&option%5Bvalue%5D%5B1030%5D%5B0%5D=Cast+Stone&option%5Bvalue%5D%5B1030%5D%5B1%5D=&option%5Bdelete%5D%5B1030%5D=1"]

All options are serialised, regardless of whether they were changed or not.

In the save controller, \Magento\Catalog\Controller\Adminhtml\Product\Attribute\Save::execute, the post data is unserialized, revealing an array structure that looks something like this:

Array
(
    [option] => Array
        (
            [order] => Array
                (
                    [31] => 0
                    [143] => 0
                    [32] => 1
                    [144] => 1
                    [33] => 2
                    [145] => 2
                    [34] => 3
                    [146] => 3
                    [147] => 4
                    [35] => 4
                    [148] => 5
                    [36] => 5
                    [149] => 6
                    [37] => 6
                    [150] => 7
                    [38] => 7
                    [39] => 8
                    [151] => 8
                    [152] => 9
                    [40] => 9
                    [153] => 10
                    [41] => 10
                    [42] => 11
                    [154] => 11
                    [43] => 12
                    [155] => 12
                    [156] => 13
                    [44] => 13
                    [157] => 14
                    [45] => 14
                    [158] => 15
                    [46] => 15
                    [47] => 16
                    [159] => 16
                    [48] => 17
                    [160] => 17
                )

            [value] => Array
                (
                    [31] => Array
                        (
                            [0] => Burlap
                            [1] =>
                            [4] =>
                        )

                    [143] => Array
                        (
                            [0] => Cocona&reg; performance fabric
                            [1] =>
                            [4] =>
                        )

                    [32] => Array
                        (
                            [0] => Canvas
                            [1] =>
                            [4] =>
                        )

                    [144] => Array
                        (
                            [0] => Wool
                            [1] =>
                            [4] =>
                        )

                    [33] => Array
                        (
                            [0] => Cotton
                            [1] =>
                            [4] =>
                        )

                    [145] => Array
                        (
                            [0] => Fleece
                            [1] =>
                            [4] =>
                        )

                    [34] => Array
                        (
                            [0] => Faux Leather
                            [1] =>
                            [4] =>
                        )

                    [146] => Array
                        (
                            [0] => Hemp
                            [1] =>
                            [4] =>
                        )

                    [147] => Array
                        (
                            [0] => Jersey
                            [1] =>
                            [4] =>
                        )

                    [35] => Array
                        (
                            [0] => Leather
                            [1] =>
                            [4] =>
                        )

                    [148] => Array
                        (
                            [0] => LumaTech&trade;
                            [1] =>
                            [4] =>
                        )

                    [36] => Array
                        (
                            [0] => Mesh
                            [1] =>
                            [4] =>
                        )

                    [149] => Array
                        (
                            [0] => Lycra&reg;
                            [1] =>
                            [4] =>
                        )

                    [37] => Array
                        (
                            [0] => Nylon
                            [1] =>
                            [4] =>
                        )

                    [150] => Array
                        (
                            [0] => Microfiber
                            [1] =>
                            [4] =>
                        )

                    [38] => Array
                        (
                            [0] => Polyester
                            [1] =>
                            [4] =>
                        )

                    [39] => Array
                        (
                            [0] => Rayon
                            [1] =>
                            [4] =>
                        )

                    [151] => Array
                        (
                            [0] => Spandex
                            [1] =>
                            [4] =>
                        )

                    [152] => Array
                        (
                            [0] => HeatTec&reg;
                            [1] =>
                            [4] =>
                        )

                    [40] => Array
                        (
                            [0] => Ripstop
                            [1] =>
                            [4] =>
                        )

                    [153] => Array
                        (
                            [0] => EverCool&trade;
                            [1] =>
                            [4] =>
                        )

                    [41] => Array
                        (
                            [0] => Suede
                            [1] =>
                            [4] =>
                        )

                    [42] => Array
                        (
                            [0] => Foam
                            [1] =>
                            [4] =>
                        )

                    [154] => Array
                        (
                            [0] => Organic Cotton
                            [1] =>
                            [4] =>
                        )

                    [43] => Array
                        (
                            [0] => Metal
                            [1] =>
                            [4] =>
                        )

                    [155] => Array
                        (
                            [0] => TENCEL
                            [1] =>
                            [4] =>
                        )

                    [156] => Array
                        (
                            [0] => CoolTech&trade;
                            [1] =>
                            [4] =>
                        )

                    [44] => Array
                        (
                            [0] => Plastic
                            [1] =>
                            [4] =>
                        )

                    [157] => Array
                        (
                            [0] => Khaki
                            [1] =>
                            [4] =>
                        )

                    [45] => Array
                        (
                            [0] => Rubber
                            [1] =>
                            [4] =>
                        )

                    [158] => Array
                        (
                            [0] => Linen
                            [1] =>
                            [4] =>
                        )

                    [46] => Array
                        (
                            [0] => Synthetic
                            [1] =>
                            [4] =>
                        )

                    [47] => Array
                        (
                            [0] => Stainless Steel
                            [1] =>
                            [4] =>
                        )

                    [159] => Array
                        (
                            [0] => Wool
                            [1] =>
                            [4] =>
                        )

                    [48] => Array
                        (
                            [0] => Silicone
                            [1] =>
                            [4] =>
                        )

                    [160] => Array
                        (
                            [0] => Terry
                            [1] =>
                            [4] =>
                        )

                )

            [delete] => Array
                (
                    [31] =>
                    [143] =>
                    [32] =>
                    [144] =>
                    [33] =>
                    [145] =>
                    [34] =>
                    [146] =>
                    [147] =>
                    [35] =>
                    [148] =>
                    [36] =>
                    [149] =>
                    [37] =>
                    [150] =>
                    [38] =>
                    [39] =>
                    [151] =>
                    [152] =>
                    [40] =>
                    [153] =>
                    [41] =>
                    [42] =>
                    [154] =>
                    [43] =>
                    [155] =>
                    [156] =>
                    [44] =>
                    [157] =>
                    [45] =>
                    [158] =>
                    [46] =>
                    [47] =>
                    [159] => 1
                    [48] =>
                    [160] =>
                )

        )

    [default] => Array
        (
            [0] => 31
        )

    [crawlable] => Array
        (
            [0] => 31
        )

)

option[order] contains the sort order of the option values, option[values] contains the frontend labels for each store and options[delete] contains flags for each row which was deleted (see below for more on deletion). default refers to the Is Default checkbox/radio button form control, and crawlable is a custom form field we've added.

The values from the rest of the form (e.g. 'Default Label') are POSTed as well. The two arrays of values are merged together.

There is some logic to create a new attribute set.

Saving the attribute

Three fields are checked to see if they need to be 'invalidated'. This field is then checked after a successful save in \Magento\CatalogSearch\Model\Indexer\Fulltext\Plugin\Attribute::afterSave. If it was set to true, the \Magento\CatalogSearch\Model\Indexer\Fulltext::INDEXER_ID (catalogsearch_fulltext) is set to invalidated and the config cache is cleared.

Deleting values

Pressing the delete button of a row executes some JS which hides the row. A hidden input field is also set which is submitted along with the other fields when the form is saved.