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 diff --git a/frontend/src/dashboard/widgets/calendar-next-events.tsx b/frontend/src/dashboard/widgets/calendar-next-events.tsx new file mode 100644 index 0000000..8661fdb --- /dev/null +++ b/frontend/src/dashboard/widgets/calendar-next-events.tsx @@ -0,0 +1,231 @@ +import { observer } from 'mobx-react-lite'; +import { Instance, onSnapshot, types } from 'mobx-state-tree'; +import React from 'react'; +import * as Luxon from 'luxon'; +import * as DashboardStoreNs from '../dashboard-store'; +import { DateTime } from 'luxon'; +import { IssueHref } from '../../misc-components/issue-href'; + +// Описание основных и вспомогательных моделей данных для календаря: + +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; + +/** + * Компонент для отображения данных из стора 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 ( + <> + + + ); +}); diff --git a/frontend/src/dashboard/widgets/widget-factory.tsx b/frontend/src/dashboard/widgets/widget-factory.tsx index df2ece7..7f3e02b 100644 --- a/frontend/src/dashboard/widgets/widget-factory.tsx +++ b/frontend/src/dashboard/widgets/widget-factory.tsx @@ -5,6 +5,7 @@ import { observer } from 'mobx-react-lite'; import * as KanbanWidgetNs from './kanban'; import { DebugInfo } from '../../misc-components/debug-info'; import * as IssuesListNs from './issues-list'; +import * as CalendarNextEventsNs from './calendar-next-events'; export type Props = { store: Instance; @@ -21,6 +22,10 @@ export const WidgetFactory = observer((props: Props): JSX.Element => { return ; } + if (type === 'calendar_next_events') { + return ; + } + return (
Unknown widget
diff --git a/libs/event-emitter/src/calendar/calendar.controller.ts b/libs/event-emitter/src/calendar/calendar.controller.ts index a62d6c0..df35ced 100644 --- a/libs/event-emitter/src/calendar/calendar.controller.ts +++ b/libs/event-emitter/src/calendar/calendar.controller.ts @@ -1,58 +1,54 @@ -import { Controller, Get, Inject, Logger, Param, Query } from "@nestjs/common"; -import { CalendarService } from "./calendar.service"; +import { Controller, Get, Inject, Logger, Param, Query } from '@nestjs/common'; +import { CalendarService } from './calendar.service'; import nano from 'nano'; -import { UNLIMITED } from "../consts/consts"; +import { UNLIMITED } from '../consts/consts'; @Controller('calendar') export class CalendarController { - private logger = new Logger(CalendarController.name); - - constructor( - @Inject('CALENDAR_SERVICE') - private calendarService: CalendarService - ) {} - - @Get() - async get(@Param('filter') filter: any): Promise { - return await this.calendarService.getICalData(filter); - } - - @Get('/simple') - async simple( - @Query('project') project?: string, - @Query('category') category?: string - ): Promise { - const andSection: any[] = [ - { - "closed_on": { - "$exists": false - } - } - ]; - if (project) { - andSection.push({ - "project.name": { - "$in": [ - project - ] - } - }); - } - if (category) { - andSection.push({ - "category.name": { - "$in": [ - category - ] - } - }); - } - const query: nano.MangoQuery = { - selector: { - "$and": andSection - }, - limit: UNLIMITED - }; - return await this.calendarService.getICalData(query); - } -} \ No newline at end of file + private logger = new Logger(CalendarController.name); + + constructor( + @Inject('CALENDAR_SERVICE') + private calendarService: CalendarService, + ) {} + + @Get() + async get(@Param('filter') filter: any): Promise { + return await this.calendarService.getICalData(filter); + } + + @Get('/simple') + async simple( + @Query('project') project?: string, + @Query('category') category?: string, + ): Promise { + const andSection: any[] = [ + { + closed_on: { + $exists: false, + }, + }, + ]; + if (project) { + andSection.push({ + 'project.name': { + $in: [project], + }, + }); + } + if (category) { + andSection.push({ + 'category.name': { + $in: [category], + }, + }); + } + const query: nano.MangoQuery = { + selector: { + $and: andSection, + }, + limit: UNLIMITED, + }; + return await this.calendarService.getICalData(query); + } +} diff --git a/libs/event-emitter/src/calendar/calendar.service.ts b/libs/event-emitter/src/calendar/calendar.service.ts index 7f0833c..a7a5ffe 100644 --- a/libs/event-emitter/src/calendar/calendar.service.ts +++ b/libs/event-emitter/src/calendar/calendar.service.ts @@ -1,8 +1,8 @@ -import { Injectable, Logger } from "@nestjs/common"; -import { CalendarEvent } from "../models/calendar-event"; -import { RedmineTypes } from "../models/redmine-types"; +import { Injectable } from '@nestjs/common'; +import { CalendarEvent } from '../models/calendar-event'; +import { RedmineTypes } from '../models/redmine-types'; import * as Luxon from 'luxon'; -import { IssuesService } from "../issues/issues.service"; +import { IssuesService } from '../issues/issues.service'; import { randomUUID } from 'crypto'; /* @@ -36,57 +36,89 @@ END:VCALENDAR */ export type IssueAndEvent = { - issue: RedmineTypes.ExtendedIssue; - event: CalendarEvent; + issue: RedmineTypes.ExtendedIssue; + event: CalendarEvent; }; @Injectable() export class CalendarService { - private interval = 30 * 24 * 60 * 60 * 1000; // 30 days - - constructor(public calendarEventsKey: string, private issuesService: IssuesService) {} - - async getICalData(filter: any): Promise { - const issues = await this.issuesService.find(filter); - const actualEvents = this.getActualEvents(issues); - const formattedEvents = actualEvents.map((event) => { - return this.generateICalendarEvent(event.issue, event.event); - }).filter((event) => { - return !!event; - }); - const res = this.generateICalendar(formattedEvents); - return res; - } - - private getActualEvents(issues: RedmineTypes.ExtendedIssue[]): IssueAndEvent[] { - const res: IssueAndEvent[] = []; - for (let i = 0; i < issues.length; i++) { - const issue = issues[i]; - if (!issue[this.calendarEventsKey] || issue[this.calendarEventsKey].length <= 0) { - continue; - } - const events = issue[this.calendarEventsKey]; - for (let j = 0; j < events.length; j++) { - const event = events[j]; - if (this.actualEvent(event)) res.push({event: event, issue: issue}); - } - } - return res; - } - - private actualEvent(event: CalendarEvent): boolean { - const now = Luxon.DateTime.now().toMillis(); - const from = now - this.interval; - const to = now + this.interval; - return Boolean( - (from <= event.fromTimestamp && event.fromTimestamp <= to) || - (from <= event.toTimestamp && event.toTimestamp <= to) - ); - } - - private generateICalendarEvent(issue: RedmineTypes.Issue, data: CalendarEvent): string | null { - if (!data) return null; - return `BEGIN:VEVENT + private defaultInterval = 30 * 24 * 60 * 60 * 1000; // 30 days + + constructor( + public calendarEventsKey: string, + private issuesService: IssuesService, + ) {} + + /** + * @param filter фильтр для первичной выборки данных из couchdb через nano.filter + * @param interval период в милисекундах + * @returns + */ + async getICalData(filter: any, interval?: number): Promise { + const issues = await this.issuesService.find(filter); + const actualEvents = this.getActualEvents(issues, interval); + const formattedEvents = actualEvents + .map((event) => { + return this.generateICalendarEvent(event.issue, event.event); + }) + .filter((event) => { + return !!event; + }); + const res = this.generateICalendar(formattedEvents); + return res; + } + + /** + * @param filter фильтр для первичной выборки данных из couchdb через nano.filter + * @param interval период в милисекундах + * @returns + */ + async getRawData(filter: any, interval?: number): Promise { + const issues = await this.issuesService.find(filter); + return this.getActualEvents(issues, interval); + } + + private getActualEvents( + issues: RedmineTypes.ExtendedIssue[], + interval?: number, + ): IssueAndEvent[] { + if (typeof interval !== 'number') interval = this.defaultInterval; + const res: IssueAndEvent[] = []; + for (let i = 0; i < issues.length; i++) { + const issue = issues[i]; + if ( + !issue[this.calendarEventsKey] || + issue[this.calendarEventsKey].length <= 0 + ) { + continue; + } + const events = issue[this.calendarEventsKey]; + for (let j = 0; j < events.length; j++) { + const event = events[j]; + if (this.actualEvent(event, interval)) { + res.push({ event: event, issue: issue }); + } + } + } + return res; + } + + private actualEvent(event: CalendarEvent, interval: number): boolean { + const now = Luxon.DateTime.now().toMillis(); + const from = now - interval; + const to = now + interval; + return Boolean( + (from <= event.fromTimestamp && event.fromTimestamp <= to) || + (from <= event.toTimestamp && event.toTimestamp <= to), + ); + } + + private generateICalendarEvent( + issue: RedmineTypes.Issue, + data: CalendarEvent, + ): string | null { + if (!data) return null; + return `BEGIN:VEVENT UID:${randomUUID()}@example.com DTSTAMP:${this.formatTimestamp(data.fromTimestamp, data.fullDay)} ORGANIZER;CN=John Doe:MAILTO:john.doe@example.com @@ -94,20 +126,20 @@ DTSTART:${this.formatTimestamp(data.fromTimestamp, data.fullDay)} DTEND:${this.formatTimestamp(data.toTimestamp, data.fullDay)} SUMMARY:#${issue.id} - ${data.description} - ${issue.subject} END:VEVENT`; - } - - private formatTimestamp(timestamp: number, fullDay?: boolean): string { - let format: string = fullDay ? "yyyyMMdd" : "yyyyMMdd'T'HHmmss'Z'"; - let datetime = Luxon.DateTime.fromMillis(timestamp); - if (!fullDay) datetime = datetime.setZone('utc'); - return datetime.toFormat(format); - } - - private generateICalendar(events: string[]): string { - return `BEGIN:VCALENDAR + } + + private formatTimestamp(timestamp: number, fullDay?: boolean): string { + const format: string = fullDay ? 'yyyyMMdd' : "yyyyMMdd'T'HHmmss'Z'"; + let datetime = Luxon.DateTime.fromMillis(timestamp); + if (!fullDay) datetime = datetime.setZone('utc'); + return datetime.toFormat(format); + } + + private generateICalendar(events: string[]): string { + return `BEGIN:VCALENDAR VERSION:2.0 PRODID:-//Example Corp.//CalDAV Client//EN -${events.join("\n")} +${events.join('\n')} END:VCALENDAR`; - } -} \ No newline at end of file + } +} diff --git a/libs/event-emitter/src/dashboards/dashboards-data.service.ts b/libs/event-emitter/src/dashboards/dashboards-data.service.ts index f240bd1..0ba0ddb 100644 --- a/libs/event-emitter/src/dashboards/dashboards-data.service.ts +++ b/libs/event-emitter/src/dashboards/dashboards-data.service.ts @@ -22,6 +22,7 @@ export class DashboardsDataService { const cfg = await this.dashboardsService.load(id); const results: WidgetWithData[] = []; let isSuccess = false; + let counter = 0; if (!cfg?.widgets || cfg?.widgets?.length <= 0) { return results; } @@ -35,12 +36,13 @@ export class DashboardsDataService { cfg, ); if (loadRes.result) { + counter++; isSuccess = true; loadRes.result.widgetId = widget.id; results.push({ data: loadRes.result, widgetId: widget.id }); } } - if (!isSuccess) throw createAppError('CANNOT_LOAD_DATA'); + if (!isSuccess && counter > 0) throw createAppError('CANNOT_LOAD_DATA'); return results; } diff --git a/libs/event-emitter/src/dashboards/widget-data-loader/calendar.widget-data-loader.service.ts b/libs/event-emitter/src/dashboards/widget-data-loader/calendar.widget-data-loader.service.ts new file mode 100644 index 0000000..70f017c --- /dev/null +++ b/libs/event-emitter/src/dashboards/widget-data-loader/calendar.widget-data-loader.service.ts @@ -0,0 +1,49 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { WidgetDataLoaderInterface } from '../widget-data-loader-interface'; +import { + Result, + AppError, + success, + fail, + createAppError, +} from '@app/event-emitter/utils/result'; +import { + CalendarService, + IssueAndEvent, +} from '@app/event-emitter/calendar/calendar.service'; +import nano from 'nano'; + +export type DataLoaderParams = { + /** Период для выборки предстоящих событий в днях */ + period: number; + /** Фильтр для выборки данных из couchdb */ + filter: nano.MangoQuery; +}; + +@Injectable() +export class CalendarWidgetDataLoaderService + implements WidgetDataLoaderInterface +{ + constructor( + @Inject('CALENDAR_SERVICE') 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')); + } + } +} diff --git a/libs/event-emitter/src/dashboards/widgets-collection.service.ts b/libs/event-emitter/src/dashboards/widgets-collection.service.ts index a09685a..0bcfbbf 100644 --- a/libs/event-emitter/src/dashboards/widgets-collection.service.ts +++ b/libs/event-emitter/src/dashboards/widgets-collection.service.ts @@ -5,6 +5,7 @@ import { ListIssuesByUsersLikeJiraWidgetDataLoaderService } from './widget-data- import { RootIssueSubTreesWidgetDataLoaderService } from './widget-data-loader/root-issue-subtrees.widget-data-loader.service'; import { createInteractiveWidget } from './interactive-widget-factory'; import { Result, success } from '@app/event-emitter/utils/result'; +import { CalendarWidgetDataLoaderService } from './widget-data-loader/calendar.widget-data-loader.service'; @Injectable() export class WidgetsCollectionService { @@ -14,6 +15,7 @@ export class WidgetsCollectionService { private listIssuesByFieldsWidgetDataLoaderService: ListIssuesByFieldsWidgetDataLoaderService, private listIssuesByUsersLikeJiraWidgetDataLoaderService: ListIssuesByUsersLikeJiraWidgetDataLoaderService, private rootIssueSubTreesWidgetDataLoaderService: RootIssueSubTreesWidgetDataLoaderService, + private calendarWidgetDataLoaderService: CalendarWidgetDataLoaderService, ) { const collection = [ createInteractiveWidget( @@ -40,6 +42,10 @@ export class WidgetsCollectionService { this.rootIssueSubTreesWidgetDataLoaderService, 'issues_list_by_tree', ), + createInteractiveWidget( + this.calendarWidgetDataLoaderService, + 'calendar_next_events', + ), ]; collection.forEach((w) => this.appendWidget(w)); diff --git a/libs/event-emitter/src/event-emitter.module.ts b/libs/event-emitter/src/event-emitter.module.ts index 5aef956..2e69e41 100644 --- a/libs/event-emitter/src/event-emitter.module.ts +++ b/libs/event-emitter/src/event-emitter.module.ts @@ -36,6 +36,7 @@ import { ListIssuesByUsersLikeJiraWidgetDataLoaderService } from './dashboards/w import { ListIssuesByFieldsWidgetDataLoaderService } from './dashboards/widget-data-loader/list-issues-by-fields.widget-data-loader.service'; import { WidgetsCollectionService } from './dashboards/widgets-collection.service'; import { DashboardsController } from './dashboards/dashboards.controller'; +import { CalendarWidgetDataLoaderService } from './dashboards/widget-data-loader/calendar.widget-data-loader.service'; @Module({}) export class EventEmitterModule implements OnModuleInit { @@ -104,6 +105,7 @@ export class EventEmitterModule implements OnModuleInit { DashboardsService, DashboardsDataService, WidgetsCollectionService, + CalendarWidgetDataLoaderService, ], exports: [ EventEmitterService, @@ -142,6 +144,7 @@ export class EventEmitterModule implements OnModuleInit { DashboardsService, DashboardsDataService, WidgetsCollectionService, + CalendarWidgetDataLoaderService, ], controllers: [ MainController, diff --git a/libs/event-emitter/src/issue-enhancers/calendar-enhancer.ts b/libs/event-emitter/src/issue-enhancers/calendar-enhancer.ts index 92dfc8c..c222a9b 100644 --- a/libs/event-emitter/src/issue-enhancers/calendar-enhancer.ts +++ b/libs/event-emitter/src/issue-enhancers/calendar-enhancer.ts @@ -24,7 +24,7 @@ export const UNKNOWN_CALENDAR_EVENT = 'Unknown calendar event'; @Injectable() export class CalendarEnhancer implements IssueEnhancerInterface { private logger = new Logger(CalendarEnhancer.name); - + name = 'calendar'; constructor( @@ -33,8 +33,15 @@ export class CalendarEnhancer implements IssueEnhancerInterface { public descriptionCalendarParams: DescriptionParserParams, public calendarEventsKey: string, ) { - const initParams = {useForProjects, customFields, descriptionCalendarParams, calendarEventsKey}; - this.logger.debug(`Calendar enhancer init with ${JSON.stringify(initParams)}`); + const initParams = { + useForProjects, + customFields, + descriptionCalendarParams, + calendarEventsKey, + }; + this.logger.debug( + `Calendar enhancer init with ${JSON.stringify(initParams)}`, + ); } async enhance( @@ -49,10 +56,18 @@ export class CalendarEnhancer implements IssueEnhancerInterface { try { res[this.calendarEventsKey] = this.getCalendarEvents(res); } catch (ex) { - this.logger.error(`Error at parsing calendar events, message - ${ex}: ${(ex as Error)?.stack}`); + this.logger.error( + `Error at parsing calendar events, message - ${ex}: ${ + (ex as Error)?.stack + }`, + ); return res; } - this.logger.debug(`Calendar events for #${issue.id}: issue.${this.calendarEventsKey} = ${JSON.stringify(res[this.calendarEventsKey])}`); + this.logger.debug( + `Calendar events for #${issue.id}: issue.${ + this.calendarEventsKey + } = ${JSON.stringify(res[this.calendarEventsKey])}`, + ); return res; } @@ -147,7 +162,9 @@ export class CalendarEnhancer implements IssueEnhancerInterface { const lines = text.split('\n').map((line) => line.trim()); - const calendarStartIndex = lines.indexOf(this.descriptionCalendarParams.title); + const calendarStartIndex = lines.indexOf( + this.descriptionCalendarParams.title, + ); if (calendarStartIndex < 0) return []; let index = calendarStartIndex + 1; diff --git a/libs/event-emitter/src/utils/string-with-dates-parser.ts b/libs/event-emitter/src/utils/string-with-dates-parser.ts index b1c39ec..62225cd 100644 --- a/libs/event-emitter/src/utils/string-with-dates-parser.ts +++ b/libs/event-emitter/src/utils/string-with-dates-parser.ts @@ -35,7 +35,9 @@ export function parse(str: string, params?: Moo.Rules): Moo.Token[] { } return res; } catch (ex) { - logger.error(`Error at parse str=${str} with params=${params}, error message - ${ex}`); + logger.error( + `Error at parse str=${str} with params=${params}, error message - ${ex}`, + ); return []; } } @@ -140,7 +142,7 @@ export function parseToCalendarEvent( to = date1.set({ hour: time2.hours, minute: time2.minutes, - second: time2.seconds + second: time2.seconds, }); } else { to = from.plus(Luxon.Duration.fromMillis(DEFAULT_EVENT_DURATION));