UNPKG

@c8y/ngx-components

Version:

Angular modules for Cumulocity IoT applications

1,020 lines (1,015 loc) 336 kB
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