06.07.2019

Пользовательское свойство инфоблока, для связи с ORM таблицами

Кастомное свойство инфоблока, для связи с ORM таблицами

Сколько раз вам доводилось создавать инфоблоки со свойствами в которые нужно вводить какие либо числовые (или буквенные) значения? Это могли быть привязки к группам пользователя, или службам доставки, оплаты. А как насчет привязки к местоположению с вводом в элемент его CODE?!. Всегда хотелось иметь возможность "нормального" интерфейса для этих привязок.

Так появился на свет данный функционал. Как он работает?
В Битриксе имеется возможность создавать пользовательские свойства для ИБ. Пользовательские свойства модуля информационных блоков позволяют изменять представление (формы ввода и т.п.) стандартных свойств расширяя их возможности. Фактически такое свойство представляет собой набор обработчиков событий вызываемых при построении административного интерфейса, публичной части сайта или API функций. При первом обращении к методам пользовательских свойств вызываются обработчики события OnIBlockPropertyBuildList. Строится список свойств и при необходимости вызываются их методы. Примеры конкретной реализации свойств можно посмотреть в файлах модуля информационных блоков classes/general/prop_*.php .
Подробнее об API пользовательских свойств можно почитать в документации

Рассмотрим задачу на основе привязки к службе доставки. Основная задумка в том, чтобы можно было на странице элемента вместо ввода ID службы, выбрать метод доставки в привычном виде - с помощью dropdown или select.
Выбор службы доставки
В идеале надо сделать так, чтобы не нужно было для каждой сущности: доставка, оплата, группа пользователей - создавать своё пользовательское свойство. Так появилась идея делать привязку к ORM таблице.
Настройка пользовательского свойства
Как видно из скриншота, можно задать модуль который автоматически подключиться, ORM класс, из которого будут получаться данные, а так же два поля: одно будет записываться в БД, а другое показываться пользователю. Небольшим дополнением является выбор внешнего вида элемента управления.

Моя реализация данной задачи это несколько классов, подключенных через autoload.
Структура файлов
Структура классов

Base.php

<?php

namespace Gricuk\Iblock\EnumProperty;


use Bitrix\Main\Loader;

class Base extends \Bitrix\Main\UserField\TypeBase
{
    const USER_TYPE_ID = "customList";
    protected $property = NULL;

    /** Интерфейсный метод, который описывает всю работу со свойством
     * @return array
     */
    public static function GetUserTypeDescription()
    {
        $params = array(
            "USER_TYPE" => static::USER_TYPE_ID,
            "USER_TYPE_ID" => static::USER_TYPE_ID,
            "CLASS_NAME" => __CLASS__,
            "DESCRIPTION" => "Кастомный список",
            "BASE_TYPE" => \CUserTypeManager::BASE_TYPE_STRING,
            "GetPropertyFieldHtml" => [__CLASS__, 'GetEditFormHTML'],
            "GetEditFormHTML" => [__CLASS__, "GetEditFormHTML"],
            "PrepareSettings" => [__CLASS__, 'PrepareSettings'],
            "GetSettingsHTML" => [__CLASS__, 'GetSettingsHTML']
        );

        return $params;
    }

    public function __construct($property)
    {
        $this->property = $property;
    }

    /** Интерфейсный метод, который описывает тип поля в БД
     * @param $arUserField
     * @return string
     */
    public static function GetDBColumnType($arUserField)
    {
        global $DB;
        switch (strtolower($DB->type)) {
            case "mysql":
                return "text";
            case "oracle":
                return "varchar2(2000 char)";
            case "mssql":
                return "varchar(2000)";
        }
        return "text";
    }

    /**Подгатавливает массив настроект для записи в БД
     * @param $arUserField
     * @return array
     */
    function PrepareSettings($arUserField)
    {
        $module = trim($arUserField["USER_TYPE_SETTINGS"]["MODULE"]);
        $ormFieldCodeId = trim($arUserField["USER_TYPE_SETTINGS"]["ORM_FIELD_CODE_ID"]);
        $ormFieldCodeName = trim($arUserField["USER_TYPE_SETTINGS"]["ORM_FIELD_CODE_NAME"]);
        $ormClass = trim($arUserField["USER_TYPE_SETTINGS"]["ORM_CLASS"]);
        $height = intval($arUserField["USER_TYPE_SETTINGS"]["LIST_HEIGHT"]);
        $disp = $arUserField["USER_TYPE_SETTINGS"]["DISPLAY"];
        $caption_no_value = trim($arUserField["USER_TYPE_SETTINGS"]["CAPTION_NO_VALUE"]);
        $show_no_value = $arUserField["USER_TYPE_SETTINGS"]["SHOW_NO_VALUE"] === 'N' ? 'N' : 'Y';

        if ($disp !== "CHECKBOX" && $disp !== "LIST" && $disp !== 'UI') {
            $disp = "LIST";
        }

        return array(
            "MODULE" => $module,
            "ORM_CLASS" => $ormClass,
            "ORM_FIELD_CODE_ID" => $ormFieldCodeId,
            "ORM_FIELD_CODE_NAME" => $ormFieldCodeName,
            "DISPLAY" => $disp,
            "LIST_HEIGHT" => ($height < 1 ? 1 : $height),
            "CAPTION_NO_VALUE" => $caption_no_value, // no default value - only in output
            "SHOW_NO_VALUE" => $show_no_value, // no default value - only in output
        );
    }

    /** Интерфейсный метод, который описывает настройки поля в настройках свойства
     * @param bool $arProperty
     * @param $strHTMLControlName
     * @param $arPropertyFields
     * @return string
     */
    public static function GetSettingsHTML($arProperty = false, $strHTMLControlName, &$arPropertyFields)
    {
        $result = '';
        $userProperties = $arProperty["USER_TYPE_SETTINGS"];

        $value = $userProperties["MODULE"];
        $result .= '
		<tr>
			<td class="adm-detail-valign-top">' . 'Модуль' . ':</td>
			<td>
				<input type="text" name="' . $strHTMLControlName["NAME"] . '[MODULE]" value="' . $value . '">
			</td>
		</tr>
		';

        $value = $userProperties["ORM_CLASS"];
        $result .= '
		<tr>
			<td class="adm-detail-valign-top">' . 'ORM класс' . ':</td>
			<td>
				<input type="text" name="' . $strHTMLControlName["NAME"] . '[ORM_CLASS]" value="' . $value . '">
			</td>
		</tr>
		';

        if ($userProperties["ORM_FIELD_CODE_ID"]) {
            $value = trim($userProperties["ORM_FIELD_CODE_ID"]);
        } else {
            $value = '';
        }

        $result .= '
		<tr>
			<td>' . "Код поля для ID" . ':</td>
			<td>
				<input type="text" name="' . $strHTMLControlName["NAME"] . '[ORM_FIELD_CODE_ID]" size="10" value="' . htmlspecialcharsbx($value) . '">
			</td>
		</tr>
		';

        if ($userProperties["ORM_FIELD_CODE_NAME"]) {
            $value = trim($userProperties["ORM_FIELD_CODE_NAME"]);
        } else {
            $value = '';
        }

        $result .= '
		<tr>
			<td>' . "Код поля для отображения" . ':</td>
			<td>
				<input type="text" name="' . $strHTMLControlName["NAME"] . '[ORM_FIELD_CODE_NAME]" size="10" value="' . htmlspecialcharsbx($value) . '">
			</td>
		</tr>
		';

        if ($userProperties["DISPLAY"]) {
            $value = $userProperties["DISPLAY"];
        } else {
            $value = "LIST";
        }

        $result .= '
		<tr>
			<td class="adm-detail-valign-top">' . "Способ отображения" . ':</td>
			<td>
				<label><input type="radio" name="' . $strHTMLControlName["NAME"] . '[DISPLAY]" value="LIST" ' . ("LIST" == $value ? 'checked="checked"' : '') . '>' . "Список" . '</label><br>
				<label><input type="radio" name="' . $strHTMLControlName["NAME"] . '[DISPLAY]" value="CHECKBOX" ' . ("CHECKBOX" == $value ? 'checked="checked"' : '') . '>' . "Чекбоксы" . '</label><br>
				<label><input type="radio" disabled name="' . $strHTMLControlName["NAME"] . '[DISPLAY]" value="UI" ' . ("UI" == $value ? 'checked="checked"' : '') . '>' . "UI" . '</label><br>
			</td>
		</tr>
		';


        if ($userProperties["LIST_HEIGHT"]) {
            $value = intval($userProperties["LIST_HEIGHT"]);
        } else {
            $value = 5;
        }

        $result .= '
		<tr>
			<td>' . 'Высота списка' . ':</td>
			<td>
				<input type="text" name="' . $strHTMLControlName["NAME"] . '[LIST_HEIGHT]" size="10" value="' . $value . '">
			</td>
		</tr>
		';

        if ($userProperties["CAPTION_NO_VALUE"]) {
            $value = trim($userProperties["CAPTION_NO_VALUE"]);
        } else {
            $value = '';
        }

        $result .= '
		<tr>
			<td>' . "Подпись при отсутствии значения" . ':</td>
			<td>
				<input type="text" name="' . $strHTMLControlName["NAME"] . '[CAPTION_NO_VALUE]" size="10" value="' . htmlspecialcharsbx($value) . '">
			</td>
		</tr>
		';

        if ($userProperties["CAPTION_NO_VALUE"]) {
            $value = trim($userProperties["CAPTION_NO_VALUE"]);
        } else {
            $value = '';
        }


        if ($userProperties["SHOW_NO_VALUE"]) {
            $value = trim($userProperties["SHOW_NO_VALUE"]);
        } else {
            $value = '';
        }


        $result .= '
		<tr>
			<td>' . "Показывать пустое значение для обязательного поля" . ':</td>
			<td>
				<input type="hidden" name="' . $strHTMLControlName["NAME"] . '[SHOW_NO_VALUE]" value="N" />
				<label><input type="checkbox" name="' . $strHTMLControlName["NAME"] . '[SHOW_NO_VALUE]" value="Y" ' . ($value === 'N' ? '' : ' checked="checked"') . ' /> ' . GetMessage('MAIN_YES') . '</label>
			</td>
		</tr>
		';

        return $result;
    }

    /**интерфейсный метод для вывода HTML на странице редактирования элемента
     * @param $arProperty
     * @param $propertyValue
     * @param $propertyFormCfg
     * @return string
     * @throws \Bitrix\Main\ArgumentException
     */
    function GetEditFormHTML($arProperty, $propertyValue, $propertyFormCfg)
    {
        $enum = static::getEnumList($arProperty);

        if (empty($enum))
            return '';

        $propertyFactory = new Factory();
        $property = $propertyFactory->getProperty($arProperty);
        $result = $property->getEditHTML($enum, $propertyValue, $propertyFormCfg);
        return $result;
    }

    /** интерфейсный метод для вывода HTML в списке
     * @param $arUserField
     * @param $arHtmlControl
     * @return string
     */
    public static function GetFilterHTML($arUserField, $arHtmlControl)
    {
        if (!is_array($arHtmlControl["VALUE"]))
            $arHtmlControl["VALUE"] = array();

        $enum = static::getEnumList($arUserField);
        if (empty($enum))
            return '';

        if ($arUserField["SETTINGS"]["LIST_HEIGHT"] < 5)
            $size = ' size="5"';
        else
            $size = ' size="' . $arUserField["SETTINGS"]["LIST_HEIGHT"] . '"';

        $result = '<select multiple name="' . $arHtmlControl["NAME"] . '[]"' . $size . '>';
        $result .= '<option value=""' . (!$arHtmlControl["VALUE"] ? ' selected' : '') . '>' . GetMessage("MAIN_ALL") . '</option>';
        foreach ($enum as $idEnum => $enumName) {
            $result .= '<option value="' . $idEnum . '"' . (in_array($idEnum, $arHtmlControl["VALUE"]) ? ' selected' : '') . '>' . $enumName . '</option>';
        }
        $result .= '</select>';
        return $result;
    }

    /**
     * @param $arUserField
     * @param $arHtmlControl
     * @return array
     */
    function GetFilterData($arUserField, $arHtmlControl)
    {
        $items = static::getEnumList($arUserField);
        return array(
            "id" => $arHtmlControl["ID"],
            "name" => $arHtmlControl["NAME"],
            "type" => "list",
            "items" => $items,
            "params" => array("multiple" => "Y"),
            "filterable" => ""
        );
    }

    /** Вовзращает  массив из таблицы описанный в ORM_CLASS
     * @param $arUserField
     * @param array $arParams
     * @return array
     */
    protected static function getEnumList(&$arUserField, $arParams = array())
    {
        $enum = array();
        $showNoValue = $arUserField["MANDATORY"] != "Y"
            || $arUserField['SETTINGS']['SHOW_NO_VALUE'] != 'N'
            || (isset($arParams["SHOW_NO_VALUE"]) && $arParams["SHOW_NO_VALUE"] == true);

        if ($showNoValue
            && ($arUserField["SETTINGS"]["DISPLAY"] != "CHECKBOX" || $arUserField["MULTIPLE"] <> "Y")
        ) {
            $enum = array(null => htmlspecialcharsbx(static::getEmptyCaption($arUserField)));
        }

        $ormFieldCodeId = $arUserField["USER_TYPE_SETTINGS"]["ORM_FIELD_CODE_ID"];
        $ormFieldCodeName = $arUserField["USER_TYPE_SETTINGS"]["ORM_FIELD_CODE_NAME"];

        try {
            Loader::includeModule($arUserField["USER_TYPE_SETTINGS"]["MODULE"]);
        } catch (\Exception $e) {
            return [];
        }

        /** @var \Bitrix\Main\ORM\Query\Result $rsEnum */
        $rsEnum = call_user_func_array(
            array($arUserField["USER_TYPE_SETTINGS"]["ORM_CLASS"], "getList"),
            array()
        );

        while ($arEnum = $rsEnum->fetch()) {
            $enum[$arEnum[$ormFieldCodeId]] = $arEnum[$ormFieldCodeName];
        }

        $arUserField["USER_TYPE"]["FIELDS"] = $enum;

        return $enum;
    }

    /** Возвращает то что надо выводить в случае отсутвия значения
     * @param $arUserField
     * @return mixed|string
     */
    protected static function getEmptyCaption($arUserField)
    {
        return $arUserField["SETTINGS"]["CAPTION_NO_VALUE"] <> ''
            ? $arUserField["SETTINGS"]["CAPTION_NO_VALUE"]
            : "не задано";
    }

    /** Возвращает HTML для вывода свойства в админке
     * @param $propertyValue
     * @param $propertyFormCfg
     */
    protected function getEditHTML($enum, $propertyValue, $propertyFormCfg)
    {

    }
}

Checkbox.php

<?php

namespace Gricuk\Iblock\EnumProperty;


class Checkbox extends Base
{
    public function getEditHTML($enum, $propertyValue, $propertyFormCfg)
    {
        $arProperty = $this->property;

        $bWasSelect = false;
        $result2 = '';
        foreach ($enum as $idEnum => $valEnum) {
            $bSelected = $propertyValue["VALUE"] == $idEnum;
            $bWasSelect = $bWasSelect || $bSelected;
            $result2 .= '<label><input type="radio" value="' . $idEnum . '" name="' . $propertyFormCfg["VALUE"] . '"' . ($bSelected ? ' checked' : '') . '>' . $valEnum . '</label><br>';
        }

        $result = $result2;
        return $result;
    }
}

Select.php

<?php

namespace Gricuk\Iblock\EnumProperty;


class Select extends Base
{
    public function getEditHTML($enum, $propertyValue, $propertyFormCfg)
    {
        $arProperty = $this->property;
        $bWasSelect = false;
        $result2 = '';
        foreach ($enum as $idEnum => $valEnum) {
            $bSelected = $propertyValue["VALUE"] == $idEnum;
            $bWasSelect = $bWasSelect || $bSelected;
            $result2 .= '<option value="' . $idEnum . '"' . ($bSelected ? ' selected' : '') . '>' . $valEnum . '</option>';
        }

        if ($arProperty["SETTINGS"]["LIST_HEIGHT"] > 1) {
            $size = ' size="' . $arProperty["SETTINGS"]["LIST_HEIGHT"] . '"';
        } else {
            $propertyValue["VALIGN"] = "middle";
            $size = '';
        }

        $result = '<select name="' . $propertyFormCfg["VALUE"] . '"' . $size . '>';

        $result .= $result2;
        $result .= '</select>';

        return $result;
    }
}

Factory.php

<?php

namespace Gricuk\Iblock\EnumProperty;


class Factory
{
    public function __construct()
    {
    }

    public function getProperty($property)
    {
        switch ($property["USER_TYPE_SETTINGS"]["DISPLAY"]) {
            case "CHECKBOX":
                return new Checkbox($property);
            case "LIST":
            default:
                return new Select($property);
        }
    }
}

Вот так подключаем все это к событию

<?
    $bxEventManager = \Bitrix\Main\EventManager::getInstance();

	/**
     * Регистрация пользовательского свойства "Кастомный список"
     */
    $bxEventManager->addEventHandler(
        "iblock",
        "OnIBlockPropertyBuildList",
        array(
            '\Gricuk\Iblock\EnumProperty\Base',
            "GetUserTypeDescription",
        )
    );