Magento 2 – How to Add a New Customizable Product Option

cartcustom-optionsmagento-2.1magento2product

I want to add a new custom option that has a special datepicker. So basically I just need the type text with a new template, that template x-magento-init a js-file and this template gets passed some variables needed for the calendar (day names and such).
I thought thus I just copy the text type code and change it to my needs slightly.
Most like proposed by this stack answer: https://magento.stackexchange.com/a/176217/42007

If no code is pasted it means these files are just copies from the base magento files, with changed namespace of course.

1. Create the module

<?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="<vendor>_<module>" setup_version="1.0.0">
        <sequence>
            <module name="Magento_Catalog"/>
        </sequence>
    </module>
</config>

2. Declare your new product option : app/code/Vendor/Module/etc/product_options.xml

<?xml version="1.0"?>
<!--
/**
 * Copyright © 2013-2017 Magento, Inc. All rights reserved.
 * See COPYING.txt for license details.
 */
-->
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Catalog:etc/product_options.xsd">
    <option name="<vendor>" label="<Vendor>" renderer="<vendor>\<module\Block\Adminhtml\Product\Edit\Tab\Options\Type\DatePicker">
        <inputType name="datepicker" label="DatePicker" />
    </option>
</config>

3. Add the new custom Option in Admin/Product :

  • <vendor>\<module>\Block\Adminhtml\Catalog\Product\Edit\Tab\Options\Type\<myType>.php
  • <vendor>/<module>/view/adminhtml/templates/<myType>.phtml (a copy of Magento/Catalog/view/adminhtml/templates/catalog/product/edit/options/type/text.phtml)

4. Add the new custom Option in Frontend Catalog :

<vendor>\<module>\Block\Product\View\Options\Type\DatePicker.php

<?php

namespace <vendor>\<module>\Block\Product\View\Options\Type;

use Magento\Framework\Locale\Bundle\DataBundle;

class DatePicker extends \Magento\Catalog\Block\Product\View\Options\AbstractOptions
{

    /**
     * JSON Encoder
     *
     * @var \Magento\Framework\Json\EncoderInterface
     */
    protected $encoder;

    /**
     * @var \Magento\Framework\Locale\ResolverInterface
     */
    protected $_localeResolver;

    public function __construct(
        \Magento\Framework\View\Element\Template\Context $context,
        \Magento\Framework\Pricing\Helper\Data $pricingHelper,
        \Magento\Catalog\Helper\Data $catalogData,
        \Magento\Framework\Json\EncoderInterface $encoder,
        \Magento\Framework\Locale\ResolverInterface $localeResolver,
        array $data = [])
    {
        parent::__construct($context, $pricingHelper, $catalogData, $data);

        $this->encoder = $encoder;
        $this->_localeResolver = $localeResolver;
        $this->assignCalendarData(); // basically what lib/internal/Magento/Framework/View/Element/Html/Calendar.php::_toHtml does without the return just the assigning of variables
    }

<vendor>/<module>/view/frontend/templates/datepicker.phtml

<?php
/**
 * Copyright © 2013-2017 Magento, Inc. All rights reserved.
 * See COPYING.txt for license details.
 */

// @codingStandardsIgnoreFile

?>
<?php
/**
 * @var \Magento\Catalog\Model\Product\Option
 */
$_option = $block->getOption();
$class = ($_option->getIsRequire()) ? ' required' : '';
?>

<div class="field <?php /* @escapeNotVerified */ echo $class ?>">
    <label class="label" for="options_<?php /* @escapeNotVerified */ echo $_option->getId() ?>_text">
        <span><?php echo $block->escapeHtml($_option->getTitle()) ?></span>
        <?php /* @escapeNotVerified */ echo $block->getFormatedPrice() ?>
    </label>

    <div class="control">
        <?php if ($_option->getType() == \<vendor>\<module>\Model\Product\Option::OPTION_TYPE_DATEPICKER): ?>
            <?php $_textValidate = null;
            if ($_option->getIsRequire()) {
                $_textValidate['required'] = true;
            }
            if ($_option->getMaxCharacters()) {
                $_textValidate['maxlength'] = $_option->getMaxCharacters();
            }
            ?>
            <input type="text"
                   id="<?php /* @escapeNotVerified */ echo $_option->getElementId() ?>"
                   class="<vendor>_datepicker" readonly="readonly" />
            <input type="hidden"
                   id="options_<?php /* @escapeNotVerified */ echo $_option->getId() ?>_text"
                   class="input-text product-custom-option"
                <?php if (!empty($_textValidate)) {?>
                    data-validate="<?php echo  $block->escapeHtml(json_encode($_textValidate));?>"
                <?php } ?>
                   name="options[<?php /* @escapeNotVerified */ echo $_option->getId() ?>]"
                   data-selector="options[<?php /* @escapeNotVerified */ echo $_option->getId() ?>]"
                   value="<?php echo $block->escapeHtml($block->getDefaultValue()) ?>"/>
        <?php endif; ?>
        <?php if ($_option->getMaxCharacters()): ?>
            <p class="note"><?php /* @escapeNotVerified */ echo __('Maximum number of characters:') ?>
                <strong><?php /* @escapeNotVerified */ echo $_option->getMaxCharacters() ?></strong></p>
        <?php endif; ?>
    </div>
</div>
<script type="text/x-magento-init">
    {
       "#<?php /* @escapeNotVerified */ echo $_option->getElementId(); ?>": {
            "<vendor>_<module>/js/calendar_picker": {
                "dayNames": <?php /* @escapeNotVerified */ echo $days['wide']?>,
                "dayNamesMin": <?php /* @escapeNotVerified */ echo $days['abbreviated']?>,
                "monthNames": <?php /* @escapeNotVerified */ echo $months['wide']?>,
                "monthNamesShort": <?php /* @escapeNotVerified */ echo $months['abbreviated']?>,
                "infoTitle": "<?php /* @escapeNotVerified */ echo __('About the calendar');?>",
                "firstDay": <?php /* @escapeNotVerified */ echo $firstDay?>,
                "closeText": "<?php /* @escapeNotVerified */ echo __('Close');?>",
                "currentText": "<?php /* @escapeNotVerified */ echo __('Go Today'); ?>",
                "prevText": "<?php /* @escapeNotVerified */ echo __('Previous');?>",
                "nextText": "<?php /* @escapeNotVerified */ echo __('Next');?>",
                "weekHeader": "<?php /* @escapeNotVerified */ echo __('WK'); ?>",
                "timeText": "<?php /* @escapeNotVerified */ echo __('Time');?>",
                "hourText": "<?php /* @escapeNotVerified */ echo __('Hour');?>",
                "minuteText": "<?php /* @escapeNotVerified */ echo __('Minute');?>",
                "buttonText": "<?php /* @escapeNotVerified */ echo __('Change date');?>",
                "pickupPlaceholderText": "<?php /* @escapeNotVerified */ echo __('add or tell us later');?>",
                "altFieldId": "options_<?php /* @escapeNotVerified */ echo $_option->getId() ?>_text"
            }
        }
    }
</script>

Now I have a calendar I can control to my needs in calendar_picker.js.

<vendor>\<module>\view\frontend\layout\catalog_product_view.xml

<?xml version="1.0"?>
<page layout="1column" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
      xsi:noNamespaceSchemaLocation="urn:magento:framework:View/Layout/etc/page_configuration.xsd">
    <body>
        <referenceBlock name="product.info.options.wrapper">
            <block class="<vendor>\<module>\Block\Product\View\Options" name="product.<vendor>.options" as="product_options_<vendor>" template="<vendor>_<module>::options.phtml">
                <block class="<vendor>\<module>\Block\Product\View\Options\Type\DatePicker" as="<vendor>" template="<vendor>_<module>::datepicker.phtml"/>
            </block>
        </referenceBlock>
    </body>
</page>

5. the Models for all that

  • <vendor>\<module>\Model\Product\Option\Type\<vendor>.php same as Magento\Catalog\Model\Product\Option\Type\Text.php
  • <vendor>\<module>\Model\Product\Option.php same as Magento\Catalog\Model\Product\Option.php but

    class Option extends AbstractExtensibleModel implements ProductCustomOptionInterface
    {
    
        const OPTION_GROUP_DATEPICKER = '<vendor>';
    
        const OPTION_TYPE_DATEPICKER = 'datepicker';
    
        // code same as original file ...
    
        protected function _construct()
        {
            $this->_init('<vendor>\<module>\Model\ResourceModel\Product\Option');
            parent::_construct();
        }
    
    
        public function getGroupByType($type = null)
        {
            if ($type === null) {
                $type = $this->getType();
            }
            $optionGroupsToTypes = [
                self::OPTION_TYPE_DATEPICKER => self::OPTION_GROUP_DATEPICKER,
            ];
    
            return isset($optionGroupsToTypes[$type]) ? $optionGroupsToTypes[$type] : '';
        }
    
        public function groupFactory($type)
        {
            $group = $this->getGroupByType($type);
            if (!empty($group)) {
                return $this->optionTypeFactory->create(
                    '<vendor>\<vendor>\Model\Product\Option\Type\\' . $this->string->upperCaseWords($group)
                );
            }
            throw new LocalizedException(__('The option type to get group instance is incorrect.'));
        }
    
        public function beforeSave()
        {
            parent::beforeSave();
            if ($this->getData('previous_type') != '') {
                $previousType = $this->getData('previous_type');
    
                if ($this->getGroupByType($previousType) != $this->getGroupByType($this->getData('type'))) {
                    switch ($this->getGroupByType($previousType)) {
                        case self::OPTION_GROUP_DATEPICKER:
                            $this->setData('max_characters', '0');
                            break;
                    }
                }
            }
            return $this;
        }
    
  • <vendor>\<module>\Model\ResourceModel\Product\Option.php same as Magento\Catalog\Model\ResourceModel\Product\Option.php just with changed objects to my classes like public function duplicate(\<vendor>\<module>\Model\Product\Option $object, $oldProductId, $newProductId)

6. Extend Ui :

  • <vendor>\<module>\Ui\DataProvider\Catalog\Product\Form\Modifier\CustomOptions.php

7. added a Product\View\Options.php plugin

in the di.xml

<type name="Magento\Catalog\Block\Product\View\Options">
        <plugin name="<vendor>_<module>_foo"
                type="<vendor>\<module>\Plugin\Catalog\Block\Product\View\OptionsPlugin"
                sortOrder="1"/>
    </type>

The plugin removes my option from the standard magento option array.

public function afterGetOptions(
    \Magento\Catalog\Block\Product\View\Options $subject,
    $options
)
{
    foreach ($options as $key => $option) {
        if(Option::OPTION_TYPE_DATEPICKER === $option->getType()) {
            unset($options[$key]);
        }
    }
    return $options;
}

8. add a new options field for element_id

I also added a new option field to hand over the <input id="<myid>"> as you can see in the datepicker.phtml. I do not add that code here.


Now this works as far as:

  1. I can select and save the new type in the backend to my product
  2. On the product detail page I get that option, meaning I get my calendar picker, can select the date and so on.

But upon placing the product into the cart I get this error:

You need to choose options for your item.

I guess my problem comes with the plugin for Magento…\Options. Here I need to remove my custom option from the standard array which seems dirty already.
But when my option is handled it will still be called as a Magento\Catalog\...\Option and not <vendor>\<module>\...\Option.
Thus placing it in the cart fails as the Magento resource model does not have my type in protected function _saveValuePrices.

How can I add that new type so I can place products to the cart without problems?

UPDATE

Thanks to @EmiproTechnologiesPvt.Ltd. I got a bit closer. As I thought the problem is that Magento calls a Magento\Catalog…\Option so it will end up in groupFactory of that class and there my type is not available. Thus it will already fail at getting the group but even if it would find it. It would fail to create the correct object.

If I add the group to “ and change this function like below, everything works as expected. So the problem really is that Magento won't call my Option class.

public function getGroupByType($type = null)
    {
        if ($type === null) {
            $type = $this->getType();
        }
        $optionGroupsToTypes = [
            self::OPTION_TYPE_FIELD => self::OPTION_GROUP_TEXT,
            self::OPTION_TYPE_AREA => self::OPTION_GROUP_TEXT,
            self::OPTION_TYPE_FILE => self::OPTION_GROUP_FILE,
            self::OPTION_TYPE_DROP_DOWN => self::OPTION_GROUP_SELECT,
            self::OPTION_TYPE_RADIO => self::OPTION_GROUP_SELECT,
            self::OPTION_TYPE_CHECKBOX => self::OPTION_GROUP_SELECT,
            self::OPTION_TYPE_MULTIPLE => self::OPTION_GROUP_SELECT,
            self::OPTION_TYPE_DATE => self::OPTION_GROUP_DATE,
            self::OPTION_TYPE_DATE_TIME => self::OPTION_GROUP_DATE,
            self::OPTION_TYPE_TIME => self::OPTION_GROUP_DATE,
            'datepicker' => '<vendor>',
        ];

        return isset($optionGroupsToTypes[$type]) ? $optionGroupsToTypes[$type] : '';
    }

    /**
     * Group model factory
     *
     * @param string $type Option type
     * @return \Magento\Catalog\Model\Product\Option\Type\DefaultType
     * @throws LocalizedException
     */
    public function groupFactory($type)
    {
        $group = $this->getGroupByType($type);

        if (!empty($group)) {
            if('<vendor>' === $group) {
                return $this->optionTypeFactory->create(
                    '<vendor>\<module>\Model\Product\Option\Type\\' . $this->string->upperCaseWords($group)
                );
            } else {
                return $this->optionTypeFactory->create(
                    'Magento\Catalog\Model\Product\Option\Type\\' . $this->string->upperCaseWords($group)
                );
            }
        }
        throw new LocalizedException(__('The option type to get group instance is incorrect.'));
    }

Best Answer

I ended up creating an additional plugin:

for the di.xml:

<type name="Magento\Catalog\Model\Product\Option">
    <plugin name="<vendor>_<module>_option" type="<vendor>\<module>\Plugin\Catalog\Model\Product\OptionPlugin"
            sortOrder="1"/>
</type>

The plugin:

<?php

namespace <vendor>\<module>\Plugin\Catalog\Model\Product;

use Magento\Catalog\Model\Product\Option;
use Magento\Framework\Exception\LocalizedException;
use Magento\Framework\Stdlib\StringUtils;

class OptionPlugin
{

    /**
     * Product option factory
     *
     * @var \Magento\Catalog\Model\Product\Option\Type\Factory
     */
    protected $optionTypeFactory;

    /**
     * @var \Magento\Framework\Stdlib\StringUtils
     */
    protected $string;

    public function __construct(
        \Magento\Catalog\Model\Product\Option\Type\Factory $optionFactory,
        StringUtils $string
    )
    {
        $this->optionTypeFactory = $optionFactory;
        $this->string = $string;
    }

    /**
     * Group model factory
     *
     * @param Option $subject
     * @param $proceed
     * @param string $type
     *
     * @return \Magento\Catalog\Model\Product\Option\Type\DefaultType
     * @throws LocalizedException
     */
    public function aroundGroupFactory(Option $subject, callable $proceed, $type = null)
    {
        try {
            $result =  $proceed($type);
            return $result;
        } catch (LocalizedException $ex) {
            $group = $this->getGroupByType($type);
            if (!empty($group)) {
                return $this->optionTypeFactory->create(
                    '<vendor>\<module>\Model\Product\Option\Type\\' . $this->string->upperCaseWords($group)
                );
            }
            throw new LocalizedException(__('The option type to get group instance is incorrect.'));
        }
    }

    private function getGroupByType($type = null)
    {
        $optionGroupsToTypes = [
            \<vendor>\<module>\Model\Product\Option::OPTION_TYPE_DATEPICKER => \<vendor>\<module>\Model\Product\Option::OPTION_GROUP_DATEPICKER,
        ];

        return isset($optionGroupsToTypes[$type]) ? $optionGroupsToTypes[$type] : '';
    }
}
Related Topic