Подготовил к интеграции с битриксом
This commit is contained in:
@@ -0,0 +1,261 @@
|
||||
# Интеграция с 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`:
|
||||
|
||||
```php
|
||||
AddEventHandler("iblock", "OnIBlockPropertyBuildList", ["DiagramProperty", "GetUserTypeDescription"]);
|
||||
```
|
||||
|
||||
После регистрации новый тип появляется в **Контент → Инфоблоки → [нужный] → Свойства → Тип свойства**. Свойство должно быть **одиночным** (`MULTIPLE = N`) — одна диаграмма на элемент.
|
||||
|
||||
## 3. Скелет PHP-класса
|
||||
|
||||
Ниже — иллюстративный каркас, не готовый к продакшену код. Реализация валидации, экранирования и т.д. — на усмотрение разработчика, но с обязательным соблюдением требований из раздела 5.
|
||||
|
||||
```php
|
||||
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'] ?? '';
|
||||
// JSON_HEX_* флаги обязательны — см. раздел 5.
|
||||
$jsValue = json_encode($rawValue, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT);
|
||||
|
||||
// Вариант A — рекомендуемый, приложение лежит внутри дерева портала (см. раздел 7):
|
||||
// appOrigin — это просто origin текущего запроса, appUrl — конкретный файл.
|
||||
$appOrigin = (CMain::IsHTTPS() ? 'https://' : 'http://') . $_SERVER['HTTP_HOST'];
|
||||
$appUrl = $appOrigin . '/local/diagram-app/index.html';
|
||||
// Вариант 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 () {
|
||||
var appOrigin = <?= json_encode($appOrigin) ?>;
|
||||
var initialValue = <?= $jsValue ?>; // строка JSON или ""
|
||||
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: initialValue || 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` (или пустой строкой) — валидный случай: новый элемент без диаграммы, приложение стартует с чистого холста.
|
||||
|
||||
## 5. Требования безопасности (обязательны, не опциональны)
|
||||
|
||||
- При встраивании `initialValue` в инлайн-скрипт — **обязательно** `JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT` (см. пример в разделе 3). Без этого — риск XSS через выход из `<script>`-тега при вредоносном содержимом свойства.
|
||||
- `event.origin` проверяется **в обе стороны**: и в мосте (когда он слушает сообщения от iframe), и в приложении (уже реализовано во frontend).
|
||||
- `targetOrigin` у любого `postMessage` — всегда конкретный домен (`appOrigin`), **никогда `'*'`**.
|
||||
- `name` у hidden input должен **точно** совпадать с `$strHTMLControlName['VALUE']` — это единственная связь между кастомным рендером и штатным сохранением формы.
|
||||
|
||||
## 6. Формат JSON диаграммы
|
||||
|
||||
Пример реального значения свойства (то, что лежит в hidden input и уходит в `ConvertToDB`):
|
||||
|
||||
```json
|
||||
{
|
||||
"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, поэтому в `$appUrl` достаточно явно указывать файл — `/local/diagram-app/index.html`.
|
||||
- Так как приложение и портал теперь на одном домене, `VITE_BITRIX_ORIGIN`/`appOrigin` — это просто origin самого портала, а не адрес отдельного сервиса.
|
||||
|
||||
Порядок действий:
|
||||
|
||||
1. В репозитории `portal-chart-builder` собрать статику с двумя build-time переменными:
|
||||
```
|
||||
VITE_BITRIX_ORIGIN=https://ВАШ-ДОМЕН-ПОРТАЛА VITE_BASE_PATH=/local/diagram-app/ npm run build
|
||||
```
|
||||
`VITE_BASE_PATH` — путь, под которым будет отдаваться приложение (нужен, чтобы ссылки на JS/CSS внутри `dist/index.html` были корректными под этим подпутём; см. `vite.config.ts`). По умолчанию, если переменная не задана, `base` — `/` (не влияет на локальную разработку, `npm run dev`).
|
||||
2. Скопировать содержимое `dist/` в `local/diagram-app/` внутри host-каталога, который смонтирован как `bitrix_www` (на текущем дев-стенде это `/home/nikit/bitrix/www/local/diagram-app/` — точный путь для конкретного стенда уточняется у того, кто его поднимал/администрирует).
|
||||
3. В PHP-классе (раздел 3): `$appUrl = $appOrigin . '/local/diagram-app/index.html';`, `$appOrigin` — origin портала (без пути).
|
||||
4. Обновление после правок в React-приложении — тот же `npm run build` + копирование поверх старых файлов. Пересборка/перезапуск контейнеров Bitrix не нужны.
|
||||
|
||||
### Вариант B — отдельный Docker-контейнер
|
||||
|
||||
В репозитории `portal-chart-builder` есть `Dockerfile` (multi-stage: сборка через node, раздача статики через nginx) и `.dockerignore` — для случая, если приложение должно жить отдельно от Bitrix-стенда (свой домен, свой деплой).
|
||||
|
||||
Ключевое рассуждение: iframe грузит **браузер администратора**, а не сервер Bitrix — запроса «сервер → приложение» не происходит вообще. Значит, если контейнер с приложением опубликован портом на тот же хост, откуда админ открывает Bitrix (текущий дев-стенд: это одна и та же машина), достаточно сослаться на `http://localhost:<порт>` — отдельный домен или туннель не нужны.
|
||||
|
||||
Пример сервиса для добавления в `docker-compose.yml` Bitrix-стенда (иллюстративный — правки самого файла делает тот, кто ведёт стенд):
|
||||
|
||||
```yaml
|
||||
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`, не читается заново при каждом запуске/обновлении. Без неё приложение намеренно упадёт при загрузке (см. раздел 5) — чтобы нельзя было собрать билд с `postMessage('*')`.
|
||||
|
||||
Схема с `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.
|
||||
Reference in New Issue
Block a user