Перейти к основному содержимому

08 Internal

Класс Internal, изначально реализующий возможность написания кастомных реакт-компонентов, хранящихся в ресурсах и позволяющих использовать экспортирумые обвязкой модули и методы как нативные.
Ряд экспортируемых модулей описан и типизирован тут https://github.com/luxms/bi-internal
Но этот проект может и иногда отстает в описании того, что доступно. Мы пополняем информацию по мере того, как у нас на это есть время.

Концепция

Если речь идет о банальной кастомной визуализации на React - то тут последовательность действий схожа с external:

  • Выбрать нужный дешлет
  • Указать тип визуализации Внутренний
  • Указать файл сбилженного компонента MyComponent.tsx (ищем MyComponent.js)
  • Сохраняем и видим результат рендера данного компонента.

Почему это работает? Потому что обвязкой совершаются следующие шаги, когда она видит у дешлета

"viewClass": "internal"

  1. Специальный контроллер обвязки InternalComponentVC загружает указанный контент файла-ресурса, содержащего реакт компонент с export default
  2. Он формирует из этого функциональный компонент с генерируемыми дополнительными аргументами и прокидывает туда определяемые на лету модули:
react // ту версию, которая есть в обвязке
react-dom // ту версию, которая есть в обвязке
classnames // https://www.npmjs.com/package/classnames
jquery
axios
three // Three.js
@react-three/fiber // Модуль Three.js
@react-three/drei // Модуль Three.js
echarts // Apache Echarts, разово определяем и экспортируем, иначе очень тяжелая библиотека

/* Далее ряд модулей из проекта bi-internal. содержащие модули, сервисы, элементы UI и вспомогательные функции */

bi-internal/utils
bi-internal/core
bi-internal/face
bi-internal/root
bi-internal/types
bi-internal/ui
bi-internal/services
bi-internal/ds-helpers

Если вы заглянете в webpack.config.js то увидите там


externals: {
'react': 'react',
'react-dom': 'react-dom',
'classnames': 'classnames',
'jquery': 'jquery',
'axios': 'axios',
'three': 'three',
'@react-three/fiber': '@react-three/fiber',
'@react-three/drei': '@react-three/drei',
'echarts': 'echarts',
'bi-internal': 'bi-internal',
'bi-internal/font': 'bi-internal/font',
'bi-internal/core': 'bi-internal/core',
'bi-internal/face': 'bi-internal/face',
'bi-internal/root': 'bi-internal/root',
'bi-internal/types': 'bi-internal/types',
'bi-internal/services': 'bi-internal/services',
'bi-internal/utils': 'bi-internal/utils',
'bi-internal/ds-helpers': 'bi-internal/ds-helpers',
'bi-internal/ui': 'bi-internal/ui'
},

Эта связка и позволяет прокидывать в произвольные компоненты указанные модули вдобавок к тем, что у вас есть в package.json. Так что нигде на сервере они не хранятся, это происходит “на лету” средствами обвязки.

Сам пакет bi-internal будет описан в конце руководства.

Пример кастомного компонента

Он просто ищет в конфиге дешлета ключ formattedString и заменяет в ней указание дименшна на его текущее значение (например "Моей собаке Жучке $age$ лет").
При условии, что age содержит фильтр, то он вставится в эту строку на место $age$


import React from "react";
import {KoobFiltersService, KoobService} from 'bi-internal/services'; // вот пример того, как можно получить компоненты из на лету создаваемого экспортируемого модуля (в package.json вы его не увидите!)
import {$eid} from "bi-internal/utils"; // то же)

export default class MyComponent extends React.Component<any> {
private _koobFiltersService: KoobFiltersService;
private _koobService: KoobService;
public state: {
data: any;
parsedString: string;
};

public constructor(props) {
super(props);
this.state = {
data: [],
parsedString: ""
};
}
public componentDidMount(): void {
const {cfg, dp, subspace} = this.props;
this._koobFiltersService = KoobFiltersService.getInstance();
this._koobService = KoobService.createInstance(cfg.getRaw().dataSource.koob);
this._koobFiltersService.subscribeUpdatesAndNotify(this._onFiltersUpdated);
this._koobService.subscribeUpdatesAndNotify(this._onFiltersUpdated);
// Получение данных
dp.getMatrixYX(subspace).then(data => {
this.setState({data});
})
}

public componentWillUnmount(): void {
this._koobFiltersService.unsubscribe(this._onFiltersUpdated);
this._koobService.unsubscribe(this._onFiltersUpdated);
}
// Колбек, чтобудет вызван автоматически каждый раз, когда выставлен фильтр в упр.деше (или программно в другом месте)
private _onFiltersUpdated = () => {
const {cfg} = this.props;
let formattedString = cfg.getRaw()?.formattedString || "";
const koobModel = this._koobService.getModel();
const koobFiltersModel = this._koobFiltersService.getModel();
// Проверяем, что сервисы загрузили модели, нет ошибок и они готовы к работе
if (koobModel.loading || koobModel.error || koobFiltersModel.loading || koobFiltersModel.error) return;
if (Object.keys(koobFiltersModel.filters).length) {
Object.keys(koobFiltersModel.filters).map(dimensionId => {
formattedString = formattedString.replaceAll(`$${dimensionId}$`, koobFiltersModel.filters[dimensionId] ? koobFiltersModel.filters[dimensionId][1] : "")
});
} else {
// Тут логика выставления дефолта должна быть
const possibleDimensionIds = formattedString.match(/\$\w+\$/gi).map(el => String(el).slice(1, String(el).length - 1));
if (possibleDimensionIds.length) {
possibleDimensionIds.map(dimId => {
// Достаем по идентификатору дименшна его конфиг из куба и
// проверяем наличие специального поля defaultValue, где хранится значение по умолчанию

const currentDimensionColumn = $eid(koobModel.dimensions, dimId);
const currentDimensionDefaultValue = currentDimensionColumn?.config?.defaultValue || ""; // Можно указать или прямо в конфиге дименшна или любую произвольную строку
formattedString = formattedString.replaceAll(`$${dimId}$`, currentDimensionDefaultValue);
})

}
console.log(possibleDimensionIds, formattedString)
}
this.setState({parsedString: formattedString});
}
public render() {
const {data, parsedString} = this.state;
const {cfg, dp, subspace} = this.props;
return (
<>
<div>{parsedString}</div>
</>
);
}
}

Такой компонент должен обязательно содержать export default. Это нужно, чтобы обвязка понимала, какой именно из экспортируемых компонентов маунтить.
Компонент может принимать props, а может и нет. Но когда принимает, то ему от обвязки прилетает объект со свойствами, ключевые пропсы из которых это


const {cfg, subspace, dp} = props;

Описание полей, типизация этих пропсов будет в готовящемся разделе по кастомной разработке в документации. Понять и простить)

cfg

Объект, содержащий информацию о конфиге дешлета. Его опции, источник данных, блок отображения и информация о текущем датасете, вплоть до списка дешбордов и дешлетов, рассортированных по дешбордам.


// отдаст тот объект конфига дешлета который вы видите в режиме редактирования в блоке JSON config.
// Включая то, чего вы внесли туда вручную
cfg.getRaw()
// отдаст только блок dataSource
cfg.dataSource
// вернет ссылку на файл, указанный в конфиге, в блоке url
cfg.getUrl()
// вернет объект датасета
cfg.getDataset()

subspace

Уникальный объект, хранящий информацию об организации данных, расположении элементов на осях, их порядок и сами массивы элементов соотв. оси, какие межи, какие дименшны, лимиты, оффсеты, подытоги используются/запрашиваются.
Физического смысла не имеет, однако имеет определяющую роль в том, что в итоге мы увидим на графике, ибо аггрегирует в себе множество вспомогательной информации.

Чаще всего вам потребуется проходиться по его X (subspace.xs) и Y (subspace.ys) элементам осей, дабы строить внутреннюю структуру организации и вывода данных на ваших собственных графиках и согласно используемым вами движкам для отрисовки визуала.

В большинстве случаев вам достаточно прокинуть этот объект как есть, чтобы получить данные через провайдер данных dp

dp

Провайдер данных, который содержит методы для обращения к данным на основе имеющегося subspace

Пример из кода компонента выше


dp.getMatrixYX(subspace).then(data => {
this.setState({data});
})

Вернет для каждого элемента оси Y массив значений, принимаемых осью Х (декартово произведение меж и дименшнов (фактов и измерений, если угодно))
Итого в data в таком случае получим массив массивов всегда

Для одномерного случая типа пай чарта нам не нужно столько данных, достаточно указать


dp.getVectorY(subspace).then(data => {
this.setState({data});
})

Получит массив значений, обрезанных по оси Y.

Для получения raw ответа сервера за данными используйте


dp.getKoobData(subspace).then(data => {
this.setState({data});
})

Получит массив объектов, совпадающий с тем, что идет на api/v3/${schema_name}/data.

В качестве примера рабочего кастомного компонента типа internal приведем код следующих файлов:

Конфиг дешлета, где я это использовал:


{
url: 'res:ds_res:MyComponent.js',
frame: {
h: 8,
w: 12,
x: 0,
y: 0,
},
dataSource: {
koob: 'BeerDataSource.beerKoob',
style: {
measures: {
count_units: {
color: 'red',
},
count_quantity: {
color: 'green',
},
},
},
xAxis: 'category',
yAxis: 'measures',
measures: [
'count(units):count_units',
'count(quantity):count_quantity',
],
dimensions: [
'category',
],
},
onClickDataPoint: "lpe:setKoobFilter('','category',['=',category])",
view_class: 'internal',
title: 'Test',
}

Файл с переменными темы и прочими переменными, которые вам могут понадобиться vars.scss (создайте это файл где угодно внутри src проекта BMR). Он позволит вам использовать цвета той темы, что сейчас выбрана автоматически

// переменные из темы

$color1: var(--color1);
$color2: var(--color2);
$color3: var(--color3);
$color4: var(--color4);
$color5: var(--color5);
$color6: var(--color6);
$color7: var(--color7);
$color8: var(--color8);
$color9: var(--color9);
$color10: var(--color10);
$color11: var(--color11);
$color12: var(--color12);
$color13: var(--color13);
$color14: var(--color14);
$color15: var(--color15);
$color16: var(--color16);
$color17: var(--color17);
$color18: var(--color18);
$color19: var(--color19);
$color20: var(--color20);
$color_conditions: var(--color_conditions);
$shadow_button: var(--shadow_button);
$shadow_dash: var(--shadow_dash);
$shadow_panel: var(--shadow_panel);
$panel_push-up: var(--panel_push-up);
$panel_push-down: var(--panel_push-down);
$info: var(--info);
$system_panel: var(--system_panel);
$system_background: var(--system_background);
$system_effect: var(--system_effect);
$system_selection: var(--system_selection);
$system_element: var(--system_element);
$neutral_300: var(--neutral_300);
$neutral_400: var(--neutral_400);
$neutral_500: var(--neutral_500);
$neutral_600: var(--neutral_600);
$neutral_700: var(--neutral_700);
$neutral_800: var(--neutral_800);
$neutral_900: var(--neutral_900);
$active_firstdefault: var(--active_firstdefault);
$active_firsthover: var(--active_firsthover);
$active_firstcontrast: var(--active_firstcontrast);
$active_secondcontrast: var(--active_secondcontrast);
$active_seconddefault: var(--active_seconddefault);
$active_secondhover: var(--active_secondhover);
$state_success: var(--state_success);
$state_warning: var(--state_warning);
$state_error: var(--state_error);

Можете посмотреть текущий список css-переменных в инспекторе браузера у тега body.

Файл стилей MyComponent.scss (у меня все файлы внутри ds_res, потому будьте внимательны с адресами при импортах)

// Подключили переменные темы
@import "./vars.scss";

.MyComponent {
width: 100%;
height: 100%;
position: relative;

&__graphic {
position:absolute;
z-index: 0;
width: 100%;
height: 100%;
}
&__onClickText {
position:absolute;
z-index: 1;
left: 0;
right: 0;
top: 1.5rem;
text-align: center;
color: $color1;
}
}

И собственно файл компонента, который берет то, что ему автоматически прилетает в props от обвязки и практически как есть подает их в виде опций для графика Echarts типа bar. Умеет ловить клик по точке на графике, умеет выполнять стандартный onClickDataPoint (описание в руководстве разработчика) и использует один из стандартных observable сервисов KoobFiltersService для хранения и манипуляции фильтрами на данные для куба(ов). Таким образом данный кастомный дешлет может влиять на своих соседей при помощи фильтров, налагаемых на дименшны (при условии, что коробочный дешлет содержит блок filters с указанием у соотв. дименшна значения true)


// react и echarts на самом деле тянутся из обвязки "на лету"
import React, { useEffect, useState } from "react";
import * as echarts from 'echarts';
// KoobFiltersService - Observable сервис, управляющий фильтрами для кубов (по умолчанию его использует упр.дешлет)
// useService, useServiceItself - специальные хуки для получения только модели или всего инстанса какого-либо
// Observable сервиса. Принимает по умолчанию класс нужного сервиса и если это не singleton то еще и идентификатор (через запятую)
import {KoobFiltersService, useService, useServiceItself} from 'bi-internal/services';

import './MyComponent.scss';

const MyComponent = (props) => {
const { cfg, subspace, dp } = props;
const [data, setData] = useState<any>([]);
const [clickedPointName, setClickedPointName] = useState<string>(""); // для наглядности покажем на какую точку (Y,X) кликнули
const koobFiltersService = useServiceItself<KoobFiltersService>(KoobFiltersService); // Получили инстанс сервиса фильтров
// через метод koobFiltersService.getModel() можем получить его модель,
// а через метод koobFiltersService.setFilter(koobId, dimensionId, valueArray) // ("", "category", ["=", "Beer"])
// можем фильтровать данные дешлетов, которые подписаны в своих блоках filters на изменение этого дименшна (содержат "category": true)

// Храним реф-ссылку на контейнер для графика, сам инстанс Echarts и опции, которые ему передаем
let containerRef = null;
let chart = null;
let options = {};

// Обрабатываем клик по точке графика. Проверяем, есть ли в конфиге дешлета свойство onClickDataPoint, отвечающая
// в коробке за логику клика на точку по умолчанию
// если нет - просто реализована произвольная логика выставления текущего значения дименшна в фильтр + для наглядности
// показываем в интерфейсе строчку с "координатами" кликнутой точки

const onChartClick = (params): void => {
// о том, что входит в params можно подглядеть тут https://echarts.apache.org/en/api.html#events.Mouse%20events
if (cfg.getRaw().hasOwnProperty('onClickDataPoint')) {
// Формируем объект информации о точке для встроенного контроллера обработки клика по точке
const vcpv = {m: undefined, l: undefined, p: undefined, z: undefined, y: params.data.y, x: params.data.x, v: params.value};
cfg.controller.handleVCPClick(params.event, vcpv)
} else {
const koobFiltersModel = koobFiltersService.getModel();
if (koobFiltersModel.loading || koobFiltersModel.error) return;
koobFiltersService.setFilter('', params.data.x.axisIds[0], ["=",params.name]);
}
setClickedPointName(`${params.data.y.title} ${params.data.x.title}`);
}
// На инит рефа создаем с нуля или обновляем существующий инстанс Echarts и подаем ему опции на вход
// конфигурацию графиков Echarts смотрите тут https://echarts.apache.org/en/option.html#title
const onChartCreated = (container) => {
if (container && data.length) {
if (!containerRef) {
containerRef = container;
chart = echarts.init(containerRef, null, {renderer: 'svg'});
}
options = {
title: {
show: false
},
tooltip: {
trigger: 'item',
appendToBody: true,
show: true
},
xAxis: {
type: 'category',
data: subspace.xs.map(x => x.title)
},
yAxis: {
type: 'value'
},
series: subspace.ys.map((y, yIndex) => ({
data: subspace.xs.map((x, xIndex) => ({
name: x.title,
itemStyle: {
// контроллер, который получает информацию о цвете автоматически, исходя из контекста
color: cfg.getColor(y, null, yIndex),
},
x,
y,
value: data[yIndex][xIndex] // мы получали матрицу YX
})),
name: y.title,
type: 'bar', // я задал этот тип явно, но это можно прочитать из конфига дешлета
//как переменную cfg.getRaw().chartType например
showBackground: true,
})),
legend: {
show: true,
data: subspace.ys.map((y, yIndex) => ({
name: y.title,
icon: 'circle',
itemStyle: {
// контроллер, который получает информацию о цвете автоматически, исходя из контекста
color: cfg.getColor(y, null, yIndex),
},
}))
},
};
chart.setOption(options);
chart.resize(); // принудительно заставляем расшириться на весь контейнер
chart.on('click', 'series', onChartClick); // Обрабатываем клик по серии, если нужно
}
}

useEffect(() => {
// Получаем полное декартово произведение для указанного конфига в дешлете
// ожидаем матрицу [subspace.ys.length][subspace.xs.length]

dp.getMatrixYX(subspace).then(dataArr => {
setData(dataArr || []);
});
},[]);

return (
<div className="MyComponent">
{clickedPointName != "" && <div className="MyComponent__onClickText">Вы кликнули на {clickedPointName}</div>}
<div ref={onChartCreated} className="MyComponent__graphic"></div>
</div>
);
}
export default MyComponent;

Все, что я здесь импорчу - прилетает мне на лету из обвязки (кроме стилей, конечно). То есть, например, если вы установите в свой package.json версию ниже той, в которой разрешены хуки - то к своему удивлению вы обнаружите, что компонент все равно работает. потому что для своего выполнения он использует ту версию реакта, что приходит из обвязки. Если что это
"react": "^16.5.0"
Однако в обратную сторону это не работает.

Фактически, здесь нам не важно, как зовется сам файл и какое имя компонента вы выберете. Вам только следует помнить о концепции самого React об именовании файлов компонентов (с большой буквы, одноименно с названием основного класса по умолчанию).

Однако, если речь заходит о том, чтобы переопределить поведение компонентов, являющихся основными строительными блоками каждой из страниц соотв. раздела, то тут важно называть их и хранить в определенных местах и определенным образом.

Тут все зависит от самого компонента. Сейчас из коробки ожидают потенциального переопределения следующие компоненты (строительные блоки страницы):

DlgAuth.tsx - отвечает за рендер и поведение диалогового окошка логина
Root.tsx - стартовая страница, которую вы видите после ввода-логина и пароля
RootHeader.tsx - хедер стартовой страницы выше
DsShellHeader.tsx - на странице с дешбордами отвечает за хедер
DsShellLeftPane.tsx - на странице с дешбордами отвечает за левое меню (включая логотип и название проекта)
DsShellLayout.tsx - на странице с дешбордами отвечает за контейнер хидера, левой панели и списка дешлетов. Может быть полезен как для полного переписывания раздела с дешбордами, так и использования общих стилей или оборачивания данного компонента в компонент высшего порядка (HOC).

Все компоненты выше, кроме DsShellHeader и DsShellLeftPane не зависят от атласа просто по своему определению. Потому они могут находиться только в ресурсах ds_res, так как напоминаю, что здесь мы храним то, что нам будет нужно везде или что должно изменить встроенное поведение обвязки. То есть на сервере разумеется должны быть только их сбилженные версии в виде .js файлов (и возможно .js.map).

В bi-magic-resources - поместите в ds_res файлы с таким именем и при запуске локали увидите, что логика этих компонентов берется уже из ваших файлов. Советую помечать их спец классом, чтобы отличать ваши и коробочные компоненты в инспекторе.

Если рассмотреть DsShellHeader и DsShellLeftPane то, они могут быть размещены как в разделе ресурсов ds_res и тогда эти компоненты будут переопределять поведение одноименных компонентов на каждом из атласов, но только до тех пор, пока не встретят в разделе ресурсов текущего атласа свои копии, тогда уже берутся эти локальные компоненты атласа.

Еще раз: веб-клиент сначала проверяет для таких файлов раздел ресурсов сначала текущего атласа, затем ds_res и если их не находит, то вставляет дефолтную коробочную версию.

Таким образом, если вы достигли такой кастомизации, которая значительно отличается от коробочной Luxms BI, а после некоторых действий с nginx или БД вдруг видите стандартный внешний вид обвязки - проверьте, не удалили ли вы ресурсы атласов и не отдает ли их сервер в виде base64 в разделе Network браузера. Такая ошибка может случиться, если вы по какой-топ причине поставили на сервер версии веб-клиента и БД разных версий (например 8.10 клиента против 8.11 базы).

Если вы собираетесь настраивать какие-то разделы, которые не присутствуют в списке выше - скажите вашему менеджеру от Luxms BI о такой необходимости и мы обернем этот компонент в декоратор, который будет проверять наличие данного компонента в ресурсах атласов. Более того, мы оказываем партнерам консультационную помощь в написании кода кастомного компонента и в исключительных случаях - подготовим копию коробочного компонента, готового для использования и правки в ресурсах. Это обсуждается с менеджерами.

Мы настоятельно рекомендуем использовать именно internal подход в своей разработке как самый универсальный.

Причины использования:

  • Нативная вставка в дерево реакта
  • Возможность пользоваться широким кругом доступных из обвязки модулей, функций, сервисов и утилит
  • Возможность создавать и использовать свои собственные и существующие observable сервисы (об этом позже)
  • Все прелести React как такового)