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

17 KiB
Raw Permalink Blame History

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

В проекте представлено несколько готовых виджетов для дашбордов. Главная фича дашбордов - возможность добавления любых кастомных виджетов на основе существующих React компонентов или разработки собственных компонентов.

Чтобы добавить собственный виджет нужно

  • добавить data-loader на backend-е
  • добавить React компонент для виджета на frontend-е

data-loader и react-компонент свяжутся через совпадающие поле type.

Давайте разберём подробнее на примере создания виджета предстоящих событий.

Виджет с календарём предстоящих событий.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:

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,
  ): Promise<Result<any, AppError>> {
    // TODO: Логика для загрузки данных
    throw new Error('Method not implemented.');
  }
}

В фреймворках для классических случаев по концепции MVC делают сервисы для реализации в них логики и контроллеры для открытия ендпоинтов для передачи данных пользователям и на frontend приложения.

К представленному выше DataLoaderService можно относиться как к контроллеру в концепции MVC, который обратиться к более низкоуровневому сервису для получения данных, а так же передаст эти результаты уже в виджет на frontend-е.

Сервис для получения данных уже был разработан, и остаётся его подключить к data-loader-у класс CalendarService через конструктор и получить с его помощью нужные данные вызвав метод CalendarService.getRawData:

@Injectable()
export class CalendarWidgetDataLoaderService
  implements WidgetDataLoaderInterface<DataLoaderParams, any, IssueAndEvent[]>
{
  constructor(private calendarService: CalendarService) {}

  isMyConfig(): boolean {
    return true;
  }

  async load(
    dataLoaderParams: DataLoaderParams,
  ): 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 для этого в конструктор добавить такой код:

createInteractiveWidget(
  this.calendarWidgetDataLoaderService,
  'calendar_next_events',
)

Важно: проделать необходимые изменения в соответствии с требованиями базового фреймворка NestJS. Сюда входят требования к инъекции зависимостей в конструктор из сервисов доступных через модули libs/event-emitter/src/event-emitter.module.ts и src/app.module.ts и включение новых сервисов в эти модули. Подробнее об этом можно прочитать в документации NestJS - Providers.

На backend-е всё готово. Переходим к самому интересному - frontend-у

Виджет на frontend-е

Начнём с непосредственно компонента. Виджеты лежат в папке frontend/src/dashboard/widgets. Добавим туда же новый виджет frontend/src/dashboard/widgets/calendar-next-events.tsx:

import React from 'react';

export const CalendarNextEvents = (): JSX.Element => {
  return <></>;
};

Этот пустой компонент можно сразу добавить в фабрику виджетов дашборда на frontend-е:

frontend/src/dashboard/widgets/widget-factory.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, поэтому для компоненты можно определить следующий стор с массивом данных event+issue:

// Описание основных и вспомогательных моделей данных для календаря:

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 добавить использование описанного выше стора и сделать рендер данных:

/**
 * Компонент для отображения данных из стора 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".

Можно проверить на тестовом дашборде с конфигурацией следующего вида:

{
  "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