From eb46b75fea6f16eb64a9774d899c4e4e4ba95b20 Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Fri, 1 May 2026 20:31:07 +0300 Subject: [PATCH] feat(widgets): add HTML Container widget 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. --- .../json/system/widget_bundles/cards.json | 3 +- .../system/widget_types/html_container.json | 35 ++ .../basic/basic-widget-config.module.ts | 9 +- ...html-container-basic-config.component.html | 97 ++++++ .../html-container-basic-config.component.ts | 135 ++++++++ .../html/html-container-widget.component.ts | 321 ++++++++++++++++++ .../lib/html/html-container-widget.models.ts | 63 ++++ .../widget/widget-components.module.ts | 7 +- .../assets/locale/locale.constant-en_US.json | 11 + 9 files changed, 676 insertions(+), 5 deletions(-) create mode 100644 application/src/main/data/json/system/widget_types/html_container.json create mode 100644 ui-ngx/src/app/modules/home/components/widget/config/basic/html/html-container-basic-config.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/config/basic/html/html-container-basic-config.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/html/html-container-widget.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/html/html-container-widget.models.ts diff --git a/application/src/main/data/json/system/widget_bundles/cards.json b/application/src/main/data/json/system/widget_bundles/cards.json index 81cb2fdbb1..11d9bfbb9d 100644 --- a/application/src/main/data/json/system/widget_bundles/cards.json +++ b/application/src/main/data/json/system/widget_bundles/cards.json @@ -24,6 +24,7 @@ "cards.html_value_card", "cards.markdown_card", "cards.simple_card", - "unread_notifications" + "unread_notifications", + "html_container" ] } \ No newline at end of file diff --git a/application/src/main/data/json/system/widget_types/html_container.json b/application/src/main/data/json/system/widget_types/html_container.json new file mode 100644 index 0000000000..01ddb4a2fc --- /dev/null +++ b/application/src/main/data/json/system/widget_types/html_container.json @@ -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": "\n", + "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 +} \ No newline at end of file diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/basic-widget-config.module.ts b/ui-ngx/src/app/modules/home/components/widget/config/basic/basic-widget-config.module.ts index b703da79f6..095b122af6 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/basic-widget-config.module.ts +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/basic-widget-config.module.ts @@ -150,6 +150,9 @@ import { ValueStepperBasicConfigComponent } from '@home/components/widget/config/basic/rpc/value-stepper-basic-config.component'; import { MapBasicConfigComponent } from '@home/components/widget/config/basic/map/map-basic-config.component'; +import { + HtmlContainerBasicConfigComponent +} from '@home/components/widget/config/basic/html/html-container-basic-config.component'; @NgModule({ declarations: [ @@ -201,7 +204,8 @@ import { MapBasicConfigComponent } from '@home/components/widget/config/basic/ma LabelValueCardBasicConfigComponent, UnreadNotificationBasicConfigComponent, ScadaSymbolBasicConfigComponent, - MapBasicConfigComponent + MapBasicConfigComponent, + HtmlContainerBasicConfigComponent ], imports: [ CommonModule, @@ -255,7 +259,8 @@ import { MapBasicConfigComponent } from '@home/components/widget/config/basic/ma LabelCardBasicConfigComponent, LabelValueCardBasicConfigComponent, UnreadNotificationBasicConfigComponent, - MapBasicConfigComponent + MapBasicConfigComponent, + HtmlContainerBasicConfigComponent ] }) export class BasicWidgetConfigModule { diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/html/html-container-basic-config.component.html b/ui-ngx/src/app/modules/home/components/widget/config/basic/html/html-container-basic-config.component.html new file mode 100644 index 0000000000..5025ab4d56 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/html/html-container-basic-config.component.html @@ -0,0 +1,97 @@ + + +
+
+
widgets.html-container.container-type
+ + {{ 'widgets.html-container.type-plain' | translate }} + {{ 'widgets.html-container.type-angular' | translate }} + +
+
+ + + +
{{ 'widgets.html-container.resources' | translate }}
+
+
+ + @if (resourcesFormArray.length) { + @for (resourceControl of resourcesControls; track resourceControl; let i = $index) { +
+ + + @if (htmlContainerWidgetConfigForm.get('type').value === HtmlContainerWidgetType.ANGULAR) { + + {{ 'widget.resource-is-extension' | translate }} + + } + +
+ } + } @else { + widgets.html-container.no-resources + } +
+ +
+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/html/html-container-basic-config.component.ts b/ui-ngx/src/app/modules/home/components/widget/config/basic/html/html-container-basic-config.component.ts new file mode 100644 index 0000000000..349f339910 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/html/html-container-basic-config.component.ts @@ -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, + 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; +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/html/html-container-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/html/html-container-widget.component.ts new file mode 100644 index 0000000000..177083b844 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/html/html-container-widget.component.ts @@ -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: '
' + + '@if (widgetErrorData) {
\n' + + ' \n' + + '
}', + 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; + + @ViewChild('angularContainer', {static: true}) + angularContainer: TbAnchorComponent; + + @Input() + ctx: WidgetContext; + + private containerInstanceComponentType: Type; + + private settings: HtmlContainerWidgetSettings; + + widgetErrorData: ExceptionData; + + constructor(private elementRef: ElementRef, + private containerRef: ViewContainerRef, + @Optional() @Inject(MODULES_MAP) private modulesMap: IModulesMap, + @Inject(SHARED_MODULE_TOKEN) private sharedModule: Type, + @Inject(WIDGET_COMPONENTS_MODULE_TOKEN) private widgetComponentsModule: Type, + @Inject(HOME_COMPONENTS_MODULE_TOKEN) private homeComponentsModule: Type, + 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> = 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> { + if (isNotEmptyTbFunction(this.settings.js)) { + return parseTbFunction(this.ctx.http, this.settings.js, ['ctx']); + } else { + return of(null); + } + } + + private initAngularComponent(imports?: Type[], containerFunction?: CompiledTbFunction): 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 { + const resourceTasks: Observable[] = []; + 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('
'))); + } else { + return of(null); + } + } + )); + } else { + return of(null); + } + } + + private loadAngularModules(): Observable[]> { + const modulesTasks: Observable[] = []; + 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); + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/html/html-container-widget.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/html/html-container-widget.models.ts new file mode 100644 index 0000000000..87e5edbe41 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/html/html-container-widget.models.ts @@ -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); diff --git a/ui-ngx/src/app/modules/home/components/widget/widget-components.module.ts b/ui-ngx/src/app/modules/home/components/widget/widget-components.module.ts index bcd74e37f1..6b68f16a3a 100644 --- a/ui-ngx/src/app/modules/home/components/widget/widget-components.module.ts +++ b/ui-ngx/src/app/modules/home/components/widget/widget-components.module.ts @@ -94,6 +94,7 @@ import { SelectMapEntityPanelComponent } from '@home/components/widget/lib/maps/panels/select-map-entity-panel.component'; import { MapTimelinePanelComponent } from '@home/components/widget/lib/maps/panels/map-timeline-panel.component'; +import { HtmlContainerWidgetComponent } from '@home/components/widget/lib/html/html-container-widget.component'; @NgModule({ declarations: [ @@ -151,7 +152,8 @@ import { MapTimelinePanelComponent } from '@home/components/widget/lib/maps/pane ScadaSymbolWidgetComponent, SelectMapEntityPanelComponent, MapTimelinePanelComponent, - MapWidgetComponent + MapWidgetComponent, + HtmlContainerWidgetComponent ], imports: [ CommonModule, @@ -214,7 +216,8 @@ import { MapTimelinePanelComponent } from '@home/components/widget/lib/maps/pane UnreadNotificationWidgetComponent, NotificationTypeFilterPanelComponent, ScadaSymbolWidgetComponent, - MapWidgetComponent + MapWidgetComponent, + HtmlContainerWidgetComponent ], providers: [ {provide: WIDGET_COMPONENTS_MODULE_TOKEN, useValue: WidgetComponentsModule}, diff --git a/ui-ngx/src/assets/locale/locale.constant-en_US.json b/ui-ngx/src/assets/locale/locale.constant-en_US.json index ea11fff5a0..e8a2d3523a 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -9511,6 +9511,17 @@ "how-to-create-customer-and-assign-dashboard": "How to create Customer and assign Dashboard" } } + }, + "html-container": { + "js-function": "JavaScript function", + "html": "HTML", + "angular-html-template": "Angular HTML template", + "css": "CSS", + "container-type": "Container type", + "type-plain": "Plain HTML", + "type-angular": "Angular", + "resources": "Resources", + "no-resources": "No resources configured" } }, "color": {