Кейс Битрикс24: Внедрение кастомизированного календаря в форму сделки Битрикс24

Кейс Битрикс24: Внедрение кастомизированного календаря в форму сделки Битрикс24

20 Мая 2021
Внедрение кастомизированного календаря в форму сделки Битрикс24 (коробочная версия)

Рассмотрим решение реальной задачи, реализованное нашими специалистами и которое может быть полезно разработчикам, занимающимся доработкой и расширением функционала коробочной версии Битрикс24 под конкретные задачи.

Задача: Добавить поле выбора даты в формате «mm.yyyy» из всплывающего календаря. Значение необходимо сохранять в строковом формате.

Описание проблемы

Штатное пользовательское поле типа «Дата» или «Дата/Время» для решения задачи не подошло, так как встроенный календарь BX.calendar не поддерживает форматы отличные от «dd.mm.yyyy» и «dd.mm.yyyy H:i:s».

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

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

Решение

Новая форма сделки более интерактивная, чем старая и сочетает в себе форму редактирования и просмотра, в которой блоки или поля по клику превращаются в элементы ввода.

Приступаем к решению задачи:

  • Создаем пользовательское поле “срок реализации”, в нашем случае это будет UF_DATE_RELEASE объект CRM_DEAL, регулярное выражение для проверки /[0-9]{2,2}\.[0-9]{4,4}/.
  • В качестве календаря будет использован плагин bootstrap-datepicker.

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

Вариант 1

Использовать setInterval. Но поле после редактирования и сохранения удаляется DOM и все события, привязанные к нему, так же удаляются то setInterval будет работать постоянно так как clearInterval не представляется возможным использовать.

Вариант 2

Использовать MutationObserver и возложить отслеживание изменений DOM на браузер.

Выбираем второй вариант как наиболее оптимальный. Создаем скрипт listen_dom.js размещаем в директории /local/js/listen_dom.js

(function(win) {
    'use strict';
    var listeners = [],
        doc = win.document,
        MutationObserver = win.MutationObserver || win.WebKitMutationObserver,
        observer;
    function ready(selector, fn) {
        listeners.push({
            selector: selector,
            fn: fn
        });
        if (!observer) {
            observer = new MutationObserver(check);
            observer.observe(doc.documentElement, {
                childList: true,
                subtree: true
            });
        }
        check();
    }

    function check() {
        for (var i = 0, len = listeners.length, listener, elements; i < len; i++) {
            listener = listeners[i];
            elements = doc.querySelectorAll(listener.selector);
            for (var j = 0, jLen = elements.length, element; j < jLen; j++) {
                element = elements[j];
                if (!element.ready) {
                    element.ready = true;
                    listener.fn.call(element, element);
                }
            }
        }
    }
    win.ready = ready;
})(this);

Подключаем его в init.php:

\Bitrix\Main\Page\Asset::getInstance()->addJs('/local/js/listen_dom.js');

В результате нам доступна функция отслеживания изменений в DOM ready с callback найденного элемента. Данная функция универсальна и может быть использована для отслеживания появления по селектору.

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

ready('input[name="UF_DATE_RELEASE"]', function(element) {
      let release = $(element);
      release.datepicker({
         format: "mm.yyyy",
         startView: 1,
         minViewMode: 1,
         language: "ru",
         autoclose: true
      });
});

Определяем полученный элемент как объект jQuery и инициализируем календарь. При таком способе присутствует следующий нюанс: так инициализация input и календаря происходит одновременно, при фокусе на input календарь не открывается. Чтобы календарь появился нужно, чтобы input потерял и получил фокус снова, что нас не устраивает. Поэтому дополнительно добавляем событие открытия календаря по клику, для этих целей используем объект BX. Получаем такой код:

ready('input[name="UF_DATE_RELEASE"]', function(element) {
   let release = $(element);
      release.datepicker({
         format: "mm.yyyy",
         startView: 1,
         minViewMode: 1,
         language: "ru",
         autoclose: true
      });
      BX.bind(BX(element), 'click', (event)=>{
         release.datepicker('show');
      });
});

С помощью BX.bind добавляем слушателя на событие click элемента input. BX(element) создает объект на основе нативного элемента release аналогично jQuery. Теперь при инициализации элемента input и одновременного получения фокуса календарь появляется. Вот такая реализация получилась:

calendar.png

C теоретической точки зрения вроде все хорошо: при выборе даты плагин заменяет значение input на новое. Но после нажатия на кнопку "Сохранить" ничего не происходит. Теперь предстоит разобраться почему заполненное поле не сохраняется.

За генерацию формы отвечает компонент crm.entity.editor и, так как мы работаем с frontend частью, нам необходим шаблон данного компонента /bitrix/components/bitrix/crm.entity.editor/templates/.default/template.php

В данном шаблоне находится инициализация формы редактирования:

X.Crm.EntityEditor.setDefault(
   BX.Crm.EntityEditor.create(
      "",
      {
       //……
         model: model,
       //……
      }
   )
);

Как видно из этого фрагмента, редактор работает с объектом model, в котором хранит все данные. Инициализация данной модели выглядит следующим образом:

var model = BX.Crm.EntityEditorModelFactory.create(
   ,
   "",
   { data:  }
);

Сам редактор хранится в файле: /bitrix/js/ui/entity-editor/js/editor.js и мы постепенно подбираемся к сути. В данном файле присутствует функция Save:

save: function()
{
   if(this._toolPanel)
   {
      this._toolPanel.setLocked(true);
   }
   var result = BX.UI.EntityValidationResult.create();
   this.validate(result).then(
      BX.delegate(
         function()
         {
             //……
         },
         this
      )
   ).then(
      BX.delegate(
         function()
         {
            if(result.getStatus())
            {
               this.innerSave();
               //……
            }
            else
            {
                //……
            }
         },
         this
      )
   );
//……
}

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

innerSave: function()
{
   //……

   var i, length;
   //……

   for(i = 0, length = this._activeControls.length; i < length; i++)
   {
      var control = this._activeControls[i];

      control.save();
      control.onBeforeSubmit();

     //……
   } 

   //……

   if(this._ajaxForm) //как раз инициирует отправку и перезагрузку формы
   {
      //……
      this._ajaxForm.submit();
   }
   //endregion
}

Больше всего нас интересует данный участок кода:

for(i = 0, length = this._activeControls.length; i < length; i++)
{
   var control = this._activeControls[i];

   control.save();
   control.onBeforeSubmit();

   //……
}

Здесь как раз идет перебор активных участков редактирования. Это дает нам отправную точку. Мы не можем попасть в функцию save (сохранение), не вызвав ее напрямую, значит где-то есть контроль отслеживания изменений, после которых эта функция вызывается. Продвигаемся дальше, нам интересны объекты Control, которые расположены по адресу /bitrix/js/ui/entity-editor/js/control.js Здесь мы находим наконец-то нужную нам функцию:

markAsChanged: function(params)
{
   if(typeof(params) === "undefined")
   {
      params = {};
   }

   var control = BX.prop.get(params, "control", null);
   if(!(control && control instanceof BX.UI.EntityEditorControl))
   {
      control = params["control"] = this;
   }

   if(!control.isInEditMode())
   {
      return;
   }

   if(!this._isChanged)
   {
      this._isChanged = true;
   }

   this.notifyChanged(params);
}

Эта функция помечает Control как измененный, что как раз позволит функции save отработать и совершить нужную нам операцию. Данная функция вызывается на onChange, но в нашей ситуации событие change не происходит, так происходит замена value тега input через js. 

Применим полученные знания на практике. Для того чтобы вносить изменения, необходимо получить активный экземпляр редактора. Он глобальный, поэтому мы можем его использовать в своем скрипте. Проверим в нашем скрипте объект BX.Crm.EntityEditor на существование. Данный объект содержит в себе все активные редакторы:

  • Префикс DEAL_ - редактор сделки
  • Префикс COMPANY_ - редактор компании
  •  Префикс CONTACT_  - редактор контакта

Переберем списки редакторов в поисках нужного нам, он имеет префикc DEAL_. Получаем нужный ключ и извлекаем активный редактор в переменную через метод BX.Crm.EntityEditor.get(%ПОЛУЧЕННЫЙ_КЛЮЧ%). Теперь, имея активный редактор, мы можем перебрать все объекты Control, найти наш и пометить его как измененный.

ready('input[name="UF_DATE_RELEASE"]', function(element) {
   let release = $(element);
      release.datepicker({
         format: "mm.yyyy",
         startView: 1,
         minViewMode: 1,
         language: "ru",
         autoclose: true
      }).on('changeDate', function(ev){
         let editor = {};
         if(BX.Crm.EntityEditor !== undefined) {
            for(var k in BX.Crm.EntityEditor.items) {
               if(/deal_/.test(k)) {
                  editor = BX.Crm.EntityEditor.get(k);
                  var i, length;
                  for(i = 0, length = editor._activeControls.length; i < length; i++) {
                     var control = editor._activeControls[i];
                     if(control._id == "UF_DATE_RELEASE") {
                        control.markAsChanged();
                     }
                  }
               }
            }
         }
      });
      BX.bind(BX(element), 'click', (event)=>{
         release.datepicker('show');
      });
});

Дополнительно нам необходимо учесть момент комплексной правки формы через ссылку "Изменить", так как в этом случае _activeControls в себе содержит перечень измененных полей и его структура отличается от одиночного. Сделаем перебор полей в активном контроле, найдем нужный нам и пометим его как измененный. Финальный вариант:

ready('input[name="UF_DATE_RELEASE"]', function(element) {
   let release = $(element);
      release.datepicker({
         format: "mm.yyyy",
         startView: 1,
         minViewMode: 1,
         language: "ru",
         autoclose: true
      }).on('changeDate', function(ev){
         let editor = {};
         if(BX.Crm.EntityEditor !== undefined) {
            for(var k in BX.Crm.EntityEditor.items) {
               if(/deal_/.test(k)) {
                  editor = BX.Crm.EntityEditor.get(k);
                  var i, length;
                  for(i = 0, length = editor._activeControls.length; i < length; i++) {
                     var control = editor._activeControls[i];
                     if(control._fields !== undefined) {
                        for(var f in control._fields){
                           let field = control._fields[f];
                           if (field._id == "UF_DATE_RELEASE") {
                              control.markAsChanged();
                           }
                        }
                     } else {
                        if (control._id == "UF_DATE_RELEASE") {
                           control.markAsChanged();
                        }
                     }
                  }
               }
            }
         }
      });
      BX.bind(BX(element), 'click', (event)=>{
         release.datepicker('show');
      });
});

Итог

Вот так с помощью MutationObserver и небольшого скрипта можно интегрировать сторонние js-плагины, которые вносят изменения посредством изменения value. 

Жаль, что документация Битрикса по JS-скриптам системы очень скудна. Вполне возможно, что можно было решить задачу другим способом, но данный способ полностью решил поставленную задачу.

Так же может быть интересно