Автор: Денис Закусило
Приветствую всех неравнодушных! Это заключительная статья цикла о переходе от модульной архитектуры к сервисам.
[Записки тимлида] Битрикс: от модулей к сервисам
[Записки тимлида] Битрикс: от модулей к сервисам 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! Там вы найдете не только мои тексты, но и замечательные материалы от коллег по цеху.
