Привет, Хабр! Меня зовут Андрей, я руководитель команды по разработке на PHP в НЛМК ИТ. Наша команда специализируется на развитии корп. портала Группы НЛМК на платформе Битрикс, и еще нескольких корпоративных сайтов.

Значимая часть портала — это широкий набор сервисов для решения задач бизнеса. Например, регистрация и обработка заявок на перевод документов, работа с приёмом делегаций, волонтёрской активностью, проведение оценки рабочих, работа с пользователями (поздравления, благодарности, знаки отличия сотрудников) и многое другое. Несмотря на всё разнообразие сервисов, для их разработки зачастую используются одни и те же функциональные блоки — универсальные "кирпичики" для решения типовых задач.

В предыдущей статье я рассказывал про опыт работы с бизнес процессами 1С-Битрикс. Сейчас хотелось бы затронуть другую, не менее востребованную задачу — задачу по удобному и структурированному выводу данных.

В большинстве сервисов есть потребность работы с большим объёмом табличных данных. И практически всегда требуется иметь возможность не просто просматривать списки записей с постраничной навигацией, но и сортировать и фильтровать их. Неплохо было бы также персонализировать выводимые данные — причём речь идёт не только про ограничение набора отображаемых колонок для разных групп пользователей, но и про возможность пользователю самостоятельно выбирать состав и порядок интересующих его колонок. И будет очень хорошо, если решение для работы с табличными данными будет работать быстро и не заставлять пользователя подолгу ждать загрузки данных.

Компоненты битрикса для работы с таблицами

Из коробки в битриксе уже есть два готовых компонента для работы с гридами, это bitrix:main.ui.grid и bitrix:main.ui.filter. Они предоставляют визуальный интерфейс грида и фильтра для грида соответственно.

Примечание: эти компоненты системные и в визуальном редакторе не отображаются.

Для работы компонента bitrix:main.ui.grid достаточно всего лишь передать набор колонок и строк отображаемой таблицы.

Пример вызова компонента bitrix:main.ui.grid
$APPLICATION->IncludeComponent(
    'bitrix:main.ui.grid',
    '',
    [
        'GRID_ID' => 'test-grid',
        'COLUMNS' => [
            ['id' => 'ID', 'name' => 'id', 'default' => true],
 
            ['id' => 'TITLE', 'name' => 'название', 'default' => true],
            ['id' => 'ACTIVE', 'name' => 'активность', 'default' => true],
        ],
        'ROWS' => [
            [
                'id' => 1,
                'columns' => ['ID' => 1, 'TITLE' => 'Первая запись', 'ACTIVE' => 'Да']
            ],
            [
                'id' => 2,
                'columns' => ['ID' => 2, 'TITLE' => 'Вторая запись', 'ACTIVE' => 'Да']
            ],
            [
                'id' => 3,
                'columns' => ['ID' => 3, 'TITLE' => 'Третья запись', 'ACTIVE' => 'Нет']
            ],
 
        ],
        'AJAX_MODE' => 'Y',
        'AJAX_OPTION_JUMP' => 'N',
        'AJAX_OPTION_HISTORY' => 'N',
    ]
);

В результате получим следующее отображение:

Вообще, компонент bitrix:main.ui.grid достаточно гибкий и позволяет подключать дополнительный функционал: пагинацию, бургер-меню для строк, групповые действия и пр. 

Компонент bitrix:main.ui.filter позволяет выводить стандартный интерфейс фильтрации для bitrix:main.ui.grid. Достаточно вызвать их вместе и указать соответствующий ID грида.

Пример вызова компонента bitrix:main.ui.filter
$APPLICATION->IncludeComponent(
    'bitrix:main.ui.filter',
    '',
    [
        'FILTER_ID' => 'test-filter',
        'GRID_ID' => 'test-grid',
        'FILTER' => [
            ['id' => 'ID', 'name' => 'id', 'type' => 'number'],
            [
                'id' => 'ACTIVE',
                'name' => 'активность',
                'type' => 'list',
                'items' => ['Y' => 'Да', 'N' => 'Нет'],
                'params' => ['multiple' => 'Y']
            ],
            ['id' => 'TITLE', 'name' => 'название'],
        ],
        'ENABLE_LIVE_SEARCH' => true,
        'ENABLE_LABEL' => true,
    ]
);

В итоге получим грид вместе с фильтром.

Как применить компоненты гридов на практике?

Как я уже ранее упоминал, bitrix:main.ui.grid и bitrix:main.ui.filter предоставляют гибкий интерфейс для работы с таблицами, но сама логика извлечения данных должна реализовываться разработчиком по его усмотрению.

Раньше мы реализовывали эту логику для каждого сервиса корпоративного портала индивидуально, писали новые компоненты, в которых, в конечном итоге, и подключались bitrix:main.ui.grid и bitrix:main.ui.filter.

У этого подхода есть свой плюс — можно был�� смело дорабатывать каждый такой кастомный компонент, нисколько не опасаясь регресса на другие сервисы.

На этом, к сожалению, преимущества подхода с индивидуальными компонентами заканчиваются. 

Несмотря на всё разнообразие сервисов, в их основе используются в большинстве случаев стандартные сущности 1С-Битрикс: инфоблоки, хайлоадблоки, пользователи и пр. 

В итоге, при реализации новых сервисов, разработчики раз за разом решали одни и те же задачи — всё это привело к лишней работе и дублированию кода.

В какой-то момент стало очевидно, что задача извлечения и фильтрации данных для гридов типовая, и мы приняли решение написать единый компонент для работы с bitrix:main.ui.grid и bitrix:main.ui.filter.

Провайдер — единый источник данных.

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

При этом можно в параметрах вызова компонента указывать тип извлекаемых данных и в зависимости от выбранного значения вызывать тот или иной метод. 

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

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

Появилась потребность реализовать новый класс? Не проблема, пишем и подключаем его при вызове — это 0% регресса с точки зрения модификации универсального компонента гридов, так как нет самой модификации — не требуется вносить изменения ни в одну его строчку кода.

Очевидно, что такие классы-провайдеры должны реализовывать единый универсальный интерфейс.

Интерфейс провайдера
<?php
 
namespace NLMK\Grid\Contract;
 
use NLMK\Grid\Contract\Action\Action;
 
/**
 * Интерфейс предназначен для построения взаимодействия между произвольным хранилищем данных и классом для вывода
 * данных в таблицу
 *
 * Класс описывает методы для получения строк и колонок, а также типов полей, которые необходимы для вывода фильтра
 * и его сборки при фильтрации
 *
 * Interface Provider
 * @package NLMK\Grid\Contract
 */
interface Provider
{
    /**
     * Метод для получения массива доступных действий всех типов
     * @return array
     */
    public function getActions(): array;
 
    /**
     * Метод позволяет получить колонки таблицы, в формате пригодном для использования совместно с гридом
     * @return array
     */
    public function getColumns(): array;
 
    /**
     * Метод возвращает строки для грида
     * @return array
     */
    public function getRows(): array;
 
    /**
     * Метод возвращает список полей доступных для просмотра
     * @return array
     */
    public function getAvailableFields(): array;
 
    /**
     * Метод устанавливает список полей доступных для просмотра
     * @param array $fields
     * @return $this
     */
    public function setAvailableFields(array $fields): self;
 
    /**
     * Метод устанавливает базовый фильтр
     * @param array $scope
     * @return $this
     */
    public function setScope(array $scope): self;
 
    /**
     * Метод возвращает список полей с указанными типами данных,
     * пригодными для использования совместно с bitrix:main.ui.filter
     * @param array $fields
     * @return array
     */
    public function getTypedFields(array $fields = []): array;
 
    /**
     * Метод добавляет действие к данному провайдеру
     * @param Action $action
     * @return void
     */
    public function addAction(Action $action);
 
    /**
     * Возвращает число записей по заданному фильтру
     *
     * @return int
     */
    public function getRowsCount(): int;
}

Далее написали абстрактный родительский класс AbstractBasicProvider, реализующий интерфейс Provider. 

AbstractBasicProvider — универсальный родитель
<?php
 
namespace NLMK\Grid;
 
use Bitrix\Main\Application;
use Bitrix\Main\Data\Cache;
use Bitrix\Main\UI\PageNavigation;
use NLMK\Grid\Action\ActionTypes;
use NLMK\Grid\Contract\Action\Action;
use NLMK\Grid\Contract\Filter;
use NLMK\Grid\Contract\Provider;
 
/**
 * Class AbstractBasicProvider
 * @package NLMK\Grid
 */
abstract class AbstractBasicProvider implements Provider
{
    /**
     * Свойство хранит объект фильтра
     * @var Filter
     */
    protected $filterInstance;
 
    /**
     * Свойство хранит доступный набор действий
     * @var array
     */
    protected $actions = [];
 
    /**
     * Объект навигации
     * @var PageNavigation
     */
    protected $navigationInstance;
 
    /**
     * Свойство хранит флаг доступности сортировки
     * @var bool
     */
    protected $sortingEnabled = true;
 
    /**
     * Свойство хранит набор доступных для сортировки полей
     * @var
     */
    protected $sortableFields = [];
 
    /**
     * Свойство хранит набор доступных для просмотра полей
     * @var array
     */
    protected $availableFields = [];
 
    /**
     * Свойство хранит базовый фильтр
     * @var array
     */
    protected $scope = [];
 
    /**
     * Свойство хранит массив сортировки
     * @var array
     */
    protected $sorting;
 
    /**
     * Количество извлекаемых записей
     * @var int
     */
    protected $limit;
 
    /**
     * Параметры переданные в провайдер
     * @var array
     */
    protected $params;
 
    /**
     * @var bool
     */
    protected bool $cached;
 
    /**
     * Метод возвращает список параметров переданных при инициализации
     * @return array
     */
    public function getParams(): array
    {
        return $this->params ?: [];
    }
 
    /**
     * Метод устанавливает лимит для извлечения записей
     * @param int $limit
     * @return $this
     */
    public function setLimit(int $limit): AbstractBasicProvider
    {
        $this->limit = $limit;
        return $this;
    }
 
    /**
     * Метод получает лимит для извлечения в том случае, если пагинация не разрешена
     * @return int
     */
    public function getLimit(): int
    {
        return $this->limit ?: 0;
    }
 
    /**
     * Метод проверяет установлен ли лимит на получение записей
     * @return bool
     */
    public function hasLimit(): bool
    {
        return $this->limit > 0;
    }
 
    /**
     * Метод возвращает объект фильтра
     * @return Filter
     */
    public function getFilterInstance(): Filter
    {
        return $this->filterInstance;
    }
 
    /**
     * Метод устанавливает объект фильтра
     * @param Filter $filter
     */
    public function setFilterInstance(Filter $filter)
    {
        $this->filterInstance = $filter;
    }
 
    /**
     * @inheritDoc
     * @return PageNavigation
     */
    public function getNavigationInstance(): PageNavigation
    {
        return $this->navigationInstance;
    }
 
    /**
     * @inheritDoc
     * @param PageNavigation $navigation
     * @return $this|ProviderNavigation
     */
    public function setNavigationInstance(PageNavigation $navigation): ProviderNavigation
    {
        $this->navigationInstance = $navigation;
        return $this;
    }
 
    /**
     * @inheritDoc
     * @return bool
     */
    public function hasNavigation(): bool
    {
        return $this->navigationInstance instanceof PageNavigation;
    }
 
    /**
     * @inheritDoc
     * @return array
     */
    public function getSorting(): array
    {
        return $this->sorting ?: [];
    }
 
    /**
     * @inheritDoc
     * @param array $sort
     * @return $this|Sortable
     */
    public function setSorting(array $sort): Sortable
    {
        $this->sorting = $sort;
        return $this;
    }
 
    /**
     * @inheritDoc
     * @param array $fields
     * @return $this|Sortable
     */
    public function setSortableFields(array $fields): Sortable
    {
        $this->sortableFields = $fields;
        return $this;
    }
 
    /**
     * @inheritDoc
     * @param bool $state
     * @return $this|Sortable
     */
    public function setSortingActive(bool $state): Sortable
    {
        $this->sortingEnabled = $state;
        return $this;
    }
 
    /**
     * @inheritDoc
     * @param array $scope
     * @return $this|Provider
     */
    public function setScope(array $scope): Provider
    {
        $this->scope = $scope;
        return $this;
    }
 
    /**
     * @inheritDoc
     * @return array
     */
    public function getAvailableFields(): array
    {
        if (!$this->availableFields) {
            $this->availableFields = array_column($this->getTypedFields(), 'id');
        }
 
        return $this->availableFields ?: [];
    }
 
    /**
     * @inheritDoc
     * @param array $fields
     * @return $this|Provider
     */
    public function setAvailableFields(array $fields): Provider
    {
        $this->availableFields = $fields;
        return $this;
    }
 
    /**
     * Метод предназначен для модификации полей элементов
     * @param $rowItem
     * @return array
     */
    public function prepareRowItem($rowItem): array
    {
        return $rowItem;
    }
 
    /**
     * @inheritDoc
     * @return array
     */
    public function getActions(): array
    {
        return $this->actions ?: [];
    }
 
    /**
     * @inheritDoc
     * @param Action $action
     */
    public function addAction(Action $action)
    {
        $this->actions[] = $action;
    }
 
    /**
     * Метод проверяет наличие объекта фильтра для текущего провайдера
     * @return bool
     */
    protected function hasFilterInstance(): bool
    {
        return $this->filterInstance !== null;
    }
 
    /**
     * Метод проверяет является ли переданное поле (код) сортируемым
     * Если не переданы параметры сортировки, то поле считается сортируемым
     *
     * @param string $fieldCode
     * @return bool
     */
    protected function isSortableField(string $fieldCode): bool
    {
        return $this->sortingEnabled && ($this->sortableFields && in_array($fieldCode, (array)$this->sortableFields) || !$this->sortableFields);
    }
 
    /**
     * Метод проверяет, указан ли базовый фильтр
     * @return bool
     */
    protected function hasScope(): bool
    {
        return !empty($this->scope);
    }
 
    /**
     * Метод возвращает набор действий для контекстного меню строки
     * @param array $arItem
     * @return array
     */
    protected function getRowMenuValues(array $arItem): array
    {
        $menu = [];
        foreach ($this->actions as $action) {
            if ($action->isType(ActionTypes::TYPE_ROW) && $action->canFor($arItem)) {
                $menu[] = $action->createRowAction($arItem);
            }
        }
 
        return $menu;
    }
 
    /**
     * Метод возвращает данные о добавляемых колонках действиями типа "действие-колонка"
     * @return array
     */
    protected function getColumnActionsInfo(): array
    {
        $columnActions = [];
 
        foreach ($this->actions as $action) {
            if ($action->isType(ActionTypes::TYPE_COLUMN)) {
                $columnActions[] = $action->getColumnInfo();
            }
        }
 
        return $columnActions;
    }
 
    /**
     * Метод возвращает набор значений для колонок-действий
     * @param array $arItem
     * @return array
     */
    protected function getColumnActionValues(array $arItem): array
    {
        $menu = [];
        foreach ($this->actions as $action) {
            if ($action->isType(ActionTypes::TYPE_COLUMN) && $action->canFor($arItem)) {
                $menu[$action->getColumnCode()] = $action->createColumnAction($arItem);
            }
        }
 
        return $menu;
    }
 
    /**
     * Метод возвращает флаг использования кеша
     * @return bool
     */
    public function isCacheEnabled(): bool
    {
        return $this->params['USE_CACHE'] === 'Y';
    }
 
    /**
     * Метод возвращает список параметров для дальнейшего использования с функциями Битрикс
     *
     * @return array
     * @see \Bitrix\Main\Data\Cache::initCache()
     */
    public function getCacheParams(): array
    {
        return [
            'time' => $this->params['CACHE']['TIME'] ?? null,
            'dir' => $this->params['CACHE']['PATH'] ?? null,
            'key' => $this->getCacheKey($this->params['CACHE']['KEY'] ?? '')
        ];
    }
 
    /**
     * Метод возвращает ключ кеширования на основе параметров выборки провайдера с учетом дополнительного ключа
     * @param string $additionalKey
     * @return string
     */
    public function getCacheKey(string $additionalKey): string
    {
        $params = [
            'order' => $this->getSorting(),
            'filter' => $this->hasFilterInstance() ? $this->getFilterInstance()->getGridFilterValues() : [],
            'scope' => $this->hasScope() ? $this->scope : [],
            'select' => $this->getAvailableFields(),
            'additionalKey' => $additionalKey,
        ];
 
        if ($this->hasNavigation() && !$this->navigationInstance->allRecordsShown()) {
            $params['nav']['page'] = $this->navigationInstance->getCurrentPage();
            $params['nav']['size'] = $this->navigationInstance->getPageSize();
        } else {
            $params['nav']['limit'] = $this->hasLimit() ? $this->getLimit() : false;
        }
 
        return serialize($params);
    }
 
    /**
     * Метод возвращает кешированную версию результата функции на основе параметров
     * [time, key, dir, allow_tag]
     * @param array $cacheParams
     * @param callable $functionData
     * @return mixed
     */
    public function getCached(array $cacheParams, callable $functionData, ?callable $functionCached = null)
    {
        $cache = Cache::createInstance();
        $isCacheEnabled = $this->isCacheEnabled();
        $this->cached = false;
 
        if (
            $isCacheEnabled
            && $cacheParams['time']
            && $cacheParams['key']
            && $cacheParams['dir']
            && $cache->initCache(
                $cacheParams['time'],
                $cacheParams['key'],
                $cacheParams['dir']
            )
        ) {
            $result = $cache->getVars();
            if($functionCached) {
                $result = $functionCached($result);
            }
        } else {
            $isCacheEnabled && $cache->startDataCache();
            $result = $cacheParams['allow_tag'] || !isset($cacheParams['allow_tag']) ? $this->ensureTagRegistered($functionData) : $functionData();
 
            if ($result['abort_cache']) {
                $cache->abortDataCache();
                $isCacheEnabled = false;
            }
 
            $isCacheEnabled && $cache->endDataCache($result);
        }
 
        return $result;
    }
 
    /**
     * Метод добавляет указанные к кешу параметры тегов, если кеш включен
     * @param callable $function
     * @return mixed
     */
    public function ensureTagRegistered(callable $function)
    {
        $result = $function();
        if (!$result['abort_cache']) {
            $taggedCacheEnabled = $this->isCacheEnabled() && $this->params['CACHE']['PATH']
                && $this->params['USE_TAGGED_CACHE'] === 'Y' && $this->params['TAGGED_CACHE_KEYS'];
 
            if ($taggedCacheEnabled) {
                $taggedCache = Application::getInstance()->getTaggedCache();
                $taggedCache->startTagCache($this->params['CACHE']['PATH']);
 
                foreach ($this->params['TAGGED_CACHE_KEYS'] as $key) {
                    $taggedCache->registerTag($key);
                }
            }
 
            if ($taggedCacheEnabled) {
                $taggedCache->endTagCache();
            }
        }
 
        return $result;
    }
 
    /**
     * Возвращает число записей по заданному фильтру
     *
     * @return int
     */
    abstract public function getRowsCount(): int;
}

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

В первую очередь, реализовали OrmProvider для классов, наследованных от Bitrix\Main\Entity\DataManager.

Передаём в конструктор такого провайдера имя класса и далее производим с ним все требуемые операции.

OrmProvider: конструктор
public function __construct($params)
{
    if (!(isset($params['CLASS']) && is_a($params['CLASS'], DataManager::class, true))) {
        throw new InvalidArgumentException();
    }
    $this->params = $params;
    $this->instance = new $params['CLASS']();
}

Ниже приведем код класса OrmProvider:

OrmProvider
<?php
 
namespace NLMK\Grid;
 
use Bitrix\Main\ArgumentException;
use Bitrix\Main\Entity\DataManager;
use Bitrix\Main\Entity\IntegerField;
use Bitrix\Main\ObjectPropertyException;
use Bitrix\Main\ORM\Fields\ScalarField;
use Bitrix\Main\SystemException;
use Bitrix\Tasks\Util\Entity\DateTimeField;
use Exception;
use InvalidArgumentException;
 
class OrmProvider extends AbstractBasicProvider
{
    /**
     * Стандартный тип поля
     */
    const DEFAULT_FIELD_TYPE = 'string';
 
    /**
     * Набор соответствий типов
     * {ormType} => {filterType}
     */
    const ORM_TYPES_MAP = [
        'text' => 'string',
        'integer' => 'number',
        'datetime' => 'date'
    ];
 
    /**
     * Свойство хранит класс для доступа к данным DataManager
     * @var DataManager
     */
    protected $instance;
 
    /**
     * Проверяет корректность параметров
     * Инициализирует свойства
     * OrmProvider constructor.
     * @param $params
     */
    public function __construct($params)
    {
        if (!(isset($params['CLASS']) && is_a($params['CLASS'], DataManager::class, true))) {
            throw new InvalidArgumentException();
        }
        $this->params = $params;
        $this->instance = new $params['CLASS']();
    }
 
    /**
     * @inheritDoc
     * @return array
     */
    public function getColumns(): array
    {
        $arTitles = $this->getFields($this->getAvailableFields());
 
        $result = [];
        foreach ($arTitles as $code => $value) {
            $result[] = [
                'id' => $code,
                'name' => $value['title'],
                'sort' => $this->isSortableField($code) ? $code : '',
                'default' => true,
            ];
        }
 
        return array_merge($result, $this->getColumnActionsInfo());
    }
 
    /**
     * Получает список полей для orm
     * @param array $fields
     * @return array
     */
    protected function getFields(array $fields = []): array
    {
        $map = $this->instance::getMap();
        $newMap = [];
 
        foreach ($map as $key => $value) {
            if ($value instanceof ScalarField || is_array($value)) {
                if ($value instanceof ScalarField) {
                    $key = $value->getName();
                    $value = [
                        'title' => $value->getTitle(),
                        'data_type' => $this->getOrmFieldType($value),
                        'primary' => $value->isPrimary()
                    ];
                }
                if (!$fields || in_array($key, $fields)) {
                    $newMap[$key] = $value;
                }
            }
        }
 
        return $newMap;
    }
 
    /**
     * @inheritDoc
     * @return array
     */
    public function getAvailableFields(): array
    {
        if (!$this->availableFields) {
 
            $fields = $this->getFields();
            foreach ($fields as $key => $value) {
                if (!(is_array($value) && !isset($value['reference']))) {
                    unset($fields[$key]);
                }
            }
 
            $this->availableFields = array_keys($fields);
        }
 
        return $this->availableFields ?: [];
    }
 
    /**
     * @inheritDoc
     * @return array
     * @throws ArgumentException
     * @throws ObjectPropertyException
     * @throws SystemException
     */
    public function getRows(): array
    {
        $arBody = [];
        $arItems = $this->getElements();
        $fields = array_keys($this->getFields($this->getAvailableFields()));
 
        foreach ($arItems as $arItem) {
            $preparedFields = [];
 
            foreach ($fields as $field) {
                $preparedFields[$field] = $arItem[$field];
            }
 
            $preparedFields = array_merge($this->prepareRowItem($preparedFields), $this->getColumnActionValues($arItem));
            $arGridElement = [];
            $arGridElement['data'] = $preparedFields;
            $arGridElement['id'] = $arItem['ID'];
            $arGridElement['actions'] = $this->getRowMenuValues($arItem);
 
            $arBody[] = $arGridElement;
        }
 
        return $arBody;
    }
 
    protected function getFilter(): array
    {
        $arFilter = $this->hasFilterInstance() ? $this->getFilterInstance()->getGridFilterValues() : [];
 
        if ($this->hasScope()) {
            $arFilter = array_merge($arFilter, $this->scope);
        }
 
        return $arFilter;
    }
 
    /**
     * Метод возвращает группировку для запроса
     * @return array
     */
    protected function getGroup(): array
    {
        return [];
    }
 
    /**
     * Метод возвращает количество записей в выборке, без учета постраничной навигации
     * @param array $queryParams
     * @return int
     * @throws ObjectPropertyException
     * @throws SystemException
     */
    protected function getRecordCount(array $queryParams): int
    {
        return (int)$this->instance::getCount($queryParams['filter']);
    }
 
    protected function getSelect(array $primary): array
    {
        return array_merge($this->getAvailableFields(), $primary);
    }
 
    /**
     * Производит выборку элементов, согласно заданным параметрам и классу фильтрации
     * @return array
     * @throws ArgumentException
     * @throws ObjectPropertyException
     * @throws SystemException
     */
    protected function getElements(): array
    {
        $result = [];
 
        // фильтрация
        $arFilter = $this->getFilter();
 
        // выборка
        $primary = $this->getPrimaryKeys();
 
        $queryParams = [
            'order' => $this->getSorting(),
            'select' => $this->getSelect($primary),
            'filter' => $arFilter,
            'group' => $this->getGroup(),
        ];
 
        if ($this->hasNavigation() && !$this->navigationInstance->allRecordsShown()) {
            $queryParams['limit'] = $this->navigationInstance->getLimit();
            $queryParams['offset'] = $this->navigationInstance->getOffset();
        }
 
        $query = $this->instance::getList($queryParams);
 
        if ($this->hasNavigation()) {
            $this->navigationInstance->setRecordCount($this->getRecordCount($queryParams));
        }
        while ($arItem = $query->fetch()) {
            $result[$this->getItemKey($arItem, $primary)] = $arItem;
        }
 
        $result = $this->onAfterElementsFetch($result);
 
        return $result ?: [];
    }
 
    /**
     * Модификация результатов выборки
     *
     * @param array $elements
     * @return array
     */
    protected function onAfterElementsFetch(array $elements): array
    {
        return $elements ?: [];
    }
 
    /**
     * Метод возвращает список полей являющихся ключами
     * @return array
     */
    protected function getPrimaryKeys()
    {
        $fields = $this->getFields();
        $primary = [];
 
        foreach ($fields as $key => $value) {
            if ($value['primary']) {
                $primary[] = $key;
            }
        }
        return $primary;
    }
 
    /**
     * @inheritDoc
     * @return array
     */
    public function getSorting(): array
    {
        return array_intersect_key(parent::getSorting(), $this->getFields());
    }
 
    /**
     * Метод формирует ключ элемента согласно первичным ключам
     * @param array $item
     * @param array $primary
     * @return string
     */
    protected function getItemKey(array $item, array $primary)
    {
        $key = [];
        foreach ($primary as $one) {
            $key[] = $item[$one];
        }
 
        return implode('_', $key);
    }
 
    /**
     * @inheritDoc
     * @param array $fields
     * @return array
     */
    public function getTypedFields(array $fields = []): array
    {
        $result = [];
        foreach ($this->getFields($fields) as $key => $value) {
            if (is_string($key) && is_array($value) && (!$fields || in_array($key, $fields))) {
                $result[] = [
                    'id' => $key,
                    'name' => $value['title'],
                    'type' => static::getFieldType($value['data_type'])
                ];
            }
        }
 
        return $result;
    }
 
    /**
     * Вспомогательный метод, возвращает тип элемента согласно карте
     * @param string $type
     * @return string
     */
    protected static function getFieldType(string $type): string
    {
        return isset(static::ORM_TYPES_MAP[$type]) ? static::ORM_TYPES_MAP[$type] : static::DEFAULT_FIELD_TYPE;
    }
 
    /**
     * Действие удаления элемента
     * @param int $itemId
     * @throws Exception
     */
    public function delete(int $itemId)
    {
        $this->instance::delete($itemId);
    }
 
    /**
     * метод получения типа поля для orm
     *
     * @param $value
     * @return string
     */
    protected static function getOrmFieldType($value): string
    {
        switch (get_class($value)) {
            case IntegerField::class:
                $type = 'integer';
                break;
            case DateTimeField::class:
                $type = 'datetime';
                break;
            default:
                $type = 'string';
                break;
        }
 
        return $type;
    }
 
    /**
     * Возвращает число записей по заданному фильтру
     *
     * @return int
     * @throws ObjectPropertyException
     * @throws SystemException
     */
    public function getRowsCount(): int
    {
        return (int)$this->instance::getCount($this->getFilter());
    }
}

На основе OrmProvider написали дочерний класс HighloadProvider для работы с хайлоадблоками. При этом метод getElements для извлечения данных из БД не пришлось переопределять — достаточно просто было немного адаптировать OrmProvider для работы с хайлоадблоками.

И, конечно, разработали IblockProvider для взаимодействия с инфоблоками. В случае с IblockProvider, правда, оказалось более удобным не наследоваться от OrmProvider, и использовать "под капотом" старый CIBlockElement::GetList.

Примечание: в случае, если для решения задачи не подходит ни один типовой провайдер, всегда можно написать кастомный класс в пространстве имён реализуемого функционала.

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

Заметки о фильтрации данных.

Нельзя представить полноценную работу с гридом без возможности фильтровать отображаемые данные.

В случае с фильтрами использовали тот же подход, что и с провайдерами. Написали единый интерфейс для фильтров:

Интерфейс фильтра
<?php
 
namespace NLMK\Grid\Contract;
 
use Bitrix\Main\UI\Filter\Options;
 
interface Filter
{
    /**
     * Метод предназначен для определения провайдера, с которым будет производиться работа
     * @param Provider $provider
     * @return void
     */
    public function setProvider(Provider $provider);
 
    /**
     * Метод устанавливает поля для фильтрации
     * @param array $filterFields
     * @return void
     */
    public function setFilterFields(array $filterFields);
 
    /**
     * Метод получает поля для фильтрации
     * @return array
     */
    public function getFilterFields(): array;
 
    /**
     * Метод возвращает список полей пригодных для использования совместно с компонентом main.ui.grid
     * @return array
     */
    public function getFields(): array;
 
    /**
     * Метод устанавливает объект опций фильтра (битриксового)
     * @param Options $filterOptions
     * @return mixed
     */
    public function setFilterOptions(Options $filterOptions);
 
    /**
     * Метод получает объект для получения опций фильтрации
     * @return Options
     */
    public function getFilterOptions(): Options;
 
    /**
     * Метод предназначен для получения фильтра совместимого с orm/iblock
     * может использоваться в Provider::getRows
     * @return array
     */
    public function getGridFilterValues(): array;
}

И аналогичным образом разработали универсальные классы фильтров для наиболее часто востребованных сущностей.

Первым делом написали OrmFilter для работы с классами, наследованными от Bitrix\Main\Entity\DataManager.

После — IblockProvider для инфоблоков.

А вот писать отдельный фильтр для хайлоадблоков не потребовалось — с этой задачей отлично справляется "базовый" OrmFilter.

Новый компонент — nlmk:element.grid

Мы уже разобрались с вопросами фильтрации и извлечения данных. Осталось только сделать последний шаг — написать код, который объединит вместе наши провайдеры, фильтры и гриды битрикс: bitrix:main.ui.grid и bitrix:main.ui.filter.

Так родился наш компонент для гридов — nlmk:element.grid.

При его разработке мы старались добиться максимальной универсальности. Основной принцип, которым мы руководствовались для достижения этой цели — любая кастомизация должна так или иначе быть вынесена за пределы этого компонента (конечно, в том случае, если это реально можно сделать).

Перечислю ряд важных настроек, которые мы вынесли в параметры вызова компонента nlmk:element.grid:

GRID_ID — идентификатор грида PROVIDER — класс-провайдер для извлечения и фильтрации данных FILTER — класс для фильтрации данных VIEW_FIELDS — массив символьных кодов отображаемых полей SORTABLE_FIELDS — массив символьных кодов полей, для которых допустима сортировка FILTER_FIELDS — массив символьных кодов полей, для которых допустима фильтрация SCOPE — предопределенный фильтр, который пользователь не может изменить

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

// Пример вызова компонента nlmk:element.grid
$APPLICATION->IncludeComponent(
    'nlmk:element.grid',
    '',
    [
        'GRID_ID' => 'clients-grid', // идентификатор грида
        'CLASS' => ClientsTable::class, // orm класс, используется совместно с OrmProvider
        'PROVIDER' => OrmProvider::class, // название класса провайдера
        'FILTER' => OrmFilter::class, // название класса фильтра
        'VIEW_FIELDS' => [
            'ID',
            'NAME',
            'LAST_NAME',
            'SECOND_NAME',
            'WORK_POSITION',
            'COMPANY',
        ],
        'SORTABLE_FIELDS' => [
            'ID',
            'NAME',
            'LAST_NAME',
            'SECOND_NAME',
            'WORK_POSITION',
        ],
        'USE_FILTER' => 'Y',
        'USE_SORTING' => 'Y',
        'USE_NAVIGATION' => 'Y',
        'BUTTONS' => [],
        'FILTER_FIELDS' => [
            'NAME',
            'LAST_NAME',
            'SECOND_NAME'
        ],
        'SCOPE' => [], // предопределенный фильтр
        'ACTIONS' => [],
    ],
    false
);

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

Также часто удобно использовать параметр SCOPE — это предопределенный фильтр. Обычно он используется для решения задачи разграничения прав доступа. Например, мы подключаем грид для отображения списка заявок, но при этом обычный пользователь (не администратор) должен иметь возможность просматривать только свои заявки.

Мы реализовали компонент так, что SCOPE нельзя отменить в фильтре грида клиенту самостоятельно.

// Пример использования SCOPE
'SCOPE' => ($user->isAdmin()) ? ['=PROPERTY_USER_ID' => $user->getId()] : [],

В классе компонента nlmk:element.grid в методе executeComponent создаём экземпляр выбранного класса провайдера и настраиваем его в соответствии с переданным массивом параметров.

Инициализация провайдера
// создаём экземпляр класса провайдера
$provider = new $this->arParams['PROVIDER']($this->arParams);
 
$this->setProviderActions($provider, $this->arParams['ACTIONS'] ?: []);
$this->processExecutableActions($provider);
 
$provider->setAvailableFields($this->arParams['VIEW_FIELDS']);
 
// задаём предопределённый фильтр
if($this->hasScope()) {
    $provider->setScope($this->getScope());
}
 
// работаем с пагинацией
if ($this->isNavigationUsed() && $provider instanceof ProviderNavigation) {
    $provider->setNavigationInstance($this->getGridNav());
} else {
    if($this->arParams['PAGE_SIZE']) {
        $provider->setLimit($this->arParams['PAGE_SIZE']);
    }
}
 
// добавляем возможность сортировки, если предусмотрели это для провайдера
if ($provider instanceof Sortable) {
    $provider->setSortingActive($this->arParams['USE_SORTING'] === 'Y');
     
    $this->arResult['CURRENT_SORT'] = $this->getObGridParams()->getSorting(['sort' => $this->getDefaultSorting()])['sort'];
    $provider->setSorting($this->arResult['CURRENT_SORT']);
     
    if (!empty($this->arParams['SORTABLE_FIELDS'])) {
        $provider->setSortableFields($this->arParams['SORTABLE_FIELDS']);
    }
}
 
// инициализируем фильтр
if ($this->arParams['USE_FILTER'] === 'Y') {
    $filter = new $this->arParams['FILTER']();
     
    $provider->setFilterInstance($filter);
    $filter->setProvider($provider);
    $filter->setFilterFields($this->arParams['FILTER_FIELDS']);
    $filter->setFilterOptions(new Options($this->getGridId()));
    $this->arResult['GRID_FILTER'] = $filter->getFields();
}
 
if($provider instanceof ProviderNavigation && $provider->hasNavigation()) {
    $this->arResult['GRID_NAV'] = $provider->getNavigationInstance();
}
 
$this->arResult['GRID_ID'] = $this->getGridId();
$this->arResult['GRID_HEAD'] = $provider->getColumns();
 
// записываем данные для текущей страницы пагинации
$this->arResult['GRID_BODY'] = $provider->getRows();
$this->arResult['GRID_GROUP_ITEMS_ACTIONS'] = $this->getGridGroupItemsActions($provider);

И, наконец, в шаблоне nlmk:element.grid происходит подключение bitrix:main.ui.grid и bitrix:main.ui.filter:

Подключение bitrix:main.ui.filter
if ($arParams['USE_FILTER'] === 'Y' && !empty($arResult['GRID_FILTER'])) {
    $APPLICATION->IncludeComponent(
        'bitrix:main.ui.filter',
        $arParams['FILTER_TEMPLATE'],
        [
            'FILTER_ID' => $arResult['GRID_ID'],
            'GRID_ID' => $arResult['GRID_ID'],
            'FILTER' => $arResult['GRID_FILTER'],
            'ENABLE_LIVE_SEARCH' => true,
            'ENABLE_LABEL' => true,
            'FILTER_PRESETS' => $arParams['FILTER_PRESETS'] ?: [],
        ]
    );
}
Подключение bitrix:main.ui.grid
$APPLICATION->IncludeComponent(
    'bitrix:main.ui.grid',
    $arParams['GRID_TEMPLATE'],
    [
        'GRID_ID' => $arResult['GRID_ID'],
        'COLUMNS' => $arResult['GRID_HEAD'],
        'ROWS' => $arResult['GRID_BODY'],
        'NAV_OBJECT' => $arResult['GRID_NAV'],
        'AJAX_MODE' => 'Y',
        'AJAX_ID' => CAjax::getComponentID('bitrix:main.ui.grid', '.default', ''),
        'PAGE_SIZES' => [
            ['NAME' => '5', 'VALUE' => '5'],
            ['NAME' => '10', 'VALUE' => '10'],
            ['NAME' => '20', 'VALUE' => '20'],
            ['NAME' => '50', 'VALUE' => '50'],
            ['NAME' => '100', 'VALUE' => '100']
        ],
        'AJAX_OPTION_JUMP' => 'N',
        'SHOW_ROW_CHECKBOXES' => $arResult['SHOW_ROW_CHECKBOXES'],
        'SHOW_ACTION_PANEL' => $arResult['SHOW_ACTION_PANEL'],
        'SHOW_CHECK_ALL_CHECKBOXES' => $arParams['SHOW_CHECK_ALL_CHECKBOXES'],
        'SHOW_ROW_ACTIONS_MENU' => $arParams['SHOW_ROW_ACTIONS_MENU'],
        'SHOW_GRID_SETTINGS_MENU' => $arParams['SHOW_GRID_SETTINGS_MENU'],
        'SHOW_NAVIGATION_PANEL' => true,
        'SHOW_PAGINATION' => true,
        'SHOW_SELECTED_COUNTER' => false,
        'SHOW_TOTAL_COUNTER' => ($arParams['USE_TOTAL_COUNT'] === 'Y') ? true : false,
        'TOTAL_ROWS_COUNT' => $arResult['GRID_NAV'] ? $arResult['GRID_NAV']->getRecordCount() : false,
        'SHOW_PAGESIZE' => true,
        'ACTION_PANEL' => [
            'GROUPS' => [
                'TYPE' => [
                    'ITEMS' => $arResult['GRID_GROUP_ITEMS_ACTIONS'],
                ]
            ],
        ],
        'ALLOW_COLUMNS_SORT' => true,
        'ALLOW_COLUMNS_RESIZE' => true,
        'ALLOW_HORIZONTAL_SCROLL' => true,
        'ALLOW_SORT' => true,
        'ALLOW_PIN_HEADER' => true,
        'AJAX_OPTION_HISTORY' => 'N'
    ]
);

Преимущества использования nlmk:element.grid

Перечислю преимущества использования нашего подхода с nlmk:element.grid по сравнению с прямым подключением стандартного компонента bitrix:main.ui.grid.

  • Решается проблема дублирования кода для типовых кейсов подключения bitrix:main.ui.grid (работа с инфоблоками, хайлоадблоками, orm-классами, пользователями и т. д.).

  • Аналогично легко решается вопрос с фильтрацией данных в гридах (с использованием  универсальных родительских классов фильтра).

  • Значительно ускоряется написание кода взаимодействия с данными в БД (базовые родительские классы уже разработаны). 

  • Если не требуется реализации нестандартного функционала, то вообще не тратится время на написание нового кода: разработчику достаточно подключить компонент nlmk:element.grid с указанием ранее реализованного типового класса-провайдера.

  • Упрощается понимание кода портала разработчиками. Вопрос реализации гридов решается одинаково для самых разных сервисов.

  • Ускоряется процесс подключения разработчиков к незнакомым им сервисам. 

  • В принципе ускоряется процесс онбординга новых разработчиков.

Примечание: Выбор подхода с использованием универсальных компонентов (не только при работе с гридами) для разных сервисов даёт организационные преимущества. Нагрузку на разработчиков можно планировать гораздо более гибко — единые принципы разработки сервисов позволяют быстро и эффективно переключать сотрудников между задачами разных направлений.

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

Вывод

Использование единого компонента nlmk:element.grid для удобной работы с табличными данными значительно облегчило жизнь разработчиков.

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

Более того, один раз познакомившись с универсальным компонентом гридов, разработчики могут гораздо быстрее изучать и понимать код других сервисов, в которых также ведётся работа с таблицами.

В конечном итоге ощутимо ускорилась (и, соответственно, стала дешевле) разработка сервисов — появились новые организационные возможности для реализации нетипового функционала.