UNPKG

@c8y/ngx-components

Version:

Angular modules for Cumulocity IoT applications

381 lines (377 loc) 67.7 kB
import * as i0 from '@angular/core'; import { Injectable, inject, Input, Component, Injector, runInInjectionContext, signal, ViewChild } from '@angular/core'; import { DashboardDetailService } from '@c8y/ngx-components/context-dashboard'; import * as i4$1 from 'ngx-bootstrap/popover'; import { PopoverModule } from 'ngx-bootstrap/popover'; import { gettext } from '@c8y/ngx-components/gettext'; import * as i1 from '@c8y/ngx-components'; import { CoreModule, DynamicComponentService, AlertService, CommonModule, FormsModule } from '@c8y/ngx-components'; import { EditorComponent } from '@c8y/ngx-components/editor'; import Ajv from 'ajv'; import addFormats from 'ajv-formats'; import { Subject, debounceTime, takeUntil } from 'rxjs'; import * as i3$1 from '@angular/forms'; import { ReactiveFormsModule } from '@angular/forms'; import { cloneDeep } from 'lodash-es'; import { saveAs } from 'file-saver'; import { BsModalRef, BsModalService } from 'ngx-bootstrap/modal'; import * as i3 from '@c8y/ngx-components/assets-navigator'; import { AssetSelectorModule } from '@c8y/ngx-components/assets-navigator'; import * as i4 from 'ngx-bootstrap/dropdown'; import { BsDropdownModule } from 'ngx-bootstrap/dropdown'; import * as i5 from 'ngx-bootstrap/tooltip'; import { TooltipModule } from 'ngx-bootstrap/tooltip'; import * as i2 from '@angular/common'; class JsonValidationService { constructor(dynamicComponentService) { this.dynamicComponentService = dynamicComponentService; this.OMIT_ERRORS = ['must NOT be valid', 'must match exactly one schema in oneOf']; this.ajv = new Ajv({ verbose: true, allErrors: true }); addFormats(this.ajv); } async validateDashboard(dashboardJson, schemas) { try { // Parse JSON string to object const dashboard = JSON.parse(dashboardJson); // Get or use provided schemas const validationSchemas = schemas || (await this.getJsonSchemas(dashboard)); // Create a new Ajv instance const validator = new Ajv({ verbose: true, allErrors: true }); addFormats(validator); // Add all component schemas individually validationSchemas.forEach(({ uri, schema }) => { validator.addSchema(schema, uri); }); const errorsMap = new Map(); // Validate main dashboard structure first (without children) const mainSchema = validationSchemas.find(s => s.uri === 'context-dashboard.c8y-schema-loader')?.schema; if (!mainSchema) { throw new Error('Dashboard schema not found'); } // Create a copy without children for main validation const dashboardWithoutChildren = { ...dashboard }; delete dashboardWithoutChildren.children; const mainValidate = validator.compile(mainSchema); mainValidate(dashboardWithoutChildren); // Now validate each widget with its specific schema if (dashboard.children) { Object.entries(dashboard.children).forEach(([widgetId, widget]) => { const componentId = widget.componentId; // Find the correct schema for this widget type const widgetSchema = validationSchemas.find(s => s.uri === componentId)?.schema; if (widgetSchema) { // Validate this widget's config against its specific schema const validateWidgetConfig = validator.compile(widgetSchema); const isValid = validateWidgetConfig(widget.config); if (!isValid && validateWidgetConfig.errors) { validateWidgetConfig.errors.forEach(error => { const message = error.message || 'Unknown error'; const basePath = `/children/${widgetId}/config`; const relativePath = error.instancePath; const fullPath = basePath + relativePath; if (!this.OMIT_ERRORS.includes(message)) { errorsMap.set(`${message}::${fullPath}`, { message, path: fullPath }); } }); } } }); } // Process any errors from main validation if (mainValidate.errors) { mainValidate.errors.forEach(error => { const message = error.message || 'Unknown error'; const path = error.instancePath; // Skip children validation errors as we handled them separately if (!path.includes('/children/') && !this.OMIT_ERRORS.includes(message)) { errorsMap.set(`${message}::${path}`, { message, path }); } }); } return Array.from(errorsMap.values()); } catch (error) { if (error instanceof SyntaxError) { return [{ message: 'Invalid JSON syntax', path: '' }]; } throw error; } } /** * Returns JSON schema for dashboard and all its children. * Children are referenced by their componentId. Dashboard schema is enriched with references to children schemas. * @param dashboard Context dashboard object. */ async getJsonSchemas(dashboard) { try { const dashboardChildrenIds = [ ...new Set(Object.entries(dashboard.children || {}).map(([_, value]) => value.componentId)) ]; const widgetSchemasPromises = dashboardChildrenIds.map(id => this.getSchemaForWidget(id)); const widgetSchemasResults = await Promise.allSettled(widgetSchemasPromises); // Filter out failed or null schemas const validWidgetSchemas = widgetSchemasResults .filter((result) => result.status === 'fulfilled' && result.value !== null) .map(result => result.value); const { schema: dashboardSchemaOriginal } = await import('c8y-schema-loader?interfaceName=ContextDashboard!@c8y/ngx-components/context-dashboard'); const dashboardSchema = JSON.parse(JSON.stringify(dashboardSchemaOriginal)); // Modify Widget definition with proper schema structure if (validWidgetSchemas.length > 0) { // Remove any existing allOf or oneOf conditions that might interfere if (dashboardSchema.definitions.Widget.allOf) { delete dashboardSchema.definitions.Widget.allOf; } // Set up the oneOf condition dashboardSchema.definitions.Widget.oneOf = this.getWidgetSchemasReferences(validWidgetSchemas.map(({ id }) => id)); } return [ { uri: 'context-dashboard.c8y-schema-loader', fileMatch: ['*'], schema: dashboardSchema }, ...validWidgetSchemas.map(({ id, schema }) => ({ uri: id, schema: schema })) ]; } catch (error) { console.error('Error processing dashboard schemas:', error); throw error; } } async getSchemaForWidget(id) { try { const def = await this.dynamicComponentService.getById(id); if (def?.data?.schema && typeof def.data.schema === 'function') { const { schema } = await def.data.schema(); return { id, schema }; } return null; } catch (error) { console.warn(`Failed to fetch schema for widget ${id}:`, error); return null; } } getWidgetSchemasReferences(widgetIds) { // provide references to all children schemas const schemasPart = widgetIds.map(id => { return { properties: { componentId: { const: id }, config: { $ref: `${id}#` } // Ensure proper URI reference format }, required: ['componentId'] // Only require componentId }; }); // provide default schema for object if component has no schema definition const defaultPart = { not: { properties: { componentId: { enum: widgetIds } } }, properties: { config: { type: 'object', additionalProperties: true } } }; return [...schemasPart, defaultPart]; } static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.15", ngImport: i0, type: JsonValidationService, deps: [{ token: i1.DynamicComponentService }], target: i0.ɵɵFactoryTarget.Injectable }); } static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.15", ngImport: i0, type: JsonValidationService }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.15", ngImport: i0, type: JsonValidationService, decorators: [{ type: Injectable }], ctorParameters: () => [{ type: i1.DynamicComponentService }] }); class AssignWidgetAssetModalComponent { constructor() { this.notSupportedWidgets = []; this.modalTitle = gettext(` For each asset in the supported widgets, the suggested source is based on the original one. You can either accept the suggestion, select a different asset, or decide to configure it later. Please note: If you don't pick any of the options available, the widget will not be imported. `); this.assetIdInfo = gettext('id: {{ assetId }}'); this.acceptSuggestedLabel = gettext('Accept suggested asset'); this.noSuggestedAssetLabel = gettext('No suggestion available'); this.updatedWidgetAssets = []; this.result = new Promise((resolve, reject) => { this._close = resolve; this._cancel = reject; }); this.bsModalRef = inject(BsModalRef); } ngOnInit() { this.updatedWidgetAssets = this.assetsToAlign.map(asset => ({ ...asset })); this.hasSuggestedAssets = this.updatedWidgetAssets.some(asset => !!asset.deviceRef.value.suggestedDevice); } apply() { const alignedAssets = this.updatedWidgetAssets.map(asset => { return { widgetId: asset.widgetId, title: asset.title, deviceRef: { path: asset.deviceRef.path, value: asset.selectedAsset == null ? asset.selectedAsset : { ...asset.selectedAsset, suggestedDevice: null } } }; }); this._close(alignedAssets); this.bsModalRef.hide(); } acceptAllSuggested() { this.updatedWidgetAssets.forEach(asset => { asset.selectedAsset = asset.deviceRef.value.suggestedDevice; }); } acceptAllSuggestedAndApply() { this.acceptAllSuggested(); this.apply(); } cancel() { this.bsModalRef.hide(); this._cancel(); } selectionChange($event, widgetAsset) { widgetAsset.selectedAsset = $event.change.item; } static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.15", ngImport: i0, type: AssignWidgetAssetModalComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); } static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.3.15", type: AssignWidgetAssetModalComponent, isStandalone: true, selector: "c8y-assign-widget-asset-modal", inputs: { assetsToAlign: "assetsToAlign", contextAsset: "contextAsset", notSupportedWidgets: "notSupportedWidgets" }, ngImport: i0, template: "<div class=\"viewport-modal has-asset-selector\">\n <div class=\"modal-header dialog-header\">\n <i [c8yIcon]=\"'th-large'\"></i>\n <div\n class=\"modal-title\"\n id=\"modal-title\"\n translate\n >\n Select and confirm widget assets mapping\n </div>\n </div>\n <div\n class=\"inner-scroll\"\n id=\"modal-body\"\n >\n <div class=\"p-16\">\n <p class=\"text-center text-balance\">\n {{ modalTitle | translate }}\n </p>\n </div>\n <div\n class=\"d-flex fit-w j-c-center p-16 p-t-0\"\n *ngIf=\"notSupportedWidgets.length\"\n >\n <div class=\"alert alert-warning\">\n {{\n 'Widgets not supported for asset mapping: {{ notSupportedWidgets\n\n\n\n }}' | translate: { notSupportedWidgets: notSupportedWidgets.join(', ') } }}\n </div>\n </div>\n <div\n class=\"p-16 d-flex j-c-center\"\n *ngIf=\"!assetsToAlign.length\"\n >\n <c8y-ui-empty-state\n class=\"\"\n [icon]=\"'empty-box'\"\n [title]=\"'No assets to assign' | translate\"\n ></c8y-ui-empty-state>\n </div>\n <div *ngIf=\"assetsToAlign.length\">\n <c8y-list-group class=\"separator-top no-border-last\">\n <c8y-li class=\"sticky-top bg-level-1 hidden-sm hidden-xs\">\n <c8y-li-icon></c8y-li-icon>\n <div class=\"d-flex\">\n <div class=\"col-md-2\">\n <!-- title -->\n <p\n class=\"text-truncate text-medium\"\n translate\n >\n Widget\n </p>\n </div>\n <div class=\"col-md-10\">\n <div class=\"row\">\n <div class=\"col-md-4 p-r-0\">\n <p\n class=\"text-medium\"\n translate\n >\n Original source\n </p>\n </div>\n <div class=\"col-md-1\">\n <button\n class=\"btn btn-default btn-sm\"\n [attr.aria-label]=\"'Accept all suggested' | translate\"\n [tooltip]=\"'Accept all suggested' | translate\"\n placement=\"right\"\n (click)=\"acceptAllSuggested()\"\n >\n <i [c8yIcon]=\"'check'\"></i>\n </button>\n </div>\n <div class=\"col-md-7 p-l-8\">\n <p\n class=\"text-medium\"\n translate\n >\n Selected source\n </p>\n </div>\n </div>\n </div>\n </div>\n </c8y-li>\n\n <c8y-li\n class=\"c8y-list__item--overflow-visible\"\n *ngFor=\"let widgetAsset of updatedWidgetAssets; let i = index\"\n >\n <c8y-li-icon\n class=\"p-r-0 p-t-16\"\n icon=\"th-large\"\n ></c8y-li-icon>\n <div class=\"d-flex-md\">\n <div class=\"col-md-2\">\n <!-- title -->\n <p\n class=\"text-truncate text-medium boxed-label p-l-0\"\n title=\"{{ widgetAsset.title }}\"\n >\n {{ widgetAsset.title }}\n </p>\n </div>\n <div class=\"col-md-10\">\n <div class=\"row\">\n <div class=\"col-md-4 p-l-md-0 p-r-0\">\n <!-- origin device -->\n <p class=\"visible-sm visible-xs text-label-small\">\n {{ 'Original source' | translate }}\n </p>\n <div class=\"d-flex gap-4 boxed-label\">\n <ng-container\n *ngTemplateOutlet=\"\n assetIcon;\n context: { $implicit: widgetAsset.deviceRef.value }\n \"\n ></ng-container>\n <span class=\"text-truncate\">\n <span\n class=\"text-truncate\"\n title=\"{{ widgetAsset.deviceRef.value.name }}\"\n >\n {{ widgetAsset.deviceRef.value.name }}\n </span>\n <small class=\"text-muted\">\n {{ assetIdInfo | translate: { assetId: widgetAsset.deviceRef.value.id } }}\n </small>\n </span>\n </div>\n </div>\n <div\n class=\"col-md-1 col-xs-1 p-l-xs-0 p-l-sm-0 p-r-sm-0 p-r-xs-0 text-center p-t-16\"\n >\n <i\n class=\"icon-20\"\n [c8yIcon]=\"widgetAsset.selectedAsset !== undefined ? 'ok' : 'inactive-state'\"\n [title]=\"\n widgetAsset.selectedAsset !== undefined\n ? ('Asset selected' | translate)\n : ('Asset not selected' | translate)\n \"\n [ngClass]=\"{\n 'text-success': widgetAsset.selectedAsset !== undefined,\n 'text-muted': !widgetAsset.selectedAsset === undefined\n }\"\n ></i>\n </div>\n <div class=\"col-md-4 col-xs-11 p-l-md-0\">\n <!-- Suggested/Selected source -->\n <p class=\"visible-sm visible-xs text-label-small m-t-16 m-b-8\">\n {{ 'Selected source' | translate }}\n </p>\n <div\n class=\"d-flex gap-4 text-muted boxed-label\"\n *ngIf=\"\n widgetAsset.deviceRef.value.suggestedDevice && !widgetAsset.selectedAsset\n \"\n [attr.data-label]=\"'Suggested' | translate\"\n >\n <ng-container\n *ngTemplateOutlet=\"\n assetIcon;\n context: { $implicit: widgetAsset.deviceRef.value.suggestedDevice }\n \"\n ></ng-container>\n <span class=\"text-truncate\">\n <span\n class=\"text-truncate\"\n title=\"{{ widgetAsset.deviceRef.value.suggestedDevice?.name }}\"\n >\n {{ widgetAsset.deviceRef.value.suggestedDevice?.name }}\n </span>\n <small class=\"text-muted\">\n {{\n assetIdInfo\n | translate\n : { assetId: widgetAsset.deviceRef.value.suggestedDevice?.id }\n }}\n </small>\n </span>\n </div>\n <div\n class=\"d-flex gap-4 boxed-label\"\n *ngIf=\"widgetAsset.selectedAsset\"\n >\n <!-- selectedAsset icon -->\n <ng-container\n *ngTemplateOutlet=\"\n assetIcon;\n context: { $implicit: widgetAsset.selectedAsset }\n \"\n ></ng-container>\n\n <!-- selectedAsset Name and ID -->\n <span class=\"text-truncate\">\n <span\n class=\"text-truncate\"\n title=\"{{ widgetAsset.selectedAsset?.name }}\"\n >\n {{ widgetAsset.selectedAsset?.name }}\n </span>\n <small class=\"text-muted\">\n {{ assetIdInfo | translate: { assetId: widgetAsset.selectedAsset.id } }}\n </small>\n </span>\n </div>\n\n <div\n class=\"text-muted boxed-label\"\n *ngIf=\"\n !widgetAsset.deviceRef.value.suggestedDevice && !widgetAsset.selectedAsset\n \"\n >\n <em>{{ 'No suggestion available' | translate }}</em>\n </div>\n </div>\n <div class=\"col-md-3 p-0 flex-grow\">\n <div class=\"d-flex a-i-center j-c-end-md gap-8 boxed-label p-l-md-0 p-r-0\">\n <div class=\"input-group max-width-fit\">\n <div class=\"input-group-btn\">\n <button\n class=\"btn btn-default\"\n [attr.aria-label]=\"\n (widgetAsset.deviceRef.value.suggestedDevice\n ? acceptSuggestedLabel\n : noSuggestedAssetLabel\n ) | translate\n \"\n [tooltip]=\"\n (widgetAsset.deviceRef.value.suggestedDevice\n ? acceptSuggestedLabel\n : noSuggestedAssetLabel\n ) | translate\n \"\n placement=\"top\"\n container=\"body\"\n [ngClass]=\"{\n active:\n widgetAsset.selectedAsset &&\n widgetAsset.selectedAsset ===\n widgetAsset.deviceRef.value.suggestedDevice\n }\"\n [disabled]=\"!widgetAsset.deviceRef.value.suggestedDevice\"\n (click)=\"\n widgetAsset.selectedAsset = widgetAsset.deviceRef.value.suggestedDevice\n \"\n >\n <i [c8yIcon]=\"'check'\"></i>\n </button>\n </div>\n <div\n class=\"dropdown input-group-btn\"\n container=\"body\"\n dropdown\n [insideClick]=\"true\"\n #dropdown=\"bs-dropdown\"\n >\n <button\n class=\"btn btn-default dropdown-toggle c8y-dropdown\"\n [attr.aria-label]=\"'Select asset' | translate\"\n tooltip=\"{{ 'Select asset' | translate }}\"\n placement=\"top\"\n container=\"body\"\n type=\"button\"\n dropdownToggle\n [ngClass]=\"{\n active:\n widgetAsset.selectedAsset &&\n widgetAsset.selectedAsset !==\n widgetAsset.deviceRef.value.suggestedDevice\n }\"\n >\n <i [c8yIcon]=\"'card-exchange'\"></i>\n </button>\n <div\n class=\"dropdown-menu dropdown-menu-right--md\"\n style=\"width: 300px\"\n *dropdownMenu\n >\n <c8y-asset-selector-miller\n *ngIf=\"dropdown.isOpen\"\n (onSelected)=\"selectionChange($event, widgetAsset); dropdown.hide()\"\n [asset]=\"contextAsset\"\n [config]=\"{\n groupsSelectable: true,\n showChildDevices: true,\n showFilter: true,\n showSelected: false,\n columnHeaders: true,\n singleColumn: true,\n preventInitialSelect: true\n }\"\n ></c8y-asset-selector-miller>\n </div>\n </div>\n <div class=\"input-group-btn\">\n <button\n class=\"btn btn-default\"\n [attr.aria-label]=\"'Configure later' | translate\"\n tooltip=\"{{ 'Configure later' | translate }}\"\n placement=\"top\"\n container=\"body\"\n [ngClass]=\"{ active: widgetAsset.selectedAsset === null }\"\n (click)=\"widgetAsset.selectedAsset = null\"\n >\n <i [c8yIcon]=\"'link-off'\"></i>\n </button>\n </div>\n </div>\n </div>\n </div>\n </div>\n </div>\n </div>\n </c8y-li>\n </c8y-list-group>\n </div>\n </div>\n <div class=\"modal-footer\">\n <button\n class=\"btn btn-default\"\n [title]=\"'Cancel' | translate\"\n type=\"button\"\n (click)=\"cancel()\"\n >\n {{ 'Cancel' | translate }}\n </button>\n <button\n class=\"btn btn-default\"\n [title]=\"'Accept all suggested' | translate\"\n type=\"button\"\n (click)=\"acceptAllSuggestedAndApply()\"\n [disabled]=\"!hasSuggestedAssets\"\n >\n {{ 'Accept all suggested' | translate }}\n </button>\n <button\n class=\"btn btn-primary\"\n [title]=\"'Apply' | translate\"\n type=\"button\"\n (click)=\"apply()\"\n >\n {{ 'Apply' | translate }}\n </button>\n </div>\n</div>\n\n<ng-template\n #assetIcon\n let-device\n>\n <i\n class=\"l-h-inherit\"\n [c8yIcon]=\"'data-transfer'\"\n *ngIf=\"device.c8y_IsDevice\"\n ></i>\n <i\n class=\"l-h-inherit\"\n [c8yIcon]=\"'c8y-group'\"\n *ngIf=\"device.c8y_IsDeviceGroup && !device.c8y_IsAsset\"\n ></i>\n <i\n class=\"l-h-inherit\"\n [c8yIcon]=\"device.icon?.name || 'c8y-group'\"\n *ngIf=\"device.c8y_IsAsset\"\n ></i>\n</ng-template>\n", dependencies: [{ kind: "ngmodule", type: CoreModule }, { kind: "component", type: i1.EmptyStateComponent, selector: "c8y-ui-empty-state", inputs: ["icon", "title", "subtitle", "horizontal"] }, { kind: "directive", type: i1.IconDirective, selector: "[c8yIcon]", inputs: ["c8yIcon"] }, { kind: "directive", type: i1.C8yTranslateDirective, selector: "[translate],[ngx-translate]" }, { kind: "directive", type: i2.NgClass, selector: "[ngClass]", inputs: ["class", "ngClass"] }, { kind: "directive", type: i2.NgForOf, selector: "[ngFor][ngForOf]", inputs: ["ngForOf", "ngForTrackBy", "ngForTemplate"] }, { kind: "directive", type: i2.NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }, { kind: "directive", type: i2.NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }, { kind: "component", type: i1.ListGroupComponent, selector: "c8y-list-group" }, { kind: "component", type: i1.ListItemComponent, selector: "c8y-list-item, c8y-li", inputs: ["active", "highlighted", "emptyActions", "dense", "collapsed", "selectable"], outputs: ["collapsedChange"] }, { kind: "component", type: i1.ListItemIconComponent, selector: "c8y-list-item-icon, c8y-li-icon", inputs: ["icon", "status"] }, { kind: "ngmodule", type: AssetSelectorModule }, { kind: "component", type: i3.MillerViewComponent, selector: "c8y-asset-selector-miller", inputs: ["config", "asset", "selectedDevice", "rootNode", "container"], outputs: ["onSelected", "onClearSelected"] }, { kind: "ngmodule", type: BsDropdownModule }, { kind: "directive", type: i4.BsDropdownMenuDirective, selector: "[bsDropdownMenu],[dropdownMenu]", exportAs: ["bs-dropdown-menu"] }, { kind: "directive", type: i4.BsDropdownToggleDirective, selector: "[bsDropdownToggle],[dropdownToggle]", exportAs: ["bs-dropdown-toggle"] }, { kind: "directive", type: i4.BsDropdownDirective, selector: "[bsDropdown], [dropdown]", inputs: ["placement", "triggers", "container", "dropup", "autoClose", "isAnimated", "insideClick", "isDisabled", "isOpen"], outputs: ["isOpenChange", "onShown", "onHidden"], exportAs: ["bs-dropdown"] }, { kind: "ngmodule", type: TooltipModule }, { kind: "directive", type: i5.TooltipDirective, selector: "[tooltip], [tooltipHtml]", inputs: ["adaptivePosition", "tooltip", "placement", "triggers", "container", "containerClass", "boundariesElement", "isOpen", "isDisabled", "delay", "tooltipHtml", "tooltipPlacement", "tooltipIsOpen", "tooltipEnable", "tooltipAppendToBody", "tooltipAnimation", "tooltipClass", "tooltipContext", "tooltipPopupDelay", "tooltipFadeDuration", "tooltipTrigger"], outputs: ["tooltipChange", "onShown", "onHidden", "tooltipStateChanged"], exportAs: ["bs-tooltip"] }, { kind: "pipe", type: i1.C8yTranslatePipe, name: "translate" }] }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.15", ngImport: i0, type: AssignWidgetAssetModalComponent, decorators: [{ type: Component, args: [{ selector: 'c8y-assign-widget-asset-modal', standalone: true, imports: [CoreModule, AssetSelectorModule, BsDropdownModule, TooltipModule], template: "<div class=\"viewport-modal has-asset-selector\">\n <div class=\"modal-header dialog-header\">\n <i [c8yIcon]=\"'th-large'\"></i>\n <div\n class=\"modal-title\"\n id=\"modal-title\"\n translate\n >\n Select and confirm widget assets mapping\n </div>\n </div>\n <div\n class=\"inner-scroll\"\n id=\"modal-body\"\n >\n <div class=\"p-16\">\n <p class=\"text-center text-balance\">\n {{ modalTitle | translate }}\n </p>\n </div>\n <div\n class=\"d-flex fit-w j-c-center p-16 p-t-0\"\n *ngIf=\"notSupportedWidgets.length\"\n >\n <div class=\"alert alert-warning\">\n {{\n 'Widgets not supported for asset mapping: {{ notSupportedWidgets\n\n\n\n }}' | translate: { notSupportedWidgets: notSupportedWidgets.join(', ') } }}\n </div>\n </div>\n <div\n class=\"p-16 d-flex j-c-center\"\n *ngIf=\"!assetsToAlign.length\"\n >\n <c8y-ui-empty-state\n class=\"\"\n [icon]=\"'empty-box'\"\n [title]=\"'No assets to assign' | translate\"\n ></c8y-ui-empty-state>\n </div>\n <div *ngIf=\"assetsToAlign.length\">\n <c8y-list-group class=\"separator-top no-border-last\">\n <c8y-li class=\"sticky-top bg-level-1 hidden-sm hidden-xs\">\n <c8y-li-icon></c8y-li-icon>\n <div class=\"d-flex\">\n <div class=\"col-md-2\">\n <!-- title -->\n <p\n class=\"text-truncate text-medium\"\n translate\n >\n Widget\n </p>\n </div>\n <div class=\"col-md-10\">\n <div class=\"row\">\n <div class=\"col-md-4 p-r-0\">\n <p\n class=\"text-medium\"\n translate\n >\n Original source\n </p>\n </div>\n <div class=\"col-md-1\">\n <button\n class=\"btn btn-default btn-sm\"\n [attr.aria-label]=\"'Accept all suggested' | translate\"\n [tooltip]=\"'Accept all suggested' | translate\"\n placement=\"right\"\n (click)=\"acceptAllSuggested()\"\n >\n <i [c8yIcon]=\"'check'\"></i>\n </button>\n </div>\n <div class=\"col-md-7 p-l-8\">\n <p\n class=\"text-medium\"\n translate\n >\n Selected source\n </p>\n </div>\n </div>\n </div>\n </div>\n </c8y-li>\n\n <c8y-li\n class=\"c8y-list__item--overflow-visible\"\n *ngFor=\"let widgetAsset of updatedWidgetAssets; let i = index\"\n >\n <c8y-li-icon\n class=\"p-r-0 p-t-16\"\n icon=\"th-large\"\n ></c8y-li-icon>\n <div class=\"d-flex-md\">\n <div class=\"col-md-2\">\n <!-- title -->\n <p\n class=\"text-truncate text-medium boxed-label p-l-0\"\n title=\"{{ widgetAsset.title }}\"\n >\n {{ widgetAsset.title }}\n </p>\n </div>\n <div class=\"col-md-10\">\n <div class=\"row\">\n <div class=\"col-md-4 p-l-md-0 p-r-0\">\n <!-- origin device -->\n <p class=\"visible-sm visible-xs text-label-small\">\n {{ 'Original source' | translate }}\n </p>\n <div class=\"d-flex gap-4 boxed-label\">\n <ng-container\n *ngTemplateOutlet=\"\n assetIcon;\n context: { $implicit: widgetAsset.deviceRef.value }\n \"\n ></ng-container>\n <span class=\"text-truncate\">\n <span\n class=\"text-truncate\"\n title=\"{{ widgetAsset.deviceRef.value.name }}\"\n >\n {{ widgetAsset.deviceRef.value.name }}\n </span>\n <small class=\"text-muted\">\n {{ assetIdInfo | translate: { assetId: widgetAsset.deviceRef.value.id } }}\n </small>\n </span>\n </div>\n </div>\n <div\n class=\"col-md-1 col-xs-1 p-l-xs-0 p-l-sm-0 p-r-sm-0 p-r-xs-0 text-center p-t-16\"\n >\n <i\n class=\"icon-20\"\n [c8yIcon]=\"widgetAsset.selectedAsset !== undefined ? 'ok' : 'inactive-state'\"\n [title]=\"\n widgetAsset.selectedAsset !== undefined\n ? ('Asset selected' | translate)\n : ('Asset not selected' | translate)\n \"\n [ngClass]=\"{\n 'text-success': widgetAsset.selectedAsset !== undefined,\n 'text-muted': !widgetAsset.selectedAsset === undefined\n }\"\n ></i>\n </div>\n <div class=\"col-md-4 col-xs-11 p-l-md-0\">\n <!-- Suggested/Selected source -->\n <p class=\"visible-sm visible-xs text-label-small m-t-16 m-b-8\">\n {{ 'Selected source' | translate }}\n </p>\n <div\n class=\"d-flex gap-4 text-muted boxed-label\"\n *ngIf=\"\n widgetAsset.deviceRef.value.suggestedDevice && !widgetAsset.selectedAsset\n \"\n [attr.data-label]=\"'Suggested' | translate\"\n >\n <ng-container\n *ngTemplateOutlet=\"\n assetIcon;\n context: { $implicit: widgetAsset.deviceRef.value.suggestedDevice }\n \"\n ></ng-container>\n <span class=\"text-truncate\">\n <span\n class=\"text-truncate\"\n title=\"{{ widgetAsset.deviceRef.value.suggestedDevice?.name }}\"\n >\n {{ widgetAsset.deviceRef.value.suggestedDevice?.name }}\n </span>\n <small class=\"text-muted\">\n {{\n assetIdInfo\n | translate\n : { assetId: widgetAsset.deviceRef.value.suggestedDevice?.id }\n }}\n </small>\n </span>\n </div>\n <div\n class=\"d-flex gap-4 boxed-label\"\n *ngIf=\"widgetAsset.selectedAsset\"\n >\n <!-- selectedAsset icon -->\n <ng-container\n *ngTemplateOutlet=\"\n assetIcon;\n context: { $implicit: widgetAsset.selectedAsset }\n \"\n ></ng-container>\n\n <!-- selectedAsset Name and ID -->\n <span class=\"text-truncate\">\n <span\n class=\"text-truncate\"\n title=\"{{ widgetAsset.selectedAsset?.name }}\"\n >\n {{ widgetAsset.selectedAsset?.name }}\n </span>\n <small class=\"text-muted\">\n {{ assetIdInfo | translate: { assetId: widgetAsset.selectedAsset.id } }}\n </small>\n </span>\n </div>\n\n <div\n class=\"text-muted boxed-label\"\n *ngIf=\"\n !widgetAsset.deviceRef.value.suggestedDevice && !widgetAsset.selectedAsset\n \"\n >\n <em>{{ 'No suggestion available' | translate }}</em>\n </div>\n </div>\n <div class=\"col-md-3 p-0 flex-grow\">\n <div class=\"d-flex a-i-center j-c-end-md gap-8 boxed-label p-l-md-0 p-r-0\">\n <div class=\"input-group max-width-fit\">\n <div class=\"input-group-btn\">\n <button\n class=\"btn btn-default\"\n [attr.aria-label]=\"\n (widgetAsset.deviceRef.value.suggestedDevice\n ? acceptSuggestedLabel\n : noSuggestedAssetLabel\n ) | translate\n \"\n [tooltip]=\"\n (widgetAsset.deviceRef.value.suggestedDevice\n ? acceptSuggestedLabel\n : noSuggestedAssetLabel\n ) | translate\n \"\n placement=\"top\"\n container=\"body\"\n [ngClass]=\"{\n active:\n widgetAsset.selectedAsset &&\n widgetAsset.selectedAsset ===\n widgetAsset.deviceRef.value.suggestedDevice\n }\"\n [disabled]=\"!widgetAsset.deviceRef.value.suggestedDevice\"\n (click)=\"\n widgetAsset.selectedAsset = widgetAsset.deviceRef.value.suggestedDevice\n \"\n >\n <i [c8yIcon]=\"'check'\"></i>\n </button>\n </div>\n <div\n class=\"dropdown input-group-btn\"\n container=\"body\"\n dropdown\n [insideClick]=\"true\"\n #dropdown=\"bs-dropdown\"\n >\n <button\n class=\"btn btn-default dropdown-toggle c8y-dropdown\"\n [attr.aria-label]=\"'Select asset' | translate\"\n tooltip=\"{{ 'Select asset' | translate }}\"\n placement=\"top\"\n container=\"body\"\n type=\"button\"\n dropdownToggle\n [ngClass]=\"{\n active:\n widgetAsset.selectedAsset &&\n widgetAsset.selectedAsset !==\n widgetAsset.deviceRef.value.suggestedDevice\n }\"\n >\n <i [c8yIcon]=\"'card-exchange'\"></i>\n </button>\n <div\n class=\"dropdown-menu dropdown-menu-right--md\"\n style=\"width: 300px\"\n *dropdownMenu\n >\n <c8y-asset-selector-miller\n *ngIf=\"dropdown.isOpen\"\n (onSelected)=\"selectionChange($event, widgetAsset); dropdown.hide()\"\n [asset]=\"contextAsset\"\n [config]=\"{\n groupsSelectable: true,\n showChildDevices: true,\n showFilter: true,\n showSelected: false,\n columnHeaders: true,\n singleColumn: true,\n preventInitialSelect: true\n }\"\n ></c8y-asset-selector-miller>\n </div>\n </div>\n <div class=\"input-group-btn\">\n <button\n class=\"btn btn-default\"\n [attr.aria-label]=\"'Configure later' | translate\"\n tooltip=\"{{ 'Configure later' | translate }}\"\n placement=\"top\"\n container=\"body\"\n [ngClass]=\"{ active: widgetAsset.selectedAsset === null }\"\n (click)=\"widgetAsset.selectedAsset = null\"\n >\n <i [c8yIcon]=\"'link-off'\"></i>\n </button>\n </div>\n </div>\n </div>\n </div>\n </div>\n </div>\n </div>\n </c8y-li>\n </c8y-list-group>\n </div>\n </div>\n <div class=\"modal-footer\">\n <button\n class=\"btn btn-default\"\n [title]=\"'Cancel' | translate\"\n type=\"button\"\n (click)=\"cancel()\"\n >\n {{ 'Cancel' | translate }}\n </button>\n <button\n class=\"btn btn-default\"\n [title]=\"'Accept all suggested' | translate\"\n type=\"button\"\n (click)=\"acceptAllSuggestedAndApply()\"\n [disabled]=\"!hasSuggestedAssets\"\n >\n {{ 'Accept all suggested' | translate }}\n </button>\n <button\n class=\"btn btn-primary\"\n [title]=\"'Apply' | translate\"\n type=\"button\"\n (click)=\"apply()\"\n >\n {{ 'Apply' | translate }}\n </button>\n </div>\n</div>\n\n<ng-template\n #assetIcon\n let-device\n>\n <i\n class=\"l-h-inherit\"\n [c8yIcon]=\"'data-transfer'\"\n *ngIf=\"device.c8y_IsDevice\"\n ></i>\n <i\n class=\"l-h-inherit\"\n [c8yIcon]=\"'c8y-group'\"\n *ngIf=\"device.c8y_IsDeviceGroup && !device.c8y_IsAsset\"\n ></i>\n <i\n class=\"l-h-inherit\"\n [c8yIcon]=\"device.icon?.name || 'c8y-group'\"\n *ngIf=\"device.c8y_IsAsset\"\n ></i>\n</ng-template>\n" }] }], propDecorators: { assetsToAlign: [{ type: Input }], contextAsset: [{ type: Input }], notSupportedWidgets: [{ type: Input }] } }); class ImportExportWidgetsService { constructor() { this.dynamicComponentService = inject(DynamicComponentService); this.injector = inject(Injector); this.alertService = inject(AlertService); this.dashboardDetailService = inject(DashboardDetailService); this.modal = inject(BsModalService); } /** * Export the dashboard to a JSON file. If the widget has an export method, it will be called to export * the widget's configuration. * @param dashboard Context dashboard to be exported */ async export(dashboard) { const copiedDashboard = cloneDeep(dashboard); for (const [id, child] of Object.entries(copiedDashboard.children || {})) { try { const definition = await this.dynamicComponentService.getById(child.componentId); if (definition && typeof definition.data.export === 'function') { const widgetData = definition.data; const pluginInjector = definition.injector || this.injector; await runInInjectionContext(pluginInjector, async () => { const exportedConfig = await widgetData.export(child.config, this.dashboardDetailService.details, { pluginInjector, injector: this.injector }); copiedDashboard.children[id].config = exportedConfig; }); } } catch (error) { this.alertService.addServerFailure(error); } } const dateStr = new Date().toISOString().replace(/[:.]/g, '-'); const filename = `dashboard_${dateStr}.json`; const blob = new Blob([JSON.stringify(copiedDashboard)], { type: 'application/json' }); saveAs(blob, filename); this.alertService.success(gettext('Dashboard exported.')); } /** * Import the dashboard from a JSON file. If the widget has an import method, it will be called to import * widget's configuration. * @param file JSON file with context dashboard configuration */ async import(file) { if (!file || file.length === 0) { return; } const dashboard = await file[0].readAsJson(); const notSupportedWidgets = []; for (const [id, child] of Object.entries(dashboard.children)) { try { const definition = await this.dynamicComponentService.getById(child.componentId); if (definition && typeof definition.data.import === 'function') { const widgetData = definition.data; const pluginInjector = definition.injector || this.injector; await runInInjectionContext(pluginInjector, async () => { const importedConfig = await widgetData.import(child.config, this.dashboardDetailService.details, { pluginInjector, injector: this.injector }); dashboard.children[id].config = importedConfig; }); } else { notSupportedWidgets.push(child.title); } } catch (error) { console.error(`Error importing widget with id ${id}:`, error); } } if (Object.entries(dashboard.children).length) { dashboard.children = await this.assignWidgetAssets(dashboard.children, notSupportedWidgets); if (!dashboard.children) { return; } } return JSON.stringify(dashboard, null, 2); } async assignWidgetAssets(widgets, notSupportedWidgets) { const assetsToAlign = this.findWidgetsWithSuggestedDevice(widgets); const contextAsset = this.dashboardDetailService.details.context; try { const modalRef = this.modal.show(AssignWidgetAssetModalComponent, { class: 'modal-lg', ariaDescribedby: 'modal-body', ariaLabelledBy: 'modal-title', ignoreBackdropClick: true, keyboard: false, initialState: { assetsToAlign, contextAsset, notSupportedWidgets } }).content; const result = await modalRef.result; return this.updateWidgets(widgets, result); } catch (_) { // cancel clicked return null; } } findWidgetsWithSuggestedDevice(widgets) { const results = []; const searchObject = (obj, path = []) => { if (!obj || typeof obj !== 'object') return; for (const [key, value] of Object.entries(obj)) { const currentPath = [...path, key]; if (key === 'suggestedDevice') { // Find the nearest device/__target reference let deviceRef = null; let devicePath = []; // Search for __target or device in parent objects let currentObj = obj; const currentSearchPath = [...path]; while (currentObj && !deviceRef) {