Перейти к содержанию

Дашборд

Начиная с версии 4.1, пользователю доступно рабочее место с дашбордом, который обеспечивает быстрый доступ к различной информации.

Со стороны проектных решений доступно создание кастомных видов виджетов, а также создание расширений для дашборда типа DashboardUIExtension.

Лэйаут дашборда

  • Дашборд представляет собой сетку, которая состоит из константного числа колонок определенной ширины и неограниченного числа строк.
  • Лейаут дашборда является адаптивным - для каждого из заданных размеров контейнера (такие размеры будем называть брейкпоинтами) определяется свое расположение виджетов и их размеры на дашборде, что позволяет задать удобный внешний вид для различного вида устройств.
  • Всего определено 8 брейкпоинтов, и каждому назначено имя - от xxs до xxxl.
  • Количество колонок дашборда различается для каждого брейкпоинта - например, при размерах контейнера от 240px до 480px (брейкпоинт xxs) дашборд будет иметь всего 4 колонки, а начиная с 1920px (брейкпоинт xxxl) - 28 колонок.
  • Размеры и координаты виджетов задаются в колонках и строках. Например, некий виджет в брейкпоинте md занимает по ширине 5 колонок и 2 строки по высоте, его левый верхний угол расположен в 3 колонке и 1 строке. В брейкпоинте xl он занимает уже 7 колонок и 4 строки, а расположен в 5 колонке и 3 строке.
  • Если виджет не помещается по горизонтали, то он будет перенесен на следующую строку.
  • Дашборд может неограниченно расти вниз, но по ширине всегда ограничен шириной контейнера, поэтому в дашборде может быть вертикальный скролл, но не может быть горизонтального.
  • При добавлении виджета на дашборд он добавляется в каждый из брейкпоинтов, при этом в коде виджета можно управлять начальными размерами для каждого брейкпоинта.
  • При удалении виджета он также удаляется из каждого брейкпоинта.
  • При изменении размеров виджета или его координат эти изменения применяются только для текущего брейкпоинта.
  • В коде виджета можно задать ограничения для размеров виджета для каждого из брейкпоинтов.

Создание виджета

Для создания нового вида виджета необходимо реализовать следующие сущности:

  1. Настройки виджета IDashboardWidgetSettings.
  2. Сам виджет IDashboardWidget, который содержит основную логику виджета.
  3. UI-компонент виджета.
  4. Провайдер редактора настроек виджета IDashboardWidgetSettingsEditorProvider.
  5. Тип виджета IDashboardWidgetType, который отвечает за создание и настройку виджета.

Рассмотрим пример создания вида виджета “Мои задания”:

  • Виджет-кнопка, по нажатию на которую открывается новая вкладка с представлением Мои задания.
  • Пользователь в настройках виджета задает определенный тип задания, фильтр по которому будет применен в представлении.
  • Текст кнопки содержит название типа задания и счетчик - количество заданий этого типа.
  • Счетчик обновляется при переходе на вкладку “Дашборд”, но не чаще, чем раз в 15 минут. Для обновления будем использовать платформенный объект DashboardWidgetPeriodicRefresher.

    Note

    Не рекомендуется производить обновление данных виджета с сервера слишком часто, так как при большом количестве виджетов это может сказаться на производительности системы. Обновление раз в 15 минут является оптимальным выбором.

Note

Полный код примера расположен в папке default/examples/39_myTasksWidget.

Для виджетов-кнопок уже выделены базовые сущности, которые мы будем использовать и расширять в рамках примера.

Сначала определим настройки виджета:

  • Цвет кнопки.
  • Цвет текста.
  • Тип задания.

Настройки виджета IDashboardWidgetSettings должны поддерживать сериализацию и десериализацию, так как они передаются на сервер и сохраняются в БД в виде json.

В платформе определен базовый класс для настроек DashboardWidgetSettingsBase, который наследует StorageSerializableObject для поддержки сериализации. От наследников требуется только переопределить методы serializeToStorageCore для сериализации и deserializeFromStorageCore для десериализации.

Цвет кнопки и цвет текста уже определены в классе настроек виджета-кнопки ButtonWidgetSettings, который наследует DashboardWidgetSettingsBase, поэтому нам нужно добавить только тип задания и обеспечить сериализацию этого поля.

Так как тип задания - комплексный объект, который хранит идентификатор типа и его наименование, то создадим отдельный класс для хранения этих полей, причем он также должен быть сериализуемым.

export class TaskTypeReference extends StorageSerializableObject { //#region keys

/** @category Static Keys */ static readonly idKey = 'id';

/** @category Static Keys */ static readonly captionKey = 'caption';

//#endregion

//#region properties

id = Guid.empty;

caption = '';

//#endregion

//#region IStorageSerializable implementation

protected override serializeToStorageCore( storage: IStorage, context?: StorageSerializableContext ): void { super.serializeToStorageCore(storage, context);

const sa = this.getStorageAccessor(storage, context); sa.setGuid(TaskTypeReference.idKey, this.id).setString( TaskTypeReference.captionKey, this.caption ); }

protected override deserializeFromStorageCore( storage: IStorage, context?: StorageSerializableContext ): void { super.deserializeFromStorageCore(storage, context);

const sa = this.getStorageAccessor(storage, context); this.id = sa.tryGetGuidOrDefault(TaskTypeReference.idKey); this.caption = sa.tryGetStringOrDefault(TaskTypeReference.captionKey); }

//#endregion }

В данном классе определены два поля: id для идентификатора типа задания и caption для наименования, а также реализована сериализация и десериализация этих полей.

Теперь определим класс настроек виджета:

export class MyTasksWidgetSettings extends ButtonWidgetSettings { //#region keys

/** @category Static Keys */ static readonly taskTypeKey = 'TaskType';

//#endregion

//#region fields

@observable.ref private _taskType: TaskTypeReference | null = null;

//#endregion

//#region properties

get taskType(): TaskTypeReference | null { return this._taskType; } set taskType(value: TaskTypeReference | null) { runInAction(() => { this._taskType = value; }); }

//#endregion

//#region IStorageSerializable implementation

protected override serializeToStorageCore( storage: IStorage, context?: StorageSerializableContext ): void { super.serializeToStorageCore(storage, context);

const sa = this.getStorageAccessor(storage, context); sa.set(MyTasksWidgetSettings.taskTypeKey, this.taskType?.serializeToStorage({}, context));

sa.removeEmptyFields(); }

protected override deserializeFromStorageCore( storage: IStorage, context?: StorageSerializableContext ): void { super.deserializeFromStorageCore(storage, context);

const sa = this.getStorageAccessor(storage, context); this.taskType = sa.tryGetObject(MyTasksWidgetSettings.taskTypeKey, storage => new TaskTypeReference().deserializeFromStorage(storage) ); }

//#endregion }

Этот класс наследует ButtonWidgetSettings, в котором содержатся настройки кнопки, и определяет одно поле _taskType для хранения типа задания. Важно, что это поле - реактивное, так как оно будет меняться в редакторе настроек, и изменения должны сразу отображаться в UI. Также здесь определена сериализация типа задания, которая сводится к вызовам методов сериализации у самого _taskType.

Теперь у нас есть все, чтобы определить сам класс виджета. Мы будем наследовать ButtonWidget, чтобы переиспользовать общий код.

Также стоит отметить, что виджет-кнопка отличается от большинства текущих виджетов - у нее нет цветной подложки и заголовка, она растянута на все пространство виджета. Если необходимо реализовать виджет с заголовком и подложкой, то можно наследовать класс настроек ContainerDashboardWidgetSettingsBase, который содержит поля для цвета фона, заголовка, а также флага скрытия заголовка, и класс виджета ContainerDashboardWidgetBase.

/** Виджет "Мои задания". */ export class MyTasksWidget extends ButtonWidget<MyTasksWidgetSettings> { //#region constructors

constructor( viewRepository: IViewRepository, dashboard: DashboardViewModel, id?: string, settings = new MyTasksWidgetSettings() ) { super( 'MyTasks', settings, dashboard, id, MyTasksWidget.initialWidgetSize, MyTasksWidget.widgetSizeLimits );

this._viewRepository = viewRepository;

this.periodicRefresher = new DashboardWidgetPeriodicRefresher( this.dashboard, this._refreshPeriod, async () => await this.updateTaskCount() ); }

//#endregion

//#region fields

private _lastTaskTypeId: string | null = null;

private readonly _viewRepository: IViewRepository;

@observable.ref private _tasksCount: number | null = null;

private _refreshPeriod = 15 * 60 * 1000;

//#endregion

//#region properties

@computed get caption(): string { const taskCaption = localize(this.settings.taskType?.caption); if (!taskCaption) { return ''; }

return `${taskCaption} - ${this._tasksCount ?? 0}`; }

readonly periodicRefresher: DashboardWidgetPeriodicRefresher;

//#endregion

//#region base overrides

protected override async initializeCore(): Promise<void> { await super.initializeCore();

// Стартуем подписку на активацию хоста, чтобы выполнить рефреш this.periodicRefresher?.start(true); }

protected override disposeCore(): void { this.periodicRefresher?.dispose();

super.disposeCore(); }

//#endregion

//#region static

static initialWidgetSize: DashboardLayoutValue<DashboardWidgetSize> = { // Для всех брейкпоинтов до lg кнопка будет добавляться с размером шириной в 4 колонки, // начиная с xs и больше - в 3 колонок xxs: { columnsCount: 4, rowsCount: 1 }, xs: { columnsCount: 3, rowsCount: 1 } };

static widgetSizeLimits: DashboardLayoutValue<DashboardWidgetSizeLimits> = { // Для всех брейкпоинтов минимальные размера 2 колонки и 1 строка xxs: { minColumnsCount: 2, minRowsCount: 1 } };

//#endregion

//#region methods

protected override async handleClickCore(): Promise<void> { if (!this.settings.taskType) { return; }

const parameterMetadata = new ViewParameterMetadata(); parameterMetadata.alias = 'TaskType'; parameterMetadata.caption = 'TaskType'; parameterMetadata.hidden = true; parameterMetadata.schemeType = SchemeType.Guid; parameterMetadata.multiple = false;

const parameters = [ new ViewRequestParameterBuilder() .withMetadata(parameterMetadata) .addCriteria( ViewCriteriaOperators.EqualsTo, this.settings.taskType.caption, this.settings.taskType.id ) .asRequestParameter() ];

await showView({ viewAlias: 'MyTasks', parameters, displayValue: '$Workplaces_User_MyTasks' }); }

protected override async beforeApplyConfiguration( _configuration: DashboardWidgetConfigurationStorage ): Promise<void> { // До того, как применяется новые настройки, сохраним текущий тип задания this._lastTaskTypeId = this.settings.taskType?.id ?? null; }

protected override async afterApplyConfiguration( _configuration: DashboardWidgetConfigurationStorage ): Promise<void> { // Если в новых настройках поменялся тип задания, обновим счетчик if (this.settings.taskType?.id !== this._lastTaskTypeId) { await this.updateTaskCount(); // Обновляем время последнего рефреша this.periodicRefresher?.setLastRefresh(Date.now()); } }

/** Получает количество заданий типа, указанного в настройках, из представления "Мои задания". */ private async updateTaskCount(): Promise<void> { if (!this.settings.taskType) { return; }

const view = await this._viewRepository.getByName('MyTasks'); if (!view) { return; }

const metadata = await view.getMetadata(); const request = new ViewRequest(metadata);

request.addParameter(builder => builder .withMetadata(metadata.parameters.get('TaskType')!) .addCriteria( ViewCriteriaOperators.EqualsTo, this.settings.taskType!.caption, this.settings.taskType!.id ) .asRequestParameter() );

request.subsetName = 'Count';

const result = await view.getData(request); if (result.rows.length === 0) { return; }

const count = result.rows[0][0] as number; runInAction(() => { this._tasksCount = count; }); }

//#endregion }

Разберем, что происходит в каждой части:

  • Конструктор. Принимает следующие значения:

    • viewRepository: IViewRepository - репозиторий представлений. Необходим только текущему виджету для получения количества заданий из представления.
    • dashboard: DashboardViewModel - объект дашборда, в котором будет размещен виджет.
    • id?: string - опциональный идентификатор виджета. Если не задан, будет сгенерирован новый.
    • settings = new MyTasksWidgetSettings() - опциональные настройки виджета. Если не заданы, будет создан пустой объект настроек.

    В теле конструктора создаем объект DashboardWidgetPeriodicRefresher, который осуществляет подписку на активацию дашборда, чтобы обновить данные виджета, если истек период обновления виджета. Событие активации будет вызвано каждый раз, когда пользователь перейдет на вкладку с дашбордом.

  • caption - текст кнопки. В базовом классе текст берется из настроек виджета, но в текущем виджете мы задаем это значение сами. Оно формируется из наименования типа задания и количества заданий этого типа. Так как _tasksCount - реактивное поле, то caption будет обновляться при изменении этого поля, так как зависит от него.

  • initializeCore - метод для инициализации виджета, здесь запускаем автообновление контента виджета.
  • disposeCore - метод для освобождения ресурсов виджета.
  • initialWidgetSize - начальные размеры виджета в колонках и строках для каждого из брейкпоинтов. Ключами объекта являются брейкпоинты, а значениями - структура с размерами. При этом не обязательно указывать размеры для всех брейкпоинтов, если они совпадают. Размер, заданный для брейкпоинта, распространяется и на все брейкпоинты большего размера, если для них не заданы размеры явно. Здесь мы указали размеры для xxs брейкпоинта, и эти размеры будут применяться для брейкпоинтов xs, sm и md, и для lg брейкпоинта, и эти размеры будут применяться для брейкпоинтов xl, xxl и xxxl.
  • widgetSizeLimits - ограничения для размеров, то есть максимальные и минимальные значения для ширины и высоты виджета в колонках и строках для каждого из брейкпоинтов. Логика применения размеров такая же, как и для начальных размеров. Здесь мы указали только значение для брейкпоинта xxs, и оно будет применяться для всех брейкпоинтов.
  • handleClickCore - метод, определенный в базовом классе ButtonWidget. Задает логику нажатия кнопки. Здесь мы открываем в новой вкладке представление Мои задания с фильтром по типу задания, указанном в настройках виджета.
  • beforeApplyConfiguration - пользователь может отредактировать настройки виджета, поменяв тип задания, и нужно отреагировать на эти изменения: обновить текст на кнопке и пересчитать количество заданий. Этот метод будет вызван до применения новых настроек, и здесь мы сохраняем текущий тип задания, чтобы сравнить его с типом задания из новых настроек.
  • afterApplyConfiguration - вызывается после того, как новые настройки были применены (записаны в this.settings). Здесь мы сравниваем тип задания со старым значением, чтобы обновить счетчик, только если изменение было.
  • updateTaskCount - содержит логику обновления количества заданий. Запрашивает представление MyTasks с фильтром по типу и сабсетом Count.

Далее определим провайдер редактора настроек виджета. Его основная задача - настраивать проперти грид для редактирования всех необходимых настроек виджета.

export class MyTasksWidgetSettingsEditorProvider extends ButtonWidgetSettingsEditorProvider<MyTasksWidgetSettings> { //#region ctor

constructor(widget: MyTasksWidget) { super(widget); }

//#endregion

//#region methods

protected override modifyBuilder( builder: PropertyGridBuilderInstance, data: PropertyGridDataProvider ): PropertyGridBuilderInstance { const taskTypeContext = new AutocompleteDataViewContext({ unique: true, viewAlias: 'TaskTypes', idColumn: 'TypeID', nameColumn: 'TypeCaption', parameterAlias: 'Caption', multiple: false }); const converter = new TaskTypeDataConverter();

const propertiesToHide = ['caption', 'captionHidden', 'icon'];

return builder .addAutocompleteProperty({ alias: 'taskType', caption: 'Тип задания', data: data, required: true, dataContext: taskTypeContext, dataConverter: converter, onInitialized: async ({ control }) => { control.mode = AutocompleteMode.NonDroppable; control.menu.openAction.isCollapsed = true; } }) .onGridCreated(grid => { propertiesToHide.forEach(propertyName => { const property = grid.findProperty(propertyName); if (property) { property.visibility = false; } });

grid.title = MyTasksWidgetType.title; }); }

//#endregion }

class TaskTypeDataConverter implements IAutocompleteDataConverter<TaskTypeReference> { toRecord(value: TaskTypeReference): IAutocompleteRecord { return { id: value.id, name: localize(value.caption) }; } fromRecord(record: IAutocompleteRecord): TaskTypeReference { const taskType = new TaskTypeReference(); taskType.id = record.id as string; taskType.caption = record.name!;

return taskType; } }

Как и в случае остальных классов, мы будем наследовать провайдер виджета-кнопки ButtonWidgetSettingsEditorProvider, в котором уже определена настройка базовых свойств кнопки. Нам нужно только добавить свойство для редактирования типа задания, для чего переопределим метод modifyBuilder. У него есть следующие параметры:

  • builder - сам билдер проперти грида.
  • data - провайдер данных для проперти грида, который уже был создан в ButtonWidgetSettingsEditorProvider на основе настроек нашего виджета.

Note

Код для виджета-кнопки, как и для всех платформенных виджетов, также расположен в дефолтных расширениях, где можно ознакомиться с созданием провайдера данных и билдера для проперти грида.

Для типа задания нам нужно свойство “Автокомплит”, кроме этого, он должен выбираться из представления, как в ссылочном контроле карточки. В качестве источника данных мы указываем свойство taskType из объекта настроек. Для автокомплита нужно определить вспомогательные сущности: IAutocompleteDataContext и IAutocompleteDataConverter. Если данные должны браться из представления, то тогда можно использовать платформенный контекст AutocompleteDataViewContext, для которого нужно указать, из какого представления нужно брать данные и какой параметр использовать для поиска. В конвертере нужно задать, каким образом объект типа задания TaskTypeReference должен конвертироваться в IAutocompleteRecord и наоборот.

Кроме этого, в базовом провайдере редактора добавляются свойства, которые не нужны для нашего виджета, поэтому мы их скроем в методе билдера onGridCreated, который вызовется после построения проперти грида.

Теперь определим тип виджета, который будет использоваться для создания виджетов “Мои задания”, так как виджеты в дашборде напрямую не создаются.

@injectable() export class MyTasksWidgetType extends DashboardWidgetTypeBase<MyTasksWidgetSettings> { //#region constructors

constructor(@inject(IViewRepository$) private readonly _viewRepository: IViewRepository) { super(MyTasksWidgetType._descriptor); } //#endregion

//#region static

static readonly title = 'Мои задания';

private static readonly _descriptor = new DashboardWidgetTypeDescriptor( 'MyTasks', MyTasksWidgetType.title, { icon: 'ta icon-thin-091', title: MyTasksWidgetType.title, description: 'Открывает вкладку с представлением "Мои задания" с фильтром по заданному типу задания.' } );

//#endregion

//#endregion

//#region base overrides

override createNewWidget( dashboard: DashboardViewModel, args?: DashboardWidgetCreateNewParams<MyTasksWidgetSettings> ): MyTasksWidget { const settings = args?.settings ?? new MyTasksWidgetSettings();

return new MyTasksWidget(this._viewRepository, dashboard, args?.id, settings); }

override deserializeWidget( storage: DashboardWidgetStorage, dashboard: DashboardViewModel ): MyTasksWidget { const widget = new MyTasksWidget(this._viewRepository, dashboard, storage.id); widget.setStorage(storage);

return widget; }

override createWidgetByTemplate( templateStorage: DashboardWidgetStorage, dashboard: DashboardViewModel, _templateOptions: DashboardWidgetTemplateOptions ): IDashboardWidget { return this.deserializeWidget(templateStorage, dashboard); }

override getSettingsEditorProvider( widget: IDashboardWidget<IDashboardWidgetSettings> ): IDashboardWidgetSettingsEditorProvider { if (!(widget instanceof MyTasksWidget)) { throw new Error('Widget must be of type MyTasksWidget'); }

return new MyTasksWidgetSettingsEditorProvider(widget); }

override getWidgetPreview(): object | null { // может быть любая вью-модель, зарегистрированная во ViewComponentRegistry return new PreviewWidgetTileViewModel(this.descriptor); }

//#endregion }

Рассмотрим подробнее:

  • @injectable() - обязательный декоратор, так как все типы виджетов должны регистрироваться в DI.
  • extends DashboardWidgetTypeBase<MyTasksWidgetSettings> - наш тип наследует базовый тип, который определяет свойства интерфейса IDashboardWidgetType.
  • Конструктор. Передает в базовый класс дескриптор текущего типа, который описывает следующие свойства типа:

    • id - уникальное название типа виджета.
    • title - заголовок типа виджета.
    • preview - объект, описывающий настройки превью виджета. Если у виджета есть превью, то для него будет добавлен тайл в список виджетов, по нажатию на который можно создать виджет. Если виджет должен создаваться иначе, необходимо оставить preview пустым.
    • shared - признак того, что виджет является общим.
  • createNewWidget - создает новый виджет, то есть виджет только добавляется на дашборд. В опциональных параметрах могут быть заданы начальные настройки виджета или его контент.

  • deserializeWidget - десериализует виджет из хранилища. Вызывается при загрузке дашборда.
  • createWidgetByTemplate - создает виджет по шаблону. Вызывается как при создании по шаблону, так и при добавлении экземпляра общего виджета на дашборд.
  • getSettingsEditorProvider - возвращает редактор настроек виджета.
  • getWidgetPreview - возвращает вью-модель превью виджета, т.е. тайла, который будет отображаться в списке виджетов. В данном случае используется типовая вью-модель с заголовком, иконкой и описанием, но это может быть любой объект, для которого зарегистрирован компонент во ViewComponentRegistry.

Для виджета “Мои задания” мы не будем создавать компонент, а будем использовать компонент виджета-кнопки ButtonWidgetComponent.

Осталось зарегистрировать все сущности.

В registerTypes класса регистратора регистрируем тип виджета и компонент для его отображения:

container.bind(IDashboardWidgetType$).to(MyTasksWidgetType).inSingletonScope(); ComponentsRegistry.instance.register(MyTasksWidget, ButtonWidgetComponent);

Теперь все готово, после сборки в списке виджетов должно появиться превью нашего виджета:

При клике открывается диалог редактирования настроек виджета:

После сохранения настроек виджет добавляется на дашборд:

Back to top