pinkmine/docs/Создание кастомного виджета для дашборда.md

413 lines
No EOL
17 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Создание кастомного виджета для дашборда
В проекте представлено несколько готовых виджетов для дашбордов. Главная фича дашбордов - возможность добавления любых кастомных виджетов на основе существующих React компонентов или разработки собственных компонентов.
Чтобы добавить собственный виджет нужно
- добавить data-loader на backend-е
- добавить React компонент для виджета на frontend-е
data-loader и react-компонент свяжутся через совпадающие поле `type`.
Давайте разберём подробнее на примере создания виджета предстоящих событий.
![Виджет с календарём предстоящих событий.png](_resources/Виджет%20с%20календарём%20предстоящих%20событий.png)
## Data loader на backend-е
Выберем расположение файла для класса data-loader-а на backend-е.
Data-loader для нового виджета можно расположить где угодно.
1. Для единообразия его можно расположить в стандартном месте с универсальными виджетами в папке `libs/event-emitter/src/dashboards/widget-data-loader`
2. Либо если виджет ускоспециализирован под нужды вашего проекта, то можно сделать отдельную папку под ваш проект: `src/<PROJECT_NAME>/dashboards/widget-data-loader`
Рассматриваемый виджет будет универсальным, поэтому файл сохраним в `libs/event-emitter/src/dashboards/widget-data-loader/calendar.widget-data-loader.service.ts`:
```ts
import { Injectable } from '@nestjs/common';
import { WidgetDataLoaderInterface } from '../widget-data-loader-interface';
import { Result, AppError } from '@app/event-emitter/utils/result';
@Injectable()
export class CalendarWidgetDataLoaderService
implements WidgetDataLoaderInterface<any, any, any>
{
// TODO: Добавить конструктор для подключения
// дополнительных зависимостей
isMyConfig(dataLoaderParams: any): boolean {
return true;
}
load(
dataLoaderParams: any,
dashboardParams: any,
dashboardId: string,
widgetId: string,
): Promise<Result<any, AppError>> {
// TODO: Логика для загрузки данных
throw new Error('Method not implemented.');
}
}
```
В фреймворках для классических случаев по концепции MVC делают сервисы для реализации в них логики и контроллеры для открытия ендпоинтов для передачи данных пользователям и на frontend приложения.
К представленному выше `DataLoaderService` можно относиться как к контроллеру в концепции MVC, который обратиться к более низкоуровневому сервису для получения данных, а так же передаст эти результаты уже в виджет на frontend-е.
Сервис для получения данных уже был разработан, и остаётся его подключить к data-loader-у класс `CalendarService` через конструктор и получить с его помощью нужные данные вызвав метод `CalendarService.getRawData`:
```ts
@Injectable()
export class CalendarWidgetDataLoaderService
implements WidgetDataLoaderInterface<DataLoaderParams, any, IssueAndEvent[]>
{
constructor(private calendarService: CalendarService) {}
isMyConfig(): boolean {
return true;
}
async load(
dataLoaderParams: DataLoaderParams,
dashboardParams: any,
dashboardId: string,
widgetId: string,
): Promise<Result<IssueAndEvent[], AppError>> {
let data: IssueAndEvent[];
try {
data = await this.calendarService.getRawData(
dataLoaderParams.filter,
dataLoaderParams.period * 24 * 60 * 60 * 1000,
);
return success(data);
} catch (ex) {
return fail(createAppError(ex.message ? ex.message : 'UNKNOWN_ERROR'));
}
}
}
```
Остаётся зарегистрировать data-loader в коллекцию доступных виджетов на стороне backend-а и задать ему свой уникальный `type`.
> Архитектура предполагает кроме реализации `WidgetDataLoaderInterface` ещё и реализацию собственно виджета `WidgetInterface`, но последнее можно упросить с помощью вызова фабричного метода `createInteractiveWidget` - он создаст экземпляр `InteractiveWidget` для реализованного ранее data-loader-а. Более подробно можно ознакомитсья с разными типами виджетов, как они устроены в разделе [[Архитектура дашбордов]].
Зарегистрировать новый тип виджета можно в коллекции `libs/event-emitter/src/dashboards/widgets-collection.service.ts` для этого в конструктор добавить такой код:
```ts
createInteractiveWidget(
this.calendarWidgetDataLoaderService,
'calendar_next_events',
)
```
**Важно:** проделать необходимые изменения в соответствии с требованиями базового фреймворка NestJS. Сюда входят требования к инъекции зависимостей в конструктор из сервисов доступных через модули `libs/event-emitter/src/event-emitter.module.ts` и `src/app.module.ts` и включение новых сервисов в эти модули. Подробнее об этом можно прочитать в документации [NestJS - Providers](https://docs.nestjs.com/providers).
На backend-е всё готово. Переходим к самому интересному - frontend-у
## Виджет на frontend-е
Начнём с непосредственно компонента. Виджеты лежат в папке `frontend/src/dashboard/widgets`. Добавим туда же новый виджет `frontend/src/dashboard/widgets/calendar-next-events.tsx`:
```tsx
import React from 'react';
export const CalendarNextEvents = (): JSX.Element => {
return <></>;
};
```
Этот пустой компонент можно сразу добавить в фабрику виджетов дашборда на frontend-е:
`frontend/src/dashboard/widgets/widget-factory.tsx`:
```tsx
import React from 'react';
import { CalendarNextEvents } from './calendar-next-events';
// ...
export const WidgetFactory = observer((props: Props): JSX.Element => {
// ...
if (props.store.type === 'calendar_next_events') {
return <CalendarNextEvents store={props.store} />;
}
// ...
});
```
**Важно!** Чтобы `type` совпадало с объявленным типом виджета на backend-е. Объявление на backend-е было сделано в файле `libs/event-emitter/src/dashboards/widgets-collection.service.ts`.
Теперь можно снова вернуться к виджету `calendar-next-events.tsx`.
Для управления состоянием компонентов на стороне frontend-а предполагается использование [mobx-state-tree](https://mobx-state-tree.js.org/), поэтому для компоненты можно определить следующий стор с массивом данных event+issue:
```ts
// Описание основных и вспомогательных моделей данных для календаря:
export const CalendarEvent = types.model({
from: types.string,
fromTimestamp: types.number,
to: types.string,
toTimestamp: types.number,
fullDay: types.boolean,
description: types.string,
});
export type ICalendarEvent = Instance<typeof CalendarEvent>;
export const FormattedDateTime = types.model({
fromDate: types.string,
fromTime: types.string,
toDate: types.string,
toTime: types.string,
interval: types.string,
});
export type IFormattedDateTime = Instance<typeof FormattedDateTime>;
export const IssueAndEvent = types.model({
issue: types.frozen<any>(),
event: CalendarEvent,
});
export type IIssueAndEvent = Instance<typeof IssueAndEvent>;
type InDay = {
order: number;
relativeDate: string | null;
formattedDate: string;
events: string[];
};
export const DATE_FORMAT = 'dd.MM.yyyy';
export const TIME_FORMAT = 'HH:mm';
function getEventKey(e: IIssueAndEvent): string {
const description: string = e.event.description.replaceAll(/\s+/g, '_');
const fromKey = String(e.event.fromTimestamp);
const toKey = String(e.event.toTimestamp);
return `${e.issue.id}-${fromKey}-${toKey}-${description}`;
}
function getFormattedDateTime(
issueAndEvent: IIssueAndEvent,
): IFormattedDateTime {
const from = DateTime.fromMillis(issueAndEvent.event.fromTimestamp);
const to = DateTime.fromMillis(issueAndEvent.event.toTimestamp);
const fromDate: string = from.isValid ? from.toFormat(DATE_FORMAT) : '-';
const fromTime: string = from.isValid ? from.toFormat(TIME_FORMAT) : '-';
const toDate: string = to.isValid ? to.toFormat(DATE_FORMAT) : '-';
const toTime: string = to.isValid ? to.toFormat(TIME_FORMAT) : '-';
let interval: string;
if (issueAndEvent.event.fullDay) {
interval = 'весь день';
} else if (toTime != '-') {
interval = `${fromTime} - ${toTime}`;
} else {
interval = `${fromTime}`;
}
return FormattedDateTime.create({
fromDate: fromDate,
fromTime: fromTime,
toDate: toDate,
toTime: toTime,
interval: interval,
});
}
function getRelativeDate(issueAndEvent: IIssueAndEvent): string | null {
const from = Luxon.DateTime.fromMillis(issueAndEvent.event.fromTimestamp);
return from.toRelativeCalendar();
}
function getStartOfDayTimestamp(issueAndEvent: IIssueAndEvent): number {
const from = Luxon.DateTime.fromMillis(issueAndEvent.event.fromTimestamp);
return from.startOf('day').toMillis();
}
/** Стор данных для виджета "Календарь следующих событий" */
export const EventsStore = types
.model({
events: types.array(IssueAndEvent),
})
.views((self) => {
return {
/** Геттер мапы событий в формате eventKey -> issue+event */
eventsMap: (): Record<string, IIssueAndEvent> => {
return self.events.reduce((acc, issueAndEvent) => {
const key = getEventKey(issueAndEvent);
acc[key] = issueAndEvent;
return acc;
}, {} as Record<string, IIssueAndEvent>);
},
/**
* Геттер сгруппированных по дням данных.
*
* Ключ группы определяется по timestamp-у указывающему на начало дня
*
* Дополнительные поля relativeDate и formattedDate для отображения в виджете подробной
* информации об этой группе событий
*/
orderedByDates: (): InDay[] => {
const res: Record<number, InDay> = self.events.reduce(
(acc, issueAndEvent) => {
const order = getStartOfDayTimestamp(issueAndEvent);
const formattedDate = DateTime.fromMillis(
issueAndEvent.event.fromTimestamp,
).toFormat(DATE_FORMAT);
if (!acc[order]) {
acc[order] = {
order: order,
relativeDate: getRelativeDate(issueAndEvent),
formattedDate: formattedDate,
events: [],
};
}
const key = getEventKey(issueAndEvent);
acc[order].events.push(key);
return acc;
},
{} as Record<number, InDay>,
);
return Object.values(res).sort((a, b) => a.order - b.order);
},
/**
* Мапа событий с отформатированными значениями даты и времени
*
* * Ключ - это ключ события
* * Значение - вспомогательная информация для человеко-читаемого вывода даты и времени
*/
formattedDateTimes: (): Record<string, IFormattedDateTime> => {
const res: Record<string, IFormattedDateTime> = self.events.reduce(
(acc, event) => {
const key = getEventKey(event);
acc[key] = getFormattedDateTime(event);
return acc;
},
{} as Record<string, IFormattedDateTime>,
);
return res;
},
};
})
.actions((self) => {
return {
/** Сеттер основных данных */
setEvents: (events: any): void => {
self.events = events;
},
};
});
export type IEventsStore = Instance<typeof EventsStore>;
```
Этот стор будет принимать данные получаемые с backend-а через action "setEvents" и с помощью вспомогательных геттеров предоставлять нужную информацию для вывода в виджете.
Теперь остаётся в react-компоненте CalendarNextEvents добавить использование описанного выше стора и сделать рендер данных:
```tsx
/**
* Компонент для отображения данных из стора EventsStore
*
* @see {EventsStore}
*/
export const CalendarList = observer(
(props: { store: IEventsStore }): JSX.Element => {
const list = props.store.orderedByDates().map((events) => {
const keyOfGroup = `${events.order}-${events.relativeDate}`;
const item = (
<div key={keyOfGroup}>
<p title={events.formattedDate}>{events.relativeDate}:</p>
<ul>
{events.events.map((keyOfEvent) => {
const events = props.store.eventsMap();
const formatted = props.store.formattedDateTimes();
if (!events[keyOfEvent] && !formatted[keyOfEvent]) return <></>;
const issue = events[keyOfEvent].issue;
return (
<li key={keyOfEvent}>
{formatted[keyOfEvent].interval}:{' '}
<IssueHref
id={issue.id}
subject={events[keyOfEvent].event.description}
tracker={issue.tracker.name}
url={issue.url.url}
/>
</li>
);
})}
</ul>
</div>
);
return item;
});
return <>{list}</>;
},
);
export type Props = {
store: DashboardStoreNs.IWidget;
};
/**
* Основной компонент календаря
*
* Он нужен для преобразования стора абстрактного виджета в стор специфичный для
* календаря
*/
export const CalendarNextEvents = observer((props: Props): JSX.Element => {
const calendarListStore = EventsStore.create();
onSnapshot(props.store, (storeState) => {
if (storeState.data) {
calendarListStore.setEvents(storeState.data);
}
});
return (
<>
<CalendarList store={calendarListStore} />
</>
);
});
```
## Проверка
Если всё было сделано верно, то в дашбордах станет доступен новый виджет "calendar_next_events".
Можно проверить на тестовом дашборде с конфигурацией следующего вида:
```json
{
"widgets": [
{
"id": "test-calendar",
"title": "Тест календаря",
"type": "calendar_next_events",
"collapsed": true,
"dataLoaderParams": {
"filter": {
"selector": {
"project.name": "proj"
},
"limit": 10
},
"period": 30
}
}
],
"title": "Test"
}
```
И убедиться что данные соответствуют указанным данным в задачах Redmine:
![Виджет с календарём предстоящих событий получившийся результат.png](_resources/Виджет%20с%20календарём%20предстоящих%20событий%20-%20получившийся%20результат.png)