Skip to main content
Only modify meta data for Bundle product type
Source Link
Tu Van
  • 7.8k
  • 2
  • 14
  • 24
<?php
namespace Magetu\BundleSelectionImage\Ui\DataProvider\Product\Form\Modifier;

use Magento\Bundle\Model\Product\Type;
use Magento\Bundle\Model\ResourceModel\Selection as SelectionResource;
use Magento\Bundle\Model\SelectionFactory;
use Magento\Bundle\Ui\DataProvider\Product\Form\Modifier\BundlePanel;
use Magento\Catalog\Model\Category\FileInfo;
use Magento\Catalog\Model\Locator\LocatorInterface;
use Magento\Catalog\Ui\DataProvider\Product\Form\Modifier\AbstractModifier;
use Magento\Framework\UrlInterface;

class SelectionImage extends AbstractModifier
{
    public const FIELD_IMAGE = 'selection_image';

    /**
     * @var LocatorInterface
     */
    private $locator;

    /**
     * @var UrlInterface
     */
    private $urlBuilder;

    /**
     * @var FileInfo
     */
    private $fileInfo;

    /**
     * @var SelectionFactory
     */
    private $selectionFactory;

    /**
     * @var SelectionResource
     */
    private $selectionResource;

    public function __construct(
        LocatorInterface $locator,
        UrlInterface $urlBuilder,
        FileInfo $fileInfo,
        SelectionFactory $selectionFactory,
        SelectionResource $selectionResource
    ) {
        $this->locator = $locator;
        $this->urlBuilder = $urlBuilder;
        $this->fileInfo = $fileInfo;
        $this->selectionFactory = $selectionFactory;
        $this->selectionResource = $selectionResource;
    }

    /**
     * {@inheritdoc}
     *
     * Converts selection image data to acceptable for rendering format
     * Display selection image
     */
    public function modifyData(array $data)
    {
        $product = $this->locator->getProduct();
        $modelId = $product->getId();
        $isBundleProduct = $product->getTypeId() === Type::TYPE_CODE;
        if ($isBundleProduct && $modelId) {
            $selectionModel = $this->selectionFactory->create();
            foreach ($data[$modelId][BundlePanel::CODE_BUNDLE_OPTIONS][BundlePanel::CODE_BUNDLE_OPTIONS] as &$option) {
                foreach ($option['bundle_selections'] as &$selection) {
                    $this->selectionResource->load($selectionModel, $selection['selection_id']);
                    $selectionImage = $selectionModel->getData('selection_image');
                    if ($selectionImage !== null && $this->fileInfo->isExist($selectionImage)) {
                        $stat = $this->fileInfo->getStat($selectionImage);
                        $mime = $this->fileInfo->getMimeType($selectionImage);

                        // phpcs:ignore Magento2.Functions.DiscouragedFunction
                        $imageUrl = $product->getStore()->getBaseUrl() . $selectionImage;
                        $imageRendering = [];
                        $imageRendering[0]['name'] = basename($selectionImage);
                        $imageRendering[0]['url'] = $imageUrl;
                        $imageRendering[0]['size'] = $stat['size'];
                        $imageRendering[0]['type'] = $mime;

                        $selectionModel->setData('selection_image', $imageRendering);
                    }
                    $selection['selection_image'] = $selectionModel->getData('selection_image');
                }
            }
        }

        return $data;
    }

    /**
     * Add selection image field
     *
     * @param array $meta
     * @return array
     */
    public function modifyMeta(array $meta) {
        if ($this->locator->getProduct()->getTypeId() === Type::TYPE_CODE) {
            $groupCode = BundlePanel::CODE_BUNDLE_DATA;
            $meta[$groupCode]['children']['bundle_options']['children']['record']['children']
            ['product_bundle_container']['children']['bundle_selections']
            ['children']['record']['children'][static::FIELD_IMAGE] = $this->getSelectionImageFieldConfig();
        }

        return $meta;
    }

    /**
     * Get selection image field config
     *
     * @return array
     */
    private function getSelectionImageFieldConfig()
    {
        return [
            'arguments' => [
                'data' => [
                    'config' => [
                        'componentType' => 'imageUploader',
                        'formElement' => 'imageUploader',
                        'template' => 'Magetu_BundleSelectionImage/form/element/uploader/image',
                        'fileInputName' => static::FIELD_IMAGE,
                        'uploaderConfig' => [
                            'url' => $this->urlBuilder->getUrl(
                                'bundleselectionimage/product_image/upload'
                            ),
                        ],
                        'dataScope' => static::FIELD_IMAGE,
                        'sortOrder' => 129,// Before the last field (action_delete)
                    ],
                ],
            ],
        ];
    }
}

<?php
namespace Magetu\BundleSelectionImage\Ui\DataProvider\Product\Form\Modifier;

use Magento\Bundle\Model\Product\Type;
use Magento\Bundle\Model\ResourceModel\Selection as SelectionResource;
use Magento\Bundle\Model\SelectionFactory;
use Magento\Bundle\Ui\DataProvider\Product\Form\Modifier\BundlePanel;
use Magento\Catalog\Model\Category\FileInfo;
use Magento\Catalog\Model\Locator\LocatorInterface;
use Magento\Catalog\Ui\DataProvider\Product\Form\Modifier\AbstractModifier;
use Magento\Framework\UrlInterface;

class SelectionImage extends AbstractModifier
{
    public const FIELD_IMAGE = 'selection_image';

    /**
     * @var LocatorInterface
     */
    private $locator;

    /**
     * @var UrlInterface
     */
    private $urlBuilder;

    /**
     * @var FileInfo
     */
    private $fileInfo;

    /**
     * @var SelectionFactory
     */
    private $selectionFactory;

    /**
     * @var SelectionResource
     */
    private $selectionResource;

    public function __construct(
        LocatorInterface $locator,
        UrlInterface $urlBuilder,
        FileInfo $fileInfo,
        SelectionFactory $selectionFactory,
        SelectionResource $selectionResource
    ) {
        $this->locator = $locator;
        $this->urlBuilder = $urlBuilder;
        $this->fileInfo = $fileInfo;
        $this->selectionFactory = $selectionFactory;
        $this->selectionResource = $selectionResource;
    }

    /**
     * {@inheritdoc}
     *
     * Converts selection image data to acceptable for rendering format
     * Display selection image
     */
    public function modifyData(array $data)
    {
        $product = $this->locator->getProduct();
        $modelId = $product->getId();
        $isBundleProduct = $product->getTypeId() === Type::TYPE_CODE;
        if ($isBundleProduct && $modelId) {
            $selectionModel = $this->selectionFactory->create();
            foreach ($data[$modelId][BundlePanel::CODE_BUNDLE_OPTIONS][BundlePanel::CODE_BUNDLE_OPTIONS] as &$option) {
                foreach ($option['bundle_selections'] as &$selection) {
                    $this->selectionResource->load($selectionModel, $selection['selection_id']);
                    $selectionImage = $selectionModel->getData('selection_image');
                    if ($selectionImage !== null && $this->fileInfo->isExist($selectionImage)) {
                        $stat = $this->fileInfo->getStat($selectionImage);
                        $mime = $this->fileInfo->getMimeType($selectionImage);

                        // phpcs:ignore Magento2.Functions.DiscouragedFunction
                        $imageUrl = $product->getStore()->getBaseUrl() . $selectionImage;
                        $imageRendering = [];
                        $imageRendering[0]['name'] = basename($selectionImage);
                        $imageRendering[0]['url'] = $imageUrl;
                        $imageRendering[0]['size'] = $stat['size'];
                        $imageRendering[0]['type'] = $mime;

                        $selectionModel->setData('selection_image', $imageRendering);
                    }
                    $selection['selection_image'] = $selectionModel->getData('selection_image');
                }
            }
        }

        return $data;
    }

    /**
     * Add selection image field
     *
     * @param array $meta
     * @return array
     */
    public function modifyMeta(array $meta) {
        $groupCode = BundlePanel::CODE_BUNDLE_DATA;
        $meta[$groupCode]['children']['bundle_options']['children']['record']['children']
        ['product_bundle_container']['children']['bundle_selections']
        ['children']['record']['children'][static::FIELD_IMAGE] = $this->getSelectionImageFieldConfig();

        return $meta;
    }

    /**
     * Get selection image field config
     *
     * @return array
     */
    private function getSelectionImageFieldConfig()
    {
        return [
            'arguments' => [
                'data' => [
                    'config' => [
                        'componentType' => 'imageUploader',
                        'formElement' => 'imageUploader',
                        'template' => 'Magetu_BundleSelectionImage/form/element/uploader/image',
                        'fileInputName' => static::FIELD_IMAGE,
                        'uploaderConfig' => [
                            'url' => $this->urlBuilder->getUrl(
                                'bundleselectionimage/product_image/upload'
                            ),
                        ],
                        'dataScope' => static::FIELD_IMAGE,
                        'sortOrder' => 129,// Before the last field (action_delete)
                    ],
                ],
            ],
        ];
    }
}

<?php
namespace Magetu\BundleSelectionImage\Ui\DataProvider\Product\Form\Modifier;

use Magento\Bundle\Model\Product\Type;
use Magento\Bundle\Model\ResourceModel\Selection as SelectionResource;
use Magento\Bundle\Model\SelectionFactory;
use Magento\Bundle\Ui\DataProvider\Product\Form\Modifier\BundlePanel;
use Magento\Catalog\Model\Category\FileInfo;
use Magento\Catalog\Model\Locator\LocatorInterface;
use Magento\Catalog\Ui\DataProvider\Product\Form\Modifier\AbstractModifier;
use Magento\Framework\UrlInterface;

class SelectionImage extends AbstractModifier
{
    public const FIELD_IMAGE = 'selection_image';

    /**
     * @var LocatorInterface
     */
    private $locator;

    /**
     * @var UrlInterface
     */
    private $urlBuilder;

    /**
     * @var FileInfo
     */
    private $fileInfo;

    /**
     * @var SelectionFactory
     */
    private $selectionFactory;

    /**
     * @var SelectionResource
     */
    private $selectionResource;

    public function __construct(
        LocatorInterface $locator,
        UrlInterface $urlBuilder,
        FileInfo $fileInfo,
        SelectionFactory $selectionFactory,
        SelectionResource $selectionResource
    ) {
        $this->locator = $locator;
        $this->urlBuilder = $urlBuilder;
        $this->fileInfo = $fileInfo;
        $this->selectionFactory = $selectionFactory;
        $this->selectionResource = $selectionResource;
    }

    /**
     * {@inheritdoc}
     *
     * Converts selection image data to acceptable for rendering format
     * Display selection image
     */
    public function modifyData(array $data)
    {
        $product = $this->locator->getProduct();
        $modelId = $product->getId();
        $isBundleProduct = $product->getTypeId() === Type::TYPE_CODE;
        if ($isBundleProduct && $modelId) {
            $selectionModel = $this->selectionFactory->create();
            foreach ($data[$modelId][BundlePanel::CODE_BUNDLE_OPTIONS][BundlePanel::CODE_BUNDLE_OPTIONS] as &$option) {
                foreach ($option['bundle_selections'] as &$selection) {
                    $this->selectionResource->load($selectionModel, $selection['selection_id']);
                    $selectionImage = $selectionModel->getData('selection_image');
                    if ($selectionImage !== null && $this->fileInfo->isExist($selectionImage)) {
                        $stat = $this->fileInfo->getStat($selectionImage);
                        $mime = $this->fileInfo->getMimeType($selectionImage);

                        // phpcs:ignore Magento2.Functions.DiscouragedFunction
                        $imageUrl = $product->getStore()->getBaseUrl() . $selectionImage;
                        $imageRendering = [];
                        $imageRendering[0]['name'] = basename($selectionImage);
                        $imageRendering[0]['url'] = $imageUrl;
                        $imageRendering[0]['size'] = $stat['size'];
                        $imageRendering[0]['type'] = $mime;

                        $selectionModel->setData('selection_image', $imageRendering);
                    }
                    $selection['selection_image'] = $selectionModel->getData('selection_image');
                }
            }
        }

        return $data;
    }

    /**
     * Add selection image field
     *
     * @param array $meta
     * @return array
     */
    public function modifyMeta(array $meta) {
        if ($this->locator->getProduct()->getTypeId() === Type::TYPE_CODE) {
            $groupCode = BundlePanel::CODE_BUNDLE_DATA;
            $meta[$groupCode]['children']['bundle_options']['children']['record']['children']
            ['product_bundle_container']['children']['bundle_selections']
            ['children']['record']['children'][static::FIELD_IMAGE] = $this->getSelectionImageFieldConfig();
        }

        return $meta;
    }

    /**
     * Get selection image field config
     *
     * @return array
     */
    private function getSelectionImageFieldConfig()
    {
        return [
            'arguments' => [
                'data' => [
                    'config' => [
                        'componentType' => 'imageUploader',
                        'formElement' => 'imageUploader',
                        'template' => 'Magetu_BundleSelectionImage/form/element/uploader/image',
                        'fileInputName' => static::FIELD_IMAGE,
                        'uploaderConfig' => [
                            'url' => $this->urlBuilder->getUrl(
                                'bundleselectionimage/product_image/upload'
                            ),
                        ],
                        'dataScope' => static::FIELD_IMAGE,
                        'sortOrder' => 129,// Before the last field (action_delete)
                    ],
                ],
            ],
        ];
    }
}

Improve code, add solution for Magento version < 2.4.3
Source Link
Tu Van
  • 7.8k
  • 2
  • 14
  • 24

In this post, I'll give instructions on how to create and save a custom image in the bundle product custom selection image step-by-step.

  1. Create a modifier class which is defined in di.xml in the step 4 in order to modify bundle selection structure to add the selection image field:

Due to Magento 2.4.3 replacedreplacing the code of method to map product link to bundle selection model, the content of ourfor LinkManagement model class should be different:

<?php
namespace Magetu\BundleSelectionImage\Model;

use Magento\Bundle\Api\Data\LinkInterface;
use Magento\Bundle\Api\Data\LinkInterfaceFactory;
use Magento\Bundle\Model\ResourceModel\Bundle;
use Magento\Bundle\Model\ResourceModel\BundleFactory;
use Magento\Bundle\Model\ResourceModel\Option\CollectionFactory;
use Magento\Bundle\Model\Selection;
use Magento\Bundle\Model\SelectionFactory;
use Magento\Catalog\Api\Data\ProductInterface;
use Magento\Catalog\Api\ProductRepositoryInterface;
use Magento\Catalog\Model\ImageUploader;
use Magento\Catalog\Model\Product;
use Magento\Framework\Api\DataObjectHelper;
use Magento\Framework\App\Filesystem\DirectoryList;
use Magento\Framework\EntityManager\MetadataPool;
use Magento\Framework\Exception\CouldNotSaveException;
use Magento\Framework\Exception\FileSystemException;
use Magento\Framework\Exception\InputException;
use Magento\Framework\Exception\NoSuchEntityException;
use Magento\Framework\Filesystem;
use Magento\Framework\Filesystem\Directory\WriteInterface;
use Magento\Framework\Message\Manager as MessageManager;
use Magento\Store\Model\StoreManagerInterface;

class LinkManagement extends \Magento\Bundle\Model\LinkManagement
{
    /**
     * @var StoreManagerInterface
     */
    private $storeManager;

    /**
     * @var MetadataPool
     */
    private $metadataPool;

    /**
     * @var ImageUploader
     */
    private $imageUploader;

    /**
     * @var MessageManager
     */
    private $messageManager;

    public function __construct(
        ProductRepositoryInterface $productRepository,
        LinkInterfaceFactory $linkFactory,
        SelectionFactory $bundleSelection,
        BundleFactory $bundleFactory,
        CollectionFactory $optionCollection,
        StoreManagerInterface $storeManager,
        DataObjectHelper $dataObjectHelper,
        MetadataPool $metadataPool,
        ImageUploader $imageUploader,
        MessageManager $messageManager
    ) {
        parent::__construct(
            $productRepository,
            $linkFactory,
            $bundleSelection,
            $bundleFactory,
            $optionCollection,
            $storeManager,
            $dataObjectHelper,
            $metadataPool
        );
        $this->storeManager = $storeManager;
        $this->metadataPool = $metadataPool;
        $this->imageUploader = $imageUploader;
        $this->messageManager = $messageManager;
    }

    /**
     * This method has same content as in the parent class
     * Declare it here to allow override mapProductLinkToBundleSelectionModel which is private
     *
     * @inheritDoc
     *
     * @SuppressWarnings(PHPMD.CyclomaticComplexity)
     * @SuppressWarnings(PHPMD.NPathComplexity)
     */
    public function saveChild(
        $sku,
        LinkInterface $linkedProduct
    ) {
        $product = $this->productRepository->get($sku, true);
        if ($product->getTypeId() != Product\Type::TYPE_BUNDLE) {
            throw new InputException(
                __('The product with the "%1" SKU isn\'t a bundle product.', [$product->getSku()])
            );
        }

        /** @var Product $linkProductModel */
        $linkProductModel = $this->productRepository->get($linkedProduct->getSku());
        if ($linkProductModel->isComposite()) {
            throw new InputException(__('The bundle product can\'t contain another composite product.'));
        }

        if (!$linkedProduct->getId()) {
            throw new InputException(__('The product link needs an ID field entered. Enter and try again.'));
        }

        /** @var Selection $selectionModel */
        $selectionModel = $this->bundleSelection->create();
        $selectionModel->load($linkedProduct->getId());
        if (!$selectionModel->getId()) {
            throw new InputException(
                __(
                    'The product link with the "%1" ID field wasn\'t found. Verify the ID and try again.',
                    [$linkedProduct->getId()]
                )
            );
        }
        $selectionModel = $this->mapProductLinkToBundleSelectionModel(
            $selectionModel,
            $linkedProduct,
            $product,
            (int)$linkProductModel->getId()
        );

        try {
            $selectionModel->save();
        } catch (\Exception $e) {
            throw new CouldNotSaveException(__('Could not save child: "%1"', $e->getMessage()), $e);
        }

        return true;
    }

    /**
     * This method has same content as in the parent class
     * Declare it here to allow override mapProductLinkToBundleSelectionModel which is private
     *
     * @inheritDoc
     *
     * @SuppressWarnings(PHPMD.CyclomaticComplexity)
     */
    public function addChild(
        ProductInterface $product,
                         $optionId,
        LinkInterface $linkedProduct
    ) {
        if ($product->getTypeId() != Product\Type::TYPE_BUNDLE) {
            throw new InputException(
                __('The product with the "%1" SKU isn\'t a bundle product.', $product->getSku())
            );
        }

        $linkField = $this->metadataPool->getMetadata(ProductInterface::class)->getLinkField();

        $options = $this->optionCollection->create();

        $options->setIdFilter($optionId);
        $options->setProductLinkFilter($product->getData($linkField));

        $existingOption = $options->getFirstItem();

        if (!$existingOption->getId()) {
            throw new InputException(
                __(
                    'Product with specified sku: "%1" does not contain option: "%2"',
                    [$product->getSku(), $optionId]
                )
            );
        }

        /* @var $resource Bundle */
        $resource = $this->bundleFactory->create();
        $selections = $resource->getSelectionsData($product->getData($linkField));
        /** @var Product $linkProductModel */
        $linkProductModel = $this->productRepository->get($linkedProduct->getSku());
        if ($linkProductModel->isComposite()) {
            throw new InputException(__('The bundle product can\'t contain another composite product.'));
        }

        if ($selections) {
            foreach ($selections as $selection) {
                if ($selection['option_id'] == $optionId &&
                    $selection['product_id'] == $linkProductModel->getEntityId() &&
                    $selection['parent_product_id'] == $product->getData($linkField)) {
                    if (!$product->getCopyFromView()) {
                        throw new CouldNotSaveException(
                            __(
                                'Child with specified sku: "%1" already assigned to product: "%2"',
                                [$linkedProduct->getSku(), $product->getSku()]
                            )
                        );
                    }

                    return $this->bundleSelection->create()->load($linkProductModel->getEntityId());
                }
            }
        }

        $selectionModel = $this->bundleSelection->create();
        $selectionModel = $this->mapProductLinkToBundleSelectionModel(
            $selectionModel,
            $linkedProduct,
            $product,
            (int)$linkProductModel->getEntityId()
        );

        $selectionModel->setOptionId($optionId);

        try {
            $selectionModel->save();
            $resource->addProductRelation($product->getData($linkField), $linkProductModel->getEntityId());
        } catch (\Exception $e) {
            throw new CouldNotSaveException(__('Could not save child: "%1"', $e->getMessage()), $e);
        }

        return (int)$selectionModel->getId();
    }

    /**
     * Fill selection model with product link data.
     * Save selection image value
     *
     * @param Selection $selectionModel
     * @param LinkInterface $productLink
     * @param ProductInterface $parentProduct
     * @param int $linkedProductId
     * @return Selection
     * @throws FileSystemException
     * @throws NoSuchEntityException
     */
    private function mapProductLinkToBundleSelectionModel(
        Selection $selectionModel,
        LinkInterface $productLink,
        ProductInterface $parentProduct,
        int $linkedProductId
    ): Selection {
        $linkField = $this->metadataPool->getMetadata(ProductInterface::class)->getLinkField();
        $selectionModel->setProductId($linkedProductId);
        $selectionModel->setParentProductId($parentProduct->getData($linkField));
        if ($productLink->getSelectionId() !== null) {
            $selectionModel->setSelectionId($productLink->getSelectionId());
        }
        if ($productLink->getOptionId() !== null) {
            $selectionModel->setOptionId($productLink->getOptionId());
        }
        if ($productLink->getPosition() !== null) {
            $selectionModel->setPosition($productLink->getPosition());
        }
        if ($productLink->getQty() !== null) {
            $selectionModel->setSelectionQty($productLink->getQty());
        }
        if ($productLink->getPriceType() !== null) {
            $selectionModel->setSelectionPriceType($productLink->getPriceType());
        }
        if ($productLink->getPrice() !== null) {
            $selectionModel->setSelectionPriceValue($productLink->getPrice());
        }
        if ($productLink->getCanChangeQuantity() !== null) {
            $selectionModel->setSelectionCanChangeQty($productLink->getCanChangeQuantity());
        }
        if ($productLink->getIsDefault() !== null) {
            $selectionModel->setIsDefault($productLink->getIsDefault());
        }
        $selectionModel->setWebsiteId((int)$this->storeManager->getStore($parentProduct->getStoreId())->getWebsiteId());

        // Start customize
        $selectionImage = $thisnull;
        if ($productLink->getSelectionImage($parentProduct,) !== null) {
            $selectionImage = $this->getSelectionImage($productLink->getSelectionImage());
        }
        $selectionModel->setSelectionImage($selectionImage);
        // End customize

        return $selectionModel;
    }

    /**
     * Move uploaded selection image file and get selection image path
     * @param $parentProduct
     * @param $selectionImageData
     * @return string|null
     * @throws FileSystemExceptionNoSuchEntityException
     */
    private function getSelectionImage($parentProduct, $selectionImageData)
    {
        $selectionImage = null;
        if ($selectionImageData$store !=== null$this->storeManager->getStore() {;
            $baseMediaDir = $parentProduct->getStore()$store->getBaseMediaDir();
            $selectionImageName$imageName = $selectionImageData[0]['name'];
            if (isset($selectionImageData[0]['tmp_name'])) {
                // Save new uploaded selection image
                try {
                    $newImgRelativePath = $this->imageUploader->moveFileFromTmp(
                        $selectionImageName$imageName,
                        true
                    );
                    $selectionImage = $baseMediaDir . '/' . $newImgRelativePath;
                } catch (\Exception $e) {
                    $message = __('Saving selection image: ') . $e->getMessage();
                    $this->messageManager->addErrorMessage($message);
                }
            } else {
                // Save existing selection image
                /**
                 * It was converted in Modifier for rendering in the admin interface:
                 * Magetu\BundleSelectionImage\Ui\DataProvider\Product\Form\Modifier\SelectionImage::modifyData
                 */
                $selectionImage = $baseMediaDir . '/' . $this->imageUploader->getBasePath() . '/' . $selectionImageName;
            }
        } else {
            // Save existing selection image
            /**
             * It was converted in Modifier for rendering in the admin interface:
             * Magetu\BundleSelectionImage\Ui\DataProvider\Product\Form\Modifier\SelectionImage::modifyData
             */
            $selectionImage = $baseMediaDir . '/' . $this->imageUploader->getBasePath() . '/' . $imageName;
        }

        return $selectionImage;
    }
}

In this post, I'll give instructions on how to create and save the product custom selection image step-by-step.

  1. Create modifier class which defined in di.xml in the step 4 to modify bundle selection structure to add the selection image field:

Due to Magento 2.4.3 replaced the code method to map product link to bundle selection model, the content of our LinkManagement model class should be different:

<?php
namespace Magetu\BundleSelectionImage\Model;

use Magento\Bundle\Api\Data\LinkInterface;
use Magento\Bundle\Api\Data\LinkInterfaceFactory;
use Magento\Bundle\Model\ResourceModel\Bundle;
use Magento\Bundle\Model\ResourceModel\BundleFactory;
use Magento\Bundle\Model\ResourceModel\Option\CollectionFactory;
use Magento\Bundle\Model\Selection;
use Magento\Bundle\Model\SelectionFactory;
use Magento\Catalog\Api\Data\ProductInterface;
use Magento\Catalog\Api\ProductRepositoryInterface;
use Magento\Catalog\Model\ImageUploader;
use Magento\Catalog\Model\Product;
use Magento\Framework\Api\DataObjectHelper;
use Magento\Framework\App\Filesystem\DirectoryList;
use Magento\Framework\EntityManager\MetadataPool;
use Magento\Framework\Exception\CouldNotSaveException;
use Magento\Framework\Exception\FileSystemException;
use Magento\Framework\Exception\InputException;
use Magento\Framework\Exception\NoSuchEntityException;
use Magento\Framework\Filesystem;
use Magento\Framework\Filesystem\Directory\WriteInterface;
use Magento\Framework\Message\Manager as MessageManager;
use Magento\Store\Model\StoreManagerInterface;

class LinkManagement extends \Magento\Bundle\Model\LinkManagement
{
    /**
     * @var StoreManagerInterface
     */
    private $storeManager;

    /**
     * @var MetadataPool
     */
    private $metadataPool;

    /**
     * @var ImageUploader
     */
    private $imageUploader;

    /**
     * @var MessageManager
     */
    private $messageManager;

    public function __construct(
        ProductRepositoryInterface $productRepository,
        LinkInterfaceFactory $linkFactory,
        SelectionFactory $bundleSelection,
        BundleFactory $bundleFactory,
        CollectionFactory $optionCollection,
        StoreManagerInterface $storeManager,
        DataObjectHelper $dataObjectHelper,
        MetadataPool $metadataPool,
        ImageUploader $imageUploader,
        MessageManager $messageManager
    ) {
        parent::__construct(
            $productRepository,
            $linkFactory,
            $bundleSelection,
            $bundleFactory,
            $optionCollection,
            $storeManager,
            $dataObjectHelper,
            $metadataPool
        );
        $this->storeManager = $storeManager;
        $this->metadataPool = $metadataPool;
        $this->imageUploader = $imageUploader;
        $this->messageManager = $messageManager;
    }

    /**
     * This method has same content as in the parent class
     * Declare it here to allow override mapProductLinkToBundleSelectionModel which is private
     *
     * @inheritDoc
     *
     * @SuppressWarnings(PHPMD.CyclomaticComplexity)
     * @SuppressWarnings(PHPMD.NPathComplexity)
     */
    public function saveChild(
        $sku,
        LinkInterface $linkedProduct
    ) {
        $product = $this->productRepository->get($sku, true);
        if ($product->getTypeId() != Product\Type::TYPE_BUNDLE) {
            throw new InputException(
                __('The product with the "%1" SKU isn\'t a bundle product.', [$product->getSku()])
            );
        }

        /** @var Product $linkProductModel */
        $linkProductModel = $this->productRepository->get($linkedProduct->getSku());
        if ($linkProductModel->isComposite()) {
            throw new InputException(__('The bundle product can\'t contain another composite product.'));
        }

        if (!$linkedProduct->getId()) {
            throw new InputException(__('The product link needs an ID field entered. Enter and try again.'));
        }

        /** @var Selection $selectionModel */
        $selectionModel = $this->bundleSelection->create();
        $selectionModel->load($linkedProduct->getId());
        if (!$selectionModel->getId()) {
            throw new InputException(
                __(
                    'The product link with the "%1" ID field wasn\'t found. Verify the ID and try again.',
                    [$linkedProduct->getId()]
                )
            );
        }
        $selectionModel = $this->mapProductLinkToBundleSelectionModel(
            $selectionModel,
            $linkedProduct,
            $product,
            (int)$linkProductModel->getId()
        );

        try {
            $selectionModel->save();
        } catch (\Exception $e) {
            throw new CouldNotSaveException(__('Could not save child: "%1"', $e->getMessage()), $e);
        }

        return true;
    }

    /**
     * This method has same content as in the parent class
     * Declare it here to allow override mapProductLinkToBundleSelectionModel which is private
     *
     * @inheritDoc
     *
     * @SuppressWarnings(PHPMD.CyclomaticComplexity)
     */
    public function addChild(
        ProductInterface $product,
        $optionId,
        LinkInterface $linkedProduct
    ) {
        if ($product->getTypeId() != Product\Type::TYPE_BUNDLE) {
            throw new InputException(
                __('The product with the "%1" SKU isn\'t a bundle product.', $product->getSku())
            );
        }

        $linkField = $this->metadataPool->getMetadata(ProductInterface::class)->getLinkField();

        $options = $this->optionCollection->create();

        $options->setIdFilter($optionId);
        $options->setProductLinkFilter($product->getData($linkField));

        $existingOption = $options->getFirstItem();

        if (!$existingOption->getId()) {
            throw new InputException(
                __(
                    'Product with specified sku: "%1" does not contain option: "%2"',
                    [$product->getSku(), $optionId]
                )
            );
        }

        /* @var $resource Bundle */
        $resource = $this->bundleFactory->create();
        $selections = $resource->getSelectionsData($product->getData($linkField));
        /** @var Product $linkProductModel */
        $linkProductModel = $this->productRepository->get($linkedProduct->getSku());
        if ($linkProductModel->isComposite()) {
            throw new InputException(__('The bundle product can\'t contain another composite product.'));
        }

        if ($selections) {
            foreach ($selections as $selection) {
                if ($selection['option_id'] == $optionId &&
                    $selection['product_id'] == $linkProductModel->getEntityId() &&
                    $selection['parent_product_id'] == $product->getData($linkField)) {
                    if (!$product->getCopyFromView()) {
                        throw new CouldNotSaveException(
                            __(
                                'Child with specified sku: "%1" already assigned to product: "%2"',
                                [$linkedProduct->getSku(), $product->getSku()]
                            )
                        );
                    }

                    return $this->bundleSelection->create()->load($linkProductModel->getEntityId());
                }
            }
        }

        $selectionModel = $this->bundleSelection->create();
        $selectionModel = $this->mapProductLinkToBundleSelectionModel(
            $selectionModel,
            $linkedProduct,
            $product,
            (int)$linkProductModel->getEntityId()
        );

        $selectionModel->setOptionId($optionId);

        try {
            $selectionModel->save();
            $resource->addProductRelation($product->getData($linkField), $linkProductModel->getEntityId());
        } catch (\Exception $e) {
            throw new CouldNotSaveException(__('Could not save child: "%1"', $e->getMessage()), $e);
        }

        return (int)$selectionModel->getId();
    }

    /**
     * Fill selection model with product link data.
     * Save selection image value
     *
     * @param Selection $selectionModel
     * @param LinkInterface $productLink
     * @param ProductInterface $parentProduct
     * @param int $linkedProductId
     * @return Selection
     * @throws FileSystemException
     * @throws NoSuchEntityException
     */
    private function mapProductLinkToBundleSelectionModel(
        Selection $selectionModel,
        LinkInterface $productLink,
        ProductInterface $parentProduct,
        int $linkedProductId
    ): Selection {
        $linkField = $this->metadataPool->getMetadata(ProductInterface::class)->getLinkField();
        $selectionModel->setProductId($linkedProductId);
        $selectionModel->setParentProductId($parentProduct->getData($linkField));
        if ($productLink->getSelectionId() !== null) {
            $selectionModel->setSelectionId($productLink->getSelectionId());
        }
        if ($productLink->getOptionId() !== null) {
            $selectionModel->setOptionId($productLink->getOptionId());
        }
        if ($productLink->getPosition() !== null) {
            $selectionModel->setPosition($productLink->getPosition());
        }
        if ($productLink->getQty() !== null) {
            $selectionModel->setSelectionQty($productLink->getQty());
        }
        if ($productLink->getPriceType() !== null) {
            $selectionModel->setSelectionPriceType($productLink->getPriceType());
        }
        if ($productLink->getPrice() !== null) {
            $selectionModel->setSelectionPriceValue($productLink->getPrice());
        }
        if ($productLink->getCanChangeQuantity() !== null) {
            $selectionModel->setSelectionCanChangeQty($productLink->getCanChangeQuantity());
        }
        if ($productLink->getIsDefault() !== null) {
            $selectionModel->setIsDefault($productLink->getIsDefault());
        }
        $selectionModel->setWebsiteId((int)$this->storeManager->getStore($parentProduct->getStoreId())->getWebsiteId());

        // Start customize
        $selectionImage = $this->getSelectionImage($parentProduct, $productLink->getSelectionImage());
        $selectionModel->setSelectionImage($selectionImage);
        // End customize

        return $selectionModel;
    }

    /**
     * Move selection image file and get selection image path
     * @param $parentProduct
     * @param $selectionImageData
     * @return string|null
     * @throws FileSystemException
     */
    private function getSelectionImage($parentProduct, $selectionImageData)
    {
        $selectionImage = null;
        if ($selectionImageData !== null) {
            $baseMediaDir = $parentProduct->getStore()->getBaseMediaDir();
            $selectionImageName = $selectionImageData[0]['name'];
            if (isset($selectionImageData[0]['tmp_name'])) {
                // Save new uploaded selection image
                try {
                    $newImgRelativePath = $this->imageUploader->moveFileFromTmp(
                        $selectionImageName,
                        true
                    );
                    $selectionImage = $baseMediaDir . '/' . $newImgRelativePath;
                } catch (\Exception $e) {
                    $message = __('Saving selection image: ') . $e->getMessage();
                    $this->messageManager->addErrorMessage($message);
                }
            } else {
                // Save existing selection image
                /**
                 * It was converted in Modifier for rendering in the admin interface:
                 * Magetu\BundleSelectionImage\Ui\DataProvider\Product\Form\Modifier\SelectionImage::modifyData
                 */
                $selectionImage = $baseMediaDir . '/' . $this->imageUploader->getBasePath() . '/' . $selectionImageName;
            }
        }

        return $selectionImage;
    }
}

In this post, I'll give instructions on how to create and save a custom image in the bundle product selection step-by-step.

  1. Create a modifier class which is defined in di.xml in step 4 in order to modify bundle selection structure to add the selection image field:

Due to Magento 2.4.3 replacing the code of method to map product link to bundle selection model, the content for LinkManagement model class should be different:

<?php
namespace Magetu\BundleSelectionImage\Model;

use Magento\Bundle\Api\Data\LinkInterface;
use Magento\Bundle\Api\Data\LinkInterfaceFactory;
use Magento\Bundle\Model\ResourceModel\Bundle;
use Magento\Bundle\Model\ResourceModel\BundleFactory;
use Magento\Bundle\Model\ResourceModel\Option\CollectionFactory;
use Magento\Bundle\Model\Selection;
use Magento\Bundle\Model\SelectionFactory;
use Magento\Catalog\Api\Data\ProductInterface;
use Magento\Catalog\Api\ProductRepositoryInterface;
use Magento\Catalog\Model\ImageUploader;
use Magento\Catalog\Model\Product;
use Magento\Framework\Api\DataObjectHelper;
use Magento\Framework\EntityManager\MetadataPool;
use Magento\Framework\Exception\CouldNotSaveException;
use Magento\Framework\Exception\InputException;
use Magento\Framework\Exception\NoSuchEntityException;
use Magento\Framework\Message\Manager as MessageManager;
use Magento\Store\Model\StoreManagerInterface;

class LinkManagement extends \Magento\Bundle\Model\LinkManagement
{
    /**
     * @var StoreManagerInterface
     */
    private $storeManager;

    /**
     * @var MetadataPool
     */
    private $metadataPool;

    /**
     * @var ImageUploader
     */
    private $imageUploader;

    /**
     * @var MessageManager
     */
    private $messageManager;

    public function __construct(
        ProductRepositoryInterface $productRepository,
        LinkInterfaceFactory $linkFactory,
        SelectionFactory $bundleSelection,
        BundleFactory $bundleFactory,
        CollectionFactory $optionCollection,
        StoreManagerInterface $storeManager,
        DataObjectHelper $dataObjectHelper,
        MetadataPool $metadataPool,
        ImageUploader $imageUploader,
        MessageManager $messageManager
    ) {
        parent::__construct(
            $productRepository,
            $linkFactory,
            $bundleSelection,
            $bundleFactory,
            $optionCollection,
            $storeManager,
            $dataObjectHelper,
            $metadataPool
        );
        $this->storeManager = $storeManager;
        $this->metadataPool = $metadataPool;
        $this->imageUploader = $imageUploader;
        $this->messageManager = $messageManager;
    }

    /**
     * This method has same content as in the parent class
     * Declare it here to allow override mapProductLinkToBundleSelectionModel which is private
     *
     * @inheritDoc
     */
    public function saveChild(
        $sku,
        LinkInterface $linkedProduct
    ) {
        $product = $this->productRepository->get($sku, true);
        if ($product->getTypeId() != Product\Type::TYPE_BUNDLE) {
            throw new InputException(
                __('The product with the "%1" SKU isn\'t a bundle product.', [$product->getSku()])
            );
        }

        /** @var Product $linkProductModel */
        $linkProductModel = $this->productRepository->get($linkedProduct->getSku());
        if ($linkProductModel->isComposite()) {
            throw new InputException(__('The bundle product can\'t contain another composite product.'));
        }

        if (!$linkedProduct->getId()) {
            throw new InputException(__('The product link needs an ID field entered. Enter and try again.'));
        }

        /** @var Selection $selectionModel */
        $selectionModel = $this->bundleSelection->create();
        $selectionModel->load($linkedProduct->getId());
        if (!$selectionModel->getId()) {
            throw new InputException(
                __(
                    'The product link with the "%1" ID field wasn\'t found. Verify the ID and try again.',
                    [$linkedProduct->getId()]
                )
            );
        }
        $selectionModel = $this->mapProductLinkToBundleSelectionModel(
            $selectionModel,
            $linkedProduct,
            $product,
            (int)$linkProductModel->getId()
        );

        try {
            $selectionModel->save();
        } catch (\Exception $e) {
            throw new CouldNotSaveException(__('Could not save child: "%1"', $e->getMessage()), $e);
        }

        return true;
    }

    /**
     * This method has same content as in the parent class
     * Declare it here to allow override mapProductLinkToBundleSelectionModel which is private
     *
     * @inheritDoc
     */
    public function addChild(
        ProductInterface $product,
                         $optionId,
        LinkInterface $linkedProduct
    ) {
        if ($product->getTypeId() != Product\Type::TYPE_BUNDLE) {
            throw new InputException(
                __('The product with the "%1" SKU isn\'t a bundle product.', $product->getSku())
            );
        }

        $linkField = $this->metadataPool->getMetadata(ProductInterface::class)->getLinkField();

        $options = $this->optionCollection->create();

        $options->setIdFilter($optionId);
        $options->setProductLinkFilter($product->getData($linkField));

        $existingOption = $options->getFirstItem();

        if (!$existingOption->getId()) {
            throw new InputException(
                __(
                    'Product with specified sku: "%1" does not contain option: "%2"',
                    [$product->getSku(), $optionId]
                )
            );
        }

        /* @var $resource Bundle */
        $resource = $this->bundleFactory->create();
        $selections = $resource->getSelectionsData($product->getData($linkField));
        /** @var Product $linkProductModel */
        $linkProductModel = $this->productRepository->get($linkedProduct->getSku());
        if ($linkProductModel->isComposite()) {
            throw new InputException(__('The bundle product can\'t contain another composite product.'));
        }

        if ($selections) {
            foreach ($selections as $selection) {
                if ($selection['option_id'] == $optionId &&
                    $selection['product_id'] == $linkProductModel->getEntityId() &&
                    $selection['parent_product_id'] == $product->getData($linkField)) {
                    if (!$product->getCopyFromView()) {
                        throw new CouldNotSaveException(
                            __(
                                'Child with specified sku: "%1" already assigned to product: "%2"',
                                [$linkedProduct->getSku(), $product->getSku()]
                            )
                        );
                    }

                    return $this->bundleSelection->create()->load($linkProductModel->getEntityId());
                }
            }
        }

        $selectionModel = $this->bundleSelection->create();
        $selectionModel = $this->mapProductLinkToBundleSelectionModel(
            $selectionModel,
            $linkedProduct,
            $product,
            (int)$linkProductModel->getEntityId()
        );

        $selectionModel->setOptionId($optionId);

        try {
            $selectionModel->save();
            $resource->addProductRelation($product->getData($linkField), $linkProductModel->getEntityId());
        } catch (\Exception $e) {
            throw new CouldNotSaveException(__('Could not save child: "%1"', $e->getMessage()), $e);
        }

        return (int)$selectionModel->getId();
    }

    /**
     * Fill selection model with product link data.
     * Save selection image value
     *
     * @param Selection $selectionModel
     * @param LinkInterface $productLink
     * @param ProductInterface $parentProduct
     * @param int $linkedProductId
     * @return Selection
     * @throws NoSuchEntityException
     */
    private function mapProductLinkToBundleSelectionModel(
        Selection $selectionModel,
        LinkInterface $productLink,
        ProductInterface $parentProduct,
        int $linkedProductId
    ): Selection {
        $linkField = $this->metadataPool->getMetadata(ProductInterface::class)->getLinkField();
        $selectionModel->setProductId($linkedProductId);
        $selectionModel->setParentProductId($parentProduct->getData($linkField));
        if ($productLink->getSelectionId() !== null) {
            $selectionModel->setSelectionId($productLink->getSelectionId());
        }
        if ($productLink->getOptionId() !== null) {
            $selectionModel->setOptionId($productLink->getOptionId());
        }
        if ($productLink->getPosition() !== null) {
            $selectionModel->setPosition($productLink->getPosition());
        }
        if ($productLink->getQty() !== null) {
            $selectionModel->setSelectionQty($productLink->getQty());
        }
        if ($productLink->getPriceType() !== null) {
            $selectionModel->setSelectionPriceType($productLink->getPriceType());
        }
        if ($productLink->getPrice() !== null) {
            $selectionModel->setSelectionPriceValue($productLink->getPrice());
        }
        if ($productLink->getCanChangeQuantity() !== null) {
            $selectionModel->setSelectionCanChangeQty($productLink->getCanChangeQuantity());
        }
        if ($productLink->getIsDefault() !== null) {
            $selectionModel->setIsDefault($productLink->getIsDefault());
        }
        $selectionModel->setWebsiteId((int)$this->storeManager->getStore($parentProduct->getStoreId())->getWebsiteId());

        // Start customize
        $selectionImage = null;
        if ($productLink->getSelectionImage() !== null) {
            $selectionImage = $this->getSelectionImage($productLink->getSelectionImage());
        }
        $selectionModel->setSelectionImage($selectionImage);
        // End customize

        return $selectionModel;
    }

    /**
     * Move uploaded selection image file and get selection image path
     * @param $selectionImageData
     * @return string|null
     * @throws NoSuchEntityException
     */
    private function getSelectionImage($selectionImageData)
    {
        $selectionImage = null;
        $store = $this->storeManager->getStore();
        $baseMediaDir = $store->getBaseMediaDir();
        $imageName = $selectionImageData[0]['name'];
        if (isset($selectionImageData[0]['tmp_name'])) {
            // Save new uploaded selection image
            try {
                $newImgRelativePath = $this->imageUploader->moveFileFromTmp($imageName, true);
                $selectionImage = $baseMediaDir . '/' . $newImgRelativePath;
            } catch (\Exception $e) {
                $message = __('Saving selection image: ') . $e->getMessage();
                $this->messageManager->addErrorMessage($message);
            }
        } else {
            // Save existing selection image
            /**
             * It was converted in Modifier for rendering in the admin interface:
             * Magetu\BundleSelectionImage\Ui\DataProvider\Product\Form\Modifier\SelectionImage::modifyData
             */
            $selectionImage = $baseMediaDir . '/' . $this->imageUploader->getBasePath() . '/' . $imageName;
        }

        return $selectionImage;
    }
}

Add step-by-step instructions
Source Link
Tu Van
  • 7.8k
  • 2
  • 14
  • 24

In this post, I'll give instructions on how to create and save the product custom selection image step-by-step.

For the purposes of this tutorial Magetu is the name of the vendor associated with a module named BundleSelectionImage.

  1. Add a new field for selection image to catalog_product_bundle_selection tableCreate registration.php:
    Assuming you use Magento version >= 2.3, createFile path: <your module dir>app/etccode/db_schemaMagetu/BundleSelectionImage/registration.xmlphp
<?xml version="1.0"?>php
<schema xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
       use xsiMagento\Framework\Component\ComponentRegistrar;

ComponentRegistrar:noNamespaceSchemaLocation="urn:magentoregister(ComponentRegistrar:framework:Setup/Declaration/Schema/etc/schema.xsd">
    <table name="catalog_product_bundle_selection">
        <column xsi:type="varchar" name="selection_image" nullable="true" length="255" comment="Selection Image"/>
  MODULE, 'Magetu_BundleSelectionImage', </table>__DIR__);
</schema>
  1. In your custom BundleCustomOptions Modifier, addCreate module.xml:
    File path: uploaderConfigapp/code/Magetu/BundleSelectionImage/etc/module.xml to your selection image field to point to the uploader controller.
'uploaderConfig'<?xml =>version="1.0"?>
<config [xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:Module/etc/module.xsd">
    'url'<module =>name="Magetu_BundleSelectionImage">
 $this->urlBuilder->getUrl(       <sequence>
        'bundleselectionimage    <module name="Magento_Bundle"/product_image>
            <module name="Magento_Catalog"/upload'>
    ),    </sequence>
],    </module>
</config>

  1. Add an upload controller to handle selection image uploadCreate db_schema.
  2. Override Magento\Bundle\Model\LinkManagementxml to save theadd a new column for bundle selection image value to database.
    At the end of the mapProductLinkToBundleSelectionModel method, beforein return $selectionModel;catalog_product_bundle_selection, adding the below code: table.

File path: app/code/Magetu/BundleSelectionImage/etc/db_schema.xml

if<?xml ($productLinkversion="1.0"?>
<schema xmlns:xsi="http://www.w3.org/2001/XMLSchema->getSelectionImage()instance"
 !== null) {     xsi:noNamespaceSchemaLocation="urn:magento:framework:Setup/Declaration/Schema/etc/schema.xsd">
    $selectionModel->setSelectionImage($productLink->getSelectionImage());<table name="catalog_product_bundle_selection">
}        <column xsi:type="varchar" name="selection_image" nullable="true" length="255" comment="Selection Image"/>
    </table>
</schema>
  1. Create the di.xml file to:
  • define a modifier to modify bundle selection structure to add the selection image field
  • config the Upload class
  • override Magento\Bundle\Model\LinkManagement class to save the bundle selection image value

File path: app/code/Magetu/BundleSelectionImage/etc/adminhtml/di.xml

<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">
    <virtualType name="Magento\Catalog\Ui\DataProvider\Product\Form\Modifier\Pool">
        <arguments>
            <argument name="modifiers" xsi:type="array">
                <item name="bundle_selection_image" xsi:type="array">
                    <item name="class" xsi:type="string">Magetu\BundleSelectionImage\Ui\DataProvider\Product\Form\Modifier\SelectionImage</item>
                    <item name="sortOrder" xsi:type="number">181</item><!-- run after core bundle modifier -->
                </item>
            </argument>
        </arguments>
    </virtualType>

    <virtualType name="Magetu\BundleSelectionImage\ImageUpload" type="Magento\Catalog\Model\ImageUploader">
        <arguments>
            <argument name="baseTmpPath" xsi:type="string">catalog/tmp/product/selection_images</argument>
            <argument name="basePath" xsi:type="string">catalog/product/selection_images</argument>
            <argument name="allowedExtensions" xsi:type="array">
                <item name="jpg" xsi:type="string">jpg</item>
                <item name="jpeg" xsi:type="string">jpeg</item>
                <item name="gif" xsi:type="string">gif</item>
                <item name="png" xsi:type="string">png</item>
                <item name="svg" xsi:type="string">svg</item>
                <item name="svgz" xsi:type="string">svgz</item>
                <item name="webp" xsi:type="string">webp</item>
                <item name="avif" xsi:type="string">avif</item>
                <item name="avifs" xsi:type="string">avifs</item>
            </argument>
            <argument name="allowedMimeTypes" xsi:type="array">
                <item name="jpg" xsi:type="string">image/jpg</item>
                <item name="jpeg" xsi:type="string">image/jpeg</item>
                <item name="gif" xsi:type="string">image/gif</item>
                <item name="png" xsi:type="string">image/png</item>
                <item name="svg" xsi:type="string">image/svg+xml</item>
                <item name="svgz" xsi:type="string">image/svg+xml</item>
                <item name="webp" xsi:type="string">image/webp</item>
                <item name="avif" xsi:type="string">image/avif</item>
                <item name="avifs" xsi:type="string">image/avif-sequence</item>
            </argument>
        </arguments>
    </virtualType>

    <type name="Magetu\BundleSelectionImage\Controller\Adminhtml\Product\Image\Upload">
        <arguments>
            <argument name="imageUploader" xsi:type="object">Magetu\BundleSelectionImage\ImageUpload</argument>
        </arguments>
    </type>

    <preference for="Magento\Bundle\Model\LinkManagement" type="Magetu\BundleSelectionImage\Model\LinkManagement"/>

    <type name="Magetu\BundleSelectionImage\Model\LinkManagement">
        <arguments>
            <argument name="imageUploader" xsi:type="object">Magetu\BundleSelectionImage\ImageUpload</argument>
        </arguments>
    </type>
</config>

  1. Run the commandsCreate routes.xml to upgrade database, re-compile code, re-deploy static contentuse the upload controller.

File path: app/code/Magetu/BundleSelectionImage/etc/adminhtml/routes.xml

<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:App/etc/routes.xsd">
    <router id="admin">
        <route id="bundleselectionimage" frontName="bundleselectionimage">
            <module name="Magetu_BundleSelectionImage" before="Magento_Backend"/>
        </route>
    </router>
</config>

  1. Create modifier class which defined in di.xml in the step 4 to modify bundle selection structure to add the selection image field:

File path: app/code/Magetu/BundleSelectionImage/Ui/DataProvider/Product/Form/Modifier/SelectionImage.php

<?php
namespace Magetu\BundleSelectionImage\Ui\DataProvider\Product\Form\Modifier;

use Magento\Bundle\Model\Product\Type;
use Magento\Bundle\Model\ResourceModel\Selection as SelectionResource;
use Magento\Bundle\Model\SelectionFactory;
use Magento\Bundle\Ui\DataProvider\Product\Form\Modifier\BundlePanel;
use Magento\Catalog\Model\Category\FileInfo;
use Magento\Catalog\Model\Locator\LocatorInterface;
use Magento\Catalog\Ui\DataProvider\Product\Form\Modifier\AbstractModifier;
use Magento\Framework\UrlInterface;

class SelectionImage extends AbstractModifier
{
    public const FIELD_IMAGE = 'selection_image';

    /**
     * @var LocatorInterface
     */
    private $locator;

    /**
     * @var UrlInterface
     */
    private $urlBuilder;

    /**
     * @var FileInfo
     */
    private $fileInfo;

    /**
     * @var SelectionFactory
     */
    private $selectionFactory;

    /**
     * @var SelectionResource
     */
    private $selectionResource;

    public function __construct(
        LocatorInterface $locator,
        UrlInterface $urlBuilder,
        FileInfo $fileInfo,
        SelectionFactory $selectionFactory,
        SelectionResource $selectionResource
    ) {
        $this->locator = $locator;
        $this->urlBuilder = $urlBuilder;
        $this->fileInfo = $fileInfo;
        $this->selectionFactory = $selectionFactory;
        $this->selectionResource = $selectionResource;
    }

    /**
     * {@inheritdoc}
     *
     * Converts selection image data to acceptable for rendering format
     * Display selection image
     */
    public function modifyData(array $data)
    {
        $product = $this->locator->getProduct();
        $modelId = $product->getId();
        $isBundleProduct = $product->getTypeId() === Type::TYPE_CODE;
        if ($isBundleProduct && $modelId) {
            $selectionModel = $this->selectionFactory->create();
            foreach ($data[$modelId][BundlePanel::CODE_BUNDLE_OPTIONS][BundlePanel::CODE_BUNDLE_OPTIONS] as &$option) {
                foreach ($option['bundle_selections'] as &$selection) {
                    $this->selectionResource->load($selectionModel, $selection['selection_id']);
                    $selectionImage = $selectionModel->getData('selection_image');
                    if ($selectionImage !== null && $this->fileInfo->isExist($selectionImage)) {
                        $stat = $this->fileInfo->getStat($selectionImage);
                        $mime = $this->fileInfo->getMimeType($selectionImage);

                        // phpcs:ignore Magento2.Functions.DiscouragedFunction
                        $imageUrl = $product->getStore()->getBaseUrl() . $selectionImage;
                        $imageRendering = [];
                        $imageRendering[0]['name'] = basename($selectionImage);
                        $imageRendering[0]['url'] = $imageUrl;
                        $imageRendering[0]['size'] = $stat['size'];
                        $imageRendering[0]['type'] = $mime;

                        $selectionModel->setData('selection_image', $imageRendering);
                    }
                    $selection['selection_image'] = $selectionModel->getData('selection_image');
                }
            }
        }

        return $data;
    }

    /**
     * Add selection image field
     *
     * @param array $meta
     * @return array
     */
    public function modifyMeta(array $meta) {
        $groupCode = BundlePanel::CODE_BUNDLE_DATA;
        $meta[$groupCode]['children']['bundle_options']['children']['record']['children']
        ['product_bundle_container']['children']['bundle_selections']
        ['children']['record']['children'][static::FIELD_IMAGE] = $this->getSelectionImageFieldConfig();

        return $meta;
    }

    /**
     * Get selection image field config
     *
     * @return array
     */
    private function getSelectionImageFieldConfig()
    {
        return [
            'arguments' => [
                'data' => [
                    'config' => [
                        'componentType' => 'imageUploader',
                        'formElement' => 'imageUploader',
                        'template' => 'Magetu_BundleSelectionImage/form/element/uploader/image',
                        'fileInputName' => static::FIELD_IMAGE,
                        'uploaderConfig' => [
                            'url' => $this->urlBuilder->getUrl(
                                'bundleselectionimage/product_image/upload'
                            ),
                        ],
                        'dataScope' => static::FIELD_IMAGE,
                        'sortOrder' => 129,// Before the last field (action_delete)
                    ],
                ],
            ],
        ];
    }
}

  1. Create image uploader html file to upload the selection image file:

File path: app/code/Magetu/BundleSelectionImage/view/adminhtml/web/template/form/element/uploader/image.html

<!--
/**
 * Custom template for upload image file in bundle selection,
 * based on Magento_Ui::form/element/uploader/image.html
 *
 * NOTE: the variable name of the upload file input name should not be declared as 'inputName'
 * to allow the upload controller get the file
 */
-->
<div class="admin__field" visible="visible" css="$data.additionalClasses">
    <div class="admin__field-control" css="'_with-tooltip': $data.tooltip">
        <div class="file-uploader image-uploader" data-role="drop-zone" css="_loading: isLoading">
            <div class="file-uploader-area">
                <input type="file" afterRender="onElementRender" attr="id: uid, name: fileInputName, multiple: isMultipleFiles" disable="disabled" />
                <label class="file-uploader-button action-default" attr="for: uid, disabled: disabled" disable="disabled" translate="'Upload'"></label>
                <render args="fallbackResetTpl" if="$data.showFallbackReset && $data.isDifferedFromDefault"></render>
                <p class="image-upload-requirements">
                    <span if="$data.maxFileSize">
                        <span translate="'Maximum file size'"></span>: <text args="formatSize($data.maxFileSize)"></text>.
                    </span>
                    <span if="$data.allowedExtensions">
                        <span translate="'Allowed file types'"></span>: <text args="getAllowedFileExtensionsInCommaDelimitedFormat()"></text>.
                    </span>
                </p>
            </div>

            <render args="tooltipTpl" if="$data.tooltip"></render>

            <div class="admin__field-note" if="$data.notice" attr="id: noticeId">
                <span html="notice"></span>
            </div>

            <label class="admin__field-error" if="error" attr="for: uid" text="error"></label>

            <each args="data: value, as: '$file'" render="$parent.getPreviewTmpl($file)"></each>

            <div if="!hasData()" class="image image-placeholder" click="triggerImageUpload">
                <div class="file-uploader-summary product-image-wrapper">
                    <div class="file-uploader-spinner image-uploader-spinner"></div>
                    <p class="image-placeholder-text" translate="'Browse to find or drag image here'"></p>
                </div>
            </div>
        </div>
        <render args="$data.service.template" if="$data.hasService()"></render>
    </div>
</div>

  1. Create the uploader controller to handle upload the selection image file:

File path: app/code/Magetu/BundleSelectionImage/Controller/Adminhtml/Product/Image/Upload.php

<?php
namespace Magetu\BundleSelectionImage\Controller\Adminhtml\Product\Image;

use Magento\Backend\App\Action as BackendAction;
use Magento\Backend\App\Action\Context;
use Magento\Catalog\Model\ImageUploader;
use Magento\Framework\App\Action\HttpPostActionInterface;
use Magento\Framework\Controller\ResultFactory;
use Magento\Framework\Controller\ResultInterface;

/**
 * The bundle selection image upload controller
 */
class Upload extends BackendAction implements HttpPostActionInterface
{
    /**
     * Authorization level of a basic admin session
     *
     * @see _isAllowed()
     */
    public const ADMIN_RESOURCE = 'Magento_Catalog::products';

    /**
     * @var ImageUploader
     */
    private $imageUploader;

    public function __construct(
        Context $context,
        ImageUploader $imageUploader
    ) {
        parent::__construct($context);
        $this->imageUploader = $imageUploader;
    }

    /**
     * Upload selection image file controller action
     */
    public function execute(): ResultInterface
    {
        $imageId = $this->_request->getParam('param_name', 'selection_image');
        try {
            $result = $this->imageUploader->saveFileToTmpDir($imageId);
        } catch (\Exception $e) {
            $result = ['error' => $e->getMessage(), 'errorcode' => $e->getCode()];
        }
        return $this->resultFactory->create(ResultFactory::TYPE_JSON)->setData($result);
    }
}

  1. Create LinkManagement model class to override Magento\Bundle\Model\LinkManagement class to save the bundle selection image value in the database:
    File path: app/code/Magetu/BundleSelectionImage/Model/LinkManagement.php

Due to Magento 2.4.3 replaced the code method to map product link to bundle selection model, the content of our LinkManagement model class should be different:

  • For Magento version >= 2.4.3
<?php
namespace Magetu\BundleSelectionImage\Model;

use Magento\Bundle\Api\Data\LinkInterface;
use Magento\Bundle\Api\Data\LinkInterfaceFactory;
use Magento\Bundle\Model\ResourceModel\Bundle;
use Magento\Bundle\Model\ResourceModel\BundleFactory;
use Magento\Bundle\Model\ResourceModel\Option\CollectionFactory;
use Magento\Bundle\Model\Selection;
use Magento\Bundle\Model\SelectionFactory;
use Magento\Catalog\Api\Data\ProductInterface;
use Magento\Catalog\Api\ProductRepositoryInterface;
use Magento\Catalog\Model\ImageUploader;
use Magento\Catalog\Model\Product;
use Magento\Framework\Api\DataObjectHelper;
use Magento\Framework\App\Filesystem\DirectoryList;
use Magento\Framework\EntityManager\MetadataPool;
use Magento\Framework\Exception\CouldNotSaveException;
use Magento\Framework\Exception\FileSystemException;
use Magento\Framework\Exception\InputException;
use Magento\Framework\Exception\NoSuchEntityException;
use Magento\Framework\Filesystem;
use Magento\Framework\Filesystem\Directory\WriteInterface;
use Magento\Framework\Message\Manager as MessageManager;
use Magento\Store\Model\StoreManagerInterface;

class LinkManagement extends \Magento\Bundle\Model\LinkManagement
{
    /**
     * @var StoreManagerInterface
     */
    private $storeManager;

    /**
     * @var MetadataPool
     */
    private $metadataPool;

    /**
     * @var ImageUploader
     */
    private $imageUploader;

    /**
     * @var MessageManager
     */
    private $messageManager;

    public function __construct(
        ProductRepositoryInterface $productRepository,
        LinkInterfaceFactory $linkFactory,
        SelectionFactory $bundleSelection,
        BundleFactory $bundleFactory,
        CollectionFactory $optionCollection,
        StoreManagerInterface $storeManager,
        DataObjectHelper $dataObjectHelper,
        MetadataPool $metadataPool,
        ImageUploader $imageUploader,
        MessageManager $messageManager
    ) {
        parent::__construct(
            $productRepository,
            $linkFactory,
            $bundleSelection,
            $bundleFactory,
            $optionCollection,
            $storeManager,
            $dataObjectHelper,
            $metadataPool
        );
        $this->storeManager = $storeManager;
        $this->metadataPool = $metadataPool;
        $this->imageUploader = $imageUploader;
        $this->messageManager = $messageManager;
    }

    /**
     * This method has same content as in the parent class
     * Declare it here to allow override mapProductLinkToBundleSelectionModel which is private
     *
     * @inheritDoc
     *
     * @SuppressWarnings(PHPMD.CyclomaticComplexity)
     * @SuppressWarnings(PHPMD.NPathComplexity)
     */
    public function saveChild(
        $sku,
        LinkInterface $linkedProduct
    ) {
        $product = $this->productRepository->get($sku, true);
        if ($product->getTypeId() != Product\Type::TYPE_BUNDLE) {
            throw new InputException(
                __('The product with the "%1" SKU isn\'t a bundle product.', [$product->getSku()])
            );
        }

        /** @var Product $linkProductModel */
        $linkProductModel = $this->productRepository->get($linkedProduct->getSku());
        if ($linkProductModel->isComposite()) {
            throw new InputException(__('The bundle product can\'t contain another composite product.'));
        }

        if (!$linkedProduct->getId()) {
            throw new InputException(__('The product link needs an ID field entered. Enter and try again.'));
        }

        /** @var Selection $selectionModel */
        $selectionModel = $this->bundleSelection->create();
        $selectionModel->load($linkedProduct->getId());
        if (!$selectionModel->getId()) {
            throw new InputException(
                __(
                    'The product link with the "%1" ID field wasn\'t found. Verify the ID and try again.',
                    [$linkedProduct->getId()]
                )
            );
        }
        $selectionModel = $this->mapProductLinkToBundleSelectionModel(
            $selectionModel,
            $linkedProduct,
            $product,
            (int)$linkProductModel->getId()
        );

        try {
            $selectionModel->save();
        } catch (\Exception $e) {
            throw new CouldNotSaveException(__('Could not save child: "%1"', $e->getMessage()), $e);
        }

        return true;
    }

    /**
     * This method has same content as in the parent class
     * Declare it here to allow override mapProductLinkToBundleSelectionModel which is private
     *
     * @inheritDoc
     *
     * @SuppressWarnings(PHPMD.CyclomaticComplexity)
     */
    public function addChild(
        ProductInterface $product,
        $optionId,
        LinkInterface $linkedProduct
    ) {
        if ($product->getTypeId() != Product\Type::TYPE_BUNDLE) {
            throw new InputException(
                __('The product with the "%1" SKU isn\'t a bundle product.', $product->getSku())
            );
        }

        $linkField = $this->metadataPool->getMetadata(ProductInterface::class)->getLinkField();

        $options = $this->optionCollection->create();

        $options->setIdFilter($optionId);
        $options->setProductLinkFilter($product->getData($linkField));

        $existingOption = $options->getFirstItem();

        if (!$existingOption->getId()) {
            throw new InputException(
                __(
                    'Product with specified sku: "%1" does not contain option: "%2"',
                    [$product->getSku(), $optionId]
                )
            );
        }

        /* @var $resource Bundle */
        $resource = $this->bundleFactory->create();
        $selections = $resource->getSelectionsData($product->getData($linkField));
        /** @var Product $linkProductModel */
        $linkProductModel = $this->productRepository->get($linkedProduct->getSku());
        if ($linkProductModel->isComposite()) {
            throw new InputException(__('The bundle product can\'t contain another composite product.'));
        }

        if ($selections) {
            foreach ($selections as $selection) {
                if ($selection['option_id'] == $optionId &&
                    $selection['product_id'] == $linkProductModel->getEntityId() &&
                    $selection['parent_product_id'] == $product->getData($linkField)) {
                    if (!$product->getCopyFromView()) {
                        throw new CouldNotSaveException(
                            __(
                                'Child with specified sku: "%1" already assigned to product: "%2"',
                                [$linkedProduct->getSku(), $product->getSku()]
                            )
                        );
                    }

                    return $this->bundleSelection->create()->load($linkProductModel->getEntityId());
                }
            }
        }

        $selectionModel = $this->bundleSelection->create();
        $selectionModel = $this->mapProductLinkToBundleSelectionModel(
            $selectionModel,
            $linkedProduct,
            $product,
            (int)$linkProductModel->getEntityId()
        );

        $selectionModel->setOptionId($optionId);

        try {
            $selectionModel->save();
            $resource->addProductRelation($product->getData($linkField), $linkProductModel->getEntityId());
        } catch (\Exception $e) {
            throw new CouldNotSaveException(__('Could not save child: "%1"', $e->getMessage()), $e);
        }

        return (int)$selectionModel->getId();
    }

    /**
     * Fill selection model with product link data.
     * Save selection image value
     *
     * @param Selection $selectionModel
     * @param LinkInterface $productLink
     * @param ProductInterface $parentProduct
     * @param int $linkedProductId
     * @return Selection
     * @throws FileSystemException
     * @throws NoSuchEntityException
     */
    private function mapProductLinkToBundleSelectionModel(
        Selection $selectionModel,
        LinkInterface $productLink,
        ProductInterface $parentProduct,
        int $linkedProductId
    ): Selection {
        $linkField = $this->metadataPool->getMetadata(ProductInterface::class)->getLinkField();
        $selectionModel->setProductId($linkedProductId);
        $selectionModel->setParentProductId($parentProduct->getData($linkField));
        if ($productLink->getSelectionId() !== null) {
            $selectionModel->setSelectionId($productLink->getSelectionId());
        }
        if ($productLink->getOptionId() !== null) {
            $selectionModel->setOptionId($productLink->getOptionId());
        }
        if ($productLink->getPosition() !== null) {
            $selectionModel->setPosition($productLink->getPosition());
        }
        if ($productLink->getQty() !== null) {
            $selectionModel->setSelectionQty($productLink->getQty());
        }
        if ($productLink->getPriceType() !== null) {
            $selectionModel->setSelectionPriceType($productLink->getPriceType());
        }
        if ($productLink->getPrice() !== null) {
            $selectionModel->setSelectionPriceValue($productLink->getPrice());
        }
        if ($productLink->getCanChangeQuantity() !== null) {
            $selectionModel->setSelectionCanChangeQty($productLink->getCanChangeQuantity());
        }
        if ($productLink->getIsDefault() !== null) {
            $selectionModel->setIsDefault($productLink->getIsDefault());
        }
        $selectionModel->setWebsiteId((int)$this->storeManager->getStore($parentProduct->getStoreId())->getWebsiteId());

        // Start customize
        $selectionImage = $this->getSelectionImage($parentProduct, $productLink->getSelectionImage());
        $selectionModel->setSelectionImage($selectionImage);
        // End customize

        return $selectionModel;
    }

    /**
     * Move selection image file and get selection image path
     * @param $parentProduct
     * @param $selectionImageData
     * @return string|null
     * @throws FileSystemException
     */
    private function getSelectionImage($parentProduct, $selectionImageData)
    {
        $selectionImage = null;
        if ($selectionImageData !== null) {
            $baseMediaDir = $parentProduct->getStore()->getBaseMediaDir();
            $selectionImageName = $selectionImageData[0]['name'];
            if (isset($selectionImageData[0]['tmp_name'])) {
                // Save new uploaded selection image
                try {
                    $newImgRelativePath = $this->imageUploader->moveFileFromTmp(
                        $selectionImageName,
                        true
                    );
                    $selectionImage = $baseMediaDir . '/' . $newImgRelativePath;
                } catch (\Exception $e) {
                    $message = __('Saving selection image: ') . $e->getMessage();
                    $this->messageManager->addErrorMessage($message);
                }
            } else {
                // Save existing selection image
                /**
                 * It was converted in Modifier for rendering in the admin interface:
                 * Magetu\BundleSelectionImage\Ui\DataProvider\Product\Form\Modifier\SelectionImage::modifyData
                 */
                $selectionImage = $baseMediaDir . '/' . $this->imageUploader->getBasePath() . '/' . $selectionImageName;
            }
        }

        return $selectionImage;
    }
}

Full module directory structure

The module is now complete. Your module’s directory structure under app/code should look like the following:

Magetu
|-- BundleSelectionImage
    |
    |-- Controller
    |   |-- Adminhtml
    |       |-- Product
    |           |-- Image
    |               |-- Upload.php
    |-- etc
    |   |-- adminhtml
    |   |   |-- di.xml
    |   |   |-- routes.xml
    |   |-- db_schema.xml
    |   |-- module.xml
    |-- Model
    |   |-- LinkManagement.php
    |-- Ui
    |   |-- DataProvider
    |       |-- Product
    |           |-- Form
    |               |-- Modifier
    |                   |-- SelectionImage.php
    |-- view
    |   |-- adminhtml
    |       |-- web
    |           |-- template
    |               |-- form
    |                   |-- element
    |                       |-- uploader
    |                           |-- image.html
    |-- registration.php

Installing the module

Run the following commands to register the module, upgrade database, compile code and deploy static content:

bin/magento setup:upgrade
bin/magento setup:di:compile
bin/magento setup:static-content:deploy -f

enter image description here

  1. Add a new field for selection image to catalog_product_bundle_selection table. Assuming you use Magento version >= 2.3, create <your module dir>/etc/db_schema.xml
<?xml version="1.0"?>
<schema xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="urn:magento:framework:Setup/Declaration/Schema/etc/schema.xsd">
    <table name="catalog_product_bundle_selection">
        <column xsi:type="varchar" name="selection_image" nullable="true" length="255" comment="Selection Image"/>
    </table>
</schema>
  1. In your custom BundleCustomOptions Modifier, add uploaderConfig to your selection image field to point to the uploader controller.
'uploaderConfig' => [
    'url' => $this->urlBuilder->getUrl(
        'bundleselectionimage/product_image/upload'
    ),
],
  1. Add an upload controller to handle selection image upload.
  2. Override Magento\Bundle\Model\LinkManagement to save the selection image value to database.
    At the end of the mapProductLinkToBundleSelectionModel method, before return $selectionModel;, adding the below code:
if ($productLink->getSelectionImage() !== null) {
    $selectionModel->setSelectionImage($productLink->getSelectionImage());
}
  1. Run the commands to upgrade database, re-compile code, re-deploy static content.

In this post, I'll give instructions on how to create and save the product custom selection image step-by-step.

For the purposes of this tutorial Magetu is the name of the vendor associated with a module named BundleSelectionImage.

  1. Create registration.php:
    File path: app/code/Magetu/BundleSelectionImage/registration.php
<?php

use Magento\Framework\Component\ComponentRegistrar;

ComponentRegistrar::register(ComponentRegistrar::MODULE, 'Magetu_BundleSelectionImage', __DIR__);

  1. Create module.xml:
    File path: app/code/Magetu/BundleSelectionImage/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="Magetu_BundleSelectionImage">
        <sequence>
            <module name="Magento_Bundle"/>
            <module name="Magento_Catalog"/>
        </sequence>
    </module>
</config>

  1. Create db_schema.xml to add a new column for bundle selection image in catalog_product_bundle_selection table.

File path: app/code/Magetu/BundleSelectionImage/etc/db_schema.xml

<?xml version="1.0"?>
<schema xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="urn:magento:framework:Setup/Declaration/Schema/etc/schema.xsd">
    <table name="catalog_product_bundle_selection">
        <column xsi:type="varchar" name="selection_image" nullable="true" length="255" comment="Selection Image"/>
    </table>
</schema>
  1. Create the di.xml file to:
  • define a modifier to modify bundle selection structure to add the selection image field
  • config the Upload class
  • override Magento\Bundle\Model\LinkManagement class to save the bundle selection image value

File path: app/code/Magetu/BundleSelectionImage/etc/adminhtml/di.xml

<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">
    <virtualType name="Magento\Catalog\Ui\DataProvider\Product\Form\Modifier\Pool">
        <arguments>
            <argument name="modifiers" xsi:type="array">
                <item name="bundle_selection_image" xsi:type="array">
                    <item name="class" xsi:type="string">Magetu\BundleSelectionImage\Ui\DataProvider\Product\Form\Modifier\SelectionImage</item>
                    <item name="sortOrder" xsi:type="number">181</item><!-- run after core bundle modifier -->
                </item>
            </argument>
        </arguments>
    </virtualType>

    <virtualType name="Magetu\BundleSelectionImage\ImageUpload" type="Magento\Catalog\Model\ImageUploader">
        <arguments>
            <argument name="baseTmpPath" xsi:type="string">catalog/tmp/product/selection_images</argument>
            <argument name="basePath" xsi:type="string">catalog/product/selection_images</argument>
            <argument name="allowedExtensions" xsi:type="array">
                <item name="jpg" xsi:type="string">jpg</item>
                <item name="jpeg" xsi:type="string">jpeg</item>
                <item name="gif" xsi:type="string">gif</item>
                <item name="png" xsi:type="string">png</item>
                <item name="svg" xsi:type="string">svg</item>
                <item name="svgz" xsi:type="string">svgz</item>
                <item name="webp" xsi:type="string">webp</item>
                <item name="avif" xsi:type="string">avif</item>
                <item name="avifs" xsi:type="string">avifs</item>
            </argument>
            <argument name="allowedMimeTypes" xsi:type="array">
                <item name="jpg" xsi:type="string">image/jpg</item>
                <item name="jpeg" xsi:type="string">image/jpeg</item>
                <item name="gif" xsi:type="string">image/gif</item>
                <item name="png" xsi:type="string">image/png</item>
                <item name="svg" xsi:type="string">image/svg+xml</item>
                <item name="svgz" xsi:type="string">image/svg+xml</item>
                <item name="webp" xsi:type="string">image/webp</item>
                <item name="avif" xsi:type="string">image/avif</item>
                <item name="avifs" xsi:type="string">image/avif-sequence</item>
            </argument>
        </arguments>
    </virtualType>

    <type name="Magetu\BundleSelectionImage\Controller\Adminhtml\Product\Image\Upload">
        <arguments>
            <argument name="imageUploader" xsi:type="object">Magetu\BundleSelectionImage\ImageUpload</argument>
        </arguments>
    </type>

    <preference for="Magento\Bundle\Model\LinkManagement" type="Magetu\BundleSelectionImage\Model\LinkManagement"/>

    <type name="Magetu\BundleSelectionImage\Model\LinkManagement">
        <arguments>
            <argument name="imageUploader" xsi:type="object">Magetu\BundleSelectionImage\ImageUpload</argument>
        </arguments>
    </type>
</config>

  1. Create routes.xml to use the upload controller.

File path: app/code/Magetu/BundleSelectionImage/etc/adminhtml/routes.xml

<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:App/etc/routes.xsd">
    <router id="admin">
        <route id="bundleselectionimage" frontName="bundleselectionimage">
            <module name="Magetu_BundleSelectionImage" before="Magento_Backend"/>
        </route>
    </router>
</config>

  1. Create modifier class which defined in di.xml in the step 4 to modify bundle selection structure to add the selection image field:

File path: app/code/Magetu/BundleSelectionImage/Ui/DataProvider/Product/Form/Modifier/SelectionImage.php

<?php
namespace Magetu\BundleSelectionImage\Ui\DataProvider\Product\Form\Modifier;

use Magento\Bundle\Model\Product\Type;
use Magento\Bundle\Model\ResourceModel\Selection as SelectionResource;
use Magento\Bundle\Model\SelectionFactory;
use Magento\Bundle\Ui\DataProvider\Product\Form\Modifier\BundlePanel;
use Magento\Catalog\Model\Category\FileInfo;
use Magento\Catalog\Model\Locator\LocatorInterface;
use Magento\Catalog\Ui\DataProvider\Product\Form\Modifier\AbstractModifier;
use Magento\Framework\UrlInterface;

class SelectionImage extends AbstractModifier
{
    public const FIELD_IMAGE = 'selection_image';

    /**
     * @var LocatorInterface
     */
    private $locator;

    /**
     * @var UrlInterface
     */
    private $urlBuilder;

    /**
     * @var FileInfo
     */
    private $fileInfo;

    /**
     * @var SelectionFactory
     */
    private $selectionFactory;

    /**
     * @var SelectionResource
     */
    private $selectionResource;

    public function __construct(
        LocatorInterface $locator,
        UrlInterface $urlBuilder,
        FileInfo $fileInfo,
        SelectionFactory $selectionFactory,
        SelectionResource $selectionResource
    ) {
        $this->locator = $locator;
        $this->urlBuilder = $urlBuilder;
        $this->fileInfo = $fileInfo;
        $this->selectionFactory = $selectionFactory;
        $this->selectionResource = $selectionResource;
    }

    /**
     * {@inheritdoc}
     *
     * Converts selection image data to acceptable for rendering format
     * Display selection image
     */
    public function modifyData(array $data)
    {
        $product = $this->locator->getProduct();
        $modelId = $product->getId();
        $isBundleProduct = $product->getTypeId() === Type::TYPE_CODE;
        if ($isBundleProduct && $modelId) {
            $selectionModel = $this->selectionFactory->create();
            foreach ($data[$modelId][BundlePanel::CODE_BUNDLE_OPTIONS][BundlePanel::CODE_BUNDLE_OPTIONS] as &$option) {
                foreach ($option['bundle_selections'] as &$selection) {
                    $this->selectionResource->load($selectionModel, $selection['selection_id']);
                    $selectionImage = $selectionModel->getData('selection_image');
                    if ($selectionImage !== null && $this->fileInfo->isExist($selectionImage)) {
                        $stat = $this->fileInfo->getStat($selectionImage);
                        $mime = $this->fileInfo->getMimeType($selectionImage);

                        // phpcs:ignore Magento2.Functions.DiscouragedFunction
                        $imageUrl = $product->getStore()->getBaseUrl() . $selectionImage;
                        $imageRendering = [];
                        $imageRendering[0]['name'] = basename($selectionImage);
                        $imageRendering[0]['url'] = $imageUrl;
                        $imageRendering[0]['size'] = $stat['size'];
                        $imageRendering[0]['type'] = $mime;

                        $selectionModel->setData('selection_image', $imageRendering);
                    }
                    $selection['selection_image'] = $selectionModel->getData('selection_image');
                }
            }
        }

        return $data;
    }

    /**
     * Add selection image field
     *
     * @param array $meta
     * @return array
     */
    public function modifyMeta(array $meta) {
        $groupCode = BundlePanel::CODE_BUNDLE_DATA;
        $meta[$groupCode]['children']['bundle_options']['children']['record']['children']
        ['product_bundle_container']['children']['bundle_selections']
        ['children']['record']['children'][static::FIELD_IMAGE] = $this->getSelectionImageFieldConfig();

        return $meta;
    }

    /**
     * Get selection image field config
     *
     * @return array
     */
    private function getSelectionImageFieldConfig()
    {
        return [
            'arguments' => [
                'data' => [
                    'config' => [
                        'componentType' => 'imageUploader',
                        'formElement' => 'imageUploader',
                        'template' => 'Magetu_BundleSelectionImage/form/element/uploader/image',
                        'fileInputName' => static::FIELD_IMAGE,
                        'uploaderConfig' => [
                            'url' => $this->urlBuilder->getUrl(
                                'bundleselectionimage/product_image/upload'
                            ),
                        ],
                        'dataScope' => static::FIELD_IMAGE,
                        'sortOrder' => 129,// Before the last field (action_delete)
                    ],
                ],
            ],
        ];
    }
}

  1. Create image uploader html file to upload the selection image file:

File path: app/code/Magetu/BundleSelectionImage/view/adminhtml/web/template/form/element/uploader/image.html

<!--
/**
 * Custom template for upload image file in bundle selection,
 * based on Magento_Ui::form/element/uploader/image.html
 *
 * NOTE: the variable name of the upload file input name should not be declared as 'inputName'
 * to allow the upload controller get the file
 */
-->
<div class="admin__field" visible="visible" css="$data.additionalClasses">
    <div class="admin__field-control" css="'_with-tooltip': $data.tooltip">
        <div class="file-uploader image-uploader" data-role="drop-zone" css="_loading: isLoading">
            <div class="file-uploader-area">
                <input type="file" afterRender="onElementRender" attr="id: uid, name: fileInputName, multiple: isMultipleFiles" disable="disabled" />
                <label class="file-uploader-button action-default" attr="for: uid, disabled: disabled" disable="disabled" translate="'Upload'"></label>
                <render args="fallbackResetTpl" if="$data.showFallbackReset && $data.isDifferedFromDefault"></render>
                <p class="image-upload-requirements">
                    <span if="$data.maxFileSize">
                        <span translate="'Maximum file size'"></span>: <text args="formatSize($data.maxFileSize)"></text>.
                    </span>
                    <span if="$data.allowedExtensions">
                        <span translate="'Allowed file types'"></span>: <text args="getAllowedFileExtensionsInCommaDelimitedFormat()"></text>.
                    </span>
                </p>
            </div>

            <render args="tooltipTpl" if="$data.tooltip"></render>

            <div class="admin__field-note" if="$data.notice" attr="id: noticeId">
                <span html="notice"></span>
            </div>

            <label class="admin__field-error" if="error" attr="for: uid" text="error"></label>

            <each args="data: value, as: '$file'" render="$parent.getPreviewTmpl($file)"></each>

            <div if="!hasData()" class="image image-placeholder" click="triggerImageUpload">
                <div class="file-uploader-summary product-image-wrapper">
                    <div class="file-uploader-spinner image-uploader-spinner"></div>
                    <p class="image-placeholder-text" translate="'Browse to find or drag image here'"></p>
                </div>
            </div>
        </div>
        <render args="$data.service.template" if="$data.hasService()"></render>
    </div>
</div>

  1. Create the uploader controller to handle upload the selection image file:

File path: app/code/Magetu/BundleSelectionImage/Controller/Adminhtml/Product/Image/Upload.php

<?php
namespace Magetu\BundleSelectionImage\Controller\Adminhtml\Product\Image;

use Magento\Backend\App\Action as BackendAction;
use Magento\Backend\App\Action\Context;
use Magento\Catalog\Model\ImageUploader;
use Magento\Framework\App\Action\HttpPostActionInterface;
use Magento\Framework\Controller\ResultFactory;
use Magento\Framework\Controller\ResultInterface;

/**
 * The bundle selection image upload controller
 */
class Upload extends BackendAction implements HttpPostActionInterface
{
    /**
     * Authorization level of a basic admin session
     *
     * @see _isAllowed()
     */
    public const ADMIN_RESOURCE = 'Magento_Catalog::products';

    /**
     * @var ImageUploader
     */
    private $imageUploader;

    public function __construct(
        Context $context,
        ImageUploader $imageUploader
    ) {
        parent::__construct($context);
        $this->imageUploader = $imageUploader;
    }

    /**
     * Upload selection image file controller action
     */
    public function execute(): ResultInterface
    {
        $imageId = $this->_request->getParam('param_name', 'selection_image');
        try {
            $result = $this->imageUploader->saveFileToTmpDir($imageId);
        } catch (\Exception $e) {
            $result = ['error' => $e->getMessage(), 'errorcode' => $e->getCode()];
        }
        return $this->resultFactory->create(ResultFactory::TYPE_JSON)->setData($result);
    }
}

  1. Create LinkManagement model class to override Magento\Bundle\Model\LinkManagement class to save the bundle selection image value in the database:
    File path: app/code/Magetu/BundleSelectionImage/Model/LinkManagement.php

Due to Magento 2.4.3 replaced the code method to map product link to bundle selection model, the content of our LinkManagement model class should be different:

  • For Magento version >= 2.4.3
<?php
namespace Magetu\BundleSelectionImage\Model;

use Magento\Bundle\Api\Data\LinkInterface;
use Magento\Bundle\Api\Data\LinkInterfaceFactory;
use Magento\Bundle\Model\ResourceModel\Bundle;
use Magento\Bundle\Model\ResourceModel\BundleFactory;
use Magento\Bundle\Model\ResourceModel\Option\CollectionFactory;
use Magento\Bundle\Model\Selection;
use Magento\Bundle\Model\SelectionFactory;
use Magento\Catalog\Api\Data\ProductInterface;
use Magento\Catalog\Api\ProductRepositoryInterface;
use Magento\Catalog\Model\ImageUploader;
use Magento\Catalog\Model\Product;
use Magento\Framework\Api\DataObjectHelper;
use Magento\Framework\App\Filesystem\DirectoryList;
use Magento\Framework\EntityManager\MetadataPool;
use Magento\Framework\Exception\CouldNotSaveException;
use Magento\Framework\Exception\FileSystemException;
use Magento\Framework\Exception\InputException;
use Magento\Framework\Exception\NoSuchEntityException;
use Magento\Framework\Filesystem;
use Magento\Framework\Filesystem\Directory\WriteInterface;
use Magento\Framework\Message\Manager as MessageManager;
use Magento\Store\Model\StoreManagerInterface;

class LinkManagement extends \Magento\Bundle\Model\LinkManagement
{
    /**
     * @var StoreManagerInterface
     */
    private $storeManager;

    /**
     * @var MetadataPool
     */
    private $metadataPool;

    /**
     * @var ImageUploader
     */
    private $imageUploader;

    /**
     * @var MessageManager
     */
    private $messageManager;

    public function __construct(
        ProductRepositoryInterface $productRepository,
        LinkInterfaceFactory $linkFactory,
        SelectionFactory $bundleSelection,
        BundleFactory $bundleFactory,
        CollectionFactory $optionCollection,
        StoreManagerInterface $storeManager,
        DataObjectHelper $dataObjectHelper,
        MetadataPool $metadataPool,
        ImageUploader $imageUploader,
        MessageManager $messageManager
    ) {
        parent::__construct(
            $productRepository,
            $linkFactory,
            $bundleSelection,
            $bundleFactory,
            $optionCollection,
            $storeManager,
            $dataObjectHelper,
            $metadataPool
        );
        $this->storeManager = $storeManager;
        $this->metadataPool = $metadataPool;
        $this->imageUploader = $imageUploader;
        $this->messageManager = $messageManager;
    }

    /**
     * This method has same content as in the parent class
     * Declare it here to allow override mapProductLinkToBundleSelectionModel which is private
     *
     * @inheritDoc
     *
     * @SuppressWarnings(PHPMD.CyclomaticComplexity)
     * @SuppressWarnings(PHPMD.NPathComplexity)
     */
    public function saveChild(
        $sku,
        LinkInterface $linkedProduct
    ) {
        $product = $this->productRepository->get($sku, true);
        if ($product->getTypeId() != Product\Type::TYPE_BUNDLE) {
            throw new InputException(
                __('The product with the "%1" SKU isn\'t a bundle product.', [$product->getSku()])
            );
        }

        /** @var Product $linkProductModel */
        $linkProductModel = $this->productRepository->get($linkedProduct->getSku());
        if ($linkProductModel->isComposite()) {
            throw new InputException(__('The bundle product can\'t contain another composite product.'));
        }

        if (!$linkedProduct->getId()) {
            throw new InputException(__('The product link needs an ID field entered. Enter and try again.'));
        }

        /** @var Selection $selectionModel */
        $selectionModel = $this->bundleSelection->create();
        $selectionModel->load($linkedProduct->getId());
        if (!$selectionModel->getId()) {
            throw new InputException(
                __(
                    'The product link with the "%1" ID field wasn\'t found. Verify the ID and try again.',
                    [$linkedProduct->getId()]
                )
            );
        }
        $selectionModel = $this->mapProductLinkToBundleSelectionModel(
            $selectionModel,
            $linkedProduct,
            $product,
            (int)$linkProductModel->getId()
        );

        try {
            $selectionModel->save();
        } catch (\Exception $e) {
            throw new CouldNotSaveException(__('Could not save child: "%1"', $e->getMessage()), $e);
        }

        return true;
    }

    /**
     * This method has same content as in the parent class
     * Declare it here to allow override mapProductLinkToBundleSelectionModel which is private
     *
     * @inheritDoc
     *
     * @SuppressWarnings(PHPMD.CyclomaticComplexity)
     */
    public function addChild(
        ProductInterface $product,
        $optionId,
        LinkInterface $linkedProduct
    ) {
        if ($product->getTypeId() != Product\Type::TYPE_BUNDLE) {
            throw new InputException(
                __('The product with the "%1" SKU isn\'t a bundle product.', $product->getSku())
            );
        }

        $linkField = $this->metadataPool->getMetadata(ProductInterface::class)->getLinkField();

        $options = $this->optionCollection->create();

        $options->setIdFilter($optionId);
        $options->setProductLinkFilter($product->getData($linkField));

        $existingOption = $options->getFirstItem();

        if (!$existingOption->getId()) {
            throw new InputException(
                __(
                    'Product with specified sku: "%1" does not contain option: "%2"',
                    [$product->getSku(), $optionId]
                )
            );
        }

        /* @var $resource Bundle */
        $resource = $this->bundleFactory->create();
        $selections = $resource->getSelectionsData($product->getData($linkField));
        /** @var Product $linkProductModel */
        $linkProductModel = $this->productRepository->get($linkedProduct->getSku());
        if ($linkProductModel->isComposite()) {
            throw new InputException(__('The bundle product can\'t contain another composite product.'));
        }

        if ($selections) {
            foreach ($selections as $selection) {
                if ($selection['option_id'] == $optionId &&
                    $selection['product_id'] == $linkProductModel->getEntityId() &&
                    $selection['parent_product_id'] == $product->getData($linkField)) {
                    if (!$product->getCopyFromView()) {
                        throw new CouldNotSaveException(
                            __(
                                'Child with specified sku: "%1" already assigned to product: "%2"',
                                [$linkedProduct->getSku(), $product->getSku()]
                            )
                        );
                    }

                    return $this->bundleSelection->create()->load($linkProductModel->getEntityId());
                }
            }
        }

        $selectionModel = $this->bundleSelection->create();
        $selectionModel = $this->mapProductLinkToBundleSelectionModel(
            $selectionModel,
            $linkedProduct,
            $product,
            (int)$linkProductModel->getEntityId()
        );

        $selectionModel->setOptionId($optionId);

        try {
            $selectionModel->save();
            $resource->addProductRelation($product->getData($linkField), $linkProductModel->getEntityId());
        } catch (\Exception $e) {
            throw new CouldNotSaveException(__('Could not save child: "%1"', $e->getMessage()), $e);
        }

        return (int)$selectionModel->getId();
    }

    /**
     * Fill selection model with product link data.
     * Save selection image value
     *
     * @param Selection $selectionModel
     * @param LinkInterface $productLink
     * @param ProductInterface $parentProduct
     * @param int $linkedProductId
     * @return Selection
     * @throws FileSystemException
     * @throws NoSuchEntityException
     */
    private function mapProductLinkToBundleSelectionModel(
        Selection $selectionModel,
        LinkInterface $productLink,
        ProductInterface $parentProduct,
        int $linkedProductId
    ): Selection {
        $linkField = $this->metadataPool->getMetadata(ProductInterface::class)->getLinkField();
        $selectionModel->setProductId($linkedProductId);
        $selectionModel->setParentProductId($parentProduct->getData($linkField));
        if ($productLink->getSelectionId() !== null) {
            $selectionModel->setSelectionId($productLink->getSelectionId());
        }
        if ($productLink->getOptionId() !== null) {
            $selectionModel->setOptionId($productLink->getOptionId());
        }
        if ($productLink->getPosition() !== null) {
            $selectionModel->setPosition($productLink->getPosition());
        }
        if ($productLink->getQty() !== null) {
            $selectionModel->setSelectionQty($productLink->getQty());
        }
        if ($productLink->getPriceType() !== null) {
            $selectionModel->setSelectionPriceType($productLink->getPriceType());
        }
        if ($productLink->getPrice() !== null) {
            $selectionModel->setSelectionPriceValue($productLink->getPrice());
        }
        if ($productLink->getCanChangeQuantity() !== null) {
            $selectionModel->setSelectionCanChangeQty($productLink->getCanChangeQuantity());
        }
        if ($productLink->getIsDefault() !== null) {
            $selectionModel->setIsDefault($productLink->getIsDefault());
        }
        $selectionModel->setWebsiteId((int)$this->storeManager->getStore($parentProduct->getStoreId())->getWebsiteId());

        // Start customize
        $selectionImage = $this->getSelectionImage($parentProduct, $productLink->getSelectionImage());
        $selectionModel->setSelectionImage($selectionImage);
        // End customize

        return $selectionModel;
    }

    /**
     * Move selection image file and get selection image path
     * @param $parentProduct
     * @param $selectionImageData
     * @return string|null
     * @throws FileSystemException
     */
    private function getSelectionImage($parentProduct, $selectionImageData)
    {
        $selectionImage = null;
        if ($selectionImageData !== null) {
            $baseMediaDir = $parentProduct->getStore()->getBaseMediaDir();
            $selectionImageName = $selectionImageData[0]['name'];
            if (isset($selectionImageData[0]['tmp_name'])) {
                // Save new uploaded selection image
                try {
                    $newImgRelativePath = $this->imageUploader->moveFileFromTmp(
                        $selectionImageName,
                        true
                    );
                    $selectionImage = $baseMediaDir . '/' . $newImgRelativePath;
                } catch (\Exception $e) {
                    $message = __('Saving selection image: ') . $e->getMessage();
                    $this->messageManager->addErrorMessage($message);
                }
            } else {
                // Save existing selection image
                /**
                 * It was converted in Modifier for rendering in the admin interface:
                 * Magetu\BundleSelectionImage\Ui\DataProvider\Product\Form\Modifier\SelectionImage::modifyData
                 */
                $selectionImage = $baseMediaDir . '/' . $this->imageUploader->getBasePath() . '/' . $selectionImageName;
            }
        }

        return $selectionImage;
    }
}

Full module directory structure

The module is now complete. Your module’s directory structure under app/code should look like the following:

Magetu
|-- BundleSelectionImage
    |
    |-- Controller
    |   |-- Adminhtml
    |       |-- Product
    |           |-- Image
    |               |-- Upload.php
    |-- etc
    |   |-- adminhtml
    |   |   |-- di.xml
    |   |   |-- routes.xml
    |   |-- db_schema.xml
    |   |-- module.xml
    |-- Model
    |   |-- LinkManagement.php
    |-- Ui
    |   |-- DataProvider
    |       |-- Product
    |           |-- Form
    |               |-- Modifier
    |                   |-- SelectionImage.php
    |-- view
    |   |-- adminhtml
    |       |-- web
    |           |-- template
    |               |-- form
    |                   |-- element
    |                       |-- uploader
    |                           |-- image.html
    |-- registration.php

Installing the module

Run the following commands to register the module, upgrade database, compile code and deploy static content:

bin/magento setup:upgrade
bin/magento setup:di:compile
bin/magento setup:static-content:deploy -f

enter image description here

Source Link
Tu Van
  • 7.8k
  • 2
  • 14
  • 24
Loading