Files
Portal-Chart-Builder/docs/bitrix-integration.md

30 KiB
Raw Permalink Blame History

Интеграция с Bitrix — спецификация для backend-разработчика

Это документ для человека, который не участвовал в разработке самого React-приложения (portal-chart-builder) и не обязан знать TypeScript — здесь только то, что нужно на стороне PHP/Bitrix.

1. Что это

Конструктор диаграмм — отдельное React-приложение (не часть Bitrix24, продукт — «1С-Битрикс: Управление сайтом»). Встраивается как пользовательский тип свойства инфоблока: в админке, на странице редактирования элемента, вместо обычного поля отображается iframe с редактором диаграммы. Значение свойства — JSON-строка со схемой диаграммы, хранится как обычное строковое свойство (PROPERTY_TYPE => 'S').

Никакого REST-модуля, вебхуков или JS SDK Bitrix24 не требуется — это нативный механизм модуля iblock.

2. Регистрация типа свойства

В local/php_interface/init.php:

AddEventHandler("iblock", "OnIBlockPropertyBuildList", ["DiagramProperty", "GetUserTypeDescription"]);

После регистрации новый тип появляется в Контент → Инфоблоки → [нужный] → Свойства → Тип свойства. Свойство должно быть одиночным (MULTIPLE = N) — одна диаграмма на элемент.

3. Скелет PHP-класса

Ниже — иллюстративный каркас, не готовый к продакшену код. Реализация валидации, экранирования и т.д. — на усмотрение разработчика, но с обязательным соблюдением требований из раздела 5.

class DiagramProperty
{
    public static function GetUserTypeDescription(): array
    {
        return [
            'PROPERTY_TYPE' => 'S',
            'USER_TYPE' => 'DiagramEditor',
            'DESCRIPTION' => 'Конструктор диаграмм процессов',
            'GetPropertyFieldHtml' => [__CLASS__, 'GetPropertyFieldHtml'],
            'ConvertToDB' => [__CLASS__, 'ConvertToDB'],
            'ConvertFromDB' => [__CLASS__, 'ConvertFromDB'],
        ];
    }

    public static function GetPropertyFieldHtml($arProperty, $value, $strHTMLControlName): string
    {
        $inputName = htmlspecialcharsbx($strHTMLControlName['VALUE']);
        $rawValue = $value['VALUE'] ?? '';

        // Вариант A — рекомендуемый, приложение лежит внутри дерева портала (см. раздел 7):
        // appOrigin — это просто origin текущего запроса, appUrl — конкретный файл.
        // ВАЖНО: имя файла — не константа. dist/index.html выходит из сборки как
        // index-<hash>.html (хеш меняется при каждой значимой пересборке, см.
        // раздел 7) — 'index.html' строкой здесь писать нельзя, нужно получать
        // актуальное имя каким-то способом на своей стороне (см. раздел 7).
        $appOrigin = (CMain::IsHTTPS() ? 'https://' : 'http://') . $_SERVER['HTTP_HOST'];
        $appUrl = $appOrigin . '/local/diagram-app/' . GetActualDiagramAppIndexFile(); // пример, не готовая функция
        // Вариант B — если приложение развёрнуто отдельным контейнером (см. раздел 7),
        // appUrl/appOrigin вместо этого указывают на его отдельный адрес, например:
        // $appOrigin = 'http://localhost:5178';
        // $appUrl = $appOrigin;

        ob_start();
        ?>
        <input type="hidden" name="<?= $inputName ?>" id="<?= $inputName ?>" value="<?= htmlspecialcharsbx($rawValue) ?>">
        <iframe id="diagram-frame-<?= $inputName ?>" src="<?= htmlspecialcharsbx($appUrl) ?>"
                style="width:100%;height:600px;border:1px solid #ccc"></iframe>
        <script>
        (function () {
            // Читаем initialValue из самого input.value, а не встраиваем JSON в
            // инлайн-скрипт — так не нужны JSON_HEX_* флаги вообще (см. раздел 5):
            // htmlspecialcharsbx() выше уже достаточно экранирует значение
            // HTML-атрибута, а браузер сам декодирует его обратно в текст.
            var appOrigin = <?= json_encode($appOrigin) ?>;
            var input = document.getElementById(<?= json_encode($inputName) ?>);
            var frame = document.getElementById('diagram-frame-<?= $inputName ?>');

            window.addEventListener('message', function (event) {
                if (event.origin !== appOrigin) return; // обязательная проверка, см. раздел 5
                if (!event.data || typeof event.data !== 'object') return;

                if (event.data.type === 'diagram:ready') {
                    frame.contentWindow.postMessage(
                        { type: 'diagram:init', payload: input.value || null },
                        appOrigin // никогда '*', см. раздел 5
                    );
                } else if (event.data.type === 'diagram:change') {
                    input.value = event.data.payload;
                }
            });
        })();
        </script>
        <?php
        return ob_get_clean();
    }

    public static function ConvertToDB($arProperty, $value): array
    {
        // Провалидировать, что VALUE — либо пустая строка, либо синтаксически валидный JSON,
        // прежде чем отдавать штатному механизму сохранения.
        return $value;
    }

    public static function ConvertFromDB($arProperty, $value): array
    {
        return $value;
    }
}

GetPublicViewHTML/GetPublicEditHTML сейчас не реализуются — публичная часть сайта не нужна в первой версии (диаграмма видна только в админке при редактировании элемента).

4. Контракт postMessage (реализован во frontend, src/host/bitrixAdapter.ts)

Обмен — три типа сообщений, строго в этом порядке:

1. Страница элемента загружается → мост-скрипт (см. раздел 3) готов, держит initialValue
2. iframe с приложением загружается
3. Приложение → мост:  { type: 'diagram:ready' }
4. Мост → приложение:  { type: 'diagram:init', payload: <JSON-строка диаграммы или null> }
5. Пользователь редактирует → с debounce ~400мс:
   Приложение → мост:  { type: 'diagram:change', payload: '<JSON-строка диаграммы>' }
6. Мост при каждом diagram:change пишет payload в value hidden input
7. Пользователь жмёт штатное «Сохранить» → браузер сабмитит форму → ConvertToDB → сохранение

Важно: payload в diagram:change — уже сериализованная JSON-строка (JSON.stringify со стороны приложения), не объект. Мост должен положить её в input.value как есть, без повторной обработки.

diagram:init с payload: null (или пустой строкой) — валидный случай: новый элемент без диаграммы, приложение стартует с чистого холста.

Режим только для просмотра

Хост может открыть приложение на чтение, добавив ?mode=view к URL iframe:

/local/diagram-app/index.html?mode=view

Тогда редактор стартует в режиме «Просмотр» и переключатель «Просмотр/Редактирование» скрыт — фигуры нельзя двигать, создавать и удалять. Без параметра поведение прежнее: редактирование с переключателем. Параметр читается один раз при загрузке (src/App.tsx), в контракте postMessage ничего не меняется — diagram:init/diagram:change работают одинаково в обоих режимах.

Зачем: встраивающая сторона, которая не слушает diagram:change (например, страница портала, показывающая утверждённую схему), иначе предлагала бы правки, которые молча никуда не сохраняются.

5. Требования безопасности (обязательны, не опциональны)

  • Значение всегда попадает на страницу только через value-атрибут hidden input (htmlspecialcharsbx($rawValue), см. раздел 3), не через встраивание в инлайн-скрипт — так JSON-содержимое никогда не оказывается внутри JS-исходника, и JSON_HEX_*-экранирование не требуется вообще. Мост читает input.value напрямую в JS. Если по каким-то причинам всё же понадобится встроить значение в инлайн-скрипт (например, отдельный initialValue вместо чтения из DOM) — тогда обязателен JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT при json_encode.
  • event.origin проверяется в обе стороны: и в мосте (когда он слушает сообщения от iframe), и в приложении (уже реализовано во frontend).
  • targetOrigin у любого postMessage — всегда конкретный домен (appOrigin), никогда '*'.
  • name у hidden input должен точно совпадать с $strHTMLControlName['VALUE'] — это единственная связь между кастомным рендером и штатным сохранением формы.

6. Формат JSON диаграммы

Пример реального значения свойства (то, что лежит в hidden input и уходит в ConvertToDB):

{
  "nodes": [
    {
      "id": "start",
      "type": "startEvent",
      "position": { "x": 0, "y": 80 },
      "width": 48,
      "height": 48,
      "data": { "label": "Заявка получена" }
    },
    {
      "id": "review",
      "type": "task",
      "position": { "x": 220, "y": 60 },
      "width": 140,
      "height": 50,
      "data": {
        "label": "Приём заявок",
        "description": "Проводится ответственным лицом, 1-2 дня.",
        "responsible": "Иван Иванов",
        "duration": "1-2 дня",
        "color": "#eef2ff"
      },
      "parentId": "some-other-node-id",
      "zIndex": 1
    }
  ],
  "edges": [
    {
      "id": "start-review",
      "source": "start",
      "target": "review",
      "sourceHandle": "right",
      "targetHandle": "left",
      "label": "Да"
    }
  ]
}

Пояснения по полям (все, кроме перечисленных обязательными, — опциональны и могут отсутствовать):

  • nodes[].id / edges[].id — уникальные строки, обязательны.
  • nodes[].type — один из: task, startEvent, intermediateEvent, endEvent, exclusiveGateway, parallelGateway, inclusiveGateway.
  • nodes[].position — координаты на холсте, обязательны.
  • nodes[].width/height — размер фигуры (пользователь может менять руками).
  • nodes[].data.label — название, обязательно; остальные поля data — произвольные метаданные, отображаемые в форме редактирования.
  • nodes[].parentId — если узел вложен в другой (drill-down «под-процесс» внутри той же диаграммы, не переход на другой элемент).
  • nodes[].zIndex / edges[].zIndex — порядок наложения друг на друга.
  • edges[].source/target — id узлов, обязательны.
  • edges[].sourceHandle/targetHandle — с какой стороны фигуры выходит/входит линия (top/right/bottom/left).

Backend не обязан ничего валидировать глубже «это либо пустая строка, либо синтаксически корректный JSON» — вся содержательная валидация (обязательные поля и т.п.) — на стороне приложения.

7. Деплой приложения

Два варианта. Вариант A проще и ближе к тому, как уже устроен сам портал — рекомендуется. Вариант B (отдельный контейнер) остаётся как альтернатива на случай, если приложение когда-нибудь понадобится хостить отдельно от Bitrix-стенда (другой домен, отдельный CI/CD и т.п.).

Вариант A (рекомендуется) — статика прямо в дереве Bitrix

Проверено на текущем дев-стенде (portal-bitrix-docker): каталог сайта — не часть Docker-образа, а volume bitrix_www, смонтированный в контейнеры nginx и php как bind-mount на каталог хоста (docker volume inspect bitrix_www → device: ..., o: bind). Именно так уже хостится сам портал: файлы лежат на диске хоста, штатный nginx их и раздаёт — значит собранную статику приложения можно положить туда же, без отдельного контейнера.

Что это даёт технически:

  • Nginx-конфиг стенда отдаёт файлы по расширению (js|css|html|svg|png|...) напрямую с диска — это срабатывает раньше, чем запрос попадёт в PHP-роутер Bitrix. Любой статический файл под корнем сайта отдаётся сам, никаких правок nginx не требуется.

  • local/ уже существует как каталог для кастомного кода (ai, components, modules, php_interface, templates — по факту на дев-стенде). Новый подкаталог, например local/diagram-app/, вписывается в эту конвенцию и не пересекается с local/modules/local/php_interface — они у nginx явно помечены internal (заблокированы для прямого внешнего доступа), новый подкаталог под этот блок не попадает.

  • Нюанс: index на уровне сервера — index.php, не index.html. Открыть голый /local/diagram-app/ (без имени файла) не выйдет — nginx попытается найти там index.php и не найдёт. PHP-класс (раздел 3) сам подставляет src у iframe — но, в отличие от более ранней версии этого документа, больше нельзя просто написать /local/diagram-app/index.html строкой — см. следующий пункт.

  • Так как приложение и портал теперь на одном домене, VITE_BITRIX_ORIGIN/appOrigin — это просто origin самого портала, а не адрес отдельного сервиса.

  • Имена файлов в dist/ — с хешем содержимого, включая сам index.html. index.js/index.css/шрифты хеширует сам Vite при сборке (стандартное поведение, как в любом Vite-проекте). Кроме того, в vite.config.ts есть небольшой плагин (hashIndexHtml), который дополнительно хеширует и сам index.html — на выходе получается что-то вроде index-2fddeca6.html, а не просто index.html. Имя меняется всякий раз, когда меняется что-то значимое (сама HTML-обёртка либо то, что она подключает), и не меняется при пересборке без реальных изменений.

    Из этого следует главное отличие от более ранней версии данного документа: $appUrl в PHP-классе (раздел 3) больше нельзя хардкодить как строку — конкретное имя index-<hash>.html меняется при каждой пересборке приложения. Бэкенду нужен свой способ узнавать актуальное имя файла на момент рендера страницы (например, взять единственный index-*.html из каталога деплоя, или через отдельный метод/эндпоинт, который вернёт актуальное имя, — механизм на усмотрение бэкенда, здесь не фиксирован, уточняйте на момент внедрения).

    Готовый механизм — app-manifest.js рядом с билдом. Тот же плагин hashIndexHtml кладёт в dist/ файл со стабильным, никогда не меняющимся именем:

    { "index": "index-2fddeca6.html" }
    

    Его можно прочитать как с бэкенда при рендере страницы, так и на клиенте — fetch('<каталог>/app-manifest.js', { cache: 'no-store' }), — и уже к полученному имени дописывать ?mode=view. no-store здесь обязателен: имя файла стабильно, значит сам манифест попадает под тот же 30-дневный Cache-Control, что и остальная статика. Этим сейчас пользуется portal-front (src/helpers/chart-builder.ts), пока у бэка нет своего метода.

    Взамен это полностью снимает необходимость в отдельном cache-busting query-параметре (?v=...) — раньше он был обязателен и для самого index.html, и (до появления хеша у самого файла) для index.js/index.css внутри него. Теперь URL меняется вместе с содержимым: старый закэшированный index-OLDHASH.html просто никогда больше не запрашивается, как только хост узнаёт новое имя — что убирает разом оба уровня кэш-проблемы, описанной в более ранней версии этого раздела.

Порядок действий:

  1. В репозитории portal-chart-builder собрать статику с двумя build-time переменными:
    npm run build
    
    Переменные окружения не нужны. VITE_BITRIX_ORIGIN в этом варианте не задаётся — см. примечание в конце раздела. Пути к JS/CSS внутри index.html относительные (base: './' в vite.config.ts), поэтому один и тот же dist/ работает из любого каталога размещения — раньше это задавалось переменной VITE_BASE_PATH, которая должна была точно совпадать с каталогом деплоя и при расхождении давала пустой iframe (html грузится, его JS уходит в 404).
  2. Скопировать содержимое dist/ в local/diagram-app/ внутри host-каталога, который смонтирован как bitrix_www (на текущем дев-стенде это /home/nikit/bitrix/www/local/diagram-app/ — точный путь для конкретного стенда уточняется у того, кто его поднимал/администрирует).
  3. В PHP-классе (раздел 3): $appUrl = $appOrigin . '/local/diagram-app/' . $actualIndexFileName;, $appOrigin — origin портала (без пути), $actualIndexFileName — актуальное имя index-<hash>.html в local/diagram-app/, полученное способом на усмотрение бэкенда (см. врезку про кэш выше — хардкодить index.html строкой уже нельзя).
  4. Обновление после правок в React-приложении — тот же npm run build + копирование поверх старых файлов. Пересборка/перезапуск контейнеров Bitrix не нужны. Старый index-<hash>.html от предыдущей сборки при этом не удаляется автоматически — если способ поиска актуального файла (шаг 3) предполагает «взять единственный файл по маске», после нескольких деплоев в каталоге стоит убирать файлы прошлых версий, иначе маска перестанет быть однозначной.

Вариант B — отдельный Docker-контейнер

В репозитории portal-chart-builder есть Dockerfile (multi-stage: сборка через node, раздача статики через nginx) и .dockerignore — для случая, если приложение должно жить отдельно от Bitrix-стенда (свой домен, свой деплой).

Ключевое рассуждение: iframe грузит браузер администратора, а не сервер Bitrix — запроса «сервер → приложение» не происходит вообще. Значит, если контейнер с приложением опубликован портом на тот же хост, откуда админ открывает Bitrix (текущий дев-стенд: это одна и та же машина), достаточно сослаться на http://localhost:<порт> — отдельный домен или туннель не нужны.

Пример сервиса для добавления в docker-compose.yml Bitrix-стенда (иллюстративный — правки самого файла делает тот, кто ведёт стенд):

services:
  diagram-app:
    build:
      context: ../portal-chart-builder
      args:
        VITE_BITRIX_ORIGIN: http://portal.local   # origin самого Bitrix-стенда
    container_name: portal_diagram_app
    restart: unless-stopped
    ports:
      - "5178:80"                                 # публикуем на хост

Тогда:

  • VITE_BITRIX_ORIGIN (build-arg, см. раздел 5) = origin Bitrix-портала — http://portal.local.
  • $appUrl/$appOrigin в PHP-классе (раздел 3) = адрес самого приложения — http://localhost:5178.

Как передать сам аргумент:

  • Через docker-compose (как в примере выше) — значение прописывается в build.args сервиса; при docker compose up --build/docker compose build compose сам подставит его в сборку, никаких дополнительных флагов не нужно.
  • Через docker build напрямую (ручная сборка, например для локальной проверки) — флагом --build-arg: docker build --build-arg VITE_BITRIX_ORIGIN=http://portal.local -t portal-diagram-app . — именно так проверялось выше.
  • Чтобы не хардкодить реальный адрес прямо в docker-compose.yml (он попадёт в git) — вынести значение в .env-файл рядом с docker-compose.yml (VITE_BITRIX_ORIGIN=http://portal.local) и сослаться на него в compose как VITE_BITRIX_ORIGIN: ${VITE_BITRIX_ORIGIN} — Compose сам подхватывает .env из той же папки.

Собрал и проверил локально (docker build + docker run -p 5178:80) — контейнер поднимается и корректно отдаёт приложение по http://localhost:5178.

Важно (общее для обоих вариантов): VITE_BITRIX_ORIGIN — build-time переменная, вшивается в JS-бандл на этапе npm run build/docker build, не читается заново при каждом запуске/обновлении.

В варианте A она не нужна: приложение и портал на одном домене, и origin хоста приложение берёт из window.location.origin. Более того, заданная переменная там вредна — она перебьёт рантайм-значение, и на любом домене, кроме зашитого, обмен молча не состоится. Нужна она только в варианте B (или при локальной разработке), где хост и приложение на разных origin. Требование «никогда postMessage('*')» соблюдается в обоих случаях: targetOrigin всегда конкретный origin.

Схема с localhost (вариант B) работает, пока браузер администратора и Docker-хост — одна машина (текущий дев-стенд). Если позже появится реальный удалённый прод с админами не с этой машины — понадобится либо настоящий домен, либо reverse-proxy; это отдельная, более поздняя задача. Вариант A этой проблемы не имеет вообще, так как всегда живёт на домене портала.

8. Открытые решения (нужно определить до/во время внедрения)

  • Конкретный инфоблок, к которому подключается свойство, — не выбран, определяется пользователем/владельцем задачи отдельно. Код из раздела 3 не зависит от конкретного инфоблока — тип свойства регистрируется глобально и включается точечно через админку (см. раздел 2).
  • Публичная часть сайта — решено, что в первой версии не нужна (только админка). React-приложение уже поддерживает read-only «режим просмотра» — если публичная часть понадобится позже, доработка небольшая (реализовать GetPublicViewHTML, встроить тот же iframe в режиме просмотра).

9. Definition of done

  1. Открыть страницу редактирования элемента (для которого включено свойство) — поле показывает iframe с пустой диаграммой (для нового элемента) или с ранее сохранённой.
  2. Отредактировать диаграмму (подвигать фигуры, поменять текст).
  3. Нажать штатную кнопку «Сохранить» на странице элемента (не внутри iframe).
  4. Перезагрузить страницу — изменения на месте.
  5. Открыть значение свойства напрямую в БД/через API инфоблоков — там лежит валидный JSON вида из раздела 6.