From eb46b75fea6f16eb64a9774d899c4e4e4ba95b20 Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Fri, 1 May 2026 20:31:07 +0300 Subject: [PATCH 1/4] 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": { From 910be7d6114b0bdfa0bb86a6199e8bff73bdc6d7 Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Mon, 4 May 2026 11:24:40 +0300 Subject: [PATCH 2/4] feat(widgets): add HTML Container widget settings and metadata - Fill description and tags for the HTML Container widget type JSON. - Add basic config component (plain HTML / Angular mode editor). - Add advanced settings component and shared common settings. --- .../system/widget_types/html_container.json | 6 +- ...html-container-basic-config.component.html | 79 +------- .../html-container-basic-config.component.ts | 85 +------- .../html-container-settings.component.html | 97 ++++++++++ .../html/html-container-settings.component.ts | 183 ++++++++++++++++++ .../common/widget-settings-common.module.ts | 9 +- ...l-container-widget-settings.component.html | 20 ++ ...tml-container-widget-settings.component.ts | 62 ++++++ .../lib/settings/widget-settings.module.ts | 9 +- 9 files changed, 385 insertions(+), 165 deletions(-) create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/common/html/html-container-settings.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/common/html/html-container-settings.component.ts create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/html/html-container-widget-settings.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/html/html-container-widget-settings.component.ts 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 index 01ddb4a2fc..00c0c8590d 100644 --- a/application/src/main/data/json/system/widget_types/html_container.json +++ b/application/src/main/data/json/system/widget_types/html_container.json @@ -3,7 +3,7 @@ "name": "HTML Container", "deprecated": false, "image": "tb-image;/api/images/system/html_card_system_widget_image.png", - "description": null, + "description": "Configurable HTML, CSS and JavaScript widget with access to the Widget API via WidgetContext and the ability to load external resources or modules. Supports two modes: plain HTML (regular HTML/JS bound to the widget container) and Angular (Angular template with bound variables and functions). Use for custom complex visualizations or actions when system widgets are not enough.", "descriptor": { "type": "static", "sizeX": 9.5, @@ -12,7 +12,7 @@ "templateHtml": "\n", "templateCss": "", "controllerScript": "self.onInit = function() {\n \n}\n", - "settingsDirective": "", + "settingsDirective": "tb-html-container-widget-settings", "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\"}" @@ -31,5 +31,5 @@ } ], "scada": false, - "tags": null + "tags": ["html", "css", "javascript", "custom", "script", "code", "container", "angular", "template", "external resources", "widget api", "advanced", "custom visualization", "custom action", "web", "markup"] } \ No newline at end of file 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 index 5025ab4d56..298bdb6e61 100644 --- 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 @@ -16,82 +16,5 @@ --> -
-
-
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 index 349f339910..5c367697a2 100644 --- 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 @@ -14,22 +14,17 @@ /// limitations under the License. /// -import { ChangeDetectorRef, Component, Injector } from '@angular/core'; -import { UntypedFormArray, UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; +import { Component } from '@angular/core'; +import { UntypedFormBuilder, UntypedFormGroup } 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 + HtmlContainerWidgetSettings } 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', @@ -39,27 +34,10 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; }) 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); } @@ -71,65 +49,12 @@ export class HtmlContainerBasicConfigComponent extends BasicWidgetConfigComponen 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))) + settings: [settings, []] }); - 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; + this.widgetConfig.config.settings = {...(this.widgetConfig.config.settings || {}), ...config.settings}; 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/settings/common/html/html-container-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/html/html-container-settings.component.html new file mode 100644 index 0000000000..985bd1bcaa --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/html/html-container-settings.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 (htmlContainerSettingsForm.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/lib/settings/common/html/html-container-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/html/html-container-settings.component.ts new file mode 100644 index 0000000000..9850339761 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/html/html-container-settings.component.ts @@ -0,0 +1,183 @@ +/// +/// 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, DestroyRef, forwardRef, Input, OnInit } from '@angular/core'; +import { WidgetResource } from '@shared/models/widget.models'; +import { + ControlValueAccessor, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + UntypedFormArray, + UntypedFormBuilder, + UntypedFormControl, + UntypedFormGroup, + Validator, + Validators +} from '@angular/forms'; +import { + ContainerFunctionEditorCompleter, + HtmlContainerWidgetSettings, + HtmlContainerWidgetType +} from '@home/components/widget/lib/html/html-container-widget.models'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { isJSResource } from '@shared/models/resource.models'; +import { WidgetService } from '@core/http/widget.service'; + +@Component({ + selector: 'tb-html-container-settings', + templateUrl: './html-container-settings.component.html', + styleUrls: [], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => HtmlContainerSettingsComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => HtmlContainerSettingsComponent), + multi: true, + } + ], + standalone: false +}) +export class HtmlContainerSettingsComponent implements OnInit, ControlValueAccessor, Validator { + + HtmlContainerWidgetType = HtmlContainerWidgetType; + + functionScopeVariables = this.widgetService.getWidgetScopeVariables(); + + containerFunctionEditorCompleter = ContainerFunctionEditorCompleter; + + @Input() + disabled: boolean; + + htmlContainerSettingsForm: UntypedFormGroup; + private modelValue: HtmlContainerWidgetSettings; + + constructor(private fb: UntypedFormBuilder, + private widgetService: WidgetService, + private destroyRef: DestroyRef) { + } + + get resourcesFormArray(): UntypedFormArray { + return this.htmlContainerSettingsForm.get('resources') as UntypedFormArray; + } + + get resourcesControls(): UntypedFormGroup[] { + return this.resourcesFormArray.controls as UntypedFormGroup[]; + } + + ngOnInit(): void { + this.htmlContainerSettingsForm = this.fb.group({ + type: [null, []], + html: [null, []], + css: [null, []], + js: [null, []], + resources: this.fb.array([]) + }); + this.htmlContainerSettingsForm.get('type').valueChanges.pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe(() => this.updateResources()); + this.htmlContainerSettingsForm.valueChanges.pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe(() => { + this.updateModel(); + }); + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (isDisabled) { + this.htmlContainerSettingsForm.disable({emitEvent: false}); + } else { + this.htmlContainerSettingsForm.enable({emitEvent: false}); + } + } + + writeValue(value: HtmlContainerWidgetSettings): void { + this.modelValue = value; + this.htmlContainerSettingsForm.get('type').patchValue(value.type, {emitEvent: false}); + this.htmlContainerSettingsForm.get('html').patchValue(value.html, {emitEvent: false}); + this.htmlContainerSettingsForm.get('css').patchValue(value.css, {emitEvent: false}); + this.htmlContainerSettingsForm.get('js').patchValue(value.js, {emitEvent: false}); + this.resourcesFormArray.clear({emitEvent: false}); + value.resources.forEach(r => { + this.resourcesFormArray.push(this.buildResourceFormGroup(r), {emitEvent: false}); + }); + } + + validate(_c: UntypedFormControl) { + return this.htmlContainerSettingsForm.valid ? null : { + htmlContainerSettings: { + valid: false, + } + }; + } + + addResource() { + const newResource: WidgetResource = { + url: '', + isModule: false + }; + this.resourcesFormArray.push(this.buildResourceFormGroup(newResource)); + } + + removeResource(index: number) { + this.resourcesFormArray.removeAt(index); + } + + private propagateChange = (v: any) => { }; + + private updateModel() { + this.modelValue = this.htmlContainerSettingsForm.value; + this.propagateChange(this.modelValue); + } + + private updateResources() { + if (this.htmlContainerSettingsForm.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)); + }); + } + } + } + + private buildResourceFormGroup(resource: WidgetResource): UntypedFormGroup { + return this.fb.group({ + url: [resource.url, [Validators.required]], + isModule: [resource.isModule] + }); + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-settings-common.module.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-settings-common.module.ts index 9004928078..28c5f3d13b 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-settings-common.module.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-settings-common.module.ts @@ -267,6 +267,9 @@ import { import { ShapeFillStripeSettingsPanelComponent } from '@home/components/widget/lib/settings/common/map/shape-fill-stripe-settings-panel.component'; +import { + HtmlContainerSettingsComponent +} from '@home/components/widget/lib/settings/common/html/html-container-settings.component'; @NgModule({ declarations: [ @@ -372,7 +375,8 @@ import { DataKeysComponent, DataKeyConfigDialogComponent, DataKeyConfigComponent, - WidgetSettingsComponent + WidgetSettingsComponent, + HtmlContainerSettingsComponent ], imports: [ CommonModule, @@ -453,7 +457,8 @@ import { DataKeysComponent, DataKeyConfigDialogComponent, DataKeyConfigComponent, - WidgetSettingsComponent + WidgetSettingsComponent, + HtmlContainerSettingsComponent ], providers: [ ColorSettingsComponentService, diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/html/html-container-widget-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/html/html-container-widget-settings.component.html new file mode 100644 index 0000000000..826c71abe9 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/html/html-container-widget-settings.component.html @@ -0,0 +1,20 @@ + + + + diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/html/html-container-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/html/html-container-widget-settings.component.ts new file mode 100644 index 0000000000..46a235c0ee --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/html/html-container-widget-settings.component.ts @@ -0,0 +1,62 @@ +/// +/// 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 } from '@angular/core'; +import { WidgetSettings, WidgetSettingsComponent } from '@shared/models/widget.models'; +import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { htmlContainerDefaultSettings } from '@home/components/widget/lib/html/html-container-widget.models'; + +@Component({ + selector: 'tb-html-container-widget-settings', + templateUrl: './html-container-widget-settings.component.html', + styleUrls: [], + standalone: false +}) +export class HtmlContainerWidgetSettingsComponent extends WidgetSettingsComponent { + + htmlContainerWidgetSettingsForm: UntypedFormGroup; + + constructor(protected store: Store, + private fb: UntypedFormBuilder) { + super(store); + } + + protected settingsForm(): UntypedFormGroup { + return this.htmlContainerWidgetSettingsForm; + } + + protected defaultSettings(): WidgetSettings { + return htmlContainerDefaultSettings; + } + + protected onSettingsSet(settings: WidgetSettings) { + this.htmlContainerWidgetSettingsForm = this.fb.group({ + htmlContainerSettings: [settings.htmlContainerSettings, []] + }); + } + + protected prepareInputSettings(settings: WidgetSettings): WidgetSettings { + return { + htmlContainerSettings: settings + }; + } + + protected prepareOutputSettings(settings: any): WidgetSettings { + return settings.htmlContainerSettings; + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/widget-settings.module.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/widget-settings.module.ts index af8694d24c..dbb7258823 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/widget-settings.module.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/widget-settings.module.ts @@ -375,6 +375,9 @@ import { ValueStepperWidgetSettingsComponent } from '@home/components/widget/lib/settings/control/value-stepper-widget-settings.component'; import { MapWidgetSettingsComponent } from '@home/components/widget/lib/settings/map/map-widget-settings.component'; +import { + HtmlContainerWidgetSettingsComponent +} from '@home/components/widget/lib/settings/html/html-container-widget-settings.component'; @NgModule({ declarations: [ @@ -508,7 +511,8 @@ import { MapWidgetSettingsComponent } from '@home/components/widget/lib/settings LabelValueCardWidgetSettingsComponent, UnreadNotificationWidgetSettingsComponent, ScadaSymbolWidgetSettingsComponent, - MapWidgetSettingsComponent + MapWidgetSettingsComponent, + HtmlContainerWidgetSettingsComponent ], imports: [ CommonModule, @@ -647,7 +651,8 @@ import { MapWidgetSettingsComponent } from '@home/components/widget/lib/settings LabelValueCardWidgetSettingsComponent, UnreadNotificationWidgetSettingsComponent, ScadaSymbolWidgetSettingsComponent, - MapWidgetSettingsComponent + MapWidgetSettingsComponent, + HtmlContainerWidgetSettingsComponent ] }) export class WidgetSettingsModule { From 967991004a5d431694c49b7d80f33ae0b5a6b31f Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Mon, 4 May 2026 11:44:31 +0300 Subject: [PATCH 3/4] chore(html-container): drop unused ViewContainerRef and stale comments Remove the no-longer-used ViewContainerRef injection and the commented- out fallback branches now that initAngularComponent uses the angularContainer view container exclusively. --- .../widget/lib/html/html-container-widget.component.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) 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 index 177083b844..f7bae926bc 100644 --- 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 @@ -24,7 +24,6 @@ import { Optional, Type, ViewChild, - ViewContainerRef, ViewEncapsulation } from '@angular/core'; import { WidgetContext } from '@home/models/widget-component.models'; @@ -91,7 +90,6 @@ export class HtmlContainerWidgetComponent implements OnInit { 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, @@ -190,7 +188,6 @@ export class HtmlContainerWidgetComponent implements OnInit { } 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 || ''; @@ -225,9 +222,9 @@ export class HtmlContainerWidgetComponent implements OnInit { ).subscribe({ next: (componentType) => { this.containerInstanceComponentType = componentType; - const injector: Injector = Injector.create({providers: [], parent: this.angularContainer.viewContainerRef.injector/*this.containerRef.injector*/}); + const injector: Injector = Injector.create({providers: [], parent: this.angularContainer.viewContainerRef.injector}); try { - /*this.containerRef*/this.angularContainer.viewContainerRef.createComponent(this.containerInstanceComponentType, + this.angularContainer.viewContainerRef.createComponent(this.containerInstanceComponentType, {index: 0, injector}); } catch (error) { From dc479185a4f5f481712f9e360f6b494d7bd68c0f Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Mon, 4 May 2026 17:19:12 +0300 Subject: [PATCH 4/4] feat(html-container): tabbed settings UI, register widget, fill-height layout - Restructure HTML Container settings into a mat-tab-group (Resources / HTML / CSS / JavaScript) instead of a single resources expansion panel followed by stacked editor blocks. Each editor tab uses [fillHeight] so the editors fill the panel. - Wire fill-height plumbing: tb-widget-settings host h-full, basic and advanced settings @HostBinding('style.height')='100%', advanced panel switched from inline height:100% to flex-1, mat-content height:100%. - Register html_container in widget_bundles/html_widgets.json so the widget appears in the HTML Widgets bundle. - Replace the placeholder html-card image reference with a dedicated html-container.png asset and embedded data. - Add 'JavaScript' translation key for the new tab label. --- .../system/widget_bundles/html_widgets.json | 3 +- .../system/widget_types/html_container.json | 31 ++++++-- .../html-container-basic-config.component.ts | 4 +- .../html-container-settings.component.html | 73 ++++++++++--------- .../html-container-settings.component.scss | 28 +++++++ .../html/html-container-settings.component.ts | 7 +- .../widget/widget-settings.component.html | 2 +- ...tml-container-widget-settings.component.ts | 5 +- .../widget/widget-config.component.html | 2 +- .../widget/widget-config.component.scss | 1 + .../assets/locale/locale.constant-en_US.json | 1 + 11 files changed, 107 insertions(+), 50 deletions(-) create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/settings/common/html/html-container-settings.component.scss diff --git a/application/src/main/data/json/system/widget_bundles/html_widgets.json b/application/src/main/data/json/system/widget_bundles/html_widgets.json index c8215d7fe1..e242bb00e7 100644 --- a/application/src/main/data/json/system/widget_bundles/html_widgets.json +++ b/application/src/main/data/json/system/widget_bundles/html_widgets.json @@ -11,6 +11,7 @@ "widgetTypeFqns": [ "cards.html_card", "cards.html_value_card", - "cards.markdown_card" + "cards.markdown_card", + "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 index 00c0c8590d..70818154af 100644 --- a/application/src/main/data/json/system/widget_types/html_container.json +++ b/application/src/main/data/json/system/widget_types/html_container.json @@ -2,7 +2,7 @@ "fqn": "html_container", "name": "HTML Container", "deprecated": false, - "image": "tb-image;/api/images/system/html_card_system_widget_image.png", + "image": "tb-image;/api/images/system/html-container.png", "description": "Configurable HTML, CSS and JavaScript widget with access to the Widget API via WidgetContext and the ability to load external resources or modules. Supports two modes: plain HTML (regular HTML/JS bound to the widget container) and Angular (Angular template with bound variables and functions). Use for custom complex visualizations or actions when system widgets are not enough.", "descriptor": { "type": "static", @@ -19,17 +19,34 @@ }, "resources": [ { - "link": "/api/images/system/html_card_system_widget_image.png", - "title": "\"HTML Card\" system widget image", + "link": "/api/images/system/html-container.png", + "title": "\"HTML Container\" system widget image", "type": "IMAGE", "subType": "IMAGE", - "fileName": "html_card_system_widget_image.png", - "publicResourceKey": "4NhB6vxsi6JSm0lhcAQCZRRB6pLdcUa4", + "fileName": "html-container.png", + "publicResourceKey": "0CBg8htTwiFsclrm44sIp5pQNS4MM35l", "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=", + "data": "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", "public": true } ], "scada": false, - "tags": ["html", "css", "javascript", "custom", "script", "code", "container", "angular", "template", "external resources", "widget api", "advanced", "custom visualization", "custom action", "web", "markup"] + "tags": [ + "html", + "css", + "javascript", + "custom", + "script", + "code", + "container", + "angular", + "template", + "external resources", + "widget api", + "advanced", + "custom visualization", + "custom action", + "web", + "markup" + ] } \ No newline at end of file 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 index 5c367697a2..d057acf057 100644 --- 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 @@ -14,7 +14,7 @@ /// limitations under the License. /// -import { Component } from '@angular/core'; +import { Component, HostBinding } from '@angular/core'; import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms'; import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; @@ -34,6 +34,8 @@ import { }) export class HtmlContainerBasicConfigComponent extends BasicWidgetConfigComponent { + @HostBinding('style.height') height = '100%'; + htmlContainerWidgetConfigForm: UntypedFormGroup; constructor(protected store: Store, diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/html/html-container-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/html/html-container-settings.component.html index 985bd1bcaa..4ccf475dc9 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/html/html-container-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/html/html-container-settings.component.html @@ -16,7 +16,7 @@ --> -
+
widgets.html-container.container-type
@@ -24,15 +24,13 @@ {{ 'widgets.html-container.type-angular' | translate }}
-
- - - -
{{ 'widgets.html-container.resources' | translate }}
-
-
- + + + +
{{ 'widgets.html-container.resources' | translate }}
+
+
@if (resourcesFormArray.length) { @for (resourceControl of resourcesControls; track resourceControl; let i = $index) {
@@ -60,7 +58,7 @@ widgets.html-container.no-resources } -
+
- - -
-
- - -
-
- - -
-
- - -
+
+ + + + + + + + + + + + + +
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/html/html-container-settings.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/html/html-container-settings.component.scss new file mode 100644 index 0000000000..d2385459e0 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/html/html-container-settings.component.scss @@ -0,0 +1,28 @@ +/** + * 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. + */ + + :host { + &.tb-html-container-settings { + height: 100%; + ::ng-deep { + .mat-mdc-tab-body-wrapper { + position: relative; + top: 0; + flex: 1; + } + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/html/html-container-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/html/html-container-settings.component.ts index 9850339761..116d69d468 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/html/html-container-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/html/html-container-settings.component.ts @@ -14,7 +14,7 @@ /// limitations under the License. /// -import { Component, DestroyRef, forwardRef, Input, OnInit } from '@angular/core'; +import { Component, DestroyRef, forwardRef, HostBinding, Input, OnInit } from '@angular/core'; import { WidgetResource } from '@shared/models/widget.models'; import { ControlValueAccessor, @@ -39,7 +39,7 @@ import { WidgetService } from '@core/http/widget.service'; @Component({ selector: 'tb-html-container-settings', templateUrl: './html-container-settings.component.html', - styleUrls: [], + styleUrls: ['./html-container-settings.component.scss'], providers: [ { provide: NG_VALUE_ACCESSOR, @@ -62,6 +62,9 @@ export class HtmlContainerSettingsComponent implements OnInit, ControlValueAcces containerFunctionEditorCompleter = ContainerFunctionEditorCompleter; + @HostBinding('class') + hostClass = 'tb-html-container-settings'; + @Input() disabled: boolean; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget/widget-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget/widget-settings.component.html index 65f2044a7b..401c5d258a 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget/widget-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget/widget-settings.component.html @@ -15,7 +15,7 @@ limitations under the License. --> -
+
{{definedDirectiveError}}
, diff --git a/ui-ngx/src/app/modules/home/components/widget/widget-config.component.html b/ui-ngx/src/app/modules/home/components/widget/widget-config.component.html index a792f50147..f35a1f9ae1 100644 --- a/ui-ngx/src/app/modules/home/components/widget/widget-config.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/widget-config.component.html @@ -308,7 +308,7 @@
-
+
.mat-content { + height: 100%; padding-top: 8px; @media #{$mat-xs} { padding-left: 8px; 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 e8a2d3523a..039e7a33e7 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -9513,6 +9513,7 @@ } }, "html-container": { + "java-script": "JavaScript", "js-function": "JavaScript function", "html": "HTML", "angular-html-template": "Angular HTML template",