Как с помощью Angular доработать CRM-систему: наш опыт с BPMSoft

от автора

Всем привет.

Меня зовут Илья Чубко, я являюсь техническим архитектором в направлении, которое занимается  внедрением 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-приложения 

  1. Настройка приложения и проектов

Проект Angular я рекомендую хранить в папке Pkg, где находятся основные пакеты CRM-системы, но вы можете использовать любое место хранения.

Сам шаблон проекта можно получить из github командой

Ангуляр-приложение состоит из 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
    }
}

  1. Настройка библиотек

Внешние библиотеки, которые я использую для разработки и компиляции приложения, описаны в следующей таблице:

Название

Компонент

Назначение

Порядок установки

Angular Elements

@angular/elements

npm-пакет, который позволяет упаковывать Angular-компоненты в Custom Elements и определять новые HTML-элементы со стандартным поведением

ng i @angular/elements

Build Plus

ngx-build-plus

npm-пакета, который позволяет производить сборку и упаковку компонентов

ng i ngx-build-plus

PrimeNg

priming

npm-пакет, который содержит набор уже готовых компонентов для создания UI

npm i primeng

primeflex

npm-пакет для удобной работы со стилями, аналогично bootstrap

npm i primeflex

primicons

Набор иконок для использования в приложении

npm i primeicons

Guid Typescript

guid-typescript

npm-пакет для работы с типом данных Guid

npm i guid-typescript

NgRx

@ngrx/store

npm-пакет для хранения глобального состояния приложения

npm i @ngrx/store

@ngrx/signals

npm-пакет, который позволяет использовать сигналы для хранения глобального состояния приложения

npm i @ngrx/signals

In memory WebApi

angular-in-memory-web-api

инструмент для эмуляции http-запросов

  1. Создание модели данных

Для хранения записей задач создаем файл 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;
}

  1. Создание сервиса и имитация данных

Создание сервиса

Для работы с данными необходимо выполнение 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 секунды. В случае необходимости можно добавить индикатор загрузки и увеличить данное значение для тестирования.

  1. Подключение и использование статического контента

Весь статический контент можно хранить в 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.

  1. Подключение и настройка менеджера состояний

Создание основного хранилища

Для работы с массивом задач будем использовать 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: «»
    })
);

В данном коде нам нужно хранить несколько значений в рамках всего приложения:

  1.  _contactId – ID записи контакта, задачи которого должны отображаться. Сделаем переменную приватной, для этого добавим префикс _ в начале

  2. loading – для хранения состояния процесса загрузки данных

  3. statuses – для хранения справочника статусов задач

  4. 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.

  1. setTodoData — полная инициализация массива с помощью setAllEntities

  2. addTodoItem — добавление элемента с помощью addEntity

  3. 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»/>

  1. Создание верстки

Основной модуль 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, хотя это и не рекомендуется, т.к. он вызывается на каждом изменении входящих параметров.

В подходе без использования менеджера состояний вы должны сами определять, каким образом производить обмен данными между компонентами, например, в моем случае есть один баг: когда мы отмечаем задание выполненным, то информация о нем не обновляется в правой части приложения в блоке информации о задаче.

  1. Параметры и события

В качестве входящего параметра возьмем 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();
          },

          . . .

    ))
)),

  1. Создание модуля 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-проект следующим образом:

  1. Cоздадим новый скрипт в package.json и назовем его staging, в котором мы будем использовать configuration=stage, а проект app-serve

«scripts»: {
. . .

  «staging»: «ng build —configuration=stage —project app-serve»,
. . .

}

  1. Добавляем новую конфигурацию для 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»: {}
    }
}

  1. Указываем 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

Алгоритм сборки приложения выглядит следующим образом:

  1. Выполняем сборку приложения

ng build —configuration=stage —project app-serve

  1. Далее нам необходимо собрать образ с названием ngtemplate с помощью команды

docker build -f Dockerfile -t ngtemplate .

  1. Запускаем контейнер с названием 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 контакта при инициализации

const ngComponent = document.createElement(«ng-todo»);

ngComponent.contactId = this.ngValue.contactId;

@Input(«contactId») contactId!: string;

OnReloadTodoData

Сообщение для фиксации событий изменения карточки CRM системы и обновления данных Angular

// Отправка сообщения при открытии страницы

onEntityInitialized: function() {
    this.callParent(arguments);
    this.sandbox.publish(«OnReloadTodoData», null, [this.getTodoModuleSandboxId()]);
},

// при изменения дашборда

this.sandbox.subscribe(«ChangeDashboardTab», (tabName) => {
    this.sandbox.publish(«OnReloadTodoData», null, [this.getTodoModuleSandboxId()]);
}, this);

ngOnInit(): void {
. . .

  if (this.sandbox) {
      this.sandbox.subscribe(«OnReloadTodoData», () => this.store.loadTodoData(), this, [this.sandbox.id]);
    }
}

Angular > CRM

TodoListChanged

Сообщение для фиксации событий изменения данных Angular и обновления карточки CRM системы

@Output() TodoListChanged = new EventEmitter<void>();

. . .

this.TodoListChanged.emit());

messages: {
«TodoListChanged»: {
    mode: BPMSoft.MessageMode.PTP,
    direction: BPMSoft.MessageDirectionType.PUBLISH
}
},

ngComponent.addEventListener(«TodoListChanged», () => this.sandbox.publish(«TodoListChanged», null, [this.sandbox.id]));

Создание сервиса

Для работы с данными нам необходимо создать сервис ActivityService и основные методы:

  • GetRecords

  • GetRecord

  • GetStatuses

  • AddRecord

  • CheckRecord

Настройка ссылки на сервис и методы описаны в environment.prod.ts Angular-приложения.

Заключение

Готовое решение в BPMSoft выглядит следующим образом:

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

Исходный код данного Angular-приложения находится на github https://github.com/IlyaChubko/NgTemplate

Также можно  открыть онлайн редактор через stackblitz https://stackblitz.com/~/github.com/IlyaChubko/NgTemplate

Исходный код пакета BPMSoft находится по адресу: https://github.com/IlyaChubko/BPMSoft_NgExample

Готовый пакет, собранный из исходных кодов, основанный на сборке BPMSoft 1.5 версии NetCore для установки на стенд находится по ссылке:
https://github.com/user-attachments/files/17578170/BPMSoft_NgExample.zip

Если остались вопросы, или по ходу чтения возникли идеи, делитесь в комментариях. 


ссылка на оригинал статьи https://habr.com/ru/articles/860270/