Magento 2: добавление колонки к гриду админки

  • Tutorial

Под катом пример добавления в гриде админки Magento 2 дополнительной колонки с данными из таблицы, связанной с основной таблицей грида, и "грязный хак" для работы фильтра по дополнительной колонке. Допускаю, что это не вполне "Magento 2 way", но это как-то работает, а потому — имеет право на существование.


Структура данных


Я решал задачу по формированию реферального дерева клиентов (клиент-родитель привлекает клиента-потомка), поэтому я создал дополнительную таблицу, завязанную на customer_entity. Если коротко, то дополнительная таблица содержит отношение "родитель-потомок" и информацию по дереву ("глубина залегания" клиента и путь к клиенту в дереве).


Структура таблицы
CREATE TABLE prxgt_dwnl_customer (
  customer_id int(10) UNSIGNED NOT NULL COMMENT 'Reference to the customer.',
  parent_id int(10) UNSIGNED NOT NULL COMMENT 'Reference to the customer''s parent.',
  depth int(10) UNSIGNED NOT NULL DEFAULT 0 COMMENT 'Depth of the node in the tree.',
  path varchar(255) NOT NULL COMMENT 'Path to the node - /1/2/3/.../'
  PRIMARY KEY (customer_id),
  CONSTRAINT FK_CUSTOMER FOREIGN KEY (customer_id)
  REFERENCES customer_entity (entity_id) ON DELETE RESTRICT ON UPDATE RESTRICT,
  CONSTRAINT FK_PARENT FOREIGN KEY (parent_id)
  REFERENCES prxgt_dwnl_customer (customer_id) ON DELETE RESTRICT ON UPDATE RESTRICT
)

UI Component


Моей целью являлись 2 дополнительные колонки к гриду клиентов, содержащие информацию о родителе текущего клиента и о глубине залегания клиента в дереве. Грид клиентов описывается в XML-файле vendor/magento/module-customer/view/adminhtml/ui_component/customer_listing.xml. Нас интересует узел dataSource, а конкретно — имя источника данных (customer_listing_data_source):


<dataSource name="customer_listing_data_source">
    <argument name="dataProvider" xsi:type="configurableObject">
        <argument name="name" xsi:type="string">customer_listing_data_source</argument>
        ...
    </argument>
</dataSource>

(что из этого является именем источника данных — атрибут name или argument-узел с именем name, сказать сложно, в Magento еще с первой версии есть хорошая традиция использовать одинаковые названия для различных типов элементов, чтобы держать разработчиков в тонусе)


Data Provider


Источником данных для грида является коллекция, как бы ни банально это звучало. Вот описание источника данных с именем customer_listing_data_source в файле vendor/magento/module-customer/etc/di.xml:


<type name="Magento\Framework\View\Element\UiComponent\DataProvider\CollectionFactory">
    <arguments>
        <argument name="collections" xsi:type="array">
            <item name="customer_listing_data_source" xsi:type="string">Magento\Customer\Model\ResourceModel\Grid\Collection</item>
            ...
        </argument>
    </arguments>
</type>

Т.е., класс, который поставляет данные для грида клиентов — \Magento\Customer\Model\ResourceModel\Grid\Collection.


Модификация коллекции


Если влезть отладчиком внутрь коллекции, то можно увидеть, что SQL-запрос для выборки данных выглядит примерно так:


SELECT `main_table`.* FROM `customer_grid_flat` AS `main_table`

Это другая хорошая традиция в Magento — преодолевать повышенную неповоротливость приложения, связанную с повышенной гибкостью, путим использования вот таких вот "индексных таблиц". В случае с клиентами flat-таблица есть, вполне возможно, что можно было бы встроиться и в нее, но я искал более универсальный путь. Мне нужен был JOIN.


Возможность JOIN'а я нашел только в методе \Magento\Framework\Model\ResourceModel\Db\Collection\AbstractCollection::_beforeLoad:


protected function _beforeLoad()
{
    ...
    $this->_eventManager->dispatch('core_collection_abstract_load_before', ['collection' => $this]);
    ...
}

Я подписался в своем модулей на событие core_collection_abstract_load_before (файл etc/events.xml):


<event name="core_collection_abstract_load_before">
    <!-- Add additional attributes to the Customer Grid in adminhtml. -->
    <observer
            name="praxigento_donwlilne_on_core_collection_abstract_load_before"
            instance="Praxigento\Downline\Observer\CoreCollectionAbstractLoadBefore"/>
</event>

И создал класс, реагирующий на это событие, в котором и модифицировал первоначальный запрос:


class CoreCollectionAbstractLoadBefore implements ObserverInterface
{
    const AS_FLD_CUSTOMER_DEPTH = 'prxgtDwnlCustomerDepth';
    const AS_FLD_PARENT_ID = 'prxgtDwnlParentId';
    const AS_TBL_CUST = 'prxgtDwnlCust';

    public function execute(\Magento\Framework\Event\Observer $observer)
    {
        $collection = $observer->getData('collection');
        if ($collection instanceof \Magento\Customer\Model\ResourceModel\Grid\Collection) {
            $query = $collection->getSelect();
            $conn = $query->getConnection();
            /* LEFT JOIN `prxgt_dwnl_customer` AS `prxgtDwnlCust` */
            $tbl = [self::AS_TBL_CUST => $conn->getTableName('prxgt_dwnl_customer')];
            $on = self::AS_TBL_CUST . 'customer_id.=main_table.entity_id';
            $cols = [
                self::AS_FLD_CUSTOMER_DEPTH => 'depth',
                self::AS_FLD_PARENT_ID => 'parent_id'
            ];
            $query->joinLeft($tbl, $on, $cols);
            $sql = (string)$query;
            /* dirty hack for filters goes here ... */
        }
        return;
    }
}

В итоге, после модификации SQL-запрос стал выглядеть примерно так:


SELECT
  `main_table`.*,
  `prxgtDwnlCust`.`depth` AS `prxgtDwnlCustomerDepth`
  `prxgtDwnlCust`.`parent_id` AS `prxgtDwnlParentId`
FROM `customer_grid_flat` AS `main_table`
  LEFT JOIN `prxgt_dwnl_customer` AS `prxgtDwnlCust`
    ON prxgtDwnlCust.customer_id = main_table.entity_id

Т.к. я использую алиасы для данных из "своей" таблицы (prxgtDwnlCustomerDepth и prxgtDwnlParentId), то я могу не сильно опасаться, что какой-то другой разработчик, применив подобный подход, совпадет со мной по наименованию дополнительных полей (вряд ли кто-то начнет называть свои данные с prxgt), но это же и привело к тому, что фильтрация с грида перестала работать.


Добавление колонки


Чтобы доопределить колонки в гриде нужно создать в своем модуле XML-файл с таким же именем, как и описывающий оригинальный UI-компонент (view/adminhtml/ui_component/customer_listing.xml), и создать в нем дополнительные колонки, используя в качестве имен полей данных алиасы:


<?xml version="1.0" encoding="UTF-8"?>
<listing
        xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Ui:etc/ui_configuration.xsd">

    <columns name="customer_columns">
        <column name="prxgtDwnlParentId">
            <argument name="data" xsi:type="array">
                <item name="config" xsi:type="array">
                    <item name="filter" xsi:type="string">textRange</item>
                    <item name="label" xsi:type="string" translate="true">Parent ID</item>
                </item>
            </argument>
        </column>
        <column name="prxgtDwnlCustomerDepth">
            <argument name="data" xsi:type="array">
                <item name="config" xsi:type="array">
                    <item name="filter" xsi:type="string">textRange</item>
                    <item name="label" xsi:type="string" translate="true">Depth</item>
                </item>
            </argument>
        </column>
    </columns>

</listing>

Результат



(колонки я подвигал руками и попрятал лишнее — отличная функция в новой Magento)


"Грязный хак" для фильтра


EDITED: Более прямое решение — через плагины с использованием метода $collection->addFilterToMap(...). В этом случае происходит изменение коллекции сразу посел ее создания, а не непосредственно перед ее использованием.


Чтобы заработали фильтры по новым столбцам я не придумал ничего лучшего, как сделать обратное преобразование "алиас" => "таблица.поле" все в том же классе по добавлению JOIN'а к первоначальному запросу (CoreCollectionAbstractLoadBefore):


public function execute(\Magento\Framework\Event\Observer $observer)
{
    ...
    /* the dirty hack */
    $where = $query->getPart('where');
    $replaced = $this->_replaceAllAliasesInWhere($where);
    $query->setPart('where', $replaced);
    ...
}

protected function _replaceAllAliasesInWhere($where)
{
    $result = [];
    foreach ($where as $item) {
        $item = $this->_replaceAliaseInWhere($item, self::AS_FLD_CUSTOMER_DEPTH, self::AS_TBL_CUST, 'depth');
        $item = $this->_replaceAliaseInWhere($item, self::AS_FLD_PARENT_ID, self::AS_TBL_CUST, 'parent_id');
        $result[] = $item;
    }
    return $result;
}

protected function _replaceAliaseInWhere($where, $fieldAlias, $tableAlias, $fieldName)
{
    $search = "`$fieldAlias`";
    $replace = "`$tableAlias`.`$fieldName`";
    $result = str_replace($search, $replace, $where);
    return $result;
}
Поделиться публикацией
Похожие публикации
Реклама помогает поддерживать и развивать наши сервисы

Подробнее
Реклама
Комментарии 14
  • +1
    Стоит отметить, что пути к классам в статье указаны для мадженты установленной через composer. Для всех остальных случаев установки vendor/magento/module-customer надо заменить на app/code/Magento/Customer
    • 0
      ох уж это магенто

      даже в bitrix меньше телодвижений.
      • 0

        Хотел бы подчеркнуть такой момент — все эти телодвижения приводят к тому, что колонки в грид добавляются независимо. Т.е., разработчика модуля не заботит, какие еще модули будут стоять в приложении, и на какие еще таблицы пойдут JOIN'ы.

        • 0
          > что колонки в грид добавляются независимо. Т.е., разработчика модуля не заботит, какие еще модули будут стоять в приложении, и на какие еще таблицы пойдут JOIN'ы.

          вот это прям фича из фичей.

          в любой нормальной CMS например в bitrix это достигается добавлением нового свойства через админку, без написания такой кучи кода — без программирования вообще.
          и вывод поля в формах и списках админки настраивается через админку.

          в wordpress к слову аналогично. разве что надо вывод в списках добавить, написав строк десять кода.

          и вот у меня возникает резонный вопрос — зачем в магенто столько кода надо писать для реализации элементарной вещи?
          • 0
            Так в статье большой кастыль. Достаточно добавить атрибут через инстал скрипт, и конфигурацию 10 строк кода и всё работает. А тут автор забил на орм(как это многие делают, думая что я умнее тех чуваков которые 5 лет какую то орм разрабатывали), придумал кастыль с фильтрацией так как теперь табличка main_table.user получилось а не user просто и сменился index.
            • 0

              ORM в Magento? Его там нет и никогда не было.

              • 0
                http://devdocs.magento.com/guides/m1x/magefordev/mage-for-dev-5.html ха насмешили. Вы наверное с bitrix'a на magento пересели или незнаете что такое ORM она всегда там была. А прямые самосконструированные запросы зло.
                • 0

                  Я пересел на Magento с Java, на которой имел дело и с Hibernate, и с DataNucleus. То, что в статье говорится "It should be no surprise that Magento takes the ORM approach", не является основанием для заявления, что ORM в Magento присутствует. Для разминки изобразите средствами "Magento ORM" сущность с составным первичным ключом (состоящим из двух полей таблицы в БД), а затем попробуйте средствами "Magento ORM" извлечь коллекцию таких объектов. Для Hibernate и DataNucleus данная задача является тривиальной.


                  Ваша критика статьи станет конструктивной тогда, когда вы предложите свое решение описанной задачи, а не пронесетесь вихрем по комментам с шашкой наголо "так никто не делает, там делов-то на пару строк кода".

                  • 0
                    один простой метод `addUniqueField`… И у вас какой то аргумент уж слишком похож на `пила по дереву не пила по тому что она не пилит метал, я же пилой по металу пользовался` ORM — штука которая представляет запросы к б.д. на объктно ориентированном диалекте.
                    • 0
                      ORM — штука которая представляет запросы к б.д. на объктно ориентированном диалекте.

                      Ну и как будет выглядеть запрос на выборку объектов с составным первичным ключом в Magento ORM? А на обновление объекта?

                      • 0

                        Кстати, можете еще попробовать вытащить данные (email & full name) по всем клиентам, которые совершили транзакции (sales_payment_transaction, нужны данные из поля tnx_id), которые соответствуют определенным методам платежа (sales_order_payment.method) по заказам, созданным в определенный промежуток времени, используя всю мощь Magento ORM.

                      • 0
                        protected function _replaceAllAliasesInWhere($where)
                        {
                            $result = [];
                            foreach ($where as $item) {
                                $item = $this->_replaceAliaseInWhere($item, self::AS_FLD_CUSTOMER_DEPTH, self::AS_TBL_CUST, 'depth');
                                $item = $this->_replaceAliaseInWhere($item, self::AS_FLD_PARENT_ID, self::AS_TBL_CUST, 'parent_id');
                                $result[] = $item;
                            }
                            return $result;
                        }
                        
                        protected function _replaceAliaseInWhere($where, $fieldAlias, $tableAlias, $fieldName)
                        {
                            $search = "`$fieldAlias`";
                            $replace = "`$tableAlias`.`$fieldName`";
                            $result = str_replace($search, $replace, $where);
                            return $result;
                        }
                        

                        это всё заменяеться одной строкой
                        `$collection->addFieldToFilter('mytable.myfield',$yuorFilterValue)`
                        • 0

                          Вы немножко неправильно поняли назначение моего кода — он подменяет алиасы для дополнительных столбцов исходного SQL'а для WHERE-правила (да-да, в background'е "Magento ORM" спрятан самый обычный SQL, впрочем, как и в background'е других ORM framework'ов) полным значением имени столбца, с добавлением алиаса таблицы.


                          В вашем примере код


                          $collection->addFieldToFilter('mytable.myfield',$yuorFilterValue)

                          просто добавляет в список WHERE-правил еще одно условие. Этот код и так исполняется, когда Magento разбирает условия фильтрации данных грида, заданные пользователем через WebUI (трассировка от \Magento\Framework\View\Element\UiComponent\DataProvider\FilterPool::applyFilters как раз и выведет на метод "addFieldToFilter"). У Magento-коллекции есть метод "addFilterToMap", который позволяет ввести карту преобразований, аналогичных тем, которые делаю я, и выполнять их перед тем, как добавить условие фильтрации (\Magento\Framework\Data\Collection\AbstractDb::_translateCondition, вызывается из addFieldToFilter), вот только нет возможности вклиниться в поток выполнения команд через событие (применение фильтров идет после создания коллекции и до генерации события "core_collection_abstract_load_before"). Можно использовать механизм плагинов и обернуть, например, метод \Magento\Framework\View\Element\UiComponent\DataProvider\CollectionFactory::getReport, чтобы он выполнял те же самые действия, что и в обсервере, плюс добавлял маппинг.


                          Регистрация around-плагина:
                          etc/di.xml


                          <type name="Magento\Framework\View\Element\UiComponent\DataProvider\CollectionFactory">
                              <plugin
                                      name="vendor_module_data_provider_collection_factory"
                                      type="Vendor\Module\Plugin\Framework\View\Element\UiComponent\DataProvider\CollectionFactory"
                                      sortOrder="100"
                                      disabled="false"
                              />
                          </type>

                          Код самого плагина, вызывающий класс-модификатор для добавления JOIN'ов к выборке и маппинг полей для их преобразования в фильтрах:


                          namespace Vendor\Module\Plugin\Framework\View\Element\UiComponent\DataProvider;
                          class CollectionFactory
                          {
                              protected $_subQueryModifier;
                          
                              public function __construct(
                                  Sub\QueryModifier $subQueryModufier
                              ) {
                                  $this->_subQueryModifier = $subQueryModufier;
                              }
                          
                              public function aroundGetReport(
                                  \Magento\Framework\View\Element\UiComponent\DataProvider\CollectionFactory $subject,
                                  \Closure $proceed,
                                  $requestName
                              ) {
                                  $result = $proceed($requestName);
                                  if ($requestName == 'customer_listing_data_source') {
                                      if ($result instanceof \Magento\Customer\Model\ResourceModel\Grid\Collection) {
                                          /* add JOINs to the select query */
                                          $this->_subQueryModifier->populateSelect($result);
                                          /* add fields to mapping */
                                          $this->_subQueryModifier->addFieldsMapping($result);
                                      }
                                  }
                                  return $result;
                              }
                          }

                          Код, который модифицирует выбоку аналогичный тому, что в статье. Код для маппинга трививален:


                              // depth
                              $fieldAlias = self::AS_FLD_CUSTOMER_DEPTH;
                              $fieldFullName = self::AS_TBL_CUST . '.' . Customer::ATTR_DEPTH;
                              $collection->addFilterToMap($fieldAlias, $fieldFullName);

                          Этот подход позволяет использовать механизмы Magento для замены алиасов истинными именами полей вместо "грязного хака".

          • 0

            Возможно потому, что Magento — не CMS.

            Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.