Categories
Magento 2

Fixing Quote Item Extension Attributes (Magento 2.3.5)

Do you know that feeling as a developer when you do things the Right Way(tm) according to a framework’s best practices only to get tripped up by a half-baked implementation? Welcome to Magento 2’s extension attributes!

Magento 2.3.5 doesn’t appear to pass cart item extension attributes to the javascript checkoutConfig object on the cart and checkout pages. This issue appears to be similar to this bug which stopped extension attributes from permeating to the checkoutConfig.quoteData object. Unfortunately, the bug has only been fixed for quote objects, not quote items.

The Fix

Add a plugin to the Checkout module’s DefaultConfigProvider in di.xml;

<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">
    <type name="Magento\Checkout\Model\DefaultConfigProvider">
        <plugin name="vendor_module_checkout_model_defaultconfigprovider" type="Vendor\Module\Plugin\Checkout\Model\DefaultConfigProvider" />
    </type>
</config>

Now we can manipulate the data in the plugin to make it do the thing it was intended to do when Magento 2 launched in 2015. Snark.

<?php
namespace Vendor\Namespace\Plugin\Checkout\Model;

class DefaultConfigProvider
{ 
    /** @var \Vendor\Namespace\Helper\Quote */
    protected $quoteHelper;

    /**
     *
     * @param \Magento\Checkout\Model\DefaultConfigProvider $subject
     * @param array $result
     * @return void
     */
    public function afterGetConfig(
        \Magento\Checkout\Model\DefaultConfigProvider $subject,
        $result
    ) {
        if(isset($result['quoteItemData'])) {
            foreach($result['quoteItemData'] as $itemKey => $quoteItem) {
                if(isset($quoteItem['extension_attributes']) && !is_array($quoteItem['extension_attributes'])) {
                    $extensionAttributes = $quoteItem['extension_attributes'];
                    $data = [];
                    foreach ($extensionAttributes->__toArray() as $key => $value) {
                        if (!is_object($value)) {
                            $data[$key] = $value;
                        }
                    }
                    $result['quoteItemData'][$itemKey]['extension_attributes'] = $data;
                }
            }
        }
        return $result;
    }
}

Next half baked implementation bug, please!

Categories
Magento 2

Magento 2: Disable All Modules By Vendor (Regex)

Use this find/replace regex in config.php to set all modules by a particular vendor to disabled.

Find Tab

(Amasty_[a-z0-9_]+'\s?=>\s?)(1)

Replace Tab

$10
Categories
Magento 2

Stop Magento 2 from Redirecting on Ajax Add to Cart

Useful for debugging the ajax request. Comment out the location.reload() in the ajaxSubmit function in module-catalog/view/frontend/web/js/catalog-add-to-cart.js

/** @inheritdoc */
complete: function (res) {
    if (res.state() === 'rejected') {
        // location.reload();
    }
}
Categories
Magento 2

Magento 2’s Customer Attribute (And Address) Bizarreness

Adding customer attributes to be editable only for admin accounts, you would think would be a straightforward process. Well let me tell you that Magento 2 has other ideas!

Firstly, adding to forms. You’ll notice that just adding attributes to either adminhtml_customer, or adminhtml_customer_address does nothing in the admin area. For some ungodly reason, to get them to show, attributes must also be added to the customer_account_edit and customer_address_edit forms.

Great! attributes now appear in the admin area, but woe unto thee that thinks we’re done here!

Saving from the Admin Area

Hahaha, expecting them to save… just like that? Fool. Make sure you’ve also set the system key of your attribute to false, otherwise Magento will just ignore them.

They’ll need adding to an attribute set and group for this to work correctly; typically this is just the default of each for the entity type.

So now we should be done… right? RIGHT?! Well, young urchin, try updating a customer account information or address from the frontend of your store. What’s that? Hahaha, yes, Magento just obliterated your custom customer attribute values. Serves you right for being so optimistic.

Now, you’ll notice, that even though we’ve added the attributes to the customer_account_edit and customer_address_edit forms to get them to show in the admin area, which foolishly in my mind should have no effect there, that do not show in the frontend.

No, if you want them to appear for customers, you’ll have to do more than add the attribute to a form (guffaws). But, for this example, we don’t want them to show in the frontend and certainly don’t want the data to be sent into the flaming abyss by a customer’s saving of them.

The fix, in true Magento style, is to set the visible key to false when creating the attribute, which persists to the customer_eav_attribute‘s is_visible column. Yes… visible prevents Magento from trying to save the data from the frontend. Cue manic laughter from the Magento Devs as they revel in our pain.

Recap

customer_account_edit and customer_address_edit forms – shows the field in the admin area.
is_system – Set to false for Magento to save the value from the admin area.
Add to attribute set and group – Also required to save from the admin area.
visible – Prevents Magento from trying to save the values from the frontend’s customer account section.

I’m not 100% sure what the adminhtml_customer and adminhtml_customer_address forms are actually used for anymore, but I’ll keep them for a laugh, just in case the Magento devs change their minds.

A code snippet:

protected function createCustomerAttributes(
    \Magento\Eav\Setup\EavSetup $eavSetup,
    ModuleDataSetupInterface $setup
) {
    $attributes = [];

    $customerSetup = $this->customerSetupFactory->create(['setup' => $setup]);
    $customerEntity = $customerSetup->getEavConfig()->getEntityType(\Magento\Customer\Model\Customer::ENTITY);
    $attributeSetId = $customerEntity->getDefaultAttributeSetId();
    $attributeSet = $this->attributeSetFactory->create();
    $attributeGroupId = $attributeSet->getDefaultGroupId($attributeSetId);

    // ... Add other attributes here 

    $attributes['attribute_code'] = [
        'type'     => 'varchar',
        'label'    => 'Attribute label',
        'input'    => 'text',
        'visible'  => false,
        'system' => false,
        'required' => false,
        'user_defined' => true,
        'position' => 290
    ];

    foreach ($attributes as $code => $attribute) {

        $eavSetup->addAttribute(
            \Magento\Customer\Model\Customer::ENTITY,
            $code,
            $attribute
        );

        $createdAttribute = $this->eavConfig->getAttribute(
            \Magento\Customer\Model\Customer::ENTITY,
            $code
        );

        $createdAttribute->setData('used_in_forms', ['customer_account_edit', 'adminhtml_customer'])
            ->setData('attribute_set_id', $attributeSetId)
            ->setData('attribute_group_id', $attributeGroupId)
            ->save();
    }
}

Come on Magento 2, we need more hoops to jump through than that! It’s like it’s not even trying to be frustrating some days.

Categories
Magento 2

Create Attributes In Magento 2

Creating attributes in Magento 2 is very similar to Magento 1. Magento 2, for the most part is smart enough to ignore the creation of attributes which already exist, and will instead update them. This is all well and good, unless your attributes have options in them. In which case, you will need to check whether the attribute exists already or Magento will duplicate option values on subsequent runs of your installer or updater.

The fix for this is to check whether the attribute already exists before attempting to create it, however this causes another issue. Magento’s EAVConfig class caches attributes after a the getAttribute method is called – so this cache needs cleaning if we need to set other data on the attribute after creation, such as the forms it needs to exist in.

The following is an example to create a customer address attribute, taken from an installer class where $this->eavConfig is an instance of \Magento\Eav\Model\Config:

protected function createCustomerAddressAttributes(
    \Magento\Eav\Setup\EavSetup $eavSetup,
    ModuleDataSetupInterface $setup
) {

    $customerAddressSetup = $this->customerSetupFactory->create(['setup' => $setup]);
    $customerAddressEntity = $customerAddressSetup->getEavConfig()->getEntityType(
        \Magento\Customer\Api\AddressMetadataInterface::ENTITY_TYPE_ADDRESS
    );
    $attributeSetId = $customerAddressEntity->getDefaultAttributeSetId();
    $attributeSet = $this->attributeSetFactory->create();
    $attributeGroupId = $attributeSet->getDefaultGroupId($attributeSetId);

    $attributes = [];

    $attributes['attribute_code'] = [
        'type'     => 'text',
        'label'    => 'Attribute Label',
        'input'    => 'textarea',
        'visible'  => true,
        'required' => false,
        'user_defined' => true,
        'position' => 220
    ];

    foreach ($attributes as $code => $attribute) {
        $this->eavConfig->clear();
        // Don't create the attribute if it already exists
        $attributeCheck = $this->eavConfig->getAttribute(
            \Magento\Customer\Api\AddressMetadataInterface::ENTITY_TYPE_ADDRESS,
            $code
        );
        
        if ($attributeCheck->getAttributeId()) {
            continue;
        }

        // Stop Magento from loading the cached attribute with no Id.
        $this->eavConfig->clear();

        $attribute['system'] = 0;

        $eavSetup->addAttribute(
            \Magento\Customer\Api\AddressMetadataInterface::ENTITY_TYPE_ADDRESS,
            $code,
            $attribute
        );

        $createdAttribute = $this->eavConfig->getAttribute(
            \Magento\Customer\Api\AddressMetadataInterface::ENTITY_TYPE_ADDRESS,
            $code
        );

        $createdAttribute->setData('used_in_forms', ['adminhtml_customer_address', 'customer_address_edit'])
            ->setData('attribute_set_id', $attributeSetId)
            ->setData('attribute_group_id', $attributeGroupId)
            ->save();
    }

    return $this;        
}

If we didn’t have the $this->eavConfig->clear(); clear in there, the installer would use the cached version of the attribute when performing our initial $attributeCheck, and the final save would try and create the attribute again.

Categories
Magento 2

Fixing slow reindexes in Magento 2 & MariaDb

We recently noticed some discrepancies when indexing catalog_category_product on Mariadb when compared to Mysql 5.7. The indexer would take around 13 minutes on MariaDb, compared to around 3 seconds on MySql. To add more confusion to the mix, MariaDb was running on a powerful staging server, and MySQL running on a lowly MacBook Pro development machine.

After much head-scratching and career path questioning, the issue seemed to be related to the following statement in Magento\Catalog\Model\Indexer\Category\Product\Action\Full.php

$this->connection->query(
    $this->connection->insertFromSelect(
        $resultSelect,
        $this->tableMaintainer->getMainTmpTable((int)$store->getId()),
        $columns,
        AdapterInterface::INSERT_ON_DUPLICATE
    )
);

This is executed in the reindexCategoriesBySelect method, which creates a temporary table to work with when regenerating the index for a particular store. It turns out that MariaDb’s temporary table usage woefully bad when large amounts of data are being inserted. This appears to be related when aria_used_for_temp_tables is set to ON, a value which can only be changed by recompiling MariaDb. See here and here.

The fix, without switching database engines, or recompiling MariaDb, is to adjust Magento’s batchRowsCount to a lower number so that the database isn’t dealing with as many temporary table inserts at a time. Magento provide config settings for this value for all of their indexers which use temporary tables, so adjust this value for whichever indexer is giving slow performance. For our case, changing the default value from 100000 (the default) to 500 brought the indexer time down from 13 minutes to 6 seconds. The following, added to di.xml was the panacea for our case

<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">
    <type name="Magento\Catalog\Model\Indexer\Category\Product\Action\Full">
        <arguments>
            <argument name="batchRowsCount" xsi:type="number">500</argument>
        </arguments>
    </type>
</config>

Magento’s documentation on use of this value can be found here, which states

“You can experiment to determine the ideal batch size. In general, halving the batch size can decrease the indexer execution time”.

Yup, experiment indeed with this value, YMMV! Happy indexing!

Categories
Magento 2

Magento 2’s uiRegistry Debugging

On the frontend of Magento 2 components are constructed hierarchically. Their names are derived from a concatenation of their parent names. Looking at the checkout_index_index.xml, we can see that the checkout component contains a horrendous amount of config.

To make debugging of this gargantuan hellspawned rat’s nest easier, we can use the uiRegistry object in our browser’s console.

If we would like to get a particular object and we know the full concatenated name of the item, we can simply use something akin to the following;

requirejs('uiRegistry').get("checkout.steps.shipping-step.shippingAddress");

If however, we would like to get a uiComponent using a property name, we can instead use the get method as a query. In the example above, if we only knew the script location of the uiComponent in question, we could instead perform

requirejs('uiRegistry').get("component = Magento_Checkout/js/view/shipping");

We can also pass in a callback method as the second parameter, where the item(s) returned are passed in as parameters to that function.

Get a uiComponent By XML Name

The un-concatenated index property of a uiComponent contains the name attribute on item XML node

requirejs('uiRegistry').get('index=<XML_NAME_ATTRIBUTE>');

Getting all registered uiComponents

The get method also allows us to pass in a callback function as the first parameter, instead of a query. This will pass all items sequentially through our callback, allowing us to see exactly what is registered;

requirejs('uiRegistry').get(function(item){
    console.log(item.name, item);
});

Do this on the checkout and prepare to have an exorcist level of data vomited into your poor, unsuspecting console.

Getting a uiComponent’s children

This property is an observable knockout object, so to get the actual array, execute it as a function.

componentInstance.elems();

Traversing a uiComponent’s Children

This will output the full concatenated name of all children of the uiComponent instance

_.each(uiComponentInstance.elems(), function(child) {
    console.log(child.name);
});

Using the uiRegistry to search for uiComponents

The get method of the uiRegistry allows the searching for properties. E.g. on the checkout, the sidebar item has a sortOrder of 50. The get command always returns the first element encountered, regardless of how many match.

var component = registry.get('sortOrder = 50');

Related Magento DevDoc

Using MageSpecialist’s Debug Module

MageSpecialist’s module allows the discovery of uiComponents through their Magento browser plugin and Magento Module. This allows two way debugging of uiComponents. They can be selected and shown on the page using the extension’s console.

MageSpecialist’s module adds the attribute data-mspdevtools-ui to the root node of the uiComponent. Using the hash value of this attribute, we can use MSP’s console to search for the hash, revealing the uiComponent details.

Structure of uiComponents

Imports

Imports allow data to be imported at the time the object is created from another uiComponent. E.g.

defaults: {
    imports: {
        foo: 'customer_listing.customer_listing:name'
    }
}

This will assign foo as the value of the variable in the customer_listing name property.

Listens

The listens object on a uiComponent can be set up to fire when imports are received. E.g. using the ‘Imports’ ‘foo’ example above;

listens: {
    foo: 'functionToCall'
}

Regions

Regions allow a group of uiComponents to be rendered in one area. E.g. on the checkout page, retrieving the summary component, we can get items for a region.

requirejs('uiRegistry').get('checkout.sidebar.summary.cart_items.details').getRegion('after_details')();

Will output child items subscribed to a region; the regions array is an observable hence the function call after retreival. Region is stored in the displayArea property of child items.

uiComponent.get('displayArea');

Disabling uiComponents with XML

Use the item’s config array and set item componentDisabled to true. Follow the same XML Path of the module structure to be disabled and it will be merged.

<item name="fee" xsi:type="array">
    <item name="config" xsi:type="array">
        <item name="componentDisabled" xsi:type="boolean">true</item>
    </item>
</item>

Attaching a Component

In the component’s argument data/config/component node to set the component JS

<uicomponent_name>
    <argument name="data" xsi:type="array">
        <item name="config" xsi:type="array">
            <item name="component xsi:type="string">VENDOR_MODULE/js/js-file-path</item>
        </item>
    </argument>
</uicomponent_name>

The file itself is translated to Vendor/ModuleName/view/<area>/web/js/js-file-path.js

The actual name of the component in the uiRegistry is namespaced against of the component, essentially duplicated.

This example would be uicomponent_name.uicomponent_name

Binding using scope

Note: This is done with XHTML in adminhtml.

<div data-bind="scope:uicomponent_name.uicomponent_name">
    <!-- ko template:getTemplate() --><!-- /ko>
</div>

This will use the view model’s js for every child node of the div

getTemplate() is defined in the uiElement base class which returns this.template

Categories
Magento 2

Stopping Magento 2 Redirecting from the Checkout

Sometimes, we want to add debug information when submitting Magento’s checkout to see what exactly is going on in the backend. Adding any debug information to the payload of Magento’s payment-information call will cause a redirect back to the cart page. This will render any information we’ve output as unobtainable even when using “Preserve Log” in Chrome Dev Tools. To stop this redirect, we can temporarily comment out the following in module-checkout/view/frontend/web/js/view/payment/default.js in the placeOrder method

if (self.redirectAfterPlaceOrder) {
    redirectOnSuccessAction.execute();
}
Categories
Magento 2

collectTotals in Magento 2

If we were to have a custom quote object which we were adding items to, we can re-calculate the totals in the cart using the following method.

$shippingAddress = $preorderQuote->getShippingAddress();
$shippingAddress->unsetData('cached_items_all');            
$quote->setTotalsCollectedFlag(false)->collectTotals();
$this->quoteRepository->save($quote);

We unset the cached item on the quote address object to force Magento to re-load the items for the address. This is necessary as Magento will sometimes not update this automatically, leading to an incorrect zero value quote and quote items.

Categories
Magento 2

Resolving XSD Locations for PHP Storm in Magento 2 Projects

Magento 2 ships with a handy console command to generate an XML file containing the locations of all of its XSD files:

bin/magento dev:urn-catalog:generate ../.idea/misc.xml

The file should be generated in the .idea directory in the root of the project in PHP Storm, and once generated should automatically be associated with the application.