This post automatically translated

Task

Add a date selection field in the format “mm.yyyy” from the popup calendar. The value must be saved in string format.

Description of the problem

The standard user field of the “Date” or “Date/Time” type was not suitable for solving the problem, since the built-in BX.calendar calendar does not support formats other than “dd.mm.yyyy” and “dd.mm.yyyy H:i:s”.

Creating your own custom data type for such a task is redundant, as is copying a template or component, since losing the ability to update due to a small modification is highly undesirable.

Therefore, it was decided to use a custom data type string with the connection of a third-party calendar on JS in the form of a jQuery plugin. You can take any calendar on native JS, but since the project already included jQuery for other tasks, we settled on this option.

Solution

The new deal form is more interactive than the old one and combines an editing and viewing form, where blocks or fields turn into input elements when clicked.

Let’s start solving the problem:

  • Create a custom field “implementation period”, in our case it will be UF_DATE_RELEASE object CRM_DEAL, regular expression for checking /[0-9]{2,2}.[0-9]{4,4}/.
  • Bootstrap-datepicker plugin will be used as a calendar.

We need to track when this field will appear on the deal page, you can use one of the options below: Option 1

Use setInterval. But the field after editing and saving is deleted by DOM and all events bound to it are also deleted, then setInterval will work constantly since clearInterval is not possible to use.

Option 2

Use MutationObserver and let the browser monitor DOM changes.

We choose the second option as the most optimal. We create the script listen_dom.js and place it in the directory /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);

We connect it in init.php:

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

As a result, we have access to the function of tracking changes in DOM ready with the callback of the found element. This function is universal and can be used to track the appearance by selector.

Now we need to track the creation of the element we need and, when this selector appears, initialize the calendar on it. In our case, this code will look like this:

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

We define the received element as a jQuery object and initialize the calendar. This method has the following nuance: the initialization of the input and the calendar occurs simultaneously, when the input is focused, the calendar does not open. In order for the calendar to appear, the input needs to lose and gain focus again, which does not suit us. Therefore, we additionally add an event for opening the calendar on click, for these purposes we use the BX object. We get the following code:

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');
      });
});

Using BX.bind we add a listener to the click event of the input element. BX(element) creates an object based on the native release element, similar to jQuery. Now, when the input element is initialized and the focus is received at the same time, the calendar appears. Here is the implementation.

From a theoretical point of view, everything seems to be fine: when selecting a date, the plugin replaces the input value with a new one. But after clicking the “Save” button, nothing happens. Now we need to figure out why the filled field is not saved.

The crm.entity.editor component is responsible for generating the form, and since we are working with the frontend part, we need a template for this component /bitrix/components/bitrix/crm.entity.editor/templates/.default/template.php

This template contains the initialization of the editing form:

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

As you can see from this fragment, the editor works with the model object, in which it stores all the data. The initialization of this model looks like this:

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

The editor itself is stored in the file: /bitrix/js/ui/entity-editor/js/editor.js and we are gradually getting to the point. This file contains the Save function:

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
      )
);
//……
}

If you use this function directly, there will be an automatic call to save, which is not very convenient when several sections are edited and the calendar is used at the beginning or in the middle. In the Save function, we are interested in the innerSave function, since it is called after successful form validation:

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
}

We are most interested in this section of code:

for(i = 0, length = this._activeControls.length; i < length; i++) {
var control = this._activeControls[i];
control.save();
control.onBeforeSubmit();
//……
}

Here we are just sorting through the active editing areas. This gives us a starting point. We can’t get to the save function without calling it directly, so somewhere there is a change tracking control, after which this function is called. Moving on, we are interested in the Control objects, which are located at /bitrix/js/ui/entity-editor/js/control.js Here we finally find the function we need:

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);
}

This function marks Control as changed, which will allow the save function to work and perform the operation we need. This function is called on onChange, but in our situation the change event does not occur, so the value of the input tag is replaced via js.

Let’s apply the acquired knowledge in practice. In order to make changes, you need to get an active copy of the editor. It is global, so we can use it in our script. Let’s check the existence of the BX.Crm.EntityEditor object in our script. This object contains all active editors:

  • DEAL_ prefix - deal editor
  • COMPANY_ prefix - company editor
  • CONTACT_ prefix - contact editor

Let’s go through the lists of editors in search of the one we need, it has the DEAL_ prefix. We get the required key and extract the active editor into a variable via the BX.Crm.EntityEditor.get(%RECEIVED_KEY%) method. Now that we have the editor active, we can loop through all the Control objects, find ours, and mark it as modified.

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');
      });
});

Additionally, we need to take into account the moment of complex editing of the form via the “Edit” link, since in this case _activeControls contains a list of changed fields and its structure differs from a single one. Let’s iterate over the fields in the active control, find the one we need and mark it as changed. The final version:

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');
      });
});

Summary

This is how you can integrate third-party js plugins that make changes by changing value using MutationObserver and a small script.

It’s a pity that Bitrix documentation on the system’s JS scripts is very poor. It is quite possible that the problem could have been solved in another way, but this method completely solved the problem.