Приветствую вас, хабравчане! Немного набравшись смелости решил написать свою первую статью, точнее поделиться небольшим опытом, в интересной, как мне показалось теме, а именно как в динамический массив в конфиге, добавить загрузчик файлов.

Итак начнем.

Для начала создадим модуль, и базовую структуру модуля

Mr/ImageDynamicConfig/registration.php
<?php

\Magento\Framework\Component\ComponentRegistrar::register(
    \Magento\Framework\Component\ComponentRegistrar::MODULE,
    'Mr_ImageDynamicConfig',
    __DIR__
);
Mr/ImageDynamicConfig/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="Mr_ImageDynamicConfig" setup_version="1.0.0"/>
</config>

Далее начнем описывать все необходимые элементы, шаг за шагом:

И первым на очереди, создадим сам конфиг:

Mr/ImageDynamicConfig/etc/adminhtml/system.xml
<?xml version="1.0"?>

<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Config:etc/system_file.xsd">
    <system>
        <tab id="mr" translate="label" sortOrder="400">
            <label>Mr</label>
        </tab>
        <section id="swatch" translate="label" type="text" sortOrder="300" showInDefault="1" showInWebsite="1" showInStore="1">
            <class>separator-top</class>
            <label>Image Array Swatch</label>
            <tab>mr</tab>
            <resource>Mr_ImageDynamicConfig::config</resource>
            <group id="image_serializer" translate="label" type="text" sortOrder="140" showInDefault="1" showInWebsite="1" showInStore="1">
                <field id="image" translate="label" type="text" sortOrder="20" showInDefault="1" showInWebsite="1" showInStore="0">
                    <label>Image</label>
                    <frontend_model>Mr\ImageDynamicConfig\Block\Adminhtml\System\Config\ImageFields</frontend_model>
                    <backend_model>Mr\ImageDynamicConfig\Model\Config\Backend\Serialized\ArraySerialized</backend_model>
                    <upload_dir>var/uploads/swatch/image_serializer</upload_dir>
                </field>
            </group>
        </section>
    </system>
</config>

Для динамического массива строка <frontend_model>Mr\ImageDynamicConfig\Block\Adminhtml\System\Config\ImageFields</frontend_model> совсем не нова, и класс ImageFields рендерит все основные колонки и показывает как они должны выглядеть

Mr/ImageDynamicConfig/Block/Adminhtml/System/Config/ImageFields.php
<?php
declare(strict_types=1);

namespace Mr\ImageDynamicConfig\Block\Adminhtml\System\Config;

use Magento\Config\Block\System\Config\Form\Field\FieldArray\AbstractFieldArray;

class ImageFields extends AbstractFieldArray
{
    const IMAGE_FIELD = 'image';
    const NAME_FIELD = 'name';
    private $imageRenderer;

    protected function _prepareToRender()
    {
        $this->addColumn(
            self::IMAGE_FIELD,
            [
                'label' => __('Image'),
                'renderer' => $this->getImageRenderer()
            ]
        );

        $this->addColumn(
            self::NAME_FIELD,
            [
                'label' => __('Name'),
            ]
        );

        $this->_addAfter       = false;
        $this->_addButtonLabel = __('Add');
    }

    private function getImageRenderer()
    {
        if (!$this->imageRenderer) {
            $this->imageRenderer = $this->getLayout()->createBlock(
                \Mr\ImageDynamicConfig\Block\Adminhtml\Form\Field\ImageColumn::class,
                '',
                ['data' => ['is_render_to_js_template' => true]]
            );
        }
        return $this->imageRenderer;
    }
}

тут в методе _prepareToRender объявляем колонки, которые будут в динамическом массиве, и если в колонке есть поле отличное от текстового инпута, описываем для этого поля рендерер (метод getImageRenderer). На строке 38 рендерим блок \Mr\ImageDynamicConfig\Block\Adminhtml\Form\Field\ImageColumn, который и будет отдавать нам вместо инпута html - код с выбором файлов и отображением файла

Mr/ImageDynamicConfig/Block/Adminhtml/Form/Field/ImageColumn.php
<?php
declare(strict_types=1);

namespace Mr\ImageDynamicConfig\Block\Adminhtml\Form\Field;

use Mr\ImageDynamicConfig\Block\Adminhtml\ImageButton;

class ImageColumn extends \Magento\Framework\View\Element\AbstractBlock
{
    public function setInputName(string $value)
    {
        return $this->setName($value);
    }

    public function setInputId(string $value)
    {
        return $this->setId($value);
    }

    protected function _toHtml(): string
    {
        $imageButton = $this->getLayout()
            ->createBlock(ImageButton::class)
            ->setData('id', $this->getId())
            ->setData('name', $this->getName());
        return $imageButton->toHtml();
    }
}

В перегруженном методе _toHtml рендерим блок Mr\ImageDynamicConfig\Block\Adminhtml\ImageButton, который будет отдавать нам темплейт с html - кодом

Mr/ImageDynamicConfig/Block/Adminhtml//ImageButton.php
<?php
declare(strict_types=1);

namespace Mr\ImageDynamicConfig\Block\Adminhtml;

class ImageButton extends \Magento\Backend\Block\Template
{
    protected $_template = 'Mr_ImageDynamicConfig::config/array_serialize/swatch_image.phtml';

    private $assetRepository;

    public function __construct(
        \Magento\Backend\Block\Template\Context $context,
        \Magento\Framework\View\Asset\Repository $assetRepository,
        array $data = []
    ) {
        $this->assetRepository = $assetRepository;
        parent::__construct($context, $data);
    }

    public function getAssertRepository(): \Magento\Framework\View\Asset\Repository
    {
        return $this->assetRepository;
    }
}

Публичный метод getAssertRepository нам нужен, чтобы вывести полный url на css файл в темплейте.

Mr/ImageDynamicConfig/view/adminhtml/templates/config/array_serialize/swatch_image.phtml
<?php
/*** @var \Mr\ImageDynamicConfig\Block\Adminhtml\ImageButton $block */
$css = $block->getAssertRepository()->createAsset("Mr_ImageDynamicConfig::css/image_button.css");
?>
<link rel="stylesheet" type="text/css" media="all" href="<?php /* @escapeNotVerified */echo $css->getUrl() ?>"/>

<div class="upload-file" data-id="<?=$block->getId()?>">
    <div class="upload-file__block upload-file__block_first">
        <img class="upload-file__block__img" id="swatch_image_image_<?= $block->getId() ?>" src="">
    </div>
    <div class="upload-file__block">
        <input class="upload-file__input" hidden type="file" name="<?= $block->getName() ?>" id="swatch_image_input_<?= $block->getId() ?>" value=""/>
        <label class="upload-file__label" for="swatch_image_input_<?= $block->getId() ?>">
            <?= __("File") ?>
        </label>
    </div>
    <input class="upload-file__input" type="hidden" id="<?=$block->getId()?>">
</div>


<script type="text/javascript">
    require(["jquery"], function (jq) {
        jq(function () {
            const id = "<?=$block->getId()?>"
            const imageId = "swatch_image_image_<?=$block->getId()?>"
            const data = jq("#" + id).val();
            if (data) {
                jq("#" + imageId).attr("src", data)
                jq("#" + imageId).attr("value", data)
            }
        });
    });
</script>

В этом темплейте отображается инпут для загрузки, и вывода загруженной картинки. С одной стороны очень странное решение сделать скрытый инпут:

<input class="upload-file__input" type="hidden" id="<?=$block->getId()?>">

а после из него вставлять в img тег значение:

jq(function () {
            const id = "<?=$block->getId()?>"
            const imageId = "swatch_image_image_<?=$block->getId()?>"
            const data = jq("#" + id).val();
            if (data) {
                jq("#" + imageId).attr("src", data)
                jq("#" + imageId).attr("value", data)
            }
        });

Но, когда Magento рендерит форму в конфиге, чтобы вставить туда значение, она пытается найти input с id и записать в value это значение. По-этому я сделал скрытый инпут и через jquery прокинул в source img путь на картинку

Таким образом, мы разобрали frontend_model и как вывести image input в динамический массив.

Теперь рассмотрим этап - загрузки картинок.

Для этого используется backend_model, и в обычных случаях, когда нужно просто добавить динамический массив в конфиг, то прокидываем в backend_model Magento\Config\Model\Config\Backend\Serialized\ArraySerialized и на этом все наши проблемы решены, но ArraySerialized не работает с загрузкой и сохранением картинок, и по этому на его основе делаем свой array serializer

Mr/ImageDynamicConfig/Model/Config/Backend/Serialized/ArraySerialized
<?php
declare(strict_types=1);

namespace Mr\ImageDynamicConfig\Model\Config\Backend\Serialized;

use Magento\Framework\Serialize\Serializer\Json;
use Mr\ImageDynamicConfig\Block\Adminhtml\System\Config\ImageFields;

class ArraySerialized extends \Magento\Config\Model\Config\Backend\Serialized\ArraySerialized
{
    private $imageUploaderFactory;
    private $imageConfig;

    public function __construct(
        \Magento\Framework\Model\Context $context,
        \Magento\Framework\Registry $registry,
        \Magento\Framework\App\Config\ScopeConfigInterface $config,
        \Magento\Framework\App\Cache\TypeListInterface $cacheTypeList,
        \Mr\ImageDynamicConfig\Model\Config\ImageConfig $imageConfig,
        \Mr\ImageDynamicConfig\Model\ImageUploaderFactory $imageUploaderFactory,
        \Magento\Framework\Model\ResourceModel\AbstractResource $resource = null,
        \Magento\Framework\Data\Collection\AbstractDb $resourceCollection = null,
        array $data = [],
        Json $serializer = null
    ) {
        $this->imageUploaderFactory = $imageUploaderFactory;
        $this->imageConfig = $imageConfig;
        parent::__construct(
            $context,
            $registry,
            $config,
            $cacheTypeList,
            $resource,
            $resourceCollection,
            $data,
            $serializer
        );
    }

    public function beforeSave(): ArraySerialized
    {
        $value = $this->getValue();
        $value = $this->mapRows($value);
        $this->setValue($value);
        return parent::beforeSave();
    }

    private function mapRows(array $rows): array
    {
        $iconUploader = $this->imageUploaderFactory->create([
            'path' => $this->getPath(),
            'uploadDir' => $this->getUploadDir(),
        ]);
        $uploadedFiles = $iconUploader->upload();
        $swatches = $this->imageConfig->getSwatches();
        foreach ($rows as $id => $data) {
            if (isset($uploadedFiles[$id])) {
                $rows[$id][ImageFields::IMAGE_FIELD] = $uploadedFiles[$id];
                continue;
            }
            if (!isset($swatches[$id])) {
                unset($swatches[$id]);
            } else {
                $rows[$id] = $this->matchRow($data, $swatches[$id]);
            }
        }
        return $rows;
    }

    private function matchRow(array $row, array $configTabIcon): array
    {
        foreach ($row as $fieldName => $value) {
            if (is_array($value) && $fieldName == ImageFields::IMAGE_FIELD) {
                $row[ImageFields::IMAGE_FIELD] = $configTabIcon[ImageFields::IMAGE_FIELD];
            }
        }
        return $row;
    }

    private function getUploadDir(): string
    {
        $fieldConfig = $this->getFieldConfig();

        if (!array_key_exists('upload_dir', $fieldConfig)) {
            throw new \Magento\Framework\Exception\LocalizedException(
                __('The base directory to upload file is not specified.')
            );
        }

        if (is_array($fieldConfig['upload_dir'])) {
            $uploadDir = $fieldConfig['upload_dir']['value'];
            if (array_key_exists('scope_info', $fieldConfig['upload_dir'])
                && $fieldConfig['upload_dir']['scope_info']
            ) {
                $uploadDir = $this->_appendScopeInfo($uploadDir);
            }

            if (array_key_exists('config', $fieldConfig['upload_dir'])) {
                $uploadDir = $this->getUploadDirPath($uploadDir);
            }
        } else {
            $uploadDir = (string)$fieldConfig['upload_dir'];
        }

        return $uploadDir;
    }
}

Тут немного заострим внимание на методе mapRows, на строках 50-54 загружаем картинку, на строках 56-66 модифицируем данные из конфига, добавляем/заменяем картинку в массив конфига и остальные поля тоже добавляем/обновляем

класс ImageUploader:

Mr/ImageDynamicConfig/Model/ImageUploader.php
<?php
declare(strict_types=1);

namespace Mr\ImageDynamicConfig\Model;

use Magento\MediaStorage\Model\File\Uploader;

class ImageUploader
{
    private $arrayFileModifier;
    private $uploaderFactory;
    private $uploadDir;
    private $allowExtensions;

    public function __construct(
        \Mr\ImageDynamicConfig\Model\ArrayFileModifier $arrayFileModifier,
        \Magento\MediaStorage\Model\File\UploaderFactory $uploaderFactory,
        string $uploadDir,
        array $allowExtensions
    ) {
        $this->arrayFileModifier = $arrayFileModifier;
        $this->uploaderFactory = $uploaderFactory;
        $this->uploadDir = $uploadDir;
        $this->allowExtensions = $allowExtensions;
    }

    public function upload(): array
    {
        $result = [];
        $files = $this->arrayFileModifier->modify();
        if (!$files) {
            return $result;
        }

        foreach ($files as $id => $file) {
            try {
                $uploader = $this->uploaderFactory->create(['fileId' => $id]);
                $uploader->setAllowedExtensions($this->allowExtensions);
                $uploader->setAllowRenameFiles(true);
                $uploader->addValidateCallback('size', $this, 'validateMaxSize');
                $newFileName = $this->getNewFileName($uploader);
                $uploader->save($this->uploadDir, $newFileName);
                $result[$id] = $this->getFullFilPath($newFileName);
            } catch (\Exception $e) {
                throw new \Magento\Framework\Exception\LocalizedException(__('%1', $e->getMessage()));
            }
        }
        return $result;
    }

    private function getNewFileName(Uploader $uploader): string
    {
        return sprintf(
            '%s.%s',
            uniqid(),
            $uploader->getFileExtension()
        );
    }

    private function getFullFilPath(string $filename): string
    {
        return sprintf(
            '/%s/%s',
            $this->uploadDir,
            $filename
        );
    }
}

В этом классе есть строчка $files = $this->arrayFileModifier->modify(); Этот modifier нам нужен чтобы привести массив, который к нам пришел, из формы такого вида:

в понятный для аплоудера:

чтобы передать id $uploader = $this->uploaderFactory->create(['fileId' => $id]);

и аплоудер знал с чем ему работать.

И последний пазлик - класс для работы с конфигом

Mr/ImageDynamicConfig/Model/Config/ImageConfig
<?php
declare(strict_types=1);

namespace Mr\ImageDynamicConfig\Model\Config;

use Magento\Framework\App\Config\ScopeConfigInterface;
use Magento\Framework\Serialize\SerializerInterface;

class ImageConfig
{
    const XML_PATH_IMAGE_SERIALIZER = 'swatch/image_serializer/';

    private $scopeConfig;
    private $serializer;

    public function __construct(
        SerializerInterface $serializer,
        ScopeConfigInterface $scopeConfig
    ) {
        $this->scopeConfig = $scopeConfig;
        $this->serializer = $serializer;
    }
    
    public function getSwatches(): array
    {
        $data = $this->scopeConfig->getValue(self::XML_PATH_IMAGE_SERIALIZER . 'image');
        if (!$data) {
            return [];
        }
        return $this->serializer->unserialize($data);
    }
}

И сам результат:

Эпилог

Репозиторий модуля на гитхабе

Надеюсь данная статья покажется кому-нибудь интересной и/или полезной. Если есть замечания/предложения/вопросы добро пожаловать в комментарии.

Благодарю за внимание.

Комментарии (1)


  1. DaserafSM
    30.03.2022 16:36
    +1

    setup_version="1.0.0"

    Излишне