# Создание кастомного виджета для дашборда В проекте представлено несколько готовых виджетов для дашбордов. Главная фича дашбордов - возможность добавления любых кастомных виджетов на основе существующих 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//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 { // TODO: Добавить конструктор для подключения // дополнительных зависимостей isMyConfig(dataLoaderParams: any): boolean { return true; } load( dataLoaderParams: any, dashboardParams: any, ): Promise> { // TODO: Логика для загрузки данных throw new Error('Method not implemented.'); } } ``` В фреймворках для классических случаев по концепции MVC делают сервисы для реализации в них логики и контроллеры для открытия ендпоинтов для передачи данных пользователям и на frontend приложения. К представленному выше `DataLoaderService` можно относиться как к контроллеру в концепции MVC, который обратиться к более низкоуровневому сервису для получения данных, а так же передаст эти результаты уже в виджет на frontend-е. Сервис для получения данных уже был разработан, и остаётся его подключить к data-loader-у класс `CalendarService` через конструктор и получить с его помощью нужные данные вызвав метод `CalendarService.getRawData`: ```ts @Injectable() export class CalendarWidgetDataLoaderService implements WidgetDataLoaderInterface { constructor(private calendarService: CalendarService) {} isMyConfig(): boolean { return true; } async load( dataLoaderParams: DataLoaderParams, ): Promise> { 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 ; } // ... }); ``` **Важно!** Чтобы `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; export const FormattedDateTime = types.model({ fromDate: types.string, fromTime: types.string, toDate: types.string, toTime: types.string, interval: types.string, }); export type IFormattedDateTime = Instance; export const IssueAndEvent = types.model({ issue: types.frozen(), event: CalendarEvent, }); export type IIssueAndEvent = Instance; 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 => { return self.events.reduce((acc, issueAndEvent) => { const key = getEventKey(issueAndEvent); acc[key] = issueAndEvent; return acc; }, {} as Record); }, /** * Геттер сгруппированных по дням данных. * * Ключ группы определяется по timestamp-у указывающему на начало дня * * Дополнительные поля relativeDate и formattedDate для отображения в виджете подробной * информации об этой группе событий */ orderedByDates: (): InDay[] => { const res: Record = 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, ); return Object.values(res).sort((a, b) => a.order - b.order); }, /** * Мапа событий с отформатированными значениями даты и времени * * * Ключ - это ключ события * * Значение - вспомогательная информация для человеко-читаемого вывода даты и времени */ formattedDateTimes: (): Record => { const res: Record = self.events.reduce( (acc, event) => { const key = getEventKey(event); acc[key] = getFormattedDateTime(event); return acc; }, {} as Record, ); return res; }, }; }) .actions((self) => { return { /** Сеттер основных данных */ setEvents: (events: any): void => { self.events = events; }, }; }); export type IEventsStore = Instance; ``` Этот стор будет принимать данные получаемые с 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 = (

{events.relativeDate}:

    {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 (
  • {formatted[keyOfEvent].interval}:{' '}
  • ); })}
); 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 ( <> ); }); ``` ## Проверка Если всё было сделано верно, то в дашбордах станет доступен новый виджет "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)