diff --git a/docs/_resources/Виджет с календарём предстоящих событий - получившийся результат.png b/docs/_resources/Виджет с календарём предстоящих событий - получившийся результат.png new file mode 100644 index 0000000..165d3a2 Binary files /dev/null and b/docs/_resources/Виджет с календарём предстоящих событий - получившийся результат.png differ diff --git a/docs/_resources/Виджет с календарём предстоящих событий.png b/docs/_resources/Виджет с календарём предстоящих событий.png new file mode 100644 index 0000000..921c120 Binary files /dev/null and b/docs/_resources/Виджет с календарём предстоящих событий.png differ diff --git a/docs/Создание кастомного виджета для дашборда.md b/docs/Создание кастомного виджета для дашборда.md new file mode 100644 index 0000000..543f9b1 --- /dev/null +++ b/docs/Создание кастомного виджета для дашборда.md @@ -0,0 +1,408 @@ +# Создание кастомного виджета для дашборда + +В проекте представлено несколько готовых виджетов для дашбордов. Главная фича дашбордов - возможность добавления любых кастомных виджетов на основе существующих 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) \ No newline at end of file