@c8y/ngx-components
Version:
Angular modules for Cumulocity IoT applications
1,020 lines (1,015 loc) • 336 kB
JavaScript
import * as i0 from '@angular/core';
import { Injectable, Component, inject, InjectionToken, EventEmitter, Output, Input, signal, DestroyRef, TemplateRef, ViewChild, Optional, Inject, HostListener, input, HostBinding, NgModule, Injector, runInInjectionContext, ChangeDetectionStrategy } from '@angular/core';
import * as i2 from '@c8y/ngx-components';
import { IconDirective, C8yTranslatePipe, Permissions, ContextRouteService, Status, NavigatorNode, NEW_DASHBOARD_ROUTER_STATE_PROP, getActivatedRoute, ViewContext, TabsOutletComponent, memoize, hookGeneric, ExtensionPointForPlugins, fromTriggerOnce, getInjectedHooks, stateToFactory, ListGroupModule, C8yComponentOutlet, AlertService, C8yTranslateDirective, DynamicComponentModule, DynamicComponentComponent, sortByPriority, isPromise, CoreModule, WIDGET_CONFIGURATION_GRID_SIZE, HighlightComponent, EmptyStateComponent, FormGroupComponent, RequiredInputPlaceholderDirective, ProductExperienceDirective, ResizableGridComponent, DashboardChildChange, CopyDashboardDisabledReason, TitleComponent, ActionBarItemComponent, HelpComponent, WidgetsDashboardComponent, DatePipe, hookRoute, hookTab, PreviewService, hookNavigator, ManagedObjectRealtimeService, BottomDrawerService, ClipboardService, InputGroupEditableComponent, MessageDirective } from '@c8y/ngx-components';
import * as i1 from '@angular/router';
import { RouterOutlet, ActivatedRoute, RouterModule } from '@angular/router';
import { of, Subject, from, combineLatest, map as map$1, merge, BehaviorSubject, tap as tap$1, withLatestFrom, mergeMap as mergeMap$1, shareReplay as shareReplay$1, scan, debounceTime, isObservable, iif, timer, filter as filter$1, switchMap as switchMap$1, take, forkJoin, firstValueFrom } from 'rxjs';
import { gettext } from '@c8y/ngx-components/gettext';
import { __decorate, __metadata } from 'tslib';
import * as i3 from '@angular/common';
import { NgTemplateOutlet, CommonModule, NgClass, AsyncPipe, NgIf, NgForOf, NgFor } from '@angular/common';
import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop';
import * as i6 from '@c8y/ngx-components/context-dashboard-state';
import * as i2$1 from '@ngx-translate/core';
import { assign, pick, isEmpty, cloneDeep, some, keys, keyBy, has, set, reduce, forEach, get, isEqual, clone, merge as merge$1, omit, sortBy, isNull, escapeRegExp, findIndex, kebabCase, every, words, camelCase } from 'lodash-es';
import * as i2$2 from 'ngx-bootstrap/popover';
import { PopoverModule, PopoverDirective } from 'ngx-bootstrap/popover';
import * as i1$1 from '@c8y/client';
import { QueriesUtil, InventoryService } from '@c8y/client';
import { tap, map, catchError, throwIfEmpty, filter, mergeMap, toArray, first, distinctUntilChanged, shareReplay, switchMap, startWith, take as take$1 } from 'rxjs/operators';
import * as i1$2 from '@angular/forms';
import { Validators, NgForm, ControlContainer, FormsModule } from '@angular/forms';
import * as i2$3 from 'ngx-bootstrap/collapse';
import { CollapseDirective, CollapseModule } from 'ngx-bootstrap/collapse';
import * as i1$3 from '@c8y/ngx-components/assets-navigator';
import { AssetSelectorModule } from '@c8y/ngx-components/assets-navigator';
import * as i2$4 from 'ngx-bootstrap/tooltip';
import { TooltipDirective, TooltipModule } from 'ngx-bootstrap/tooltip';
import { IconSelectorModule } from '@c8y/ngx-components/icon-selector';
import * as i1$4 from 'ngx-bootstrap/dropdown';
import { BsDropdownModule } from 'ngx-bootstrap/dropdown';
import { defaultWidgetIds } from '@c8y/ngx-components/widgets/definitions';
import { GlobalContextWidgetWrapperComponent, ConfigModeControlsComponent } from '@c8y/ngx-components/global-context';
import { AssetPropertiesService, ComputedPropertiesService, AssetPropertySelectorDrawerComponent, AssetPropertyValuePipe } from '@c8y/ngx-components/asset-properties';
const newDashboardTab = {
featureId: 'newDashboard',
icon: 'th',
label: gettext('New dashboard'),
path: 'new-dashboard',
// place tab as last one and hide it so it won't be opened until user initiates adding new dashboard
hide: true,
priority: -Infinity
};
class NewDashboardGuard {
canActivate(route) {
const tabActive = route.routeConfig.path === newDashboardTab.path;
if (tabActive) {
const dashboard = {
c8y_Dashboard: null
};
route.data = { dashboard };
}
if (!this.tab) {
this.tab = {
...newDashboardTab,
hide: !tabActive,
priority: tabActive ? Infinity : -Infinity
};
}
return of([this.tab]);
}
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.19", ngImport: i0, type: NewDashboardGuard, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.19", ngImport: i0, type: NewDashboardGuard, providedIn: 'root' }); }
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.19", ngImport: i0, type: NewDashboardGuard, decorators: [{
type: Injectable,
args: [{ providedIn: 'root' }]
}] });
class AddDashboardComponent {
constructor(tabsService, router) {
this.tabsService = tabsService;
this.router = router;
}
addDashboard() {
const tempNewDashboardTab = [...this.tabsService.state].find(t => t.featureId === newDashboardTab.featureId);
// navigate before tab is displayed, because in DashboardDetailComponent tab is hidden on navigation from it.
this.router.navigate(typeof tempNewDashboardTab.path === 'string'
? [tempNewDashboardTab.path]
: tempNewDashboardTab.path, { replaceUrl: true });
// show tab and make it appear as first one
tempNewDashboardTab.hide = false;
tempNewDashboardTab.priority = Infinity;
this.tabsService.refresh();
}
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.19", ngImport: i0, type: AddDashboardComponent, deps: [{ token: i2.TabsService }, { token: i1.Router }], target: i0.ɵɵFactoryTarget.Component }); }
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.3.19", type: AddDashboardComponent, isStandalone: true, selector: "[c8y-add-dashboard]", host: { classAttribute: "d-flex a-i-stretch sticky-right" }, ngImport: i0, template: "<div class=\"d-flex a-i-stretch m-b-8 m-t-8 p-l-8 hidden-xs\">\n <button\n class=\"btn btn-default btn-sm p-l-8 p-r-8 fit-h p-b-0 p-t-0 d-flex a-i-center\"\n title=\"{{ 'Add dashboard' | translate }}\"\n type=\"button\"\n (click)=\"addDashboard()\"\n >\n <i\n class=\"icon-20 m-r-4\"\n c8yIcon=\"add-circle-outline\"\n ></i>\n <span>{{ 'Add dashboard' | translate }}</span>\n </button>\n <div class=\"p-r-sm-40\"></div>\n</div>\n", dependencies: [{ kind: "directive", type: IconDirective, selector: "[c8yIcon]", inputs: ["c8yIcon"] }, { kind: "pipe", type: C8yTranslatePipe, name: "translate" }] }); }
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.19", ngImport: i0, type: AddDashboardComponent, decorators: [{
type: Component,
args: [{ selector: '[c8y-add-dashboard]', host: { class: 'd-flex a-i-stretch sticky-right' }, imports: [IconDirective, C8yTranslatePipe], template: "<div class=\"d-flex a-i-stretch m-b-8 m-t-8 p-l-8 hidden-xs\">\n <button\n class=\"btn btn-default btn-sm p-l-8 p-r-8 fit-h p-b-0 p-t-0 d-flex a-i-center\"\n title=\"{{ 'Add dashboard' | translate }}\"\n type=\"button\"\n (click)=\"addDashboard()\"\n >\n <i\n class=\"icon-20 m-r-4\"\n c8yIcon=\"add-circle-outline\"\n ></i>\n <span>{{ 'Add dashboard' | translate }}</span>\n </button>\n <div class=\"p-r-sm-40\"></div>\n</div>\n" }]
}], ctorParameters: () => [{ type: i2.TabsService }, { type: i1.Router }] });
class AddDashboardFactory {
constructor() {
this.permissions = inject(Permissions);
this.contextRoute = inject(ContextRouteService);
}
async get(activatedRoute) {
this.currentContext = this.contextRoute.getContextData(activatedRoute);
if (this.currentContext?.context === this.targetContext &&
(await this.hasPermission(this.currentContext.contextData))) {
return [
{
component: AddDashboardComponent,
priority: -Infinity,
showAlways: true
}
];
}
return [];
}
async hasPermission(context) {
if (context?.id) {
return await this.permissions.canEdit([
Permissions.ROLE_INVENTORY_ADMIN,
Permissions.ROLE_INVENTORY_CREATE,
Permissions.ROLE_MANAGED_OBJECT_ADMIN,
Permissions.ROLE_MANAGED_OBJECT_CREATE
], context);
}
return this.permissions.hasAnyRole([
Permissions.ROLE_INVENTORY_ADMIN,
Permissions.ROLE_INVENTORY_CREATE,
Permissions.ROLE_MANAGED_OBJECT_ADMIN,
Permissions.ROLE_MANAGED_OBJECT_CREATE
]);
}
}
const CONTEXT_DASHBOARD_CONFIG = new InjectionToken('ContextDashboardConfig');
const DASHBOARD_SETTINGS_CHANGES = {
classes: gettext('theme'),
globalRolesIds: gettext('global roles'),
widgetClasses: gettext('widget header style'),
widgetMargin: gettext('widget margin'),
icon: gettext('icon'),
name: gettext('name'),
description: gettext('description'),
priority: gettext('priority'),
c8y_IsNavigatorNode: gettext('navigator item'),
translateWidgetTitle: gettext('translate widget title'),
translateDashboardTitle: gettext('translate dashboard title'),
children: gettext('widgets')
};
const DASHBOARD_CHILDREN_STATE_NAME = {
initial: gettext('Initial state'),
config: gettext('Widget configuration changed'),
removed: gettext('Widget removed'),
added: gettext('Widget added'),
arrangement: gettext('Widgets rearranged')
};
var ContextDashboardType;
(function (ContextDashboardType) {
ContextDashboardType["Device"] = "device";
ContextDashboardType["Type"] = "type";
ContextDashboardType["Group"] = "group";
ContextDashboardType["Named"] = "name";
ContextDashboardType["Report"] = "report";
})(ContextDashboardType || (ContextDashboardType = {}));
var DashboardDetailsTabId;
(function (DashboardDetailsTabId) {
DashboardDetailsTabId["GENERAL"] = "general";
DashboardDetailsTabId["APPEARANCE"] = "appearance";
DashboardDetailsTabId["VERSIONHISTORY"] = "versionHistory";
})(DashboardDetailsTabId || (DashboardDetailsTabId = {}));
const WIDGET_HEADER_CLASSES = [
{
label: gettext('Regular`style`'),
class: 'panel-title-regular',
description: gettext('The widget has no border between header and content.')
},
{
label: gettext('Border`style`'),
class: 'panel-title-border',
description: gettext('The widget has a small separation border between header and content.')
},
{
label: gettext('Overlay`style`'),
class: 'panel-title-overlay',
description: gettext('The widget content overlays the header.')
},
{
label: gettext('Hidden`style`'),
class: 'panel-title-hidden',
description: gettext('The widget header is not shown.')
}
];
const WIDGET_CONTENT_CLASSES = [
{
label: gettext('Branded`style`'),
class: 'panel-content-branded',
description: gettext('The widget is styled with the main brand color.')
},
{
label: gettext('Match dashboard`style`'),
class: 'panel-content-light',
description: gettext('The widget appearance matches the dashboard appearance.')
},
{
label: gettext('Light`style`'),
class: 'panel-content-white',
description: gettext('The widget has light appearance, that is, dark text on light background.')
},
{
label: gettext('Dark`style`'),
class: 'panel-content-dark',
description: gettext('The widget has dark appearance, that is, light text on dark background.')
},
{
label: gettext('Transparent`style`'),
class: 'panel-content-transparent',
description: gettext('The widget has no background.')
}
];
const DASHBOARD_THEME_CLASSES = [
{
label: gettext('Match UI`theme`'),
class: 'dashboard-theme-light',
description: gettext('The dashboard appearance matches the UI appearance.')
},
{
label: gettext('Light`theme`'),
class: 'dashboard-theme-white',
description: gettext('The dashboard has light appearance, that is, dark text on light background.')
},
{
label: gettext('Dark`theme`'),
class: 'dashboard-theme-dark',
description: gettext('The dashboard has dark appearance, that is, light text on dark background.')
},
{
label: gettext('Branded`theme`'),
class: 'dashboard-theme-branded',
description: gettext('The dashboard is styled using the brand palette.')
}
];
const STYLING_CLASS_PREFIXES = [
'dashboard-theme-',
'panel-title-',
'panel-content-'
];
const ALL_GLOBAL_ROLES_SELECTED = 'all';
const PRODUCT_EXPERIENCE = {
DASHBOARD: {
EVENTS: {
DASHBOARDS: 'dashboards',
REPORTS: 'reports',
DASHBOARD_TEMPLATE: 'dashboardTemplate'
},
COMPONENTS: {
DASHBOARD_VIEW: 'context-dashboard',
DASHBOARD_AVAILABILITY: 'dashboard-availability',
REPORTS_LIST: 'report-dashboard-list',
ADD_REPORT: 'report-dashboard-list',
ADD_DASHBOARD: 'add-dashboard',
DELETE_DASHBOARD: 'context-dashboard',
TYPED_DASHBOARD_SETTINGS: 'typed-dashboard-settings'
},
CONTEXT: {
REPORT: 'report',
DEVICE: 'device',
ASSET: 'asset',
GROUP: 'group'
},
ACTIONS: {
APPLY_GLOBAL_ROLES_CHANGES: 'applyGlobalRolesChanges',
DELETE: 'delete',
LOAD: 'load',
CREATE: 'create',
ADD_REPORT: 'addReport',
DUPLICATE_AS_REGULAR_DASHBOARD: 'duplicateAsRegularDashboard'
}
}
};
const REPORT_DEFAULT_NAVIGATION_NODE_PRIORITY = 30;
const DASHBOARD_DETAILS_OUTLET = 'dashboard-details';
const DASHBOARD_DETAILS_TABS_OUTLET_NAME = 'dashboardTabs';
class ContextDashboardService {
get formDisabled() {
return this._formDisabled;
}
set formDisabled(value) {
this._formDisabled = value;
this.formDisabledSubject.next(value);
}
constructor(inventory, tabs, modal, translateService, router, navigator, permissions, alert, dynamicComponent, groupService, optionsService, appStateService, contextRouteService) {
this.inventory = inventory;
this.tabs = tabs;
this.modal = modal;
this.translateService = translateService;
this.router = router;
this.navigator = navigator;
this.permissions = permissions;
this.alert = alert;
this.dynamicComponent = dynamicComponent;
this.groupService = groupService;
this.optionsService = optionsService;
this.appStateService = appStateService;
this.contextRouteService = contextRouteService;
this.REPORT_PARTIAL_NAME = 'report_';
this.VERSION_HISTORY_SIZE_LIMIT = 10;
this.INVENTORY_ROLES = [
Permissions.ROLE_INVENTORY_ADMIN,
Permissions.ROLE_MANAGED_OBJECT_ADMIN
];
this.cache = new Map();
this.DEFAULT_PAGESIZE = 1000;
this.FRAGMENT_NAME = 'c8y_Dashboard';
this.APPLIED_TO_FRAGMENT = 'c8y_AppliedToApplications';
this.DASHBOARD_ROUTE_PATH = 'dashboard';
this.INDEX_SPLIT = '!';
this.CACHE_TIMEOUT = 500;
this._formDisabled = true;
this.formDisabledSubject = new Subject();
this.appName = '';
this.HIDE_TYPE_DASHBOARD_FOR_ASSETS = 'hideTypeDashboardForAssets';
this.formDisabled$ = this.formDisabledSubject.asObservable();
this.queriesUtil = new QueriesUtil();
}
async create(dashboardCfg, context, name = '') {
let dashboard = {};
assign(dashboard, this.adjustDashboardFor24Columns({ c8y_Dashboard: dashboardCfg }), this.updateDashboardHistory(dashboard, dashboardCfg));
const [dashboardType, dashboardFragments] = this.getDashboardFragments({ c8y_Dashboard: dashboardCfg }, context, name, false);
dashboard = { ...dashboard, ...dashboardFragments };
if (this.shouldSetGlobal(dashboard, context)) {
assign(dashboard, { c8y_Global: {} });
}
dashboard.name = dashboard.c8y_Dashboard.name;
if (this.appStateService?.currentApplication?.value) {
dashboard[this.APPLIED_TO_FRAGMENT] = [this.appStateService?.currentApplication?.value.key];
}
const { data } = dashboardType === ContextDashboardType.Group ||
dashboardType === ContextDashboardType.Device ||
(context?.contextData?.id && dashboardType === ContextDashboardType.Named)
? await this.inventory.childAdditionsCreate(dashboard, context?.contextData.id || '')
: await this.inventory.create(dashboard);
return data;
}
async detail(dashboardMO) {
let { data } = await this.inventory.detail(dashboardMO);
data = this.adjustDashboardFor24Columns(data);
this.cache.set(dashboardMO.id, data);
return data;
}
async update(dashboard, context) {
const dashboardCfg = dashboard.c8y_Dashboard;
dashboard.name = dashboard.c8y_Dashboard.name;
assign(dashboard, this.adjustDashboardFor24Columns({ c8y_Dashboard: dashboardCfg }), this.updateDashboardHistory(dashboard, dashboardCfg));
const keepFragments = this.clean(pick(dashboard, [this.FRAGMENT_NAME, 'id', 'name', this.APPLIED_TO_FRAGMENT]));
keepFragments.c8y_DashboardHistory = dashboard.c8y_DashboardHistory;
await this.serializeWidgetConfigs(keepFragments);
const [, dashboardTypeFragments] = this.getDashboardFragments(dashboard, context, '', true);
keepFragments.c8y_Global = this.shouldSetGlobal({ ...dashboard, ...dashboardTypeFragments });
const { data } = await this.inventory.update({ ...keepFragments, ...dashboardTypeFragments });
this.cache.set(dashboard.id, data);
return data;
}
async delete(dashboard, withConfirmation = true) {
try {
if (withConfirmation) {
let msg = gettext(`You are about to delete the dashboard "{{ dashboardName }}". Do you want to proceed?`);
if (this.isDeviceType(dashboard)) {
msg = gettext(`You are about to delete the dashboard "{{ dashboardName }}" from all devices of the type "{{ deviceType }}".
Do you want to proceed?`);
}
await this.modal.confirm(gettext('Delete dashboard'), this.translateService.instant(msg, {
dashboardName: dashboard.c8y_Dashboard.name,
deviceType: dashboard.c8y_Dashboard.deviceTypeValue
}), Status.DANGER, { ok: gettext('Delete'), cancel: gettext('Cancel') });
}
await this.inventory.delete(dashboard);
const tabToRemove = Array.from(this.tabs.state).find(tab => {
if (typeof tab.path === 'string') {
return tab.path.endsWith(`${this.DASHBOARD_ROUTE_PATH}/${dashboard.id}`);
}
});
this.tabs.remove(tabToRemove);
queueMicrotask(() => {
this.tabs.refresh();
});
}
catch (ex) {
// intended empty
}
}
updateDashboardHistory(dashboard, dashboardCfg) {
if (!dashboard.c8y_DashboardHistory) {
dashboard.c8y_DashboardHistory = [];
}
if (isEmpty(dashboardCfg?.historyDescription)) {
dashboardCfg.historyDescription = { changeType: 'create' };
}
dashboardCfg.created = new Date().toISOString();
dashboard.c8y_DashboardHistory = cloneDeep([dashboardCfg, ...dashboard.c8y_DashboardHistory]);
if (dashboard.c8y_DashboardHistory.length > this.VERSION_HISTORY_SIZE_LIMIT) {
dashboard.c8y_DashboardHistory = [
...dashboard.c8y_DashboardHistory.slice(0, this.VERSION_HISTORY_SIZE_LIMIT)
];
}
return dashboard;
}
activateDashboards(route, types) {
const { dashboardId } = route.params;
if (dashboardId) {
return this.getDashboard$(dashboardId, types, route.parent.data.contextData).pipe(tap(dashboard => {
route.data = { dashboard };
}), map(() => true), catchError(() => {
return of(false);
}));
}
const { contextData: mo } = this.contextRouteService.getContextData(route);
this.dashboardTabs$ = this.getTabs$(mo, types, route?.parent?.data);
return this.dashboardTabs$;
}
getDashboard(name, defaultWidgets) {
const children = this.mapWidgets(defaultWidgets);
return this.getDashboard$(name, [ContextDashboardType.Named]).pipe(throwIfEmpty(), catchError(() => {
if (!this.hasPermissionsToCopyDashboard()) {
this.alert.warning(gettext('You are viewing a read-only dashboard because you don’t have the necessary permissions to modify it.'));
}
return of(this.getDefaultDashboard({
name,
children,
translateWidgetTitle: true,
widgetClasses: { 'dashboard-theme-light': true, 'panel-title-regular': true }
}));
}));
}
updateNavigatorItem(mo) {
this.navigator.state.forEach(node => {
if (node.path === `reports/${mo.id}`) {
this.navigator.remove(node);
}
});
if (mo.c8y_IsNavigatorNode) {
const nodeToAdd = new NavigatorNode({
label: mo.name,
path: `reports/${mo.id}`,
icon: mo.icon,
priority: mo.priority,
translateLabel: mo.translateDashboardTitle
});
this.navigator.add(nodeToAdd);
}
}
async navigateToDashboard(dashboardMO, isNewDashboard = false) {
if (/\/dashboard\//.test(this.router.url)) {
this.router.navigate(['..', dashboardMO.id], {
relativeTo: getActivatedRoute(this.router),
...(isNewDashboard && {
state: { [NEW_DASHBOARD_ROUTER_STATE_PROP]: true }
})
});
}
else if (/^\/(device|group)\/[0-9]+$/.test(this.router.url)) {
// in case the add dashboard button is the only tab on that route
this.router.navigate(['.', this.DASHBOARD_ROUTE_PATH, dashboardMO.id], {
relativeTo: getActivatedRoute(this.router),
...(isNewDashboard && {
state: { [NEW_DASHBOARD_ROUTER_STATE_PROP]: true }
})
});
}
else if (/^\/(device|group)\/[0-9]+\/device-info$/.test(this.router.url)) {
this.router.navigate(['.'], {
relativeTo: getActivatedRoute(this.router),
...(isNewDashboard && {
state: { [NEW_DASHBOARD_ROUTER_STATE_PROP]: true }
})
});
}
else {
this.router.navigate(['..', this.DASHBOARD_ROUTE_PATH, dashboardMO.id], {
relativeTo: getActivatedRoute(this.router),
...(isNewDashboard && {
state: { [NEW_DASHBOARD_ROUTER_STATE_PROP]: true }
})
});
}
}
/**
* Checks if user is able to edit dashboard according to his roles and dashboard ownership.
*
* @param mo - Dashboard managed object.
* @returns True if user is able to edit dashboard, false if he cannot.
*/
async canEditDashboard(mo) {
return await this.permissions.canEdit(this.INVENTORY_ROLES, mo);
}
/**
* Checks if user has permissions to copy dashboard according to his roles.
*
* @returns True if user has permissions to copy dashboard, false if he cannot.
*/
hasPermissionsToCopyDashboard() {
return this.permissions.hasAnyRole([
Permissions.ROLE_INVENTORY_ADMIN,
Permissions.ROLE_INVENTORY_CREATE,
Permissions.ROLE_MANAGED_OBJECT_ADMIN,
Permissions.ROLE_MANAGED_OBJECT_CREATE
]);
}
isNamed(dashboard) {
return some(keys(dashboard), prop => new RegExp(`^${this.FRAGMENT_NAME}${this.INDEX_SPLIT}${ContextDashboardType.Named}${this.INDEX_SPLIT}`).test(prop));
}
isReport(dashboard) {
return some(keys(dashboard), prop => new RegExp(`^${this.FRAGMENT_NAME}${this.INDEX_SPLIT}${ContextDashboardType.Named}${this.INDEX_SPLIT}${this.REPORT_PARTIAL_NAME}`).test(prop));
}
isDeviceType(dashboard) {
return some(keys(dashboard), prop => {
const matchingProp = new RegExp(`^${this.FRAGMENT_NAME}${this.INDEX_SPLIT}${ContextDashboardType.Type}${this.INDEX_SPLIT}`).test(prop);
if (!matchingProp) {
return false;
}
else {
// there might be matching key, but its value can be {} or null
return !!dashboard[prop];
}
});
}
isDeviceDashboard(dashboard) {
return some(keys(dashboard), prop => new RegExp(`^${this.FRAGMENT_NAME}${this.INDEX_SPLIT}${ContextDashboardType.Device}${this.INDEX_SPLIT}`).test(prop));
}
isGroupDashboard(dashboard) {
return some(keys(dashboard), prop => new RegExp(`^${this.FRAGMENT_NAME}${this.INDEX_SPLIT}${ContextDashboardType.Group}${this.INDEX_SPLIT}`).test(prop));
}
getFilteredDashboardStyles(styleList) {
return styleList.filter(c => STYLING_CLASS_PREFIXES.some(classPrefix => c.startsWith(classPrefix)));
}
getStyling(styleList, styleName, defaultValue) {
const styling = styleList.find(style => style && new RegExp(`-${styleName}$`, 'i').test(style.class));
return styling ? styling.class : defaultValue;
}
mapWidgets(widgets) {
return keyBy(widgets.map(widget => {
widget.id = String(Math.random()).substr(2);
return widget;
}), 'id');
}
getDashboard$(dashboardIdOrName, dashboardType, mo) {
const cache = this.cache.get(dashboardIdOrName);
const dashboards = mo
? this.getContextDashboards(mo, dashboardType)
: this.getNamedDashboard(dashboardIdOrName);
const cacheRefresh = this.getContextDashboards$(dashboards).pipe(tap(dashboard => this.cacheDashboard(dashboard)), filter(dashboard => dashboard.id === dashboardIdOrName ||
has(dashboard, `${this.FRAGMENT_NAME}${this.INDEX_SPLIT}${ContextDashboardType.Named}${this.INDEX_SPLIT}${dashboardIdOrName}`)));
return cache ? of(cache) : cacheRefresh;
}
async pasteDashboard(newContext) {
if (this.copyClipboard) {
try {
const dashboardToPaste = this.createContextDashboardCopy(this.copyClipboard.dashboard, newContext.contextData, this.copyClipboard.context.contextData);
const dashboard = await this.create(this.clean(dashboardToPaste), newContext);
// linking childAdditions for e.g. to grant access to the images uploaded by the image widget for users with only inventory roles.
const { data: childAdditions } = await this.inventory.childAdditionsList(this.copyClipboard.dashboardId, { pageSize: 2000 });
if (childAdditions.length) {
await this.inventory.childAdditionsBulkAdd(childAdditions, dashboard.id);
}
this.copyClipboard = undefined;
this.navigateToDashboard(dashboard);
}
catch {
this.alert.warning(gettext('Insufficient permissions for this action.'));
}
}
}
/**
* Creates fragment that associates dashboards with device/asset. It consists of three elements:
* - FRAGMENT_NAME - static string
* - dashboard type (e.g. 'group', 'device')
* - fragment value ( id of device/asset if it is not typed dashboard; deviceTypeValue property of dashboard if it is type dashboard)
* Example fragment for device dashboard: 'c8y_Dashboard!device!773200'
* Example fragment for group dashboard: 'c8y_Dashboard!group!84129208'
* Example fragment for typed device dashboard: 'c8y_Dashboard!type!c8y_lwm2m_connector_device'
*
* @param contextDashboardType Type of dashboard
* @param value Fragment value
* @returns Fragment for dashboard
*/
createFragmentKey(contextDashboardType, value) {
return `${this.FRAGMENT_NAME}${this.INDEX_SPLIT}${contextDashboardType}${this.INDEX_SPLIT}${value}`;
}
/**
* Indicates if dashboard can be set to type dashboard.
* First, it checks if deviceTypeValue exists and if user has permission to set dashboard type.
* Then, case from sensor app is checked- dashboard created with sensor app has deviceType set to true but
* type fragment is missing- we do not support this combination.
* @param mo Dashboard managed object
* @param context {ContextData} Current context
* @returns True if dashboard can be set to type dashboard, false if it is forbidden.
*/
shouldAllowToSetDashboardType(mo, context) {
// disallow if dashboard managed object or context is missing or context is not device/asset/group
if (!mo ||
!context?.contextData ||
(context.context !== ViewContext.Device && context.context !== ViewContext.Group)) {
return 'disallow';
}
// if context is asset/group and type dashboard feature is hidden for assets/groups or asset/group has no typ, return disallow
const typeDashboardHiddenForAssets = this.optionsService.get(this.HIDE_TYPE_DASHBOARD_FOR_ASSETS, true);
if (context.context === ViewContext.Group &&
(typeDashboardHiddenForAssets || !context.contextData.type)) {
return 'disallow';
}
// if user has no permission to change dashboard, return disallow
if (!this.permissions.hasAnyRole(this.INVENTORY_ROLES)) {
return 'disallow';
}
// case from sensor app is checked- dashboard created with sensor app has deviceType set to true but
// type fragment is missing- we do not support this combination.
const typeFragment = this.createFragmentKey(ContextDashboardType.Type, context?.contextData?.type);
if (mo?.c8y_Dashboard &&
mo?.c8y_Dashboard.deviceType &&
context?.contextData?.type &&
!mo[typeFragment]) {
return 'disallow';
}
// if view context is Device and contextData of this device has no type yet but type dashboard can be set when type is filled,
// return allow_if_type_filled
if (!context?.contextData?.type &&
context.context === ViewContext.Device &&
this.permissions.hasAnyRole(this.INVENTORY_ROLES)) {
return 'allow_if_type_filled';
}
return 'allow';
}
createReport(reportCfg) {
const report = {};
Object.assign(report, reportCfg);
Object.assign(report, { c8y_Report: {} });
return this.inventory.create(report);
}
addReportNavigatorNode(report) {
const node = new NavigatorNode({
label: report.name,
path: `reports/${report.id}`,
icon: report.icon,
priority: report.priority,
translateLabel: report.translateDashboardTitle
});
this.navigator.add(node);
}
getContextForGS(mo) {
if (!mo) {
return null;
}
if (this.groupService.isDevice(mo)) {
return PRODUCT_EXPERIENCE.DASHBOARD.CONTEXT.DEVICE;
}
else if (this.groupService.isAsset(mo)) {
return PRODUCT_EXPERIENCE.DASHBOARD.CONTEXT.ASSET;
}
else if (this.groupService.isGroup(mo)) {
return PRODUCT_EXPERIENCE.DASHBOARD.CONTEXT.GROUP;
}
else {
return null;
}
}
async getContextDashboards(mo, dashboardType) {
const filterCriteria = dashboardType.map(t => ({
// it's necessary to wrap fragment in quotes because dashboard type can contain spaces
__has: `'${this.createDashboardFragment(mo, t)}'`
}));
// the has query above does not work for device type dashboards where the type contains a dot
const typeFilterCriteria = dashboardType.includes(ContextDashboardType.Type) && mo.type
? {
__and: [
{ 'c8y_Dashboard.deviceType': { __eq: true } },
{ 'c8y_Dashboard.deviceTypeValue': { __eq: mo.type } }
]
}
: undefined;
const finalFilterCriteria = typeFilterCriteria
? [...filterCriteria, typeFilterCriteria]
: filterCriteria;
const query = this.queriesUtil.buildQuery({ __filter: { __or: finalFilterCriteria } });
const now = Date.now();
const cacheHasValidResponse = this.contextDashboardsCache &&
this.contextDashboardsCache.query === query &&
now - this.contextDashboardsCache.timestamp < this.CACHE_TIMEOUT;
if (cacheHasValidResponse) {
return this.contextDashboardsCache.result;
}
else {
this.contextDashboardsCache = null;
}
this.contextDashboardsCache = {
query,
result: this.listAndFilterByApplication(query),
timestamp: now
};
return this.contextDashboardsCache.result;
}
/**
* Creates query for application bound dashboards.
* If application key is not available, it returns empty object.
* Use it when you need to filter dashboards according to
* application they are applied to and need pagination.
*/
appliedToFilter() {
const key = this.appStateService?.currentApplication?.value?.key || '';
if (!key) {
return {};
}
return {
__or: [
{ __not: { __has: this.APPLIED_TO_FRAGMENT } }, // legacy / unlabeled ⇒ show in all
{ [this.APPLIED_TO_FRAGMENT]: { __in: [key] } }
]
};
}
/**
* Creates a tuple describing the dashboard type and its fragments. For assets like devices and groups, it's possible
* to have two fragments: one indicating this particular device/asset with its ID, and the second indicating
* the device/asset type (if the dashboard is meant to be applied to all assets of this type).
*
* @param dashboardMO - Dashboard managed object.
* @param context - Context data of asset.
* @param name - Name of the dashboard.
* @param isEdit - True if existing dashboard is updated, false when it's creation of new dashboard.
* @returns Tuple of dashboard type and object containing dashboard fragments.
*/
getDashboardFragments(dashboardMO, context, name, isEdit) {
let dashboardType;
const id = context?.contextData?.id || '';
const fragments = {};
if (name) {
// a named dashboard should not receive any other fragments
dashboardType = ContextDashboardType.Named;
const namedFragmentKey = this.createFragmentKey(ContextDashboardType.Named, name);
fragments[namedFragmentKey] = {};
}
else if (context?.context === ViewContext.Device || context?.context === ViewContext.Group) {
// get base type for device or group
const defaultType = context.context === ViewContext.Device
? ContextDashboardType.Device
: ContextDashboardType.Group;
dashboardType = dashboardMO.c8y_Dashboard.deviceType
? ContextDashboardType.Type
: defaultType;
// clear fragments from other asset if current asset is not origin of this dashboard
this.clearRedundantFragment(dashboardMO, defaultType, fragments);
// add base fragment for particular asset
const deviceOrGroupFragmentKey = this.createFragmentKey(defaultType, id);
fragments[deviceOrGroupFragmentKey] = {};
// add or clear type fragment
if (dashboardMO.c8y_Dashboard.deviceType || isEdit) {
const typeFragmentKey = this.createFragmentKey(ContextDashboardType.Type, dashboardMO.c8y_Dashboard.deviceTypeValue);
fragments[typeFragmentKey] = dashboardMO.c8y_Dashboard.deviceType ? {} : null;
}
}
return [dashboardType, fragments];
}
/**
* Clears fragments that originates from other managed object.
* E.g. typed dashboard is created for device A of type c8y_MQTTDevice and id 1, so it gets fragments object
* ```ts
* {
* c8y_Dashboard!device!1: {},
* c8y_Dashboard!type!c8y_MQTTDevice: {}
* }
*```
* then, on device B of type c8y_MQTTDevice and id 2, which also has access to this dashboard, deviceType is set to
* false, so dashboard is not typed dashboard anymore and now belongs to device B, therefore fragments should look like
* ```ts
* {
* c8y_Dashboard!device!1: null, // this value is cleared because dashboard is doesn't belong to device A anymore
* c8y_Dashboard!device!2: {}, // assign dashboard to device B
* c8y_Dashboard!type!c8y_MQTTDevice: null // this value is cleared in getDashboardFragments method as it's not typed dashboard anymore
* }
* ```
*
* @param dashboardMO - Dashboard managed object.
* @param type - Context dashboard type.
* @param fragments - Fragments object.
*/
clearRedundantFragment(dashboardMO, type, fragments) {
Object.keys(dashboardMO)
.filter(key => key.startsWith(this.createFragmentKey(type, '')))
.forEach(key => (fragments[key] = null));
}
adjustDashboardFor24Columns(dashboards) {
if (Array.isArray(dashboards)) {
return dashboards.map(dashboard => this.adjustDashboardFor24Columns(dashboard));
}
// if `columns` attribute exists, dashboard was already adjusted.
if (dashboards.c8y_Dashboard.columns) {
return dashboards;
}
dashboards.c8y_Dashboard.columns = 24;
if (!dashboards.c8y_Dashboard.children) {
return dashboards;
}
// Newly created NamedContextDashboards are still created with 12 columns for backwards compatibility.
// Default widgets might be already configured for 24 columns.
// If a widget is already configured for more than 12 columns, we should not adjust it.
const alreadyHasWidgetsConfiguredForMoreThan12Columns = Object.values(dashboards.c8y_Dashboard.children).some(widget => widget._x + widget._width > 12);
if (alreadyHasWidgetsConfiguredForMoreThan12Columns) {
return dashboards;
}
// we need to multiply both _width and _x attributes with 2 to migrate from 12 to 24 columns.
Object.values(dashboards.c8y_Dashboard.children).forEach(widget => {
if (widget._width) {
widget._width = widget._width * 2;
}
if (widget._x) {
widget._x = widget._x * 2;
}
});
return dashboards;
}
async serializeWidgetConfigs(dashboard) {
const children = cloneDeep(dashboard.c8y_Dashboard.children);
if (!children) {
return;
}
const configs = Object.values(children);
const details = configs.map(({ componentId, config }) => ({ componentId, config }));
const results = await this.dynamicComponent.serializeConfigs(details);
results.forEach((result, index) => {
Object.entries(result).forEach(([key, value]) => {
set(details[index].config, key, value);
});
});
dashboard.c8y_Dashboard.children = children;
}
createContextDashboardCopy(dash, newContext, oldContext) {
const children = reduce(dash.children, (_children, child) => {
const { id } = child;
const cfg = child.config;
const propertiesToCopy = {
device: device => this.replaceContextInObj(device, newContext, oldContext),
deviceIds: deviceIds => this.replaceContextInIdsObj(deviceIds, newContext, oldContext),
datapoints: dataPoints => this.replaceContextInDataPoints(dataPoints, newContext, oldContext),
dataPoints: dataPoints => this.replaceContextInDataPoints(dataPoints, newContext, oldContext),
datapointsGauge: dataPoints => this.replaceContextInDataPoints(dataPoints, newContext, oldContext),
datapointsLabels: dataPoints => this.replaceContextInDataPoints(dataPoints, newContext, oldContext)
};
if (cfg) {
this.copyProperties(cfg, propertiesToCopy);
if (cfg.options) {
this.copyProperties(cfg.options, propertiesToCopy);
}
}
_children[id] = cloneDeep(child);
return _children;
}, {});
dash.children = children;
const isTypeDashboard = dash.deviceType && !!dash.deviceTypeValue;
if (isTypeDashboard) {
dash.deviceTypeValue = newContext.type;
}
return dash;
}
copyProperties(obj, propertiesToCopy) {
forEach(propertiesToCopy, (copyFn, property) => {
if (obj[property]) {
obj[property] = copyFn(obj[property]);
}
});
}
replaceContextInDataPoints(dataPoints, newContext, oldContext) {
dataPoints.forEach(dp => {
dp.__target = this.replaceContextInObj(dp.__target, newContext, oldContext);
});
return dataPoints;
}
replaceContextInObj(obj, newContext, oldContext) {
if (obj && obj.id === oldContext.id) {
Object.assign(obj, pick(newContext, ['id', 'name']));
}
return obj;
}
/**
* Replaces context IDs in a key-value map by substituting old context IDs with new context IDs.
*
* This function handles config values that map property names to managed object IDs,
* for example used in legacy SCADA widgets (like deviceIds: { scadaPlaceholderName: 12345 }).
* It iterates through the entries and replaces any ID that matches the old context ID with the new context ID.
*
* @param ids - A record mapping property names to managed object IDs
* @param newContext - The new context managed object containing the ID to replace with
* @param oldContext - The old context managed object containing the ID to replace
* @returns A new record with old context IDs replaced by new context IDs
*/
replaceContextInIdsObj(ids, newContext, oldContext) {
return Object.fromEntries(Object.entries(ids).map(([key, id]) => [key, id === oldContext.id ? newContext.id : id]));
}
getTabs$(mo, dashboardType, context) {
const dashboards = this.getContextDashboards(mo, dashboardType);
return this.getContextDashboards$(dashboards).pipe(mergeMap(dashboard => this.verifyDashboardAvailability$(dashboard)),
// Due to MTM-62321 named context dashboards included fragments for device and groups.
// therefore some device-info dashboards (from DM app) might occur in cockpit on device level.
// We need to filter those out..
filter(([dashboard]) => {
// list all identifiers of the dashboard (name, device, group, type)
const dashboardIdentifiers = Object.keys(dashboard)
.filter(key => key.startsWith(`${this.FRAGMENT_NAME}${this.INDEX_SPLIT}`))
.map(key => key.split(this.INDEX_SPLIT)[1]);
// if dashboard is not named, it's safe to include it
if (!dashboardIdentifiers.includes(ContextDashboardType.Named)) {
return true;
}
// if dashboard is named, but also has a context identifier, we skip it
if (dashboardIdentifiers.includes(ContextDashboardType.Device) ||
dashboardIdentifiers.includes(ContextDashboardType.Group)) {
return false;
}
// all others are good..
return true;
}), mergeMap(([dashboard]) => this.removeDashboardMoProperty(dashboard)), tap(dashboard => this.cacheDashboard(dashboard)), map(dashboard => this.createDashboardTab(dashboard, context)), toArray());
}
verifyDashboardAvailability$(dashboard) {
const globalRolesIds = dashboard?.c8y_Dashboard?.globalRolesIds;
const canEdit = from(this.permissions.canEdit(this.INVENTORY_ROLES, dashboard, { skipRequestCheck: true }));
const hasAnyGlobalRole = !globalRolesIds || globalRolesIds === ALL_GLOBAL_ROLES_SELECTED
? of(true)
: of(this.permissions.hasAnyGlobalRole(globalRolesIds));
return combineLatest([of(dashboard), canEdit, hasAnyGlobalRole]).pipe(filter(([, canEdit, hasAnyGlobalRole]) => canEdit || hasAnyGlobalRole));
}
getContextDashboards$(request) {
return from(request).pipe(mergeMap(response => this.adjustDashboardFor24Columns(response.data)));
}
/**
* Cleans already corrupted dashboards from dashboardMo property.
* Added to fix dashboards on the cloud instance (eu-latest).
* @deprecated This is going to be removed after 1007.7.0.
*/
async removeDashboardMoProperty(dashboard) {
const dashboardCopy = cloneDeep(dashboard);
const children = get(dashboardCopy, 'c8y_Dashboard.children');
let updateDashboard = false;
forEach(children, child => {
if (get(child, 'componentTransformConfigWithContext')) {
delete child.componentTransformConfigWithContext;
updateDashboard = true;
}
if (get(child, 'config.dashboardMo')) {
delete child.config.dashboardMo;
updateDashboard = true;
}
});
if (updateDashboard) {
await this.update(dashboardCopy);
}
return dashboardCopy;
}
cacheDashboard(dashboard) {
this.cache.set(dashboard.id, dashboard);
}
createDashboardTab(dashboard, context) {
const { c8y_Dashboard: _dashboard, id } = dashboard;
return {
icon: _dashboard.icon,
path: `${this.DASHBOARD_ROUTE_PATH}/${id}`,
label: _dashboard.name,
priority: _dashboard.priority,
hide: this.isReport(dashboard),
translateLabel: _dashboard.translateDashboardTitle,
badge: _dashboard.deviceType && this.shouldAllowToSetDashboardType(dashboard, context)
? gettext('Dashboard template')
: null,
tooltipText: _dashboard.description || gettext('Dashboard template')
};
}
clean(dashboard) {
const jsonString = JSON.stringify(dashboard, (key, value) => {
if (key === '$$hashKey' || key === 'klasses') {
return undefined;
}
return value;
});
return JSON.parse(jsonString);
}
getNamedDashboard(name) {
return this.inventory.list({
fragmentType: `${this.FRAGMENT_NAME}${this.INDEX_SPLIT}${ContextDashboardType.Named}${this.INDEX_SPLIT}${name}`,
pageSize: 1
});
}
createDashboardFragment(mo, type) {
let value;
if (mo.c8y_Report) {
value = `${this.REPORT_PARTIAL_NAME}${mo.id}`;
}
else {
value = type === ContextDashboardType.Type ? mo.type : mo.id;
}
return `${this.FRAGMENT_NAME}${this.INDEX_SPLIT}${type}${this.INDEX_SPLIT}${value}`;
}
shouldSetGlobal(dashboard, context) {
if ((!context && this.isNamed(dashboard) && !this.isReport(dashboard)) ||
this.isDeviceType(dashboard)) {
return {};
}
return null;
}
getDefaultDashboard(dashboardCfg) {
return {
c8y_Dashboard: {
...dashboardCfg,
name: dashboardCfg.name || 'Default Dashboard',
description: gettext('This is a default dashboard.'),
columns: 24
},
[`c8y_Dashboard!name!${dashboardCfg.name}`]: {}
};
}
/*
* For dashboard views within applications, filter dashboards by application association.
* Do it on the client side as the inventory query doesn't perform well on not indexed fragments.
*/
async listAndFilterByApplication(query) {
const dashboards = await this.inventory.list({ query, pageSize: this.DEFAULT_PAGESIZE });
dashboards.data = dashboards.data.filter(dashboard => {
return (Array.from(dashboard[this.APPLIED_TO_FRAGMENT] || []).includes(this.appStateService?.currentApplication?.value?.key) || !dashboard[this.APPLI