Browse Source
New static widget that replaces the dashboard layout with configurable HTML, CSS, and JavaScript and exposes the WidgetContext to the user script. Use for custom complex visualizations or actions when system widgets are not enough.pull/15556/head
9 changed files with 676 additions and 5 deletions
@ -0,0 +1,35 @@ |
|||
{ |
|||
"fqn": "html_container", |
|||
"name": "HTML Container", |
|||
"deprecated": false, |
|||
"image": "tb-image;/api/images/system/html_card_system_widget_image.png", |
|||
"description": null, |
|||
"descriptor": { |
|||
"type": "static", |
|||
"sizeX": 9.5, |
|||
"sizeY": 5.5, |
|||
"resources": [], |
|||
"templateHtml": "<tb-html-container-widget \n [ctx]=\"ctx\">\n</tb-html-container-widget>", |
|||
"templateCss": "", |
|||
"controllerScript": "self.onInit = function() {\n \n}\n", |
|||
"settingsDirective": "", |
|||
"hasBasicMode": true, |
|||
"basicModeDirective": "tb-html-container-basic-config", |
|||
"defaultConfig": "{\"datasources\":[],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":false,\"backgroundColor\":\"rgba(255, 255, 255, 0)\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"0\",\"settings\":{\"type\":\"PLAIN\",\"html\":\"\",\"css\":\"\",\"js\":\"\",\"resources\":[]},\"title\":\"HTML Container\",\"dropShadow\":false,\"enableFullscreen\":false,\"widgetStyle\":{},\"widgetCss\":\"\",\"pageSize\":1024,\"noDataDisplayMessage\":\"\",\"configMode\":\"basic\"}" |
|||
}, |
|||
"resources": [ |
|||
{ |
|||
"link": "/api/images/system/html_card_system_widget_image.png", |
|||
"title": "\"HTML Card\" system widget image", |
|||
"type": "IMAGE", |
|||
"subType": "IMAGE", |
|||
"fileName": "html_card_system_widget_image.png", |
|||
"publicResourceKey": "4NhB6vxsi6JSm0lhcAQCZRRB6pLdcUa4", |
|||
"mediaType": "image/png", |
|||
"data": "iVBORw0KGgoAAAANSUhEUgAAAMgAAACgCAAAAABslHx1AAAAAmJLR0QA/4ePzL8AAATFSURBVHja7d3tUxNXFMdx/u7vEkAJsKQK6bilKaBV1Eh5GGNaSyq0WodhRuqMtQJqW5UHCwKGMRJiQpL99cWGEGaaTjtjgaTnvNpz7mYnn8neu/duXtwWFbdWlxs8Vrf21VJcy5TU4FHKrBVbtjJqgshstayWmgFSWm1ZVlPEskEMYhCDGMQgBjGIQQxiEIMYxCAGMYhBDGIQgxjEIAYxiEEMYhCDGMQgBjGIQQxiEIMYxCAG+T9ACrlcLjjyc7lcWflcbfhBfnByOZfL5cpSKZfL+f/2G+WAJ/8dJAHh4GgFeK4BaiOjMYDtyskzAIvSQ+BtI0JmKif3NRBk3PM8Lwwhz/M8LxtAzgf30RoNBJEkjUOk0j4GwJok6XaDQ8LwtSSVXVrP1of4v1w978bu5YNh4fHlfnfwfpDo15Hevm/eHkCyP8Tc/rGV44d8FaLbl/QCRrrqQopXg251bkdSdjBIIpuSNAtAbwXyqgsA595Hh3Sm0+l0Or1QDzJxLWiYhCftdSFJoKMHiPnyvwTOhIHeD9KGA3SfIYBkOsGJtIPz/GNDauIvIfEFGJdKnZwtOPUgOw4kfT0E1vQMmPb12IEZaRJCz1RKBpBJCG+ocAVixw25XArTUdACTBbqdvY56MhLisK8xisDXRyiUi+MStoDnqjUCT9Keg3Oh48MCcXj8Xg8PlwPMqQE/Kzr8HK3LiQBFyozhaI8uClJ8+AUi8CD6qi1DYymUqkksH7Mnf0zLcNIvo2In64LGYXhauLCHUlaAnb3gIUq5FXN7//qmCH98s8RmoXv9KYuZBI+ryZ9cFsKzsvngUdVyAowcDGIjWOGRKRpaINNrdWF3IUeX9LS/PymLsMlSZqCLikM01XIe+CnE3ogRqRtAE9arQtZCVqynbCoWXBWpfddMCaNgLsnLQSj1gVwM5Je3/RPAKIBYPYIJOp5nud5dw8uFIPQxO1eCOf1oRs6Jm71QOtG0FMiydFQAHkKnJlIjTgkTwIyB87uEUglbh5c6G13UGhdkvSyPUicB5J0HYBQe/Bkv+tUPnr/JCDZVq7obyF6d6MNnC+COdTmSCs4A78Hs5dkG3zyYqAy13oec8CJ/XZ6l7qFzfW9apJ/80dNsr5V2yNyGxt7tmY3iEEMYhCDGOTEIJnD6YtBDGKQpocsDrvR5K4kyX96PeoOzu5Lysbj8b2Zvv7SkepphgwBcD4vqRAPVlXR91IauASUVAhWgny6e7ohtEdCwcpdCaDTBYYDCED5sHrxdEOuFZRx4Ya06cC3ZS05sKI04EzM3demA1NlLTqweuo7ewI86U7lHdYQzCgNzEnSHXB9SYPw/amHpKBPGoFzqVQqFYUxpYEX0pHqeINAav4wvXoI+eyweq1BIDHorry+nT6E1FRnGgQyCtFqUxVyo7baGJBHlVG4mFivgRxU9xMbjQIpR4HBqYRLz84hpBwFhqYSLu5Og0C07VZ6dXTvEHKk2iAQZZOdQHcqX3NrSdlb1WrjLHXL26/T/j+s2prdIAYxiEEMYhCDGMQgBjGIQQxiEIMYxCAGMYhBDGIQgxjEIAYxiEEMYhCDGMQgBjGIQQxiEIMY5PRAmmaD4ObYsvndVst+c2yiXWppjm3NS/oTe0OjFEeU1MMAAAAASUVORK5CYII=", |
|||
"public": true |
|||
} |
|||
], |
|||
"scada": false, |
|||
"tags": null |
|||
} |
|||
@ -0,0 +1,97 @@ |
|||
<!-- |
|||
|
|||
Copyright © 2016-2026 The Thingsboard Authors |
|||
|
|||
Licensed under the Apache License, Version 2.0 (the "License"); |
|||
you may not use this file except in compliance with the License. |
|||
You may obtain a copy of the License at |
|||
|
|||
http://www.apache.org/licenses/LICENSE-2.0 |
|||
|
|||
Unless required by applicable law or agreed to in writing, software |
|||
distributed under the License is distributed on an "AS IS" BASIS, |
|||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
|||
See the License for the specific language governing permissions and |
|||
limitations under the License. |
|||
|
|||
--> |
|||
<ng-container [formGroup]="htmlContainerWidgetConfigForm"> |
|||
<div class="tb-form-panel no-padding no-border"> |
|||
<div class="flex flex-row items-center gap-4"> |
|||
<div class="tb-form-panel-title" translate>widgets.html-container.container-type</div> |
|||
<tb-toggle-select formControlName="type"> |
|||
<tb-toggle-option [value]="HtmlContainerWidgetType.PLAIN">{{ 'widgets.html-container.type-plain' | translate }}</tb-toggle-option> |
|||
<tb-toggle-option [value]="HtmlContainerWidgetType.ANGULAR">{{ 'widgets.html-container.type-angular' | translate }}</tb-toggle-option> |
|||
</tb-toggle-select> |
|||
</div> |
|||
<div class="tb-form-panel stroked !py-2"> |
|||
<mat-expansion-panel #resourcesPanel="matExpansionPanel" class="tb-settings" [expanded]="false" > |
|||
<mat-expansion-panel-header class="flex flex-row flex-wrap"> |
|||
<mat-panel-title class="h-10"> |
|||
<div [matBadge]="resourcesFormArray.length" [matBadgeHidden]="resourcesPanel.expanded || !resourcesFormArray.length" |
|||
matBadgeSize="small">{{ 'widgets.html-container.resources' | translate }}</div> |
|||
</mat-panel-title> |
|||
</mat-expansion-panel-header> |
|||
<ng-template matExpansionPanelContent> |
|||
@if (resourcesFormArray.length) { |
|||
@for (resourceControl of resourcesControls; track resourceControl; let i = $index) { |
|||
<div class="tb-form-row no-border no-padding" [formGroup]="resourceControl"> |
|||
<tb-resource-autocomplete class="flex-1" |
|||
formControlName="url" |
|||
inlineField |
|||
hideRequiredMarker required |
|||
[allowAutocomplete]="resourceControl.get('isModule').value && htmlContainerWidgetConfigForm.get('type').value === HtmlContainerWidgetType.ANGULAR" |
|||
placeholder="{{ 'widget.resource-url' | translate }}"> |
|||
</tb-resource-autocomplete> |
|||
@if (htmlContainerWidgetConfigForm.get('type').value === HtmlContainerWidgetType.ANGULAR) { |
|||
<mat-checkbox formControlName="isModule"> |
|||
{{ 'widget.resource-is-extension' | translate }} |
|||
</mat-checkbox> |
|||
} |
|||
<button mat-icon-button color="primary" |
|||
(click)="removeResource(i)" |
|||
matTooltip="{{'widget.remove-resource' | translate}}" |
|||
matTooltipPosition="above"> |
|||
<mat-icon>delete</mat-icon> |
|||
</button> |
|||
</div> |
|||
} |
|||
} @else { |
|||
<span translate |
|||
class="tb-prompt flex items-center justify-center">widgets.html-container.no-resources</span> |
|||
} |
|||
<div class="mt-2"> |
|||
<button mat-raised-button color="primary" |
|||
(click)="addResource()" |
|||
matTooltip="{{'widget.add-resource' | translate}}" |
|||
matTooltipPosition="above"> |
|||
<span translate>action.add</span> |
|||
</button> |
|||
</div> |
|||
</ng-template> |
|||
</mat-expansion-panel> |
|||
</div> |
|||
<div class="tb-form-row"> |
|||
<tb-html class="flex-1" |
|||
formControlName="html" |
|||
label="{{ (htmlContainerWidgetConfigForm.get('type').value === HtmlContainerWidgetType.ANGULAR ? 'widgets.html-container.angular-html-template' : 'widgets.html-container.html') | translate }}"> |
|||
</tb-html> |
|||
</div> |
|||
<div class="tb-form-row"> |
|||
<tb-css class="flex-1" |
|||
formControlName="css" |
|||
label="{{ 'widgets.html-container.css' | translate }}"> |
|||
</tb-css> |
|||
</div> |
|||
<div class="tb-form-row"> |
|||
<tb-js-func class="flex-1" |
|||
formControlName="js" |
|||
[globalVariables]="functionScopeVariables" |
|||
[editorCompleter]="containerFunctionEditorCompleter" |
|||
[functionArgs]="htmlContainerWidgetConfigForm.get('type').value === HtmlContainerWidgetType.ANGULAR ? ['ctx'] : ['ctx', 'container']" |
|||
withModules |
|||
functionTitle="{{ 'widgets.html-container.js-function' | translate }}"> |
|||
</tb-js-func> |
|||
</div> |
|||
</div> |
|||
</ng-container> |
|||
@ -0,0 +1,135 @@ |
|||
///
|
|||
/// Copyright © 2016-2026 The Thingsboard Authors
|
|||
///
|
|||
/// Licensed under the Apache License, Version 2.0 (the "License");
|
|||
/// you may not use this file except in compliance with the License.
|
|||
/// You may obtain a copy of the License at
|
|||
///
|
|||
/// http://www.apache.org/licenses/LICENSE-2.0
|
|||
///
|
|||
/// Unless required by applicable law or agreed to in writing, software
|
|||
/// distributed under the License is distributed on an "AS IS" BASIS,
|
|||
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|||
/// See the License for the specific language governing permissions and
|
|||
/// limitations under the License.
|
|||
///
|
|||
|
|||
import { ChangeDetectorRef, Component, Injector } from '@angular/core'; |
|||
import { UntypedFormArray, UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; |
|||
import { Store } from '@ngrx/store'; |
|||
import { AppState } from '@core/core.state'; |
|||
import { BasicWidgetConfigComponent } from '@home/components/widget/config/widget-config.component.models'; |
|||
import { WidgetConfigComponentData } from '@home/models/widget-component.models'; |
|||
import { WidgetConfigComponent } from '@home/components/widget/widget-config.component'; |
|||
import { |
|||
ContainerFunctionEditorCompleter, |
|||
htmlContainerDefaultSettings, |
|||
HtmlContainerWidgetSettings, HtmlContainerWidgetType |
|||
} from '@home/components/widget/lib/html/html-container-widget.models'; |
|||
import { WidgetService } from '@core/http/widget.service'; |
|||
import { WidgetResource } from '@shared/models/widget.models'; |
|||
import { isJSResource, ResourceSubType } from '@shared/models/resource.models'; |
|||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; |
|||
|
|||
@Component({ |
|||
selector: 'tb-html-container-basic-config', |
|||
templateUrl: './html-container-basic-config.component.html', |
|||
styleUrls: ['../basic-config.scss'], |
|||
standalone: false |
|||
}) |
|||
export class HtmlContainerBasicConfigComponent extends BasicWidgetConfigComponent { |
|||
|
|||
HtmlContainerWidgetType = HtmlContainerWidgetType; |
|||
|
|||
htmlContainerWidgetConfigForm: UntypedFormGroup; |
|||
|
|||
functionScopeVariables = this.widgetService.getWidgetScopeVariables(); |
|||
|
|||
containerFunctionEditorCompleter = ContainerFunctionEditorCompleter; |
|||
|
|||
get resourcesFormArray(): UntypedFormArray { |
|||
return this.htmlContainerWidgetConfigForm.get('resources') as UntypedFormArray; |
|||
} |
|||
|
|||
get resourcesControls(): UntypedFormGroup[] { |
|||
return this.resourcesFormArray.controls as UntypedFormGroup[]; |
|||
} |
|||
|
|||
constructor(protected store: Store<AppState>, |
|||
protected widgetConfigComponent: WidgetConfigComponent, |
|||
private widgetService: WidgetService, |
|||
private cd: ChangeDetectorRef, |
|||
private $injector: Injector, |
|||
private fb: UntypedFormBuilder) { |
|||
super(store, widgetConfigComponent); |
|||
} |
|||
|
|||
protected configForm(): UntypedFormGroup { |
|||
return this.htmlContainerWidgetConfigForm; |
|||
} |
|||
|
|||
protected onConfigSet(configData: WidgetConfigComponentData) { |
|||
const settings: HtmlContainerWidgetSettings = {...htmlContainerDefaultSettings, ...(configData.config.settings || {})}; |
|||
this.htmlContainerWidgetConfigForm = this.fb.group({ |
|||
type: [settings.type, []], |
|||
html: [settings.html, []], |
|||
css: [settings.css, []], |
|||
js: [settings.js, []], |
|||
resources: this.fb.array(settings.resources.map(r => this.buildResourceFormGroup(r))) |
|||
}); |
|||
this.htmlContainerWidgetConfigForm.get('type').valueChanges.pipe( |
|||
takeUntilDestroyed(this.destroyRef) |
|||
).subscribe(() => this.updateResources()); |
|||
} |
|||
|
|||
protected prepareOutputConfig(config: any): WidgetConfigComponentData { |
|||
this.widgetConfig.config.settings = this.widgetConfig.config.settings || {}; |
|||
this.widgetConfig.config.settings.type = config.type; |
|||
this.widgetConfig.config.settings.html = config.html; |
|||
this.widgetConfig.config.settings.css = config.css; |
|||
this.widgetConfig.config.settings.js = config.js; |
|||
this.widgetConfig.config.settings.resources = config.resources; |
|||
return this.widgetConfig; |
|||
} |
|||
|
|||
private updateResources() { |
|||
if (this.htmlContainerWidgetConfigForm.get('type').value === HtmlContainerWidgetType.PLAIN) { |
|||
const resources: WidgetResource[] = this.resourcesFormArray.value; |
|||
const filtered = resources.filter(r => !isJSResource(r.url)); |
|||
let updated = filtered.length !== resources.length; |
|||
filtered.forEach((r) => { |
|||
if (r.isModule) { |
|||
r.isModule = false; |
|||
updated = true; |
|||
} |
|||
}); |
|||
if (updated) { |
|||
this.resourcesFormArray.clear(); |
|||
filtered.forEach(r => { |
|||
this.resourcesFormArray.push(this.buildResourceFormGroup(r)); |
|||
}); |
|||
} |
|||
} |
|||
} |
|||
|
|||
addResource() { |
|||
const newResource: WidgetResource = { |
|||
url: '', |
|||
isModule: false |
|||
}; |
|||
this.resourcesFormArray.push(this.buildResourceFormGroup(newResource)); |
|||
} |
|||
|
|||
removeResource(index: number) { |
|||
this.resourcesFormArray.removeAt(index); |
|||
} |
|||
|
|||
private buildResourceFormGroup(resource: WidgetResource): UntypedFormGroup { |
|||
return this.fb.group({ |
|||
url: [resource.url, [Validators.required]], |
|||
isModule: [resource.isModule] |
|||
}); |
|||
} |
|||
|
|||
protected readonly ResourceSubType = ResourceSubType; |
|||
} |
|||
@ -0,0 +1,321 @@ |
|||
///
|
|||
/// Copyright © 2016-2026 The Thingsboard Authors
|
|||
///
|
|||
/// Licensed under the Apache License, Version 2.0 (the "License");
|
|||
/// you may not use this file except in compliance with the License.
|
|||
/// You may obtain a copy of the License at
|
|||
///
|
|||
/// http://www.apache.org/licenses/LICENSE-2.0
|
|||
///
|
|||
/// Unless required by applicable law or agreed to in writing, software
|
|||
/// distributed under the License is distributed on an "AS IS" BASIS,
|
|||
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|||
/// See the License for the specific language governing permissions and
|
|||
/// limitations under the License.
|
|||
///
|
|||
|
|||
import { |
|||
Component, |
|||
ElementRef, |
|||
Inject, |
|||
Injector, |
|||
Input, |
|||
OnInit, |
|||
Optional, |
|||
Type, |
|||
ViewChild, |
|||
ViewContainerRef, |
|||
ViewEncapsulation |
|||
} from '@angular/core'; |
|||
import { WidgetContext } from '@home/models/widget-component.models'; |
|||
import { |
|||
htmlContainerDefaultSettings, |
|||
HtmlContainerWidgetSettings, |
|||
HtmlContainerWidgetType, |
|||
WidgetContainerAngularFunction, |
|||
WidgetContainerPlainFunction |
|||
} from '@home/components/widget/lib/html/html-container-widget.models'; |
|||
import { hashCode, isNotEmptyStr, parseTbFunction } from '@core/utils'; |
|||
import { CompiledTbFunction, isNotEmptyTbFunction } from '@shared/models/js-function.models'; |
|||
import { catchError, forkJoin, map, Observable, of, switchMap, throwError } from 'rxjs'; |
|||
import cssjs from '@core/css/css'; |
|||
import { SHARED_MODULE_TOKEN } from '@shared/components/tokens'; |
|||
import { DynamicComponentFactoryService } from '@core/services/dynamic-component-factory.service'; |
|||
import { HOME_COMPONENTS_MODULE_TOKEN, WIDGET_COMPONENTS_MODULE_TOKEN } from '@home/components/tokens'; |
|||
import { ExceptionData } from '@shared/models/error.models'; |
|||
import { UtilsService } from '@core/services/utils.service'; |
|||
import { |
|||
flatModulesWithComponents, |
|||
ModulesWithComponents, |
|||
modulesWithComponentsToTypes, |
|||
ResourcesService |
|||
} from '@core/services/resources.service'; |
|||
import { MODULES_MAP } from '@shared/models/constants'; |
|||
import { IModulesMap } from '@modules/common/modules-map.models'; |
|||
import { TbAnchorComponent } from '@shared/components/tb-anchor.component'; |
|||
|
|||
@Component({ |
|||
selector: 'tb-html-container-widget', |
|||
template: '<div #container class="tb-absolute-fill"><tb-anchor #angularContainer></tb-anchor></div>' + |
|||
'@if (widgetErrorData) { <div class="tb-absolute-fill tb-widget-error">\n' + |
|||
' <span [innerHtml]="(\'Widget Error:<br/><br/>\' + widgetErrorData.message) | safe:\'html\'"></span>\n' + |
|||
'</div> }', |
|||
styles: '.tb-widget-error {\n' + |
|||
' display: flex;\n' + |
|||
' align-items: center;\n' + |
|||
' justify-content: center;\n' + |
|||
' background: rgba(255, 255, 255, .5);\n' + |
|||
'\n' + |
|||
' span {\n' + |
|||
' color: #f00;\n' + |
|||
' }\n' + |
|||
' }', |
|||
encapsulation: ViewEncapsulation.None, |
|||
standalone: false |
|||
}) |
|||
export class HtmlContainerWidgetComponent implements OnInit { |
|||
|
|||
@ViewChild('container', {static: true}) |
|||
containerElmRef: ElementRef<HTMLElement>; |
|||
|
|||
@ViewChild('angularContainer', {static: true}) |
|||
angularContainer: TbAnchorComponent; |
|||
|
|||
@Input() |
|||
ctx: WidgetContext; |
|||
|
|||
private containerInstanceComponentType: Type<any>; |
|||
|
|||
private settings: HtmlContainerWidgetSettings; |
|||
|
|||
widgetErrorData: ExceptionData; |
|||
|
|||
constructor(private elementRef: ElementRef<HTMLElement>, |
|||
private containerRef: ViewContainerRef, |
|||
@Optional() @Inject(MODULES_MAP) private modulesMap: IModulesMap, |
|||
@Inject(SHARED_MODULE_TOKEN) private sharedModule: Type<any>, |
|||
@Inject(WIDGET_COMPONENTS_MODULE_TOKEN) private widgetComponentsModule: Type<any>, |
|||
@Inject(HOME_COMPONENTS_MODULE_TOKEN) private homeComponentsModule: Type<any>, |
|||
private dynamicComponentFactoryService: DynamicComponentFactoryService, |
|||
private utils: UtilsService, |
|||
private resources: ResourcesService) {} |
|||
|
|||
ngOnInit(): void { |
|||
this.settings = {...htmlContainerDefaultSettings, ...(this.ctx.settings || {})}; |
|||
this.loadWidgetResources().subscribe( |
|||
{ |
|||
next: () => { |
|||
if (this.settings.type === HtmlContainerWidgetType.PLAIN) { |
|||
this.initPlain(); |
|||
} else if (this.settings.type === HtmlContainerWidgetType.ANGULAR) { |
|||
this.initAngular(); |
|||
} |
|||
}, |
|||
error: (e) => { |
|||
this.handleWidgetException(e); |
|||
} |
|||
} |
|||
); |
|||
} |
|||
|
|||
private initPlain(): void { |
|||
try { |
|||
if (isNotEmptyStr(this.settings.css)) { |
|||
const cssParser = new cssjs(); |
|||
cssParser.testMode = false; |
|||
const namespace = 'html-container-' + hashCode(this.settings.css); |
|||
cssParser.cssPreviewNamespace = namespace; |
|||
cssParser.createStyleElement(namespace, this.settings.css); |
|||
$(this.elementRef.nativeElement).addClass(namespace); |
|||
} |
|||
if (isNotEmptyStr(this.settings.html)) { |
|||
$(this.containerElmRef.nativeElement).html(this.settings.html); |
|||
} |
|||
this.compileAndExecutePlainFunction(); |
|||
} catch (e) { |
|||
this.handleWidgetException(e); |
|||
} |
|||
} |
|||
|
|||
private compileAndExecutePlainFunction(): void { |
|||
if (isNotEmptyTbFunction(this.settings.js)) { |
|||
const jsFunction: Observable<CompiledTbFunction<WidgetContainerPlainFunction>> = parseTbFunction(this.ctx.http, this.settings.js, ['ctx', 'container']); |
|||
jsFunction.subscribe({ |
|||
next: (containerFunction) => { |
|||
try { |
|||
containerFunction.execute(this.ctx, this.containerElmRef.nativeElement); |
|||
} catch (e) { |
|||
this.handleWidgetException(e); |
|||
} |
|||
}, |
|||
error: (e) => { |
|||
this.handleWidgetException(e); |
|||
} |
|||
}); |
|||
} |
|||
} |
|||
|
|||
private initAngular(): void { |
|||
this.loadAngularModules().subscribe( |
|||
{ |
|||
next: (imports) => { |
|||
this.compileAngularFunction().subscribe( |
|||
{ |
|||
next: (containerFunction) => { |
|||
try { |
|||
this.initAngularComponent(imports, containerFunction); |
|||
} catch (e) { |
|||
this.handleWidgetException(e); |
|||
} |
|||
}, |
|||
error: (e) => { |
|||
this.handleWidgetException(e); |
|||
} |
|||
} |
|||
); |
|||
}, |
|||
error: (e) => { |
|||
this.handleWidgetException(e); |
|||
} |
|||
} |
|||
); |
|||
} |
|||
|
|||
private compileAngularFunction(): Observable<CompiledTbFunction<WidgetContainerAngularFunction>> { |
|||
if (isNotEmptyTbFunction(this.settings.js)) { |
|||
return parseTbFunction(this.ctx.http, this.settings.js, ['ctx']); |
|||
} else { |
|||
return of(null); |
|||
} |
|||
} |
|||
|
|||
private initAngularComponent(imports?: Type<any>[], containerFunction?: CompiledTbFunction<WidgetContainerAngularFunction>): void { |
|||
//this.containerRef.clear();
|
|||
this.angularContainer.viewContainerRef.clear(); |
|||
const destroyContainerInstanceResources = this.destroyContainerInstanceResources.bind(this); |
|||
const template = this.settings.html || ''; |
|||
const styles: string[] = []; |
|||
if (isNotEmptyStr(this.settings.css)) { |
|||
styles.push(this.settings.css); |
|||
} |
|||
let compileModules = [this.sharedModule, this.widgetComponentsModule, this.homeComponentsModule]; |
|||
if (imports && imports.length) { |
|||
compileModules = compileModules.concat(imports); |
|||
} |
|||
const self = () => this; |
|||
this.dynamicComponentFactoryService.createDynamicComponent( |
|||
class TbContainerInstance { |
|||
ngOnInit(): void { |
|||
if (containerFunction) { |
|||
const instance = self(); |
|||
try { |
|||
containerFunction.apply(this, [instance.ctx]); |
|||
} catch (e) { |
|||
instance.handleWidgetException(e); |
|||
} |
|||
} |
|||
} |
|||
ngOnDestroy(): void { |
|||
destroyContainerInstanceResources(); |
|||
} |
|||
}, |
|||
template, |
|||
compileModules, |
|||
true, styles |
|||
).subscribe({ |
|||
next: (componentType) => { |
|||
this.containerInstanceComponentType = componentType; |
|||
const injector: Injector = Injector.create({providers: [], parent: this.angularContainer.viewContainerRef.injector/*this.containerRef.injector*/}); |
|||
try { |
|||
/*this.containerRef*/this.angularContainer.viewContainerRef.createComponent(this.containerInstanceComponentType, |
|||
{index: 0, injector}); |
|||
|
|||
} catch (error) { |
|||
this.handleWidgetException(error); |
|||
} |
|||
}, |
|||
error: (e) => { |
|||
this.handleWidgetException(e); |
|||
} |
|||
}); |
|||
} |
|||
|
|||
private destroyContainerInstanceResources() { |
|||
if (this.containerInstanceComponentType) { |
|||
this.dynamicComponentFactoryService.destroyDynamicComponent(this.containerInstanceComponentType); |
|||
this.containerInstanceComponentType = null; |
|||
} |
|||
} |
|||
|
|||
private handleWidgetException(e: any) { |
|||
console.error(e); |
|||
this.widgetErrorData = this.utils.processWidgetException(e); |
|||
this.ctx.detectChanges(); |
|||
} |
|||
|
|||
private loadWidgetResources(): Observable<any> { |
|||
const resourceTasks: Observable<string>[] = []; |
|||
this.settings.resources.filter(r => !r.isModule).forEach( |
|||
(resource) => { |
|||
resourceTasks.push( |
|||
this.resources.loadResource(resource.url).pipe( |
|||
catchError(() => of(`Failed to load widget resource: '${resource.url}'`)) |
|||
) |
|||
); |
|||
} |
|||
); |
|||
if (resourceTasks.length) { |
|||
return forkJoin(resourceTasks).pipe( |
|||
switchMap(msgs => { |
|||
let errors: string[]; |
|||
if (msgs && msgs.length) { |
|||
errors = msgs.filter(msg => msg && msg.length > 0); |
|||
} |
|||
if (errors && errors.length) { |
|||
return throwError(() => new Error(errors.join('<br/>'))); |
|||
} else { |
|||
return of(null); |
|||
} |
|||
} |
|||
)); |
|||
} else { |
|||
return of(null); |
|||
} |
|||
} |
|||
|
|||
private loadAngularModules(): Observable<Type<any>[]> { |
|||
const modulesTasks: Observable<ModulesWithComponents | string>[] = []; |
|||
this.settings.resources.filter(r => r.isModule).forEach( |
|||
(resource) => { |
|||
modulesTasks.push( |
|||
this.resources.loadModulesWithComponents(resource.url, this.modulesMap).pipe( |
|||
catchError((e: Error) => of(e?.message ? e.message : `Failed to load widget resource module: '${resource.url}'`)) |
|||
) |
|||
); |
|||
} |
|||
); |
|||
if (modulesTasks.length) { |
|||
return forkJoin(modulesTasks).pipe( |
|||
map(res => { |
|||
const msg = res.find(r => typeof r === 'string'); |
|||
if (msg) { |
|||
return msg as string; |
|||
} else { |
|||
const modulesWithComponentsList = res as ModulesWithComponents[]; |
|||
return flatModulesWithComponents(modulesWithComponentsList); |
|||
} |
|||
}), |
|||
switchMap(modulesWithComponentsList => { |
|||
if (typeof modulesWithComponentsList === 'string') { |
|||
return throwError(() => new Error(modulesWithComponentsList)); |
|||
} else { |
|||
const modules = modulesWithComponentsToTypes(modulesWithComponentsList); |
|||
return of(modules); |
|||
} |
|||
}) |
|||
); |
|||
} else { |
|||
return of(null); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,63 @@ |
|||
///
|
|||
/// Copyright © 2016-2026 The Thingsboard Authors
|
|||
///
|
|||
/// Licensed under the Apache License, Version 2.0 (the "License");
|
|||
/// you may not use this file except in compliance with the License.
|
|||
/// You may obtain a copy of the License at
|
|||
///
|
|||
/// http://www.apache.org/licenses/LICENSE-2.0
|
|||
///
|
|||
/// Unless required by applicable law or agreed to in writing, software
|
|||
/// distributed under the License is distributed on an "AS IS" BASIS,
|
|||
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|||
/// See the License for the specific language governing permissions and
|
|||
/// limitations under the License.
|
|||
///
|
|||
|
|||
import { TbFunction } from '@shared/models/js-function.models'; |
|||
import { WidgetContext } from '@home/models/widget-component.models'; |
|||
import { TbEditorCompleter, TbEditorCompletions } from '@shared/models/ace/completion.models'; |
|||
import { widgetContextCompletions } from '@shared/models/ace/widget-completion.models'; |
|||
import { WidgetResource } from '@shared/models/widget.models'; |
|||
|
|||
export enum HtmlContainerWidgetType { |
|||
PLAIN = 'PLAIN', |
|||
ANGULAR = 'ANGULAR' |
|||
} |
|||
|
|||
export interface HtmlContainerWidgetSettings { |
|||
type: HtmlContainerWidgetType; |
|||
html: string; |
|||
css: string; |
|||
js: TbFunction; |
|||
resources: WidgetResource[]; |
|||
} |
|||
|
|||
export const htmlContainerDefaultSettings: HtmlContainerWidgetSettings = { |
|||
type: HtmlContainerWidgetType.PLAIN, |
|||
html: '', |
|||
css: '', |
|||
js: '', |
|||
resources: [], |
|||
}; |
|||
|
|||
export type WidgetContainerPlainFunction = (ctx: WidgetContext, container: HTMLElement) => void; |
|||
export type WidgetContainerAngularFunction = (ctx: WidgetContext) => void; |
|||
|
|||
const containerFunctionCompletions: TbEditorCompletions = { |
|||
...{ |
|||
ctx: { |
|||
meta: 'argument', |
|||
type: widgetContextCompletions.ctx.type, |
|||
description: widgetContextCompletions.ctx.description, |
|||
children: widgetContextCompletions.ctx.children |
|||
}, |
|||
container: { |
|||
meta: 'argument', |
|||
type: 'HTMLElement', |
|||
description: 'Container element of the widget' |
|||
}, |
|||
} |
|||
}; |
|||
|
|||
export const ContainerFunctionEditorCompleter = new TbEditorCompleter(containerFunctionCompletions); |
|||
Loading…
Reference in new issue