Дашборд¶
Начиная с версии 4.1, пользователю доступно рабочее место с дашбордом, который обеспечивает быстрый доступ к различной информации.
Со стороны проектных решений доступно создание кастомных видов виджетов, а также создание расширений для дашборда типа DashboardUIExtension
.
Лэйаут дашборда¶
- Дашборд представляет собой сетку, которая состоит из константного числа колонок определенной ширины и неограниченного числа строк.
- Лейаут дашборда является адаптивным - для каждого из заданных размеров контейнера (такие размеры будем называть брейкпоинтами) определяется свое расположение виджетов и их размеры на дашборде, что позволяет задать удобный внешний вид для различного вида устройств.
- Всего определено 8 брейкпоинтов, и каждому назначено имя - от
xxs
доxxxl
. - Количество колонок дашборда различается для каждого брейкпоинта - например, при размерах контейнера от 240px до 480px (брейкпоинт
xxs
) дашборд будет иметь всего 4 колонки, а начиная с 1920px (брейкпоинтxxxl
) - 28 колонок. - Размеры и координаты виджетов задаются в колонках и строках. Например, некий виджет в брейкпоинте
md
занимает по ширине 5 колонок и 2 строки по высоте, его левый верхний угол расположен в 3 колонке и 1 строке. В брейкпоинтеxl
он занимает уже 7 колонок и 4 строки, а расположен в 5 колонке и 3 строке. - Если виджет не помещается по горизонтали, то он будет перенесен на следующую строку.
- Дашборд может неограниченно расти вниз, но по ширине всегда ограничен шириной контейнера, поэтому в дашборде может быть вертикальный скролл, но не может быть горизонтального.
- При добавлении виджета на дашборд он добавляется в каждый из брейкпоинтов, при этом в коде виджета можно управлять начальными размерами для каждого брейкпоинта.
- При удалении виджета он также удаляется из каждого брейкпоинта.
- При изменении размеров виджета или его координат эти изменения применяются только для текущего брейкпоинта.
- В коде виджета можно задать ограничения для размеров виджета для каждого из брейкпоинтов.
Создание виджета¶
Для создания нового вида виджета необходимо реализовать следующие сущности:
- Настройки виджета
IDashboardWidgetSettings
. - Сам виджет
IDashboardWidget
, который содержит основную логику виджета. - UI-компонент виджета.
- Провайдер редактора настроек виджета
IDashboardWidgetSettingsEditorProvider
. - Тип виджета
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);
Теперь все готово, после сборки в списке виджетов должно появиться превью нашего виджета:
При клике открывается диалог редактирования настроек виджета:
После сохранения настроек виджет добавляется на дашборд: