
Всем привет.
Меня зовут Илья Чубко, я являюсь техническим архитектором в направлении, которое занимается внедрением CRM-системы от вендора «БПМСофт». Этот вендор – разработчик собственной low-code платформы BPMSoft для автоматизации и управления бизнес-процессами крупных и средних компаний в единой цифровой среде.
BPMSoft позволяет не только быстро автоматизировать процессы CRM, но и запускать разнообразные клиентские и внутренние сервисы с использованием принципов low-code development. Платформа содержит инструменты для гибкой настройки и кастомизации процессов, коннекторы и расширения для эффективной адаптации к любой ИТ-инфраструктуре. Однако часто на проектах мы получаем запросы от заказчиков по доработке визуальной части программного продукта под специфику их деятельности и бизнес-логику, которые невозможно выполнить базовыми средствами самой платформы. Для решения подобных задач по созданию приложений и их интеграции с типовым программным продуктом мы используем фреймворк Angular. В этой статье покажу, как разработать такое приложение с нуля и добавить его в CRM-систему на примере BPMSoft.
Angular представляет собой бесплатный фреймворк с открытым кодом от компании Google для создания клиентских приложений. Прежде всего он нацелен на разработку SPA-решений (Single Page Application), то есть одностраничных приложений. Найти исходные файлы и дополнительную информацию можно в официальном репозитории фреймворка на GitHub.
Представим, что на странице редактирования раздела “Контакты” необходимо создать визуальный модуль в виде to-do листа, чтобы управлять активностями: добавлять, редактировать, удалять и отмечать выполненные задачи.
Основные принципы, на которых я сделал акцент при создании приложения:
- инкапсуляция (стили приложения не должны пересекаться со стилями CRM-системы);
- гексагональная архитектура (приложение должно работать внутри любой системы и даже внутри контейнера микросервисной архитектуры);
- расширяемость (можно использовать любой фреймворк для создания UI и все возможности Angular).
Процесс разработки визуального компонента можно начать с создания макета. В качестве онлайн-доски для визуализации можно использовать, например, Holst.

Приложение представляет собой 2 области:
- слева – список задач с возможностью добавлять новые записи и отмечать выполнение;
- справа – подробная информация о задаче при выделении записи.
Создание Angular-приложения
Настройка приложения и проектов
Проект Angular я рекомендую хранить в папке Pkg, где находятся основные пакеты CRM-системы, но вы можете использовать любое место хранения.
Сам шаблон проекта можно получить из github командой:
git clone https://github.com/IlyaChubko/NgTemplate.git
Ангуляр-приложение состоит из 2-х проектов: app-serve и app-build
app-serve | проект для работы standalone приложения Angular |
app-build | проект сборки готового модуля применения в CRM-системе |
Файл angular.json будет выглядеть следующим образом:
angular.json
{ "$schema": "./node_modules/@angular/cli/lib/config/schema.json", "version": 1, "newProjectRoot": "projects", "projects": { "app-build": { . . . }, "app-serve": { . . . } }, "cli": { "analytics": false } }
Настройка библиотек
Внешние библиотеки, которые я использую для разработки и компиляции приложения, описаны в следующей таблице:
Название | Компонент | Назначение | Порядок установки |
|
| npm-пакет, который позволяет упаковывать Angular-компоненты в Custom Elements и определять новые HTML-элементы со стандартным поведением |
|
|
| npm-пакета, который позволяет производить сборку и упаковку компонентов |
|
|
| npm-пакет, который содержит набор уже готовых компонентов для создания UI |
|
| npm-пакет для удобной работы со стилями, аналогично bootstrap |
| |
| Набор иконок для использования в приложении |
| |
|
| npm-пакет для работы с типом данных Guid |
|
|
| npm-пакет для хранения глобального состояния приложения |
|
| npm-пакет, который позволяет использовать сигналы для хранения глобального состояния приложения |
| |
|
| инструмент для эмуляции http-запросов |
Создание модели данных
Для хранения записей задач создаем файл TodoItem.ts и описываем интерфейс TodoItem в директории src\app\model.
export interface TodoItem { id: string; title: string; startDate: string; statusId: string; }
Для хранения подробной информации о задаче можно создать расширенный интерфейс TodoItemFull, который будет расширять интерфейс TodoItem.
export interface TodoItemFull extends TodoItem { endDate: string; author: string; category: string; }
Так как статусы задач будут приходить в виде Guid, то для хранения всех статусов необходимо создать соответствующий интерфейс StatusData.ts.
StatusData.ts
export interface StatusData { id: string; name: string; isFinal: boolean; }
Создание сервиса и имитация данных
Создание сервиса
Для работы с данными необходимо выполнение HTTP-запросов на сервер и обработки ответов.
Создадим файл todo.service.ts в директории src\app\service.
С помощью декторатора @Injectable сделаем его доступным для всего приложения, указав {providedIn: 'root'}.
Внедрим HttpClient, а для POST запросов добавим Ext из глобального window, чтобы передать необходимые хедеры при аутентификации запросов.
import {inject, Injectable} from '@angular/core'; import {Observable, Subject} from 'rxjs'; import { HttpClient, HttpHeaders } from "@angular/common/http"; import {environment} from "../../environments/environment"; import {TodoItem} from "../model/TodoItem"; @Injectable({providedIn: 'root'}) export class TodoService { private http = inject(HttpClient); private Ext = (window as any).Ext; public todoListChanged$ = new Subject<void>(); private formatString(str: string, ...val: string[]) { for (let index = 0; index < val.length; index++) { str = str.replace(`{${index}}`, val[index]); } return str; } getRecords(contactId: string): Observable<TodoItem[]> { const url = this.formatString(environment.todoService.getRecords, contactId); return this.http.get<TodoItem[]>(url); } addRecord(contactId: string, item: TodoItem) { let headers = (this.Ext) ? new HttpHeaders({"BPMCSRF": this.Ext.util.Cookies.get("BPMCSRF") || ""}) : new HttpHeaders(); const body = { contactId: contactId, data: item } return this.http.post<any>(environment.todoService.addRecord, body, {headers: headers}); } }
Здесь предоставлен пример GET-запроса getRecords, который возвращает поток с массивом элементов типа TodoItem и POST-запроса addRecord, в котором в теле запроса передаются аргументы contactId и data.
Обратите внимание, что отправляем запросы не на конкретный адрес сервера, а связываем значения с переменными окружения environment.
Создание переменных окружения
Приложение Angular по умолчанию создает 2 окружения: environment.ts и environment.prod.ts в директории src\ environments.
Можно создавать и свои окружения, но в нашем случае приложение будет работать как автономное angular-приложение (environment) и как модуль в CRM-системе (environment.prod). Соответствующие настройки окружений можно найти в файле angular.json.
Для того, чтобы использовать относительные пути к сервисам, добавим в оба файла одинаковую структуру объекта в поле todoService и заполним файл следующим образом:
файл environment.ts
export const environment = { production: false, todoService: { getRecords: "api/getRecords", addRecord: "api/addRecord", } };
файл environment.prod.ts
export const environment = { production: true, todoService: { getRecords: "../rest/ActivityService/GetRecords?ownerId={0}", addRecord: "../rest/ActivityService/AddRecord", } };
Для каждого метода в environment.ts мы указываем название этого же метода для дальнейшей имитации запросов, а в environment.prod.ts – относительный путь к методу сервиса, который будет вызываться для выполнения HTTP-запроса к данным.
Имитация запросов и получение ответа
Для того, чтобы получать данные для отображения в Angular-приложении, существует несколько способов. Можно добавить еще один сервис и с помощью внедрения зависимости добавлять тот или иной сервис в зависимости от окружения. Данный поход описан в статье (https://angdev.ru/archive/angular9/dependency-injection). Но в текущем примере будем использовать механизм In-memory Web API (https://github.com/angular/in-memory-web-api), для этого создадим файл in-memory-data.service.ts, в котором опишем, какие данные и от какого метода будем получать при выполнении http-запросов от HttpClient.
файл in-memory-data.service.ts
import {Injectable} from '@angular/core'; import {InMemoryDbService} from "angular-in-memory-web-api"; import {TodoItem} from "./model/TodoItem"; import {Guid} from "guid-typescript"; @Injectable({providedIn: 'root'}) export class InMemoryDataService implements InMemoryDbService { createDb() { const getRecords = <TodoItem[]>[ { id: Guid.create().toString(), title: "Запланировать командировку", startDate: "21.09.2024", statusId: "394d4b84-58e6-df11-971b-001d60e938c6" }, { id: Guid.create().toString(), title: "Подписать приказ", startDate: "28.09.2024", statusId: "201cfba8-58e6-df11-971b-001d60e938c6" } ] const addRecord = ["addRecord"]; return {getRecords, addRecord}; } genId(data: any): any { return []; } }
Получается, в environment был указан метод выполнения api/getRecords, поэтому в createDb должны возвращаться данные в переменной с именем getRecords, в которой укажем произвольные тестовые записи. Метод addRecord является POST-запросом, поэтому просто обернем его в массив.
Для подключения InMemoryDataService скорректируем файл app.module.ts, добавив службы в providers
файл app.module.ts
import {importProvidersFrom, NgModule} from '@angular/core'; import {BrowserModule} from "@angular/platform-browser"; import {BrowserAnimationsModule} from '@angular/platform-browser/animations'; import {AppComponent} from "./app.component"; import { provideHttpClient } from "@angular/common/http"; import {HttpClientInMemoryWebApiModule} from "angular-in-memory-web-api"; import {InMemoryDataService} from "./in-memory-data.service"; import {AngularAppComponent} from "./component/angular-app/angular-app.component"; @NgModule({ declarations: [AppComponent], bootstrap: [AppComponent], imports: [ BrowserModule, BrowserAnimationsModule, AngularAppComponent ], providers: [ provideHttpClient(), importProvidersFrom(HttpClientInMemoryWebApiModule.forRoot(InMemoryDataService, { post204: true, delay: 2000, dataEncapsulation: false })) ] }) export class AppModule {}
Для имитации задержки можно использовать параметр delay, который в данном примере равен 2000 мс, т.е. ответ http-запроса будет предоставлен через 2 секунды. В случае необходимости можно добавить индикатор загрузки и увеличить данное значение для тестирования.
Подключение и использование статического контента
Весь статический контент можно хранить в src\assets и при выполнении сборки приложения с помощью ng build все файлы будут автоматически копироваться в output директорию с аналогичным названием.
Для того, чтобы использовать статику и в отдельном приложении, и внутри CRM, нам нужно скорректировать environments, добавив путь к assert.
environment.ts
export const environment = { production: false, assert: "../assets", todoService: { getRecords: "api/getRecords", … } };
environment.prod.ts
export const environment = { production: true, assert: "../BPMSoft.Configuration/Pkg/BPMSoft_NgExample/Files/src/js/ng-todo/assets", todoService: { getRecords: "../rest/ActivityService/GetRecords?ownerId={0}", … } };
Для использования ссылок на статический контент удобно создать отдельный Pipe в отдельной директории srv\app\pipes в следующем виде:
image-url.pipe.ts
import {Pipe, PipeTransform} from '@angular/core'; import {environment} from "../../environments/environment"; @Pipe({ name: 'imageUrl', standalone: true }) export class ImageUrlPipe implements PipeTransform { transform(image: string): string { return `${environment.assert}/img/${image}` } }
Применение в шаблонах выглядит следующим образом:
<img [src]="'tasks.svg' | imageUrl" alt="image" height="20" width="20"/>
По итогу статический контент нужно добавлять в папку /src/assets, а получать его с помощью pipe imageUrl.
Подключение и настройка менеджера состояний
Создание основного хранилища
Для работы с массивом задач будем использовать NgRx Signals (https://ngrx.io/guide/signals). Для этого создадим отдельную директорию ngrx в src\app и в ней основной CommonStore
CommonStore.ts
import {signalStore, withState} from "@ngrx/signals"; import {StatusData} from "../model/StatusData"; export type CommonState = { _contactId: string; loading: boolean; statuses: StatusData[]; selectedId: string; } export const CommonStore = signalStore( {providedIn: 'root'}, withState<CommonState>({ _contactId: "", loading: false, statuses: [], selectedId: "" }) );
В данном коде нам нужно хранить несколько значений в рамках всего приложения:
_contactId – ID записи контакта, задачи которого должны отображаться. Сделаем переменную приватной, для этого добавим префикс _ в начале
loading – для хранения состояния процесса загрузки данных
statuses – для хранения справочника статусов задач
selectedId – ID выделенной задачи
Хранение состояния задач
Нам надо хранить список задач, которые мы получим с сервера, но для хранения лучше использовать не переменную c типом массива, а отдельную signalStoreFeature, и подключить её к основному хранилищу. SignalStoreFeature позволяет более удобно работать с массивом и его элементами без полного копирования сущности.
Добавим файл features в директории src\app\ngrx и создадим файл TodoListStore.ts
TodoListStore.ts
import {patchState, signalStoreFeature, type, withComputed, withMethods} from "@ngrx/signals"; import {addEntity, setAllEntities, setEntity, withEntities} from "@ngrx/signals/entities"; import {TodoItem} from "../../model/TodoItem"; export function withTodoItems() { return signalStoreFeature( withEntities({ entity: type<TodoItem>(), collection: 'todo' }), withMethods((store) => ({ setTodoData(items: TodoItem[]): void { patchState(store, setAllEntities(items, { collection: 'todo' })); }, addTodoItem(item: TodoItem): void { patchState(store, addEntity(item, { collection: 'todo' })); }, setTodoItem(item: TodoItem): void { patchState(store, setEntity(item, { collection: 'todo' })); }, })), withComputed(({ todoEntities }) => ({ todoItems: todoEntities, })) ); }
В данном примере мы создали signalStoreFeature с именем withTodoItems, которая работает с именованной коллекцией todo, и каждый его элемент имеет тип TodoItem.
setTodoData - полная инициализация массива с помощью setAllEntities
addTodoItem - добавление элемента с помощью addEntity
setTodoItem - заменой конкретного элемента массива по ключевому полю с помощью setEntity.
Примечание. Для обновления записи можно также использовать частичное обновление полей элемента массива с помощью updateEntity.
Подробнее обо всех методах работы с коллекцией вы можете прочитать в официальной документации (https://ngrx.io/guide/signals/signal-store/entity-management).
Подключение дополнительных хранилищ к основному
После создания signalStoreFeature необходимо подключить его к основному хранилищу данных CommonStore:
CommonStore.ts
import {signalStore, withState} from "@ngrx/signals"; import {StatusData} from "../model/StatusData"; import {withTodoItems} from "./features/TodoListStore"; export type CommonState = { _contactId: string; loading: boolean; statuses: StatusData[]; selectedId: string; } export const CommonStore = signalStore( {providedIn: 'root'}, withState<CommonState>({ _contactId: "", loading: false, statuses: [], selectedId: "" }), withTodoItems() );
Таким образом, вы можете выделять отдельные хранилища в обособленные по функциональности модули и подключать в основное.
Добавление логики
Добавление обработчиков происходит в блоке withMethods. Создадим метод SaveContact, и для изменения состояния можно вызвать метод patchState, в котором мы будем сохранять ID контакта.
saveContact(id: string) { patchState(store, { _contactId: id }); }
Сигналы являются синхронными, поэтому для выполнения асинхронных операций, таких как http-запросов, нужно использовать rxMethod из @ngrx/signals/rxjs-interop. Для работы приложения нам нужно получить список задач и наполнить справочник статусов, причем можно выполнять запросы либо последовательно, либо параллельно. Я покажу пример, как можно выполнить оба запроса одновременно и получить общий ответ по ним с помощью RxJs и методов mergeMap и ForkJoin.
loadTodoData: rxMethod<void>(pipe( tap(() => { patchState(store, { loading: true })}), mergeMap(() => { return forkJoin([todoService.getStatuses(), todoService.getRecords(store._contactId())]); }), tap(([statuses, todoItems]) => { patchState(store, { statuses: statuses, loading: false }); store.setTodoData(todoItems); }) ))
Для вычисляемых состояний необходимо использовать блок withComputed, в котором будем хранить список идентификаторов отмеченных задач, при условии, что они перешли в конечное состояние (isFinal = true в справочнике).
checkList: computed(() => { return store.todoItems().filter(todoItem => store.statuses().some(status => status.isFinal && status.id === todoItem.statusId)).map(x=>x.id) }),
Итоговый файл CommonStore.ts выглядит следующим образом:
CommonStore.ts
import {patchState, signalStore, withComputed, withMethods, withState} from "@ngrx/signals"; import {TodoItem} from "../model/TodoItem"; import {rxMethod} from "@ngrx/signals/rxjs-interop"; import {exhaustMap, forkJoin, mergeMap, pipe, tap} from "rxjs"; import {computed, inject} from "@angular/core"; import {TodoService} from "../service/todo.service"; import {StatusData} from "../model/StatusData"; import {withTodoItems} from "./features/TodoListStore"; import {tapResponse} from "@ngrx/operators"; export type CommonState = { _contactId: string; loading: boolean; statuses: StatusData[]; selectedId: string; } export const CommonStore = signalStore( {providedIn: 'root'}, withState<CommonState>({ _contactId: "", loading: false, statuses: [], selectedId: "" }), withTodoItems(), withMethods((store, todoService = inject(TodoService)) => ({ saveContact(id: string) { patchState(store, { _contactId: id }); }, loadTodoData: rxMethod<void>(pipe( tap(() => { patchState(store, { loading: true })}), mergeMap(() => { return forkJoin([todoService.getStatuses(), todoService.getRecords(store._contactId())]); }), tap(([statuses, todoItems]) => { patchState(store, { statuses: statuses, loading: false }); store.setTodoData(todoItems); }) )), addTodoItemQuery: rxMethod<TodoItem>(pipe( exhaustMap((item) => todoService.addRecord(store._contactId(), item).pipe( tapResponse({ next: () => { store.addTodoItem(item); }, error: () => {}, finalize: () => {} }), )) )), selectRecord(value: string) { patchState(store, { selectedId: value }); } })), withComputed((store) => ({ checkList: computed(() => { return store.todoItems().filter(todoItem => store.statuses().some(status => status.isFinal && status.id === todoItem.statusId)).map(x=>x.id) }), })) );
Использование состояния в шаблонах
Для использования состояния необходимо внедрить его в модуль:
readonly store = inject(CommonStore);
вызывать методы можно как обычные методы, например, в классе компонента angular-app.component выполним запрос данных на OnInit
ngOnInit(): void { this.store.saveContact(this.contactId); this.store.loadTodoData(); }
В шаблонах можно использовать параметры состояния как обычные сигналы
<app-todo-property [recordId]="store.selectedId()" class="w-full"/>
Создание верстки
Основной модуль Angular, который будет описывать визуальный модуль – это angular-app. Его необходимо создать в директории src\components.
В директиве @Component файла angular-app.component.ts применяем инкапсуляцию стилей и поведение:
encapsulation: ViewEncapsulation.ShadowDom, changeDetection: ChangeDetectionStrategy.OnPush
Для лучшей производительности приложения рекомендую использовать стратегию изменения OnPush и добавить в файле angular.json блока schematics проекта app-serve следующие изменения для поведения по умолчанию при добавлении компонентов.
"schematics": { "@schematics/angular:component": { "style": "scss", "changeDetection": "OnPush" } }
Визуальный модуль представляет собой 3 элемента: 2 компонента и разделитель между ними.

Для создания компонентов todo-content и todo-property необходимо перейти в директорию \src\app\component\angular-app\ и выполнить команду:
ng g c todo-content --project app-serve --skip-tests --skip-import ng g c todo-property --project app-serve --skip-tests --skip-import
Примечание. Создание компонентов можно выполнить с помощью Angular Schematic внутри IDE, например, WebStorm.
Контейнеры расположены в один ряд, поэтому применяем класс flex, добавим одинаковое расстояние между контейнерами gap равным 2rem (т.е. в классе указываем gap-2, применяя primeflex).
Файл angular-app.component.html выглядит следующим образом:
<div class="flex gap-2"> <app-todo-content class="w-full"/> <p-divider layout="vertical"/> <app-todo-property class="w-full"/> </div>
Контейнер app-todo-content можно представить в виде 3-х основных контейнеров

Контейнеры в этот раз расположены друг под другом, поэтому применяем классы flex и flex-column, добавляем gap-3 и создаем новый компонент для списка задач todo-items внутри компонента todo-content в директории src\app\component\angular-app\todo-content
ng g c todo-items --project app-serve --skip-tests --skip-import
Добавляем текстовое поле, переменную newItemValue для хранения значения, кнопку для создания новых задач. Причем можем использовать свойство disabled для того, чтобы кнопка была доступна только в том случае, если введен какой-либо текст в поле ввода.
Файл todo-content.component.html можно представить в следующем виде:
<div class="flex flex-column gap-3 p-2"> <p class="text-base p-0 m-0">Список задач</p> <div class="flex gap-2"> <input class="w-12rem p-0 m-0" [(ngModel)]="newItemValue" pInputText type="text"/> <p-button class="font-normal" label="Добавить"/> </div> <app-todo-list/> </div>
Внутри app-todo-list добавляем компонент для одной записи todo-item, а для списка записей применяем декоратор @for и @empty для отображения сообщения при отсутствии записей.
Через декоратор @let можно создавать переменные внутри шаблона, например, для loading
todo-list.component.html
@let loading = store.loading(); @if (!loading) { … } @else { <div class="h-6rem flex gap-3 py-4 justify-content-center align-items-center"> <p-progressSpinner styleClass="w-3rem h-3rem" /> <p class="p-0 m-0 text-base">Загрузка...</p> </div> }

В данном коде добавим проверку на наличие процесса получения данных и если список задач не был получен, то отображаем колесо загрузки с помощью компонента progressSpinner от PrimeNg.
Другой подход для отображения состояния загрузки был предоставлен в шаблоне todo-property.component.html, где можно использовать так называемые скелетоны.
todo-property.component.html
<p-skeleton styleClass="w-30rem h-1rem py-1" /> <p-skeleton styleClass="w-23rem h-2rem py-1" /> <p-skeleton styleClass="w-16rem h-1rem py-1" /> <p-skeleton styleClass="w-25rem h-2rem py-1" /> <p-skeleton styleClass="w-15rem h-1rem py-1" /> <p-skeleton styleClass="w-12rem h-2rem py-1" />

В этом же компоненте TodoProperty я решил показать другой способ работы с потоком данных без глобального состояния и сигналов, а с помощью вызова сервиса напрямую и работы с pipe async.
todo-property.component.html
@let todoItem = todoItem$ | async; @if (todoItem) { <app-property-item caption="Заголовок" [value]="todoItem.title"/> <app-property-item caption="Дата начала" [value]="todoItem.startDate"/> <app-property-item caption="Дата окончания" [value]="todoItem.endDate"/> <app-property-item caption="Автор" [value]="todoItem.author"/> <app-property-item caption="Статус" [value]="store.statuses() | getStatusCaption : todoItem.statusId"/> <app-property-item caption="Категория" [value]="todoItem.category"/> }
существует и другая форма записи
@if (todoItem$ | async; as todoItem) { ... }
При выделении записи мы делаем запрос на сервис TodoService метода getRecord при любом изменении recordId. В данном случае я решил использовать метод ngOnChanges, хотя это и не рекомендуется, т.к. он вызывается на каждом изменении входящих параметров.
В подходе без использования менеджера состояний вы должны сами определять, каким образом производить обмен данными между компонентами, например, в моем случае есть один баг: когда мы отмечаем задание выполненным, то информация о нем не обновляется в правой части приложения в блоке информации о задаче.
Параметры и события
В качестве входящего параметра возьмем ID контакта, т.к. мы должны получать все задачи конкретного пользователя. Для этого в главном компоненте custom element angular-app.component.ts создадим новую переменную с помощью декоратора @Input
@Input("contactId") contactId!: string;
Примечание. Обратите внимание, что описанные в camelCase свойства без указания в декораторе явного имени будут переведены в HTML-атрибуты в kebab-case.
Далее входящий параметр можно сразу же сохранить в глобальное состояние ngrx
. . . this.store.saveContact(this.contactId); . . .
saveContact(id: string) { patchState(store, { _contactId: id }); }
Для того, чтобы обрабатывать события визуального модуля Angular и передавать их во внешнее приложение, необходимо использовать декоратор @Output. В нашем примере нужно определить событие изменения листа, которое будет срабатывать при добавлении записи и отметке о выполнении задачи.
@Output() TodoListChanged = new EventEmitter<void>();
Параметры для этого события не нужны, мы будем передавать только факт события, поэтому в качестве аргумента можно передать void.
Для передачи основного события TodoListChanged из других внутренних компонентов можно создать сущность новый Subject из RxJs в сервисе TodoService, а в других – эмитить изменения. Выглядит это следующим образом.
todo.service.ts
. . . public todoListChanged$ = new Subject<void>(); . . .
Таким образом, в основном компоненте необходимо оформить подписку на события, применив pipe debounceTime, который не позволит выполнить больше одного эмита в течении 400 мс. Не забываем отписаться от потока на OnDestroy!
angular-app.component.ts
todoListChangedSub: Subscription; ngOnInit(): void { . . . this.todoListChangedSub = this.todoService.todoListChanged$.pipe( debounceTime(400) ).subscribe(() => this.TodoListChanged.emit()); . . . } ngOnDestroy(): void { this.todoListChangedSub.unsubscribe(); }
Для добавления события в поток todoListChanged$ выполняем emit после успешного выполнения запроса по добавлению записи на сервер.
addTodoItemQuery: rxMethod<TodoItem>(pipe( exhaustMap((item) => todoService.addRecord(store._contactId(), item).pipe( tapResponse({ next: () => { . . . todoService.todoListChanged$.next(); }, . . . )) )),
Создание модуля Angular Elements
Для того, чтобы внедрить готовый компонент в CRM, необходимо создать отдельный customElements с помощью @angular/elements. Для этого создадим файл main.element.ts в директории /src, где лежит основной main.ts самого приложения.
Таким образом, файл main.ts описан в angular.json для проекта app-serve, а main.element.ts – для проекта app-build.
main.element.ts
import {enableProdMode} from '@angular/core'; import {platformBrowserDynamic} from '@angular/platform-browser-dynamic'; import {ElementModule} from './app/element.module'; import {environment} from './environments/environment'; import 'zone.js'; if (environment.production) { enableProdMode(); } platformBrowserDynamic().bootstrapModule(ElementModule) .catch(err => console.error(err));
Модуль самого элемента ElementModule создадим в директории src\app с названием element.module.ts
element.module.ts
import {ApplicationRef, DoBootstrap, Injector, NgModule} from '@angular/core'; import {createCustomElement} from '@angular/elements'; import {BrowserModule} from '@angular/platform-browser'; import {BrowserAnimationsModule} from '@angular/platform-browser/animations'; import {AngularAppComponent} from "./component/angular-app/angular-app.component"; import {provideHttpClient} from "@angular/common/http"; @NgModule({ imports: [ BrowserModule, AngularAppComponent, BrowserAnimationsModule ], providers: [ provideHttpClient() ] }) export class ElementModule implements DoBootstrap { constructor(private injector: Injector) {} ngDoBootstrap(appRef: ApplicationRef) { if (!customElements.get('ng-todo')) { const elementComponent = createCustomElement(AngularAppComponent, { injector: this.injector, // This injector is used to load the component's factory }); customElements.define('ng-todo', elementComponent); } } }
В данном модуле мы создаем собственный элемент HTML с названием ng-todo, который можно добавить на любую страницу <ng-todo />, логика которого описана в компоненте AngularAppComponent.
Итоговая сборка представляет собой набор файлов, основой файл в котором main.js в директорию outputPath проекта app-build. И т.к. проект Angular и пакет BPMSoft находятся в папке Pkg, то мы можем скорректировать путь к конечной директории следующим образом в файле angular.json
angular.json
"projects": { "app-build": { "architect": { "build": { "options": { "outputPath": "../BPMSoft_NgExample/Files/src/js/ng-todo", . . .
где BPMSoft_NgExample – название пакета, а ng-todo – название компонента.
Настроив конфиг вышеуказанным способом, можно выполнить сборку проекта и получить готовый custom element командой
ng build --project app-build
Запуск Angular-приложения в Docker
Этот пункт не является обязательным и позволяет настроить работу Angular-приложений в микросервисной архитектуре.
Настройка проекта
Для сборки проекта из исходного, но уже для нового контекста, необходимо скорректировать Angular-проект следующим образом:
Cоздадим новый скрипт в package.json и назовем его staging, в котором мы будем использовать configuration=stage, а проект app-serve
"scripts": { . . . "staging": "ng build --configuration=stage --project app-serve", . . . }
Добавляем новую конфигурацию для app-serve пункта architect в build и serve
"architect": { "build": { "configurations": { "stage": { "outputHashing": "all" } }, "defaultConfiguration": "production" }, "serve": { "builder": "ngx-build-plus:dev-server", "configurations": { "stage": { "buildTarget": "app-serve:build:stage" } }, "defaultConfiguration": "development", "options": {} } }
Указываем output директорию при сборке serve, например, так:
"architect": { "build": { "options": { "outputPath": "dist/app-serve",
Сборка проекта из исходного кода
Исходный код можно собирать на уровне DevOps с помощью shell, но в этом случае необходимо уставить NodeJs, Java и прочие зависимости, либо Docker-контейнера, например, node:21.7-alpine, установив @angular/cli в Gitlab runner внутри Kubernetes.
FROM mirror.gcr.io/node:21.7-alpine ENV GENERATE_SOURCEMAP=false ENV NODE_OPTIONS=--max-old-space-size=2048 WORKDIR /app RUN npm install -g @angular/cli@17 RUN export NODE_OPTIONS="--max-old-space-size=2048"
Запуск рабочего приложения
Образ node:21.7-alpine можно использовать и для работы самого приложения с помощью команды ng serve, но он содержит большое количество установленных зависимостей, да и размер у него 70Мб. Для работы нужно минимум 2Гб ОЗУ, для чего в образ и добавляется max-old-space-size.
Так как мы делаем сборку Custom Element, то для работы с ним не требуется больше ни nodejs, ни установленного angular/cli. Для этих целей можно использовать образ nginx или минимальный образ alpine.
По итогу для подготовки образа создаем Dockerfile и выполняем следующие команды:
- устанавливаем nginx;
- копируем конфиг nginx;
- копируем собранные исходники из п.4.2 из директории dist/app-serve, которую мы указали в п.4.1;
- и публикуем сервис на порту 80.
Dockerfile
FROM alpine:3.13.3 RUN apk add --update nginx && rm -rf /var/cache/apk/* COPY nginx.non-root.conf /etc/nginx/nginx.conf COPY dist/app-serve /usr/share/nginx/html RUN nginx -t EXPOSE 80 VOLUME ["/usr/share/nginx/html"] CMD ["nginx", "-g", "daemon off;"] EXPOSE 80
Алгоритм сборки приложения выглядит следующим образом:
Выполняем сборку приложения
ng build --configuration=stage --project app-serve
Далее нам необходимо собрать образ с названием ngtemplate с помощью команды
docker build -f Dockerfile -t ngtemplate .
Запускаем контейнер с названием app1 из образа ngtemplate
docker run -p 80:80 --name app1 ngtemplate
Получилось, что сам образ nginx весит около 3Мб, а конечный образ вместе с приложением – 9Мб.
Добавление модуля в BPMSoft
Иерархия директорий внутри пакета BPMSoft выглядит следующим образом:
Files - src - js - ng-todo - bootstrap.js - descriptor.json
Подключение компонента
Создаем файл descriptor.json в директории Files и подключаем bootstraps из директории js следующим образом:
descriptor.json
{ "bootstraps": [ "src/js/bootstrap.js" ] }
В файле bootstrap.js подключаем наш компонент c с названием NgTodoComponent
bootstrap.js
(function() { require.config({ paths: { "NgTodoComponent": BPMSoft.getFileContentUrl("BPMSoft_NgExample", "src/js/ng-todo/main.js") }, shim: {} }); })();
Создание схем
Для работы с компонентом создадим новый модуль в конфигурации BPMSoft с названием UsrTodoModule

UsrTodoModule
define("UsrTodoModule", ["NgTodoComponent"], function () { Ext.define("BPMSoft.configuration.UsrTodoModule", { alternateClassName: "BPMSoft.UsrTodoModule", extend: "BPMSoft.BaseModule", Ext: null, sandbox: null, BPMSoft: null, viewModel: null, view: null, ngComponent: null, ngValue: null, render: function(renderTo) { this.callParent(arguments); const ngComponent = document.createElement("ng-todo"); ngComponent.setAttribute("id", this.sandbox.id); this.ngComponent = ngComponent; . . . renderTo.appendChild(ngComponent); }, . . . destroy: function () { this.ngComponent = null; } }); return BPMSoft.UsrTodoModule; });
Основной метод – это render, в котором с помощью createElement мы создаем компонент с именем ng-todo, который указали в файле element.module.ts Angular-приложения.
Входящие параметры опишем с помощью метода initNgComponentAttributes, а подписку на исходящие события в initNgComponentEvents.
Полный листинг схемы UsrTodoModule выглядит так:
define("UsrTodoModule", ["NgTodoComponent"], function () { Ext.define("BPMSoft.configuration.UsrTodoModule", { alternateClassName: "BPMSoft.UsrTodoModule", extend: "BPMSoft.BaseModule", Ext: null, sandbox: null, BPMSoft: null, viewModel: null, view: null, ngComponent: null, ngValue: null, messages: { "TodoListChanged": { mode: BPMSoft.MessageMode.PTP, direction: BPMSoft.MessageDirectionType.PUBLISH }, "OnReloadTodoData": { mode: BPMSoft.MessageMode.PTP, direction: BPMSoft.MessageDirectionType.SUBSCRIBE } }, init: function() { this.sandbox.registerMessages(this.messages); this.callParent(arguments); }, render: function(renderTo) { this.callParent(arguments); const ngComponent = document.createElement("ng-todo"); ngComponent.setAttribute("id", this.sandbox.id); this.ngComponent = ngComponent; this.initNgComponentAttributes(); this.initNgComponentEvents(); renderTo.appendChild(ngComponent); }, initNgComponentAttributes: function() { const ngComponent = this.ngComponent; if (ngComponent) { ngComponent.contactId = this.ngValue.contactId; ngComponent.sandbox = this.sandbox; } }, initNgComponentEvents: function() { const ngComponent = this.ngComponent; if (ngComponent) { ngComponent.addEventListener("TodoListChanged", () => this.sandbox.publish("TodoListChanged", null, [this.sandbox.id])); } }, destroy: function () { this.ngComponent = null; } }); return BPMSoft.UsrTodoModule; });
Для добавления созданного модуля на страницу можно обернуть его в дополнительную схему UsrTodoSchema

define("UsrTodoSchema", ["UsrTodoModule"], function () { return { mixins: {}, details: /**SCHEMA_DETAILS*/{}/**SCHEMA_DETAILS*/, attributes: {}, messages: { "GetContactId": { mode: BPMSoft.MessageMode.PTP, direction: BPMSoft.MessageDirectionType.PUBLISH } }, modules: /**SCHEMA_MODULES*/{}/**SCHEMA_MODULES*/, methods: { getTodoModuleName: function() { return "UsrTodoModule"; }, getTodoModuleSandboxId: function() { return this.sandbox.id + "_" + this.getTodoModuleName(); }, onRender: function() { this.callParent(arguments); this.loadTodoModule(); }, onDestroy: function() { this.sandbox.unloadModule(this.getTodoModuleName()); this.callParent(arguments); }, loadTodoModule: function() { let contactId = this.sandbox.publish("GetContactId", null, [this.sandbox.id]); this.sandbox.loadModule(this.getTodoModuleName(), { renderTo: Ext.get("UsrTodoContainer"), keepAlive: false, instanceConfig: { ngValue: { contactId: contactId } } }); } }, diff: /**SCHEMA_DIFF*/[ { "operation": "insert", "name": "UsrTodoContainer", "values": { "id": "UsrTodoContainer", "itemType": BPMSoft.ViewItemType.CONTAINER, "items": [], } }, ], /**SCHEMA_DIFF*/ rules: {} }; });
В данном коде мы создаем новый контейнер UsrTodoContainer, а с помощью loadModule загружаем в него содержимое модуля.
А подключение на саму страницу редактирования контакта выглядит следующим образом:
1. Добавляем замещающую страницу редактирования ContactPageV2;
2. Подключаем схему в блоке modules;
3. Добавляем новый элемент в блоке diff.
modules: /**SCHEMA_MODULES*/{ "UsrTodo": { "config": { "schemaName": "UsrTodoSchema", "isSchemaConfigInitialized": true, "useHistoryState": false, "showMask": true, "parameters": { "viewModelConfig": {} } } } }/**SCHEMA_MODULES*/ . . . diff: /**SCHEMA_DIFF*/[ { "operation": "insert", "parentName": "Tab91b480c3TabLabelGroup5e5b6cab", "propertyName": "items", "name": "UsrTodo", "values": { "itemType": this.BPMSoft.ViewItemType.MODULE }, "index": 1 } ]/**SCHEMA_DIFF*/
Полный текст ContactPageV2 представлен ниже:
define("ContactPageV2", ["UsrTodoSchema"], function() { return { entitySchemaName: "Contact", attributes: {}, messages: { "GetContactId": { mode: BPMSoft.MessageMode.PTP, direction: BPMSoft.MessageDirectionType.SUBSCRIBE }, "TodoListChanged": { mode: BPMSoft.MessageMode.PTP, direction: BPMSoft.MessageDirectionType.SUBSCRIBE }, "ChangeDashboardTab": { mode: this.BPMSoft.MessageMode.BROADCAST, direction: this.BPMSoft.MessageDirectionType.SUBSCRIBE }, "OnReloadTodoData": { mode: this.BPMSoft.MessageMode.PTP, direction: this.BPMSoft.MessageDirectionType.PUBLISH } }, modules: /**SCHEMA_MODULES*/{ "UsrTodo": { "config": { "schemaName": "UsrTodoSchema", "isSchemaConfigInitialized": true, "useHistoryState": false, "showMask": true, "parameters": { "viewModelConfig": {} } } } }/**SCHEMA_MODULES*/, details: /**SCHEMA_DETAILS*/{}/**SCHEMA_DETAILS*/, businessRules: /**SCHEMA_BUSINESS_RULES*/{}/**SCHEMA_BUSINESS_RULES*/, methods: { onEntityInitialized: function() { this.callParent(arguments); this.sandbox.publish("OnReloadTodoData", null, [this.getTodoModuleSandboxId()]); }, getTodoSchemaSandboxId: function() { return this.sandbox.id + "_module_UsrTodo"; }, getTodoModuleSandboxId: function() { return this.getTodoSchemaSandboxId() + "_UsrTodoModule"; }, subscribeSandboxEvents: function() { this.callParent(arguments); this.sandbox.subscribe("GetContactId", _ => this.$Id, this, [this.getTodoSchemaSandboxId()]); this.sandbox.subscribe("TodoListChanged", () => { this.sandbox.publish("ReloadDashboardItems") }, this, [this.getTodoModuleSandboxId()]); this.sandbox.subscribe("ChangeDashboardTab", (tabName) => { this.sandbox.publish("OnReloadTodoData", null, [this.getTodoModuleSandboxId()]); }, this); } }, dataModels: /**SCHEMA_DATA_MODELS*/{}/**SCHEMA_DATA_MODELS*/, diff: /**SCHEMA_DIFF*/[ { "operation": "insert", "name": "Tab91b480c3TabLabel", "values": { "caption": { "bindTo": "Resources.Strings.Tab91b480c3TabLabelTabCaption" }, "items": [], "order": 4 }, "parentName": "Tabs", "propertyName": "tabs", "index": 4 }, { "operation": "insert", "name": "Tab91b480c3TabLabelGroup5e5b6cab", "values": { "caption": { "bindTo": "Resources.Strings.Tab91b480c3TabLabelGroup5e5b6cabGroupCaption" }, "itemType": 15, "markerValue": "added-group", "items": [] }, "parentName": "Tab91b480c3TabLabel", "propertyName": "items", "index": 0 }, { "operation": "insert", "parentName": "Tab91b480c3TabLabelGroup5e5b6cab", "propertyName": "items", "name": "UsrTodo", "values": { "itemType": this.BPMSoft.ViewItemType.MODULE }, "index": 1 }, { "operation": "merge", "name": "ESNTab", "values": { "order": 6 } } ]/**SCHEMA_DIFF*/ }; });
Механизм взаимодействия
Хочу обратить внимание на то, что мы можем передать параметры в компонент при инициализации, а для того, чтобы реализовать обмен сообщениями, в процессе работы можно использовать песочницу BPMSoft.
Создаем новый входящий параметр @Input() sandbox в приложении Angular, передаем его из UsrTodoModule и можем использовать аналогичные подписки, как это сделано в CRM-системе.
направление | Название сообщения | Описание | Отправка | Получение |
CRM > Angular | ContactId | Передача ID контакта при инициализации |
|
|
OnReloadTodoData | Сообщение для фиксации событий изменения карточки CRM системы и обновления данных Angular | // Отправка сообщения при открытии страницы
// при изменения дашборда
|
| |
Angular > CRM | TodoListChanged | Сообщение для фиксации событий изменения данных Angular и обновления карточки CRM системы |
|
|
Создание сервиса
Для работы с данными нам необходимо создать сервис ActivityService и основные методы:
GetRecords
GetRecord
GetStatuses
AddRecord
CheckRecord
Настройка ссылки на сервис и методы описаны в environment.prod.ts Angular-приложения.
Заключение
Готовое решение в BPMSoft выглядит следующим образом:

Система позволяет создавать записи, отмечать выполнение и просматривать подробную информацию о задаче. Причем при изменении активности из дашборда наш компонент автоматически актуализирует данные.
Хочу обратить внимание на то, что стили полностью инкапсулированы внутри модуля, поэтому можно использовать возможность UI-фреймворка и даже подключать свой шрифт, например, Montserrat, как в текущем примере.
Angular-приложение работает автономно от CRM-системы и может дорабатываться даже разработчиками или дизайнерами, которые не имеют экспертизы для работы с BPMSoft. Таким образом, следуя инструкции, вы легко можете создать Angular-приложение самостоятельно. Ниже собрал полезные ссылки:
Исходный код данного Angular-приложения находится на github
Также можно открыть онлайн редактор на stackblitz
Исходный код пакета BPMSoft находится здесь
В BPMSoft начиная с версии 1.4 была добавлена стандартная функциональность создания компонентов на Angular с возможность доступа к контексту и подключения готовых сервисов ESQ для взаимодействия с ядром системы. Спасибо @Akhmerov
Если остались вопросы, или по ходу чтения возникли идеи, делитесь в комментариях.
