Автор: Денис Закусило
Приветствую всех неравнодушных! Это заключительная статья цикла о переходе от модульной архитектуры к сервисам.
[Записки тимлида] Битрикс: от модулей к сервисам
[Записки тимлида] Битрикс: от модулей к сервисам 2
Сегодня мы рассмотрим организацию структуры frontend стороны приложения.

Первым делом нам необходимо подключить node на сервере. В нашем случае мы добавим в docker-compose новую запись.
node:
       build:
           context: ./node
           args:
               UID: ${UID:-1000}
               GID: ${GID:-1000}
       volumes_from:
           - source
       links:
           - php
       environment:
           TZ: Europe/Moscow
           NODE_ENV: ${NODE_ENV:-production}
           HOST_FROM: ${HOST_FROM:-localhost}
       stdin_open: true
       tty: true
       networks:
           - bitrixdock
       extra_hosts:
           - "host.docker.internal:host-gateway"
       restart: unless-stoppedИ устанавливаем Bitrix cli по инструкции в нашем node/Dockerfile, прокидывая права нашего пользователя из родительской ОС.
FROM node:22-alpine
ARG UID
ARG GID
RUN echo http://dl-2.alpinelinux.org/alpine/edge/community/ >> /etc/apk/repositories
RUN apk add -U shadow
RUN npm install -g @bitrix/cli
RUN chown -R ${UID}:node /usr/local/lib/node_modules/
RUN usermod -u ${UID} node
WORKDIR "/var/www/bitrix"
EXPOSE 8888Организуем структуру, такую же, как на бекенде по DDD.

Выполним практическую задачу: Для сделки необходимо получать минимальную дату доставки от бекенда и запрещать оператору выбирать более ранние даты.
Решение:
1. Для начала нам нужно определить место для endpoint list, в котором было бы организовано хранилище адресов для запросов на бекенд.
Сделаем по аналогии с Enums php:
Мы создадим класс, который будет хранить константы.
1.1 Создаем модуль enums и переходим в директорию доменов. Для этого заходим в контейнер, переходим в домен и запускаем команду bitrix build enums:
docker compose exec -T -u node node sh  
cd <project_dir>/local/js/App/Domains
bitrix create enums
? Extension name: (enums) enums
? Extension name: enums
? Enable tests: (Y/n) Y
? Enable tests: Yes
? Use Browserslist: (Y/n) Y
? Use Browserslist: Yes
? Enable minification: (y/N) y
? Enable minification: Yes
? Enable sourceMaps: (Y/n) y
? Enable sourceMaps: Yes
   
   │   Success!                                               │
   │   Extension App.Domains.enums created                     │
   │   Run bitrix build -p ./enums for build extension         │
   │   Include extension in php                               │
   │   \Bitrix\Main\UI\Extension::load('App.Domains.enums');   │
   │   or import in your js code                              │
   │   import {enums} from 'App.Domains.enums';                 │Наш модуль создался и доступен для импортирования в другие модули через import {enums} from 'App.Domains.enums;
В директории, в конец созданного класса модуля js/App/Domains/enums/src/enums.js, добавляем наши наборы экспортируемых констант.
import {Type} from 'main.core';
export class Enums {
   constructor(options = {name: 'Enums'}) {
       this.name = options.name;
   }
   setName(name) {
       if (Type.isString(name)) {
           this.name = name;
       }
   }
   getName() {
       return this.name;
   }
}
/**
* Enum for common entity types.
* @readonly
* @enum {string}
*/
export const EntityTypeEnum = Object.freeze({
   DEALS: Symbol("crm:deal")
});
/**
* Enum for actions.
* @readonly
* @enum {string}
*/
export const ActionsLinkEnum = Object.freeze({
   DEAL_STORE: Symbol("<module>.api.ActionController.getStoreByDeal"),
   DEAL_MIN_DELIVERY_DATE_GET: Symbol("<module>.api.ActionController.getMinDeliveryDate"),
   DEAL_MIN_DELIVERY_DATE_UPDATE: Symbol("<module>.api.ActionController.updateMinDeliveryDate"),
   DEAL_ORDER_CANCEL: Symbol("<module>.api.ActionController.cancelOrderByDeal"),
   DYNAMIC_NOT_AGREE_CONTACTS_GET: Symbol("<module>.api.ActionController.getVisitNotAgreeContacts")
});
Примечание! Если хотите переименовать класс и файл по аналогии, как это делается в PHP, то необходимо поправить пути в bundle.config.js и config.php модуля.
import {ActionsLinkEnum} from "../../../enums/src/Enums";
BX.ajax.runAction(ActionsLinkEnum.DEAL_MIN_DELIVERY_DATE_UPDATE.description, {
   data: {
       iDealId: 1
   },
}).then(function (response) {
}, function (response) {
});Теперь мы можем импортировать наши константы в другие модули и делать с ними запросы к бекенду через библиотеку битрикса.
Также, как мы бы использовали подобный подход на PHP.
enum ConstantsEnum: string
{
   case CSE_TRACKING_LINK = 'https://www.cse.ru/mow/track?numbers=';
}
printf('Result:<pre>%s</pre><hr />', print_r(ConstantsEnum::CSE_TRACKING_LINK->value, true));
Для тех, кому нужно напомнить, как организовать контроллер на бекенде: пройдите курс либо просто используйте обычный AJAX, а вместо endpoints — адреса API.
2. По аналогии создадим модуль для работы с календарем. Он будет содержать методы, изменяющие битрикс-календарь. Так как это не часть бизнес-логики, а дополнительный функционал, отнесем его к инфраструктуре: local/js/App/Infrastructure/BXCalendar/src/BXCalendar.js
import {Type} from 'main.core';
/**
* Доработки для стандартного календаря.
*/
export default class BXCalendar {
   constructor(options = {name: 'BXCalendar'}) {
       this.name = options.name;
   }
   setName(name) {
       if (Type.isString(name)) {
           this.name = name;
       }
   }
   getName() {
       return this.name;
   }
   /**
    * Метод деактивирует даты в календаре, до определенной даты.
    * @param $DOMInput Инпут, на который применяется ограничение
    * @param $sDate Дата, до которой отключить выбор.
    */
   static disableDatesBefore($DOMInput, $sDate) {
       if(typeof $DOMInput !== "undefined") {
           const iMinDate = Date.parse($sDate);
           // В календаре добавим проверку выбора дат.
           let $el = BX.calendar({
               node: $DOMInput,
               field: $DOMInput.name,
               form: '',
               bTime: true,
               bHideTime: false,
               callback: function (sPickedDate) {
                   const currentDate = BX.date.format("c", new Date());
                   const pickedDate = BX.date.format("c", sPickedDate);
                   const iCurrentDate = Date.parse(currentDate);
                   const iPickedDate = Date.parse(pickedDate);
                   if (iPickedDate < iCurrentDate) {
                       BX.adjust($DOMInput, {
                           props: {
                               value: ''
                           }
                       });
                       BX.UI.Notification.Center.notify({
                           "content": "Нельзя выбрать прошедшую дату.",
                       })
                       return false;
                   } else if (iPickedDate < iMinDate) {
                       BX.adjust($DOMInput, {
                           props: {
                               value: ''
                           }
                       });
                       BX.UI.Notification.Center.notify({
                           "content": "Невозможно выбрать дату до: " + BX.date.format("d.m.Y", new Date($sDate)),
                       })
                       return false;
                   } else {
                       BX.adjust($DOMInput, {
                           props: {
                               value: BX.date.format("d.m.Y", new Date(pickedDate))
                           }
                       });
                   }
                   return true;
               }
           });
           //найдем элементы отображающие дни
           let links = $el.DIV.querySelectorAll(".bx-calendar-cell");
           let date = new Date($sDate);
           for (let i = 0; i < links.length; i++) {
               let atrDate = links[i].attributes['data-date'].value;
               let d = date.valueOf();
               let g = links[i].innerHTML;
               //меняем класс у элемента отображающего день, который меньше по дате чем текущий день
               if (date - atrDate > 0) {
                   $('[data-date="' + atrDate + '"]').addClass("bx-calendar-date-hidden disabled");
               }
           }
       } else {
       }
   }
}Теперь у модуля есть статичный метод, в который мы передаем элемент, к которому привязан календарь, и дату, до которой необходимо отключить выбор чисел.
3. Добавляем модуль для работы со сделками: local/js/Domains/deals. Лично мне нравится переименовывать его в DealsService.js по аналогии с бекендом, но это не принципиально.
Также на основе бекенда контроллеры хочется вынести в отдельный неймспейс App.Domains.Deals.controller. Для этого мы установим подмодуль в папку controllers. Кроме того, понадобятся события, которые будет отслеживать модуль, реагирующие на открытие попапа, в котором отображаются сделки. Для них сделаем подмодуль events.

Родительский модуль при инициализации вызывает метод load(), который подключает слежение за событиями.
import {Type} from 'main.core';
import {DealsEvents} from '../events/src/DealsEvents'
export class DealsService {
   constructor(options = {name: 'DealsService'}) {
       this.name = options.name;
   }
   setName(name) {
       if (Type.isString(name)) {
           this.name = name;
       }
   }
   getName() {
       return this.name;
   }
   load() {
       const $DealsEvents = new DealsEvents();
       $DealsEvents.sliderLoadEvents();
       $DealsEvents.sliderOpenEvents();
   }
}
new DealsService().load();Модуль событий отслеживает открытие и загрузку слайдера, так как в битриксе эти события разные и срабатывают в разных ситуациях, а нам нужно поведение для обоих вариантов.
import {Type} from 'main.core';
import {DealsController} from "../../controller/src/DealsController";
import {EntityTypeEnum} from "../../../enums/src/Enums";
/**
* @module BX.App.Domains.Deals.Events
*/
export class DealsEvents {
   /**
    * @constructor
    * @param options
    */
   constructor(options = {name: 'DealsEvents'}) {
       this.name = options.name;
   }
   setName(name) {
       if (Type.isString(name)) {
           this.name = name;
       }
   }
   getName() {
       return this.name;
   }
   /**
    * События по открытию слайдера.
    */
   sliderOpenEvents() {
       // Установка даты минимальной доставки
       BX.addCustomEvent("SidePanel.Slider:onOpen", function (event) {
           if (event?.slider?.minimizeOptions?.entityType === EntityTypeEnum.DEALS.description) {
               const $controller = new DealsController()
               const $dealId = event.slider.minimizeOptions.entityId;
               $controller.updateMinDlvDate($dealId);
           }
       })
   }
   sliderLoadEvents() {
       BX.addCustomEvent("SidePanel.Slider:onLoad", function (event) {
           if (event?.slider?.minimizeOptions?.entityType === EntityTypeEnum.DEALS.description) {
               /**
                * Установка даты доставки
                */
               const $dealId = event.slider.minimizeOptions.entityId;
               setTimeout(async () => {
                   const mutationObserver = new MutationObserver(function (mutations) {
                       mutations.forEach(function (mutation) {
                           if (mutation?.removedNodes[0]?.dataset?.fieldTag === "UF_DEAL_DLV_DATETIME") {
                               setTimeout(() => {
                                   const $Event = new DealsController()
                                   $Event.blockDates($dealId)
                               }, 1000)
                           }
                       });
                   });
                   mutationObserver.observe(document, {
                       childList: true,
                       subtree: true,
                       characterDataOldValue: true
                   });
               }, 1000);
               /**
                * Добавляем кнопки с остатками.
                */
               DealsController.addAvailableCountButtons($dealId);
           }
       });
   }
}Соответственно, мы обращаемся к контроллеру, который уже имеет набор методов для работы со сделками.
import {Type} from 'main.core';
import BXCalendar from '../../../../Infrastructure/BXCalendar/js/BXCalendar'
import './DealsController.css'
import {ActionsLinkEnum} from "../../../enums/src/Enums";
export class DealsController {
   constructor(options = {name: 'DealsController'}) {
       this.name = options.name;
   }
   setName(name) {
       if (Type.isString(name)) {
           this.name = name;
       }
   }
   getName() {
       return this.name;
   }
   /**
    * Получить минимальную дату доставки и установить её в сделке
    * @param $dealId Ид сделки.
    * @returns void
    * пишет в консоль SON {"success": bool, "message": string}
    */
   updateMinDlvDate($dealId) {
       BX.ajax.runAction(ActionsLinkEnum.DEAL_MIN_DELIVERY_DATE_UPDATE.description, {
           data: {
               iDealId: $dealId
           },
       }).then(function (response) {
       }, function (response) {
       });
   }
   /**
    * Заблокировать выбор даты меньше минимальной даты доставки
    * @param dealId
    * @returns {void}
    */
   blockDates(dealId) {
       let dateInput = $("input[name='UF_DEAL_DLV_DATETIME']").get()[0];
       let divCalendar = $("div[class='bx-calendar-button-block']").get()[0];
       divCalendar.style.visibility = 'hidden';
       BX.ajax.runAction(ActionsLinkEnum.DEAL_MIN_DELIVERY_DATE_GET.description, {
           data: {
               iDealId: dealId
           },
       }).then(function (response) {
           divCalendar.style.visibility = 'visible';
           let obResponse = JSON.parse(response.data)
           if (obResponse.success) {
               const minDate = obResponse.message;
               BXCalendar.disableDatesBefore(dateInput, minDate)
           } else {
           }
       }, function (response) {
       });
   }
   /**
    * Метод добавляет вывод информации об остатках на складах для сделок.
    *
    * @param dealId Ид сделки.
    */
   static addAvailableCountButtons(dealId) {
      console.log(‘секретик))’);
   }
}Все, структура есть, модули созданы. Осталось подключить их на бекенде в нужном нам месте. Создадим класс для работы с расширениями:
<?php
namespace App\Shared\Enums;
enum JSDomainsEnums: string
{
   case DEALS = 'App.Domains.deals';
   case VISITS = 'App.Domains.visits';
   case CONTACTS = 'App.Domains.contacts';
}И подключим в шаблоне, либо прямо в init.php, если требуется на всех страницах.

Осталось собрать наш фронтенд. Для этого возвращаемся в папку local и запускаем сборку:

Ну вот и все! Завершился наш цикл о переходе от модульной архитектуры к сервисам в битрикс. Делитесь в комментариях, на какие темы вы хотели бы увидеть следующие тексты.
Кстати, пока вы ждете новых статей, подпишитесь на канал DD Planet! Там вы найдете не только мои тексты, но и замечательные материалы от коллег по цеху.
 
           
 
garr1nch4
Заранее извиняюсь по поводу критики.
Статья действительно похожа на смесь записок (что соответствует названию) с инструкциями и с примерами кода, но воспринимается это больше как некая каша, нежели то, чем можно поделиться с миром.
Весьма способствует этому отсутствие нормального форматирования и вставки огромных кусков кода. Например:
- часть картинок на всю ширину, часть на 2/3, часть на половину
- исходный код тоже вставлен без какого-либо читаемого форматирования и слишком растянут по высоте (куча лишних переносов строк), банально:
DVZakusilo Автор
Не за что извиняться, вполне конструктивная критика.
Я не писатель и это мой первый опыт, конструктивная критика - именно то, что нужно.
Эти статьи родились скорее из того, что множество разработчиков, раз из раза приходили с запросами - как структурировать код, понимая, что что-то идет не так и получается хаос.
Скорее так, я написал как умею инструкцию для ребят, кто постоянно этим занимается, но не достиг уровня самостоятельного планирования архитектуры. А делиться с миром - я думаю, кто захочет, тот возьмет))
Критику учту))
DVZakusilo Автор
Ну и, кстати, тут публика довольно специфичные статьи воспринимает.
Если посмотреть в топ читаемого - это новости, там больше всего лайков. Переводы зарубежных публикаций. Не создание авторского контента.
В этих технических статьях, хоть какой-то интерес у малой публики есть, я попробовал, чтобы убедиться, взять хайповую зарубежную публикацию, что подтвердило результат, эта статья набрала самый большой отклик.
Если касаемо, красивого текста, хорошего оформления, создание собственного контента и собственных изображений, была проба https://habr.com/ru/articles/872470/
Она ожиаемо набрала самое большое количество минусов, не смотря на то, что тут все авторское, с вложением большого времени в контент, тк это часть и выжимка из европейского курса по управлению ИТ персоналом, который обошелся мне, в пересчете в 210тр, хотел перенести на русскую публику с разжевыванием нюансов))
Красивое оформление и красивый слог - не всегда рулят.
А текущая статья дает конкретно куски кода с пояснениями для тех, кто конкретно пытается у себя сделать также))