Add image upload field to Magento 2 UI component form

This post is about adding image upload field/attribute to Admin CMS page.

Start with creating a new module. In our case it is Magebrew_ImageUploadFormField.

We need to add a new field for storing image path to cms_page table. Let’s use declarative schema for this purpose (available since Magento 2.3). Create app/code/Magebrew/ImageUploadFormField/etc/db_schema.xml file with following content:

<?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="cms_page" resource="default" engine="innodb" comment="CMS Page Table">
        <column xsi:type="varchar" name="banner_image" nullable="true" length="255" comment="Banner Image"/>
    </table>
</schema>

With declarative schema we also need to add db_schema_whitelist.json file:

{
    "cms_page": {
        "column": {
            "banner_image": true
        }
    }
}

After running bin/magento setup:upgrade we should have new banner_image field added to cms_page table.

Next step is to add field to form. Create view/adminhtml/ui_component/cms_page_form.xml with the following code:

<?xml version="1.0"?>
<form xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Ui:etc/ui_configuration.xsd">
    <fieldset name="general">
        <field name="banner_image">
            <argument name="data" xsi:type="array">
                <item name="config" xsi:type="array">
                    <item name="dataType" xsi:type="string">string</item>
                    <item name="source" xsi:type="string">page</item>
                    <item name="label" xsi:type="string" translate="true">Banner Image</item>
                    <item name="visible" xsi:type="boolean">true</item>
                    <item name="formElement" xsi:type="string">fileUploader</item>
                    <item name="elementTmpl" xsi:type="string">ui/form/element/uploader/uploader</item>
                    <item name="previewTmpl" xsi:type="string">Magento_Catalog/image-preview</item>
                    <item name="baseTmpPath" xsi:type="string">cms/tmp</item>
                    <item name="required" xsi:type="boolean">false</item>
                    <item name="sortOrder" xsi:type="number">60</item>
                    <item name="uploaderConfig" xsi:type="array">
                        <item name="url" xsi:type="url" path="cmsbanner/cmspage_banner/upload"/>
                    </item>
                </item>
            </argument>
        </field>
    </fieldset>
</form>

Now CMS page form should contain new field.

Next we create image uploader model where we define some settings and how image is saved. File Model/BannerUploader.php

<?php

declare(strict_types=1);

namespace Magebrew\ImageUploadFormField\Model;

use Magento\Framework\App\Filesystem\DirectoryList;
use Magento\Framework\Exception\LocalizedException;
use Magento\Framework\Filesystem;
use Magento\Framework\UrlInterface;
use Magento\MediaStorage\Helper\File\Storage\Database;
use Magento\MediaStorage\Model\File\UploaderFactory;
use Magento\Store\Model\StoreManagerInterface;
use Psr\Log\LoggerInterface;

/**
 * Class BannerUploader
 */
class BannerUploader
{
    /**
     * @var string
     */
    const IMAGE_TMP_PATH = 'cmsbanner/tmp';

    /**
     * @var string
     */
    const IMAGE_PATH = 'cmsbanner';

    /**
     * @var string
     */
    const FILE_TMP_PATH = 'cmsbanner/tmp/images/file';

    /**
     * @var string
     */
    const FILE_PATH = 'cmsbanner/images/file';

    /**
     * Core file storage database
     *
     * @var \Magento\MediaStorage\Helper\File\Storage\Database
     */
    protected $coreFileStorageDatabase;

    /**
     * Media directory object (writable).
     *
     * @var \Magento\Framework\Filesystem\Directory\WriteInterface
     */
    protected $mediaDirectory;

    /**
     * Store manager
     *
     * @var \Magento\Store\Model\StoreManagerInterface
     */
    protected $storeManager;

    /**
     * @var \Psr\Log\LoggerInterface
     */
    protected $logger;

    /**
     * Base tmp path
     *
     * @var string
     */
    protected $baseTmpPath;

    /**
     * Base path
     *
     * @var string
     */
    protected $basePath;

    /**
     * Allowed extensions
     *
     * @var string
     */
    protected $allowedExtensions;

    /**
     * Uploader factory
     *
     * @var \Magento\MediaStorage\Model\File\UploaderFactory
     */
    private $uploaderFactory;

    /**
     * @param Database $coreFileStorageDatabase
     * @param Filesystem $filesystem
     * @param UploaderFactory $uploaderFactory
     * @param StoreManagerInterface $storeManager
     * @param LoggerInterface $logger
     * @param $baseTmpPath
     * @param $basePath
     * @param array $allowedExtensions
     * @throws \Magento\Framework\Exception\FileSystemException
     */
    public function __construct(
        Database $coreFileStorageDatabase,
        Filesystem $filesystem,
        UploaderFactory $uploaderFactory,
        StoreManagerInterface $storeManager,
        LoggerInterface $logger,
        $baseTmpPath,
        $basePath,
        $allowedExtensions = []
    ) {
        $this->coreFileStorageDatabase = $coreFileStorageDatabase;
        $this->mediaDirectory = $filesystem->getDirectoryWrite(DirectoryList::MEDIA);
        $this->uploaderFactory = $uploaderFactory;
        $this->storeManager = $storeManager;
        $this->logger = $logger;
        $this->baseTmpPath = $baseTmpPath;
        $this->basePath = $basePath;
        $this->allowedExtensions = $allowedExtensions;
    }

    /**
     * Retrieve base tmp path
     *
     * @return string
     */
    public function getBaseTmpPath()
    {
        return $this->baseTmpPath;
    }

    /**
     * Set base tmp path
     *
     * @param string $baseTmpPath
     *
     * @return void
     */
    public function setBaseTmpPath($baseTmpPath)
    {
        $this->baseTmpPath = $baseTmpPath;
    }

    /**
     * Retrieve base path
     *
     * @return string
     */
    public function getBasePath()
    {
        return $this->basePath;
    }

    /**
     * Set base path
     *
     * @param string $basePath
     *
     * @return void
     */
    public function setBasePath($basePath)
    {
        $this->basePath = $basePath;
    }

    /**
     * Retrieve base path
     *
     * @return string[]
     */
    public function getAllowedExtensions()
    {
        return $this->allowedExtensions;
    }

    /**
     * Set allowed extensions
     *
     * @param string[] $allowedExtensions
     *
     * @return void
     */
    public function setAllowedExtensions($allowedExtensions)
    {
        $this->allowedExtensions = $allowedExtensions;
    }

    /**
     * Retrieve path
     *
     * @param string $path
     * @param string $name
     *
     * @return string
     */
    public function getFilePath($path, $name)
    {
        return rtrim($path, '/') . '/' . ltrim($name, '/');
    }

    /**
     * Checking file for moving and move it
     *
     * @param string $name
     * @return string
     * @throws \Magento\Framework\Exception\LocalizedException
     */
    public function moveFileFromTmp($name)
    {
        $baseTmpPath = $this->getBaseTmpPath();
        $basePath = $this->getBasePath();
        $baseFilePath = $this->getFilePath($basePath, $name);
        $baseTmpFilePath = $this->getFilePath($baseTmpPath, $name);

        try {
            $this->coreFileStorageDatabase->copyFile(
                $baseTmpFilePath,
                $baseFilePath
            );
            $this->mediaDirectory->renameFile(
                $baseTmpFilePath,
                $baseFilePath
            );
        } catch (\Exception $e) {
            throw new LocalizedException(
                __('Something went wrong while saving the file(s).')
            );
        }

        return $name;
    }

    public function getBaseUrl()
    {
        return $this->storeManager
            ->getStore()
            ->getBaseUrl(
                UrlInterface::URL_TYPE_MEDIA
            );
    }

    /**
     * Checking file for save and save it to tmp dir
     *
     * @param string $fileId
     * @return string[]
     * @throws \Magento\Framework\Exception\LocalizedException
     */
    public function saveFileToTmpDir($fileId)
    {
        $baseTmpPath = $this->getBaseTmpPath();

        $uploader = $this->uploaderFactory->create(['fileId' => $fileId]);
        $uploader->setAllowedExtensions($this->getAllowedExtensions());
        $uploader->setAllowRenameFiles(true);
        $uploader->setFilesDispersion(false);

        $result = $uploader->save($this->mediaDirectory->getAbsolutePath($baseTmpPath));

        if (!$result) {
            throw new LocalizedException(
                __('File can not be saved to the destination folder.')
            );
        }
        /**
         * Workaround for prototype 1.7 methods "isJSON", "evalJSON" on Windows OS
         */
        $result['tmp_name'] = str_replace('\\', '/', $result['tmp_name']);
        $result['path'] = str_replace('\\', '/', $result['path']);
        $result['url'] = $this->getBaseUrl() . $this->getFilePath($baseTmpPath, $result['file']);

        if (isset($result['file'])) {
            try {
                $relativePath = rtrim($baseTmpPath, '/') . '/' . ltrim($result['file'], '/');
                $this->coreFileStorageDatabase->saveFile($relativePath);
            } catch (\Exception $e) {
                $this->logger->critical($e);
                throw new LocalizedException(
                    __('Something went wrong while saving the file(s).')
                );
            }
        }

        return $result;
    }

    /**
     * @param $input
     * @param $data
     * @return string
     */
    public function uploadFileAndGetName($input, $data)
    {
        if (!isset($data[$input])) {
            return '';
        }
        if (is_array($data[$input]) && !empty($data[$input]['delete'])) {
            return '';
        }

        if (isset($data[$input][0]['name']) && isset($data[$input][0]['tmp_name'])) {
            try {
                $result = $this->moveFileFromTmp($data[$input][0]['file']);
                return $result;
            } catch (\Exception $e) {
                return '';
            }
        } elseif (isset($data[$input][0]['name'])) {
            return $data[$input][0]['name'];
        }
        return '';
    }
}

Uploader is used in controller that responsible for saving image in filesystem. File Controller/Adminhtml/Cmspage/Banner/Upload.php

<?php

declare(strict_types=1);

namespace Magebrew\ImageUploadFormField\Controller\Adminhtml\Cmspage\Banner;

use Magebrew\ImageUploadFormField\Model\BannerUploader;
use Magento\Backend\App\Action;
use Magento\Backend\App\Action\Context;
use Magento\Framework\Controller\ResultFactory;

/**
 * Class Upload
 */
class Upload extends Action
{
    /**
     * @var string
     */
    const ACTION_RESOURCE = 'Magebrew_ImageUploadFormField::imageuploadfield';

    /**
     * @var BannerUploader
     */
    protected $uploader;

    /**
     * Upload constructor.
     *
     * @param Context $context
     * @param BannerUploader $uploader
     */
    public function __construct(
        Context $context,
        BannerUploader $uploader
    ) {
        parent::__construct($context);
        $this->uploader = $uploader;
    }

    /**
     * Upload file controller action
     *
     * @return \Magento\Framework\Controller\ResultInterface
     */
    public function execute()
    {
        try {
            $result = $this->uploader->saveFileToTmpDir('banner_image');

            $result['cookie'] = [
                'name'     => $this->_getSession()->getName(),
                'value'    => $this->_getSession()->getSessionId(),
                'lifetime' => $this->_getSession()->getCookieLifetime(),
                'path'     => $this->_getSession()->getCookiePath(),
                'domain'   => $this->_getSession()->getCookieDomain(),
            ];
        } catch (\Exception $e) {
            $result = ['error' => $e->getMessage(), 'errorcode' => $e->getCode()];
        }
        return $this->resultFactory->create(ResultFactory::TYPE_JSON)->setData($result);
    }

    /**
     * @return string
     */
    protected function getFieldName()
    {
        return $this->_request->getParam('banner_image');
    }
}

To inject uploader model to controller we define virtual type in di.xml. File etc/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="MagebrewBannerUploader" type="Magebrew\ImageUploadFormField\Model\BannerUploader">
        <arguments>
            <argument name="baseTmpPath" xsi:type="const">Magebrew\ImageUploadFormField\Model\BannerUploader::IMAGE_TMP_PATH</argument>
            <argument name="basePath" xsi:type="const">Magebrew\ImageUploadFormField\Model\BannerUploader::IMAGE_PATH</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>
            </argument>
        </arguments>
    </virtualType>
    <type name="Magebrew\ImageUploadFormField\Controller\Adminhtml\Cmspage\Banner\Upload">
        <arguments>
            <argument name="uploader" xsi:type="object">MagebrewBannerUploader</argument>
        </arguments>
    </type>
</config>

Now we need to take care of saving banner image path to cms_page table on CMS page save and retrieving existing banner image in Admin CMS form. For this purpose we create plugin for Magento\Cms\Model\PageRepository and Magento\Cms\Model\Page\DataProvider. Here is how etc/adminhtml/di.xml file looks like:

<?xml version="1.0"?>

<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">
    <type name="Magento\Cms\Model\PageRepository">
        <plugin name="magebrew_save_banner_image" type="Magebrew\ImageUploadFormField\Plugin\Cms\Model\PageRepository\BeforeSave\SaveBannerImagePlugin" />
    </type>
    <type name="Magebrew\ImageUploadFormField\Plugin\Cms\Model\PageRepository\BeforeSave\SaveBannerImagePlugin">
        <arguments>
            <argument name="uploader" xsi:type="object">MagebrewBannerUploader</argument>
        </arguments>
    </type>
    <type name="Magento\Cms\Model\Page\DataProvider">
        <plugin name="magebrew_add_banner_image_to_form" type="Magebrew\ImageUploadFormField\Plugin\Cms\Model\Page\DataProvider\AfterGetData\ModifyBannerDataPlugin"/>
    </type>
</config>

SaveBannerImagePlugin, file Plugin/Cms/Model/PageRepository/BeforeSave/SaveBannerImagePlugin.php

<?php

declare(strict_types=1);

namespace Magebrew\ImageUploadFormField\Plugin\Cms\Model\PageRepository\BeforeSave;

use Magebrew\ImageUploadFormField\Model\BannerUploader;
use Magento\Cms\Api\Data\PageInterface;
use Magento\Cms\Model\PageRepository;
use Magento\Framework\App\RequestInterface;

/**
 * Class SaveBannerImagePlugin
 */
class SaveBannerImagePlugin
{
    /**
     * @var BannerUploader
     */
    private $uploader;

    /**
     * @var RequestInterface
     */
    private $request;

    /**
     * SaveBannerImagePlugin constructor.
     * @param RequestInterface $request
     * @param BannerUploader $uploader
     */
    public function __construct(
        RequestInterface $request,
        BannerUploader $uploader
    ) {
        $this->uploader = $uploader;
        $this->request = $request;
    }

    /**
     * Save
     *
     * @param PageRepository $subject
     * @param PageInterface $page
     * @return array
     * @throws \Magento\Framework\Exception\LocalizedException
     */
    public function beforeSave(
        PageRepository $subject,
        PageInterface $page
    ): array {
        $data = $page->getData();
        $key = 'banner_image';

        if (isset($data[$key]) && is_array($data[$key])) {
            if (!empty($data[$key]['delete'])) {
                $data[$key] = null;
            } else {
                if (isset($data[$key][0]['name']) && isset($data[$key][0]['tmp_name'])) {
                    $image = $data[$key][0]['name'];

                    $image = $this->uploader->moveFileFromTmp($image);
                    $data[$key] = $image;
                } else {
                    if (isset($data[$key][0]['url'])) {
                        $data[$key] = basename($data[$key][0]['url']);
                    }
                }
            }

            $page->setData($data);
        } else {
            $data[$key] = null;
        }

        return [$page];
    }
}

ModifyBannerDataPlugin, file Plugin/Cms/Model/Page/DataProvider/AfterGetData/ModifyBannerDataPlugin.php

<?php

declare(strict_types=1);

namespace Magebrew\ImageUploadFormField\Plugin\Cms\Model\Page\DataProvider\AfterGetData;

use Magebrew\ImageUploadFormField\Model\BannerUploader;
use Magento\Cms\Model\Page\DataProvider;
use Magento\Framework\UrlInterface;
use Magento\Store\Model\StoreManagerInterface;

/**
 * Class ModifyBannerDataPlugin
 */
class ModifyBannerDataPlugin
{
    /**
     * @var StoreManagerInterface
     */
    private $storeManager;

    /**
     * ModifyBannerDataPlugin constructor.
     * @param StoreManagerInterface $storeManager
     */
    public function __construct(StoreManagerInterface $storeManager)
    {
        $this->storeManager = $storeManager;
    }

    /**
     * @param DataProvider $subject
     * @param $loadedData
     * @return array
     * @throws \Magento\Framework\Exception\NoSuchEntityException
     */
    public function afterGetData(
        DataProvider $subject,
        $loadedData
    ) {
        /** @var array $loadedData */
        if (is_array($loadedData) && count($loadedData) == 1) {
            foreach ($loadedData as $key => $item) {
                if (isset($item['banner_image']) && $item['banner_image']) {
                    $imageArr = [];
                    $imageArr[0]['name'] = 'Image';
                    $imageArr[0]['url'] = $this->storeManager->getStore()
                            ->getBaseUrl(UrlInterface::URL_TYPE_MEDIA) .
                        BannerUploader::IMAGE_PATH . DIRECTORY_SEPARATOR . $item['banner_image'];
                    $loadedData[$key]['banner_image'] = $imageArr;
                }
            }
        }

        return $loadedData;
    }
}

Actually that is it. Now we should have ability to upload and save CMS page banner image. If you also need instructions on how to display this banner on storefront check complete module on GitHub.