Loading...

Articles

Magento2 "delivery date" module creation from scratch

Hi, everybody! I had a chance to get my hands on the latest magento2 and do a learning project. I decided to make a "delivery date" module, because it is a) useful and b) it requires tuning the applications in different areas: using events, plugins, overriding js files, playing with a grid. It gives customer the possibility to choose the date when he wants his order to be delivered. The result that I came up with is a module with "less than minimum" :) features:

  1. The user will see a delivery date field on the checkout and it will use a datepicker widget
  2. Delivery date will be saved in the order
  3. Delivery date will be displayed in the order view in admin
  4. Delivery date will be displayed in the order grid

This was the first time I met magento2 and I had moments of frustration and "AHA" moments. So now I will try to minimize frustration of other developers :)

Ok, so first we set up the module structure. Create a folder inside app/code like this Vendor/Modulename. My folder structure is app/code/Oye/Deliverydate.

Next we set up the module.xml file with a setup version. It lives in app/code/Oye/Deliverydate/etc/module.xml

<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:Module/etc/module.xsd">
    <module name="Oye_Deliverydate" setup_version="1.0.0"/>
</config>

Next create a file in this location app/code/Oye/Deliverydate/registration.php

<?php
/**
 * Copyright © 2015 Magento. All rights reserved.
 * See COPYING.txt for license details.
 */
\Magento\Framework\Component\ComponentRegistrar::register(
    \Magento\Framework\Component\ComponentRegistrar::MODULE,
    'Oye_Deliverydate',
    __DIR__
);

This is our module skeleton.

And then enable the module from our magento root dir in CLI with this command: php bin/magento module:enable Oye_Deliverydate

Ok the most frustrating part is behind :)

Now let's set create the installation code for the database schema. In magento2 the setup scripts don't run on every request, they are run with the help of a console script. We will run once we create app/code/Oye/Deliverydate/Setup/InstallSchema.php

We need to add columns to three tables:

  • First one is the quote table - we need a column there because we need to save the delivery date before the actual order is created - on the shipping step.
  • Second is the sales_order table which is obvious.
  • Third is the sales_order_grid table. This is the simplest method to add the column to the order collection without touching too many xml configurations

This is the file that will get us up and running
app/code/Oye/Deliverydate/Setup/InstallSchema.php

<?php

namespace Oye\Deliverydate\Setup;
use Magento\Framework\Setup\InstallSchemaInterface;
use Magento\Framework\Setup\ModuleContextInterface;
use Magento\Framework\Setup\SchemaSetupInterface;

/**
 * @codeCoverageIgnore
 */
class InstallSchema implements InstallSchemaInterface
{

    /**
     * {@inheritdoc}
     * @SuppressWarnings(PHPMD.ExcessiveMethodLength)
     */
    public function install(SchemaSetupInterface $setup, ModuleContextInterface $context)
    {
        $installer = $setup;
        $installer->startSetup();

        $installer->getConnection()->addColumn(
            $installer->getTable('quote'),
            'delivery_date',
            [
                'type' => 'datetime',
                'nullable' => false,
                'comment' => 'Delivery Date',
            ]
        );

        $installer->getConnection()->addColumn(
            $installer->getTable('sales_order'),
            'delivery_date',
            [
                'type' => 'datetime',
                'nullable' => false,
                'comment' => 'Delivery Date',
            ]
        );

        $installer->getConnection()->addColumn(
            $installer->getTable('sales_order_grid'),
            'delivery_date',
            [
                'type' => 'datetime',
                'nullable' => false,
                'comment' => 'Delivery Date',
            ]
        );

        $setup->endSetup();
    }
}

The way to go is to just copy the same file from any magento module and make your changes. Don't forget to set the namespace of your module at the top (namespace Oye\Deliverydate\Setup). It should correspond with your folder structure.

To actually add the columns we need to launch the magento CLI utility. SSH to your server, navigate to magento_root/ and run php bin/magento setup:upgrade
You made it :)

Ok, the columns are there. We need to populate them with customers data, and first let's add a field to checkout. How to add a field to checkout? You are probably thinking of adding a block to layout, right? Not so fast, let's see how the checkout page is rendered first.

We need to add a field to the shipping address, so let's examine it's id in the chrome developer tools and find its id. The id is "co-shipping-form", a search through the project gives us a template file.

There are just some html comments and no form fields or anything like that is rendered. A google search on those html comments showed that this is knockout.js directives. I have never used knockout.js before, but it seems that we will all need to learn how to use it soon :) But this is a long process and we need our module NOW! For now I js-debugged in chrome some of the methods in the html comments and I found that there is a nested structure(object) passed to the javascript. I took some of the nested structure block names and searched through the codebase.

I've found this file, where the layout structure is merged with form field configurations. The fields get their types and options populated there. This is where the interception comes in.

In case you don't know what interception in magento2 is, I will explain. It is the substitute for rewrites in magento 1. But this time magento core developers have come up with a mechanism that is more flexible and helps to avoid conflicts. We can change the behavior of a method in a class without rewriting the class. How does this work?

We create a di.xml file in our module - Oye/Deliverydate/etc/frontend/di.xml
We use the frontend area to keep it clean, our plugin should run only on the frontend.

<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">
    <type name="Magento\Checkout\Block\Checkout\LayoutProcessor">
        <plugin name="add-delivery-date-field"
                type="Oye\Deliverydate\Model\Checkout\LayoutProcessorPlugin" sortOrder="10"/>
    </type>
</config>

In the "name" attribute of "type" node, we write the name of the class we want to override (Magento\Checkout\Block\Checkout\LayoutProcessor). The plugin name (add-delivery-date-field) inside the node should be unique. The "type" attribute of the plugin node (Oye\Deliverydate\Model\Checkout\LayoutProcessorPlugin) is your module's class that will act like a plugin. The sortOrder will influence the order of execution in reference to other plugins that could override the same class as you do.

How does this work under the hood? For every class in magento core that has a defined plugin, there's an interceptor class being generated under var/generation. For our class it's var/generation/Magento/Checkout/Block/Checkout/LayoutProcessor/Interceptor.php

<?php
namespace Magento\Checkout\Block\Checkout\LayoutProcessor;

/**
 * Interceptor class for @see \Magento\Checkout\Block\Checkout\LayoutProcessor
 */
class Interceptor extends \Magento\Checkout\Block\Checkout\LayoutProcessor implements \Magento\Framework\Interception\InterceptorInterface
{
    use \Magento\Framework\Interception\Interceptor;

    public function __construct(\Magento\Customer\Model\AttributeMetadataDataProvider $attributeMetadataDataProvider, \Magento\Ui\Component\Form\AttributeMapper $attributeMapper, \Magento\Checkout\Block\Checkout\AttributeMerger $merger)
    {
        $this->___init();
        parent::__construct($attributeMetadataDataProvider, $attributeMapper, $merger);
    }

    /**
     * {@inheritdoc}
     */
    public function process($jsLayout)
    {
        $pluginInfo = $this->pluginList->getNext($this->subjectType, 'process');
        if (!$pluginInfo) {
            return parent::process($jsLayout);
        } else {
            return $this->___callPlugins('process', func_get_args(), $pluginInfo);
        }
    }
}

When a method is called - it is called in the interceptor class - and if there are no plugins that override the subject method, then the original method is executed here:

        if (!$pluginInfo) {
            return parent::process($jsLayout);

If there are plugins that override this method, then the ___callPlugins() method is executed. The plugins and the parent method will be executed inside ___callPlugins(). My advice is to read the code of this method in lib/internal/Magento/Framework/Interception/Interceptor.php so you get a better understanding of interception.
You could override a method behaviour in 3 ways: before, after and around. See this link for more explanations - http://devdocs.magento.com/guides/v2.0/extension-dev-guide/plugins.html

This a nifty feature that will make developers' life easier.

Now let's create the plugin itself. Note that it doesn't extend any class. It just injects our field with the necessary configuration into the fields array. I took the configuration from other fields and changed the type to datepicker, according to file date element type located in Magento/Ui/view/base/web/templates/form/element/date.html (you can find all default field types in this folder)

Oye/Deliverydate/Model/Checkout/LayoutProcessorPlugin.php

<?php

namespace Oye\Deliverydate\Model\Checkout;

class LayoutProcessorPlugin
{

    /**
     * @param \Magento\Checkout\Block\Checkout\LayoutProcessor $subject
     * @param array $jsLayout
     * @return array
     */

    public function afterProcess(
        \Magento\Checkout\Block\Checkout\LayoutProcessor $subject,
        array  $jsLayout
    ) {
        $jsLayout['components']['checkout']['children']['steps']['children']['shipping-step']['children']
        ['shippingAddress']['children']['shipping-address-fieldset']['children']['delivery_date'] = [
            'component' => 'Magento_Ui/js/form/element/abstract',
            'config' => [
                'customScope' => 'shippingAddress',
                'template' => 'ui/form/field',
                'elementTmpl' => 'ui/form/element/date',
                'options' => [],
                'id' => 'delivery-date'
            ],
            'dataScope' => 'shippingAddress.delivery_date',
            'label' => 'Delivery Date',
            'provider' => 'checkoutProvider',
            'visible' => true,
            'validation' => [],
            'sortOrder' => 200,
            'id' => 'delivery-date'
        ];
        return $jsLayout;
    }
}

Let's test the results!

added-date-to-checkout

Great! We have our field on the checkout and we learned to use interception :) time to have lunch

After a lunch break with a juicy steak and french fries (magento development requires a high-calorie diet :) we can continue. The next goal is to save the field value into the quote. First let's see what info is passed into the backend

chrome-dev-request

The url is http://m2fresh.server.local/rest/default/V1/carts/e6745b4633ac9b7602f2e1630c8dd83e/shipping-information
So the first surprise is that restful API is used, not a controller/action. Good

The next thing we see is that content-type is JSON, so we can't get the data from the post array. The observer will have to wait its turn :) We will have to use something else.
Let's search the magento codebase by the key "addressInformation", it should be found somewhere in the javascript.
...
Bingo
app/code/Magento/Checkout/view/frontend/web/js/model/shipping-save-processor/default.js

To keep things simple I decided to just override this file and add my own field to the payload like this:

               addressInformation: {
                        shipping_address: quote.shippingAddress(),
                        shipping_method_code: quote.shippingMethod().method_code,
                        shipping_carrier_code: quote.shippingMethod().carrier_code,
                        delivery_date: jQuery('[name="delivery_date"]').val()
                    }
                };

How to override a js file in magento2? We can make use of require.js - it is a modern js library for loading js modules with all dependencies. To override the given file create a requirejs-config.js in this location:
app/code/Oye/Deliverydate/view/frontend/requirejs-config.js

var config = {
    "map": {
        "*": {
            "Magento_Checkout/js/model/shipping-save-processor/default" : "Oye_Deliverydate/js/shipping-save-processor-default-override"
        }
    }
};

Place the override file in app/code/Oye/Deliverydate/view/frontend/web/js/shipping-save-processor-default-override.js
And reload the checkout page. Voila :) You can place a console.log in the new js file to see if it is actually being included

If your file is not included then try to delete the file pub/static/_requirejs/frontend/Magento/luma/en_US/requirejs-config.js
Then it will be generated once more
Replace "luma" with the name of the theme you are using.

Now fill the form, choose a date when you want your "Electra Bra Top" delivered :) and click next
The checkout will freeze and if you examine the response from the server you will see an error.

error-in-response

Property :"DeliveryDate" does not exist in the provided class: Magento\Checkout\Api\Data\ShippingInformationInterface\

meme

After scratching my head for a while and some xdebugging I've found this new cool feature - extension attributes. Here's the doc page for this feature http://devdocs.magento.com/guides/v2.0/extension-dev-guide/attributes.html

You can add an attribute to any entity (our case it is Magento\Checkout\Api\Data\ShippingInformationInterface) with an xml config. This attribute could be stored in the entity table (you can create the column like we did in the beginning), or you could create a separate table and configure the extension attribute to be pulled from your table. It's all described in the doc, one xml config could substitute lots of code

In our case we just need to add this attribute to the ShippingInformationInterface by creating this file:
app/code/Oye/Deliverydate/etc/extension_attributes.xml

<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:Api/etc/extension_attributes.xsd">
    <extension_attributes for="Magento\Checkout\Api\Data\ShippingInformationInterface">
        <attribute code="delivery_date" type="string"/>
    </extension_attributes>
</config>

and then change our payload object structure in the js file like so:

                var payload = {

                addressInformation: {
                        shipping_address: quote.shippingAddress(),
                        shipping_method_code: quote.shippingMethod().method_code,
                        shipping_carrier_code: quote.shippingMethod().carrier_code,
                        extension_attributes: {
                              delivery_date: jQuery('[name="delivery_date"]').val()
                        }
                    }
                };

Next try to click next on the checkout page, and here you go - review and payment step. Finally :)

The next thing we need to do is save this to the quote. Let's make another plugin by adding an xml node in our di.xml
If you debug how the shipping info is being saved to the quote, you will end up in this class::method Magento\Checkout\Model\ShippingInformationManagement::saveAddressInformation()

Let's just add a plugin for this by adding

    <type name="Magento\Checkout\Model\ShippingInformationManagement">
        <plugin name="save-in-quote" type="Oye\Deliverydate\Model\Checkout\ShippingInformationManagementPlugin" sortOrder="10"/>
    </type>

to our di.xml

Here's the shipping information plugin i came up with:
app/code/Oye/Deliverydate/Model/Checkout/ShippingInformationManagementPlugin.php

<?php

namespace Oye\Deliverydate\Model\Checkout;

class ShippingInformationManagementPlugin
{

    protected $quoteRepository;

    public function __construct(
        \Magento\Quote\Model\QuoteRepository $quoteRepository
    ) {
        $this->quoteRepository = $quoteRepository;
    }

    /**
     * @param \Magento\Checkout\Model\ShippingInformationManagement $subject
     * @param $cartId
     * @param \Magento\Checkout\Api\Data\ShippingInformationInterface $addressInformation
     */
    public function beforeSaveAddressInformation(
        \Magento\Checkout\Model\ShippingInformationManagement $subject,
        $cartId,
        \Magento\Checkout\Api\Data\ShippingInformationInterface $addressInformation
    ) {
        $extAttributes = $addressInformation->getExtensionAttributes();
        $deliveryDate = $extAttributes->getDeliveryDate();
        $quote = $this->quoteRepository->getActive($cartId);
        $quote->setDeliveryDate($deliveryDate);
    }
}

Ok let's save the di.xml, add the plugin, return to checkout shipping step, submit the shipping form, and... nothing. The delivery_date field in the quote table is empty.
Our plugin is not called.

Time to make a cup of [--green] tea and start debugging the interception code.. 1 hour passed :)
It turns out that the rest api is not frontend scope and our plugin is not in the list of plugins. So we must make a new di.xml under the etc folder, it will have global scope instead of frontend (or it can have rest api scope of course, check out how it's done in magento2 core modules).

Submit the shipping form once again and make sure the delivery_date is saved in the quote.

The next step is saving our field to the order. Somewhere in the debts of magento2 there must be an xml config which stores the fields that are transferred from quote to order
But I will not search for it now, I want to use an event :) By the way, the observer configs are now in a separate xml file too.

app/code/Oye/Deliverydate/etc/events.xml

<?xml version="1.0" encoding="UTF-8"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:Event/etc/events.xsd">
    <event name="sales_model_service_quote_submit_before">
        <observer name="oye_deliverydate" instance="Oye\Deliverydate\Model\Observer\SaveDeliveryDateToOrderObserver"/>
    </event>
</config>

And you can't use one Observer.php file for working with multiple events, you have to use a separate file for every observer you add.
Let's add an observer at app/code/Oye/Deliverydate/Model/Observer/SaveDeliveryDateToOrderObserver.php:

<?php
namespace Oye\Deliverydate\Model\Observer;

use Magento\Framework\Event\Observer as EventObserver;
use Magento\Framework\Event\ObserverInterface;

class SaveDeliveryDateToOrderObserver implements ObserverInterface
{
    /**
     * @var \Magento\Framework\ObjectManagerInterface
     */
    protected $_objectManager;

    /**
     * @param \Magento\Framework\ObjectManagerInterface $objectmanager
     */
    public function __construct(\Magento\Framework\ObjectManagerInterface $objectmanager)
    {
        $this->_objectManager = $objectmanager;
    }

    public function execute(EventObserver $observer)
    {
        $order = $observer->getOrder();
        $quoteRepository = $this->_objectManager->create('Magento\Quote\Model\QuoteRepository');
        /** @var \Magento\Quote\Model\Quote $quote */
        $quote = $quoteRepository->get($order->getQuoteId());
        $order->setDeliveryDate( $quote->getDeliveryDate() );

        return $this;
    }

}

Notice that our observer implements ObserverInterface. The execute method must have an observer argument of class EventObserver, otherwise it throws an error. Everything is very strict in magento2 :)

The result will be a delivery_date field populated in the sales_order table. But there's no use of a populated database field if it's not displayed in the admin panel. Let's display it on the order view by adding another event, but this time into the backend area:

app/code/Oye/Deliverydate/etc/adminhtml/events.xml

<?xml version="1.0" encoding="UTF-8"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:Event/etc/events.xsd">
    <event name="core_layout_render_element">
        <observer name="oye_deliverydate_add_to_order_view" instance="Oye\Deliverydate\Model\Observer\AddHtmlToOrderShippingViewObserver" />
    </event>
</config>

And here's the brand new shiny Observer (add to app/code/Oye/Deliverydate/Model/Observer/AddHtmlToOrderShippingViewObserver.php) :

<?php

namespace Oye\Deliverydate\Model\Observer;

use Magento\Framework\Event\Observer as EventObserver;
use Magento\Framework\Event\ObserverInterface;

class AddHtmlToOrderShippingViewObserver implements ObserverInterface
{

    /**
     * @var \Magento\Framework\ObjectManagerInterface
     */
    protected $_objectManager;

    /**
     * @param \Magento\Framework\ObjectManagerInterface $objectmanager
     */
    public function __construct(\Magento\Framework\ObjectManagerInterface $objectmanager)
    {
        $this->_objectManager = $objectmanager;
    }

    public function execute(EventObserver $observer)
    {
        if($observer->getElementName() == 'order_shipping_view')
        {
            $orderShippingViewBlock = $observer->getLayout()->getBlock($observer->getElementName());
            $order = $orderShippingViewBlock->getOrder();
            $localeDate = $this->_objectManager->create('\Magento\Framework\Stdlib\DateTime\TimezoneInterface');
            $formattedDate = $localeDate->formatDate(
                $localeDate->scopeDate(
                    $order->getStore(),
                    $order->getDeliveryDate(),
                    true
                ),
                \IntlDateFormatter::MEDIUM,
                false
            );

            $deliveryDateBlock = $this->_objectManager->create('Magento\Framework\View\Element\Template');
            $deliveryDateBlock->setDeliveryDate($formattedDate);
            $deliveryDateBlock->setTemplate('Oye_Deliverydate::order_info_shipping_info.phtml');
            $html = $observer->getTransport()->getOutput() . $deliveryDateBlock->toHtml();
            $observer->getTransport()->setOutput($html);
        }
    }
}

First we check if we got the right block rendered:

if($observer->getElementName() == 'order_shipping_view')

and continue if it is.
Then we pull our block from the layout because that's the only place we can get the order object from. Next we convert the date to a readable format.

This will create an empty block

$deliveryDateBlock = $this->_objectManager->create('Magento\Framework\View\Element\Template');

it is an equivalent to the magento 1

Mage::app()->getLayout()->createBlock('core/template');

We will then set the delivery date and the template to this block and render it.

This part will glue our block to the shipping info block

            $html = $observer->getTransport()->getOutput() . $deliveryDateBlock->toHtml();
            $observer->getTransport()->setOutput($html);

Here's the template of our block:

app/code/Oye/Deliverydate/view/adminhtml/templates/order_info_shipping_info.phtml

<div id="delivery-date">
    <strong><?php echo __('Delivery Date') ?></strong>
    <span class="price"><?=$block->getDeliveryDate() ?></span></div>
<script type="text/javascript">
    require(
        ['jquery'],
        function($) {
            var element = $('#delivery-date').detach();
            $('.order-shipping-method').append(element);
        }
    );
</script>

Note how requirejs is used here so we are able to use jquery (say goodbye to prototypeJs).
We move our delivery-date element to the order-shipping-method element so it looks nice. Beautiful!

delivey-field

We are almost at the finish, just a little more effort :) I want to add the delivery date field to the orders grid. To do that we have to complete 2 tasks:
1) Save the delivery_date field into the sales_order_grid table

I've taken some attributes from the grid table and ran a search on Magento\Sales module, examined the xml files in the results and found that we need to override the file Magento/Sales/etc/di.xml
we need to add this piece of code to our di.xml to so the column is moved from sales_order to sales_order_grid every time the order is saved.

    <virtualType name="Magento\Sales\Model\ResourceModel\Order\Grid" type="Magento\Sales\Model\ResourceModel\Grid">
        <arguments>
            <argument name="columns" xsi:type="array">
                <item name="delivery_date" xsi:type="string">sales_order.delivery_date</item>
            </argument>
        </arguments>
    </virtualType>

2) Finally add it to the grid. We need to override this xml for this: app/code/Magento/Sales/view/adminhtml/ui_component/sales_order_grid.xml
We are interested in the node

To override it we add a file app/code/Oye/Deliverydate/view/adminhtml/ui_component/sales_order_grid.xml
with this content

<?xml version="1.0" encoding="UTF-8"?>
	<listing xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Ui:etc/ui_configuration.xsd">
    <columns name="sales_order_columns">
        <column name="delivery_date">
            <argument name="data" xsi:type="array">
                <item name="js_config" xsi:type="array">
                    <item name="component" xsi:type="string">Magento_Ui/js/grid/columns/column</item>
                </item>
                <item name="config" xsi:type="array">
                    <item name="visible" xsi:type="boolean">true</item>
                    <item name="dataType" xsi:type="string">text</item>
                    <item name="align" xsi:type="string">left</item>
                    <item name="label" xsi:type="string" translate="true">Delivery Date</item>
                </item>
            </argument>
        </column>
    </columns>
</listing>

And that's it. In the end of this tutorial you will have a fully functional and still useless module :)
The module is still missing

  1. Configurations for the datepicker widget, that should be done in backend by admin (range of available dates for delivery)
  2. Holidays and weekdays that should be excluded from the datepicker available dates. This should be a separate entity with its own table and a grid. I foresee a lot of fun here :)
  3. Possibility to add a delivery time to checkout by an admin setting
  4. Add the delivery date to order confirmation email

You can clone the module from this github repo http://gitlab.oye.io/oyenet/m2DeliveryDate2
And don't forget to leave your feedback or just share this post with fellow developers. I had a pleasure writing this! Thanks for reading :)

And check out how my cat is driving :)
OfL4qsID1Jo

EAS

Quickmage is a high availability and decentralized hybrid cloud management platform based on sophisticated proprietary machine learning algorithms that makes application production much more coherent, synoptic, efficient and affordable. Quickmage platform is supported by European Regional Development Fund in the sum of 301 500 EUR (07.08.2017 - 10.07.2018).