Managing the Magento 2.2.4 Category Tree Checkbox Bug

Managing the Magento 2.2.4 Category Tree Checkbox Bug

7 min read

Send to you:

When using Magento® 2.2.4, a bug was identified in the category tree on the page for editing pricing rules. When trying to create pricing rules by product category (for a catalog or cart) not every category appeared in the category tree (a component of the form for generating a pricing rule for a category). More precisely, the root category of the first store, the first category of the store, and all of its child categories were displayed. Other categories were not displayed in the tree.

To fix this problem, we must analyze the implementation of the component, which you can see in the sample code in the next section.

Analysis of the Component for Price Rule by Product Category

Like the majority of Magento 2 components, this component has a back end and front end part (Javascript). A problem like this could be caused either by the fact that not all categories were transferred from the backend or by the fact that some categories weren’t rendered by the component in the Javascript tree for some reason. The component is implemented by the Magento\Catalog\Block\Adminhtml\Category\Checkboxes\Tree block. This block’s template, Magento_Catalog: catalog/category/checkboxes/tree.phtml, is set in its methods, and in it, the component is initialized by data in JSON format. The template looks like this in Magento 2.2.4:

<?php $_divId = 'tree-div_' . time() ?>
<div id="<?= /* @escapeNotVerified */ $_divId ?>" class="tree"></div>
<script id="ie-deferred-loader" defer="defer" src="//:"></script>
<script>
    require(["Magento_Catalog/js/category-checkbox-tree"], function (element) {
        element({
            "dataUrl":       "<?= /* @escapeNotVerified */ $block->getLoadTreeUrl() ?>" ,
            "divId":         "<?= /* @escapeNotVerified */$_divId ?>",
            "rootVisible":   <?php if ($block->getRoot()->getIsVisible()): ?>true<?php else : ?>false<?php endif; ?>,
            "useAjax":       <?= /* @escapeNotVerified */ $block->getUseAjax() ?>,
            "currentNodeId": <?= (int)$block->getCategoryId() ?>,
            "jsFormObject":  <?= /* @escapeNotVerified */ $block->getJsFormObject() ?>,
            "name":          "<?= /* @escapeNotVerified */ htmlentities($block->getRoot()->getName()) ?>",
            "checked":       "<?= /* @escapeNotVerified */ $block->getRoot()->getChecked() ?>",
            "allowDrop":     <?php if ($block->getRoot()->getIsVisible()): ?>true<?php else : ?>false<?php endif; ?>,
            "rootId":        <?= (int)$block->getRoot()->getId() ?>,
            "expanded":      <?= (int)$block->getIsWasExpanded() ?>,
            "categoryId":    <?= (int)$block->getCategoryId() ?>,
            "treeJson":      <?= /* @escapeNotVerified */ $block->getTreeJson() ?>
        });
    })
</s

As you can see, the Javascript component is implemented in a separate JS file: Magento_Catalog/js/category-checkbox-tree. This is an important difference from all previous versions since previously the Javascript of this component was implemented in the template, not put to a separate file. The code of this Javascript component is as follows:

define([
    'jquery',
    'prototype',
    'extjs/ext-tree-checkbox',
    'mage/adminhtml/form'
], function (jQuery) {
    'use strict';
    return function (config) {
        var tree,
            options = {
                dataUrl: config.dataUrl,
                divId: config.divId,
                rootVisible: config.rootVisible,
                useAjax: config.useAjax,
                currentNodeId: config.currentNodeId,
                jsFormObject: config.jsFormObject,
                name: config.name,
                checked: config.checked,
                allowDrop: config.allowDrop,
                rootId: config.rootId,
                expanded: config.expanded,
                categoryId: config.categoryId,
                treeJson: config.treeJson
            },
            data = {},
            parameters = {},
            root = {},
            len = 0,
            key = '',
            i = 0;
        /* eslint-disable */
        /**
         * Fix ext compatibility with prototype 1.6
         */
        Ext.lib.Event.getTarget = function (e) {// eslint-disable-line no-undef
            var ee = e.browserEvent || e;
            return ee.target ? Event.element(ee) : null;
        };
        /**
         * @param {Object} el
         * @param {Object} config
         */
        Ext.tree.TreePanel.Enhanced = function (el, config) {// eslint-disable-line no-undef
            Ext.tree.TreePanel.Enhanced.superclass.constructor.call(this, el, config);// eslint-disable-line no-undef
        };
        Ext.extend(Ext.tree.TreePanel.Enhanced, Ext.tree.TreePanel, {// eslint-disable-line no-undef
            /* eslint-enable */
            /**
             * @param {Object} config
             * @param {Boolean} firstLoad
             */
            loadTree: function (config, firstLoad) {// eslint-disable-line no-shadow
                parameters = config.parameters,
                data = config.data,
                root = new Ext.tree.TreeNode(parameters);// eslint-disable-line no-undef
                if (typeof parameters.rootVisible != 'undefined') {
                    this.rootVisible = parameters.rootVisible * 1;
                }
                this.nodeHash = {};
                this.setRootNode(root);
                if (firstLoad) {
                    this.addListener('click', this.categoryClick.createDelegate(this));
                }
                this.loader.buildCategoryTree(root, data);
                this.el.dom.innerHTML = '';
                // render the tree
                this.render();
            },
            /**
             * @param {Object} node
             */
            categoryClick: function (node) {
                node.getUI().check(!node.getUI().checked());
            }
        });
        jQuery(function () {
            var categoryLoader = new Ext.tree.TreeLoader({// eslint-disable-line no-undef
                dataUrl: config.dataUrl
            });
            /**
             * @param {Object} response
             * @param {Object} parent
             * @param {Function} callback
             */
            categoryLoader.processResponse = function (response, parent, callback) {
                config = JSON.parse(response.responseText);
                this.buildCategoryTree(parent, config);
                if (typeof callback === 'function') {
                    callback(this, parent);
                }
            };
            /**
             * @param {Object} config
             * @returns {Object}
             */
            categoryLoader.createNode = function (config) {// eslint-disable-line no-shadow
                var node;
                config.uiProvider = Ext.tree.CheckboxNodeUI;// eslint-disable-line no-undef
                if (config.children && !config.children.length) {
                    delete config.children;
                    node = new Ext.tree.AsyncTreeNode(config);// eslint-disable-line no-undef
                } else {
                    node = new Ext.tree.TreeNode(config);// eslint-disable-line no-undef
                }
                return node;
            };
            /**
             * @param {Object} parent
             * @param {Object} config
             * @param {Integer} i
             */
            categoryLoader.processCategoryTree = function (parent, config, i) {// eslint-disable-line no-shadow
                var node,
                    _node = {};
                config[i].uiProvider = Ext.tree.CheckboxNodeUI;// eslint-disable-line no-undef
                _node = Object.clone(config[i]);
                if (_node.children && !_node.children.length) {
                    delete _node.children;
                    node = new Ext.tree.AsyncTreeNode(_node);// eslint-disable-line no-undef
                } else {
                    node = new Ext.tree.TreeNode(config[i]);// eslint-disable-line no-undef
                }
                parent.appendChild(node);
                node.loader = node.getOwnerTree().loader;
                if (_node.children) {
                    categoryLoader.buildCategoryTree(node, _node.children);
                }
            };
            /**
             * @param {Object} parent
             * @param {Object} config
             * @returns {void}
             */
            categoryLoader.buildCategoryTree = function (parent, config) {// eslint-disable-line no-shadow
                if (!config) {
                    return null;
                }
                if (parent && config && config.length) {
                    for (i = 0; i < config.length; i  ) {
                        categoryLoader.processCategoryTree(parent, config, i);
                    }
                }
            };
            /**
             *
             * @param {Object} hash
             * @param {Object} node
             * @returns {Object}
             */
            categoryLoader.buildHashChildren = function (hash, node) {// eslint-disable-line no-shadow
                // eslint-disable-next-line no-extra-parens
                if ((node.childNodes.length > 0) || (node.loaded === false && node.loading === false)) {
                    hash.children = [];
                    for (i = 0, len = node.childNodes.length; i < len; i  ) {
                        /* eslint-disable */
                        if (!hash.children) {
                            hash.children = [];
                        }
                        /* eslint-enable */
                        hash.children.push(this.buildHash(node.childNodes[i]));
                    }
                }
                return hash;
            };
            /**
             * @param {Object} node
             * @returns {Object}
             */
            categoryLoader.buildHash = function (node) {
                var hash = {};
                hash = this.toArray(node.attributes);
                return categoryLoader.buildHashChildren(hash, node);
            };
            /**
             * @param {Object} attributes
             * @returns {Object}
             */
            categoryLoader.toArray = function (attributes) {
                data = {};
                for (key in attributes) {
                    if (attributes[key]) {
                        data[key] = attributes[key];
                    }
                }
                return data;
            };
            categoryLoader.on('beforeload', function (treeLoader, node) {
                treeLoader.baseParams.id = node.attributes.id;
            });
            /* eslint-disable */
            categoryLoader.on('load', function () {
                varienWindowOnload();
            });
            tree = new Ext.tree.TreePanel.Enhanced(options.divId, {
                animate: false,
                loader: categoryLoader,
                enableDD: false,
                containerScroll: true,
                selModel: new Ext.tree.CheckNodeMultiSelectionModel(),
                rootVisible: options.rootVisible,
                useAjax: options.useAjax,
                currentNodeId: options.currentNodeId,
                addNodeTo: false,
                rootUIProvider: Ext.tree.CheckboxNodeUI
            });
            tree.on('check', function (node) {
                options.jsFormObject.updateElement.value = this.getChecked().join(', ');
                varienElementMethods.setHasChanges(node.getUI().checkbox);
            }, tree);
            // set the root node
            //jscs:disable requireCamelCaseOrUpperCaseIdentifiers
            parameters = {
                text: options.name,
                draggable: false,
                checked: options.checked,
                uiProvider: Ext.tree.CheckboxNodeUI,
                allowDrop: options.allowDrop,
                id: options.rootId,
                expanded: options.expanded,
                category_id: options.categoryId
            };
            //jscs:enable requireCamelCaseOrUpperCaseIdentifiers
            tree.loadTree({
                parameters: parameters, data: options.treeJson
            }, true);
            /* eslint-enable */
        });
    };
});

As you can see, this component is a Javascript function that initializes an object (the category tree) and also auxiliary objects, which allow to display a category tree with checkboxes in the browser. The libraries jQuery, prototype, and extjs are used to do this. The last two of these have been used since Magento 1.

Using the Javascript browser debugger, I was able to establish that the category tree from the back end is completely transferred and the problem with rendering occurs at the Javascript component level. The location of the error was established, after detailed analysis, to be the buildCategoryTree method of the categoryLoader object. In the component, it looks as follows:

categoryLoader.buildCategoryTree = function (parent, config) {// eslint-disable-line no-shadow
    if (!config) {
        return null;
    }
    if (parent && config && config.length) {
        for (i = 0; i < config.length; i  ) {
            categoryLoader.processCategoryTree(parent, config, i);
        }
    }
};

The problem was in the loop counter variable i. The developers of the last version of the Magento_Catalog module made the variable global within the whole component. As a result, after rendering all of the first category’s child categories, of which there were 42, the cycle was complete. This is because there were far fewer subcategories in the root category, and they were not rendered. The variable should have been made locally in the buildCategoryTree method. It then would have been visible in the processCategoryTree method as well, since it would have been passed as a parameter. In fact, in the component’s implementation in previous versions, this variable was local, which you can see below (in an example from Magento 2.2.3):

categoryLoader.buildCategoryTree = function(parent, config)
    {
        if (!config) return null;

        if (parent && config && config.length){
            for (var i = 0; i < config.length; i  ) {
                config[i].uiProvider = Ext.tree.CheckboxNodeUI;
                var node;
                var _node = Object.clone(config[i]);
                if (_node.children && !_node.children.length) {
                    delete(_node.children);
                    node = new Ext.tree.AsyncTreeNode(_node);
                } else {
                    node = new Ext.tree.TreeNode(config[i]);
                }
                parent.appendChild(node);
                node.loader = node.getOwnerTree().loader;
                if (_node.children) {
                    this.buildCategoryTree(node, _node.children);
                }
            }
        }
    };

The reason for this bug was established, but another problem arose while attempting to fix it. All of the component’s objects exist only in local variables within the component’s functions and are not visible externally. Passing them into the component of some object that can call external code within the component is also not possible because the component doesn’t call external callback functions and doesn’t throw events. A mixin can’t be created because the component is a Javascript function and doesn’t have methods or properties available from outside.

Ultimately, we decided to completely rewrite the component, although that only required changing one line. This is better than editing the component in the core module. You must replace the template that initializes the component, and you must replace the existing component call in the template with the correct one.

Changes to the Component Code for Fixing the Category Display Problem

Since we’re dealing with a block of the admin panel, the adminhtml_block_html_before event handler can be used, which almost all admin panel blocks call before output. We implement this:

<event name="adminhtml_block_html_before">
    <observer name="default_product" instance="Web4pro\Defaultproduct\Observer\Changetemplate" shared="false" />
</event>
class Changetemplate implements \Magento\Framework\Event\ObserverInterface {

...
    public function execute(\Magento\Framework\Event\Observer $observer){

        if($block = $observer->getEvent()->getBlock()){
….
           if($block instanceof \Magento\Catalog\Block\Adminhtml\Category\Checkboxes\Tree){
               $block->setTemplate('Web4pro_Defaultproduct::tree.phtml');
           }
        }
    }
}

The new template will look as follows:

<?php $_divId = 'tree-div_' . time() ?>
<div id="<?= /* @escapeNotVerified */ $_divId ?>" class="tree"></div>
<script id="ie-deferred-loader" defer="defer" src="//:"></script>
<script>
    require(["Web4pro_Defaultproduct/js/category-checkbox-tree"], function (element) {
        element({
            "dataUrl":       "<?= /* @escapeNotVerified */ $block->getLoadTreeUrl() ?>" ,
            "divId":         "<?= /* @escapeNotVerified */$_divId ?>",
            "rootVisible":   <?php if ($block->getRoot()->getIsVisible()): ?>true<?php else : ?>false<?php endif; ?>,
            "useAjax":       <?= /* @escapeNotVerified */ $block->getUseAjax() ?>,
            "currentNodeId": <?= (int)$block->getCategoryId() ?>,
            "jsFormObject":  <?= /* @escapeNotVerified */ $block->getJsFormObject() ?>,
            "name":          "<?= /* @escapeNotVerified */ htmlentities($block->getRoot()->getName()) ?>",
            "checked":       "<?= /* @escapeNotVerified */ $block->getRoot()->getChecked() ?>",
            "allowDrop":     <?php if ($block->getRoot()->getIsVisible()): ?>true<?php else : ?>false<?php endif; ?>,
            "rootId":        <?= (int)$block->getRoot()->getId() ?>,
            "expanded":      <?= (int)$block->getIsWasExpanded() ?>,
            "categoryId":    <?= (int)$block->getCategoryId() ?>,
            "treeJson":      <?= /* @escapeNotVerified */ $block->getTreeJson() ?>
        });
    })
<

The new component will look as follows:

define([
    'jquery',
    'prototype',
    'extjs/ext-tree-checkbox',
    'mage/adminhtml/form'
], function (jQuery) {
    'use strict';
    return function (config) {
        var tree,
            options = {
                dataUrl: config.dataUrl,
                divId: config.divId,
                rootVisible: config.rootVisible,
                useAjax: config.useAjax,
                currentNodeId: config.currentNodeId,
                jsFormObject: config.jsFormObject,
                name: config.name,
                checked: config.checked,
                allowDrop: config.allowDrop,
                rootId: config.rootId,
                expanded: config.expanded,
                categoryId: config.categoryId,
                treeJson: config.treeJson
            },
            data = {},
            parameters = {},
            root = {},
            len = 0,
            key = '',
            i = 0;
        /* eslint-disable */
        /**
         * Fix ext compatibility with prototype 1.6
         */
        Ext.lib.Event.getTarget = function (e) {// eslint-disable-line no-undef
            var ee = e.browserEvent || e;
            return ee.target ? Event.element(ee) : null;
        };
        /**
         * @param {Object} el
         * @param {Object} config
         */
        Ext.tree.TreePanel.Enhanced = function (el, config) {// eslint-disable-line no-undef
            Ext.tree.TreePanel.Enhanced.superclass.constructor.call(this, el, config);// eslint-disable-line no-undef
        };
        Ext.extend(Ext.tree.TreePanel.Enhanced, Ext.tree.TreePanel, {// eslint-disable-line no-undef
            /* eslint-enable */
            /**
             * @param {Object} config
             * @param {Boolean} firstLoad
             */
            loadTree: function (config, firstLoad) {// eslint-disable-line no-shadow
                parameters = config.parameters,
                data = config.data,
                root = new Ext.tree.TreeNode(parameters);// eslint-disable-line no-undef
                if (typeof parameters.rootVisible != 'undefined') {
                    this.rootVisible = parameters.rootVisible * 1;
                }
                this.nodeHash = {};
                this.setRootNode(root);
                if (firstLoad) {
                    this.addListener('click', this.categoryClick.createDelegate(this));
                }
                this.loader.buildCategoryTree(root, data);
                this.el.dom.innerHTML = '';
                // render the tree
                this.render();
            },
            /**
             * @param {Object} node
             */
            categoryClick: function (node) {
                node.getUI().check(!node.getUI().checked());
            }
        });
        jQuery(function () {
            var categoryLoader = new Ext.tree.TreeLoader({// eslint-disable-line no-undef
                dataUrl: config.dataUrl
            });
            /**
             * @param {Object} response
             * @param {Object} parent
             * @param {Function} callback
             */
            categoryLoader.processResponse = function (response, parent, callback) {
                config = JSON.parse(response.responseText);
                this.buildCategoryTree(parent, config);
                if (typeof callback === 'function') {
                    callback(this, parent);
                }
            };
            /**
             * @param {Object} config
             * @returns {Object}
             */
            categoryLoader.createNode = function (config) {// eslint-disable-line no-shadow
                var node;
                config.uiProvider = Ext.tree.CheckboxNodeUI;// eslint-disable-line no-undef
                if (config.children && !config.children.length) {
                    delete config.children;
                    node = new Ext.tree.AsyncTreeNode(config);// eslint-disable-line no-undef
                } else {
                    node = new Ext.tree.TreeNode(config);// eslint-disable-line no-undef
                }
                return node;
            };
            /**
             * @param {Object} parent
             * @param {Object} config
             * @param {Integer} i
             */
            categoryLoader.processCategoryTree = function (parent, config, i) {// eslint-disable-line no-shadow
                var node,
                    _node = {};
                config[i].uiProvider = Ext.tree.CheckboxNodeUI;// eslint-disable-line no-undef
                _node = Object.clone(config[i]);
                if (_node.children && !_node.children.length) {
                    delete _node.children;
                    node = new Ext.tree.AsyncTreeNode(_node);// eslint-disable-line no-undef
                } else {
                    node = new Ext.tree.TreeNode(config[i]);// eslint-disable-line no-undef
                }
                parent.appendChild(node);
                node.loader = node.getOwnerTree().loader;
                if (_node.children) {
                    categoryLoader.buildCategoryTree(node, _node.children);
                }
            };
            /**
             * @param {Object} parent
             * @param {Object} config
             * @returns {void}
             */
            categoryLoader.buildCategoryTree = function (parent, config) {// eslint-disable-line no-shadow
                if (!config) {
                    return null;
                }
                if (parent && config && config.length) {
                    for (var i = 0; i < config.length; i  ) {
                        categoryLoader.processCategoryTree(parent, config, i);
                    }
                }
            };
            /**
             *
             * @param {Object} hash
             * @param {Object} node
             * @returns {Object}
             */
            categoryLoader.buildHashChildren = function (hash, node) {// eslint-disable-line no-shadow
                // eslint-disable-next-line no-extra-parens
                if ((node.childNodes.length > 0) || (node.loaded === false && node.loading === false)) {
                    hash.children = [];
                    for (i = 0, len = node.childNodes.length; i < len; i  ) {
                        /* eslint-disable */
                        if (!hash.children) {
                            hash.children = [];
                        }
                        /* eslint-enable */
                        hash.children.push(this.buildHash(node.childNodes[i]));
                    }
                }
                return hash;
            };
            /**
             * @param {Object} node
             * @returns {Object}
             */
            categoryLoader.buildHash = function (node) {
                var hash = {};
                hash = this.toArray(node.attributes);
                return categoryLoader.buildHashChildren(hash, node);
            };
            /**
             * @param {Object} attributes
             * @returns {Object}
             */
            categoryLoader.toArray = function (attributes) {
                data = {};
                for (key in attributes) {
                    if (attributes[key]) {
                        data[key] = attributes[key];
                    }
                }
                return data;
            };
            categoryLoader.on('beforeload', function (treeLoader, node) {
                treeLoader.baseParams.id = node.attributes.id;
            });
            /* eslint-disable */
            categoryLoader.on('load', function () {
                varienWindowOnload();
            });
            tree = new Ext.tree.TreePanel.Enhanced(options.divId, {
                animate: false,
                loader: categoryLoader,
                enableDD: false,
                containerScroll: true,
                selModel: new Ext.tree.CheckNodeMultiSelectionModel(),
                rootVisible: options.rootVisible,
                useAjax: options.useAjax,
                currentNodeId: options.currentNodeId,
                addNodeTo: false,
                rootUIProvider: Ext.tree.CheckboxNodeUI
            });
            tree.on('check', function (node) {
                options.jsFormObject.updateElement.value = this.getChecked().join(', ');
                varienElementMethods.setHasChanges(node.getUI().checkbox);
            }, tree);
            // set the root node
            //jscs:disable requireCamelCaseOrUpperCaseIdentifiers
            parameters = {
                text: options.name,
                draggable: false,
                checked: options.checked,
                uiProvider: Ext.tree.CheckboxNodeUI,
                allowDrop: options.allowDrop,
                id: options.rootId,
                expanded: options.expanded,
                category_id: options.categoryId
            };
            //jscs:enable requireCamelCaseOrUpperCaseIdentifiers
            tree.loadTree({
                parameters: parameters, data: options.treeJson
            }, true);
            /* eslint-enable */
        });
    };
});

As you can see, it differs from the original by only one line. However, now all categories created are available when editing the pricing rule.

SUM MARY

The advantage of solving this problem is that Magento decided to render the component of the tree to a separate Javascript file. This component could be used by developers in custom modules if it didn't contain such a critical bug. Of course, if the Magento developers had made the component extendable from the start, then fixing a simple mistake wouldn't have required the effort of changing the entire component; but it seems that was not part of the developers' original task. If you have encountered this problem, our solution fixes the bug and helps you continue creating a pricing rule for a product category.

Posted on: September 11, 2018

4.7/5.0

Article rating (12 Reviews)

Do you find this article useful? Please, let us know your opinion and rate the post!

  • Not bad
  • Good
  • Very Good
  • Great
  • Awesome